From d300a8d78219b583943e36b5499a02d9738fc467 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Fri, 31 Jul 2026 15:19:12 +0100 Subject: [PATCH] Make the fleet dashboard usable one-handed on a phone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The M3 dashboard's network path was verified from a phone on cellular (m3-verification.md §6), but the layout was a desk's: three columns collapsing to three stacked panes, so a 390 px screen got a map canvas a couple of hundred pixels tall with the roster above it and the detail pane below the fold. The phone is the realistic off-LAN client — it is what an operator has in a corridor, which is exactly where "where is the robot and what is it doing" gets asked. Below 760 px the panes become one at a time behind a bottom tab bar, within thumb reach, and the map gets the whole screen. Two things follow from losing the side-by-side view: picking a robot in the roster now navigates to the map (the desktop layout gets that for free by showing both), and the canvas gained pinch-to-zoom, there being no wheel. One finger pans, two zoom about the point between them, and a two-finger drag carries the map along; markers get a larger hit target when the pointer is a fingertip. Dispatch gained a zone picker that *writes* `goto ` into the command box rather than sending it — the grammar stays the robot's, and the keyboard leaves the common case. The breakpoint is a silent seam: CSS decides what is displayed, JS decides when a selection navigates, and disagreement yields a tab bar over stacked panes rather than an error. It lives in layout.mjs and ui_test.mjs reads the stylesheet and holds it there — as it does for every pane having a tab, and for `touch-action: none` on the canvas, without which the browser eats both gestures before a pointer event arrives. The pinch arithmetic is pure and tested for the same reason the Q5 transform is: a division by a zero span puts NaN in the view scale and blanks the map for good. Three pre-existing bugs fell out, all of which a desk hides: - `hidden` did not hide. `.revisions` and `.dispatch` set `display: flex`, which outranks the attribute's UA-stylesheet `display: none`, so the promote picker rendered empty on every floor with no candidate. - The canvas backing store was resized on width alone, so a height change — a tab switch, the toolbar rewrapping, a mobile URL bar sliding away — left `clearRect` unable to reach the bottom of it and the previous frame's scale bar stayed on screen under the new one. - The scale bar was drawn in the dark theme's near-white over a basemap whose free space is white. A canvas gets no cascade, so it now reads `--dim` off the element and is legible in both themes. Verified against a live stack (container broker, fleet server, three scripted robots on the office_world bundle): ui_test.mjs 24/24 under node, the fleet server and registry suites 64/64, and browser_check.mjs grew a phone pass — 390x844 at device scale 3 with mobile metrics and touch emulation, driven by real Input.dispatchTouchEvent gestures. In a touch-enabled Playwright context a tap 20 px off a marker selected it, and after a pinch centred on that marker a tap at the same point still did, which is what shows the zoom anchors where the fingers are. A `goto dropoff` chosen from the picker dispatched through the API and wrote the expected audit row. Measurements in m3-verification.md §9. A real device is still the acceptance: emulation gets the viewport and the touch points right and the thumb wrong. --- CLAUDE.md | 2 +- docs/fleet/README.md | 31 +++++- docs/fleet/m3-verification.md | 75 ++++++++++++- docs/images/fleet-ui-phone.webp | Bin 0 -> 62278 bytes mote_fleet/README.md | 2 +- mote_fleet/server/ui/app.mjs | 48 ++++++++- mote_fleet/server/ui/index.html | 37 ++++++- mote_fleet/server/ui/layout.mjs | 51 +++++++++ mote_fleet/server/ui/map.mjs | 158 ++++++++++++++++++++++++--- mote_fleet/server/ui/style.css | 171 ++++++++++++++++++++++++++++++ mote_fleet/test/browser_check.mjs | 115 ++++++++++++++++++++ mote_fleet/test/ui_test.mjs | 86 ++++++++++++++- 12 files changed, 748 insertions(+), 28 deletions(-) create mode 100644 docs/images/fleet-ui-phone.webp create mode 100644 mote_fleet/server/ui/layout.mjs diff --git a/CLAUDE.md b/CLAUDE.md index fb09e20..eedd4ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,7 +108,7 @@ Milestone M2 of `docs/design/fleet.md`; the operator flow is `docs/fleet/README. ## Fleet: the operator view + dispatch API (M3) -Milestone M3 of `docs/design/fleet.md`, and the end of v0. The **HTTP** wire is specified as its own versioned contract in **`docs/fleet/fleet-api.md`** (M1's MQTT one is `control-plane.md`); the operator flow is `docs/fleet/README.md` §6–9 and the measurements are `m3-verification.md`. **The two directions of the loop take different paths on purpose.** *Reads* ride MQTT: the browser subscribes to `mote/v1/+/{presence,health,pose,task/status}` over WebSockets, and because all of those are retained it has the whole fleet's state within a second of loading — no polling, no service in the middle. *Writes* ride HTTP: `POST /v1/robots//dispatch` authorizes an operator token (`fleetctl operator new --name `; the name is what the audit row records), writes the audit row, then publishes to the same `task/command` topic. **The topic tree did not change — only who publishes to it**, and `fleetctl dispatch` moved to the API too, so there is one write path rather than one per client. The command grammar is still parsed only by the robot's task layer: a parser in the server would be a second grammar to keep in step. **The browser cannot publish**: `server/ui/mqtt.mjs` is a hand-rolled subscribe-only MQTT 3.1.1 client that implements no PUBLISH packet, so the split is enforced by omission (M7 makes it structural with a subscribe-only broker credential). The UI is static ES modules — no bundler, no npm, no vendored library — served by the same stdlib `http.server`; `map.mjs` holds the Q5 world→pixel transform (`px = (wx-origin_x)/res`, `py = height - (wy-origin_y)/res`) and a pan/zoom/follow canvas, and only draws robots on the *same* site+floor as the selected one because a pose from another floor is a different map frame. **Basemaps come from site bundles on the fleet box** (`--maps-dir`, default `$MOTE_FLEET_HOME/sites`, the layout `sites.py` writes, seeded by rsync until **M4** makes the registry canonical behind the same two routes). **M1's websockets blocker is settled**: `pixi run fleet-broker` runs `eclipse-mosquitto` under docker with the repo's own `mosquitto.conf`, because conda-forge's build has none; `pixi run -e fleet fleet-broker-local` is the conda binary for a box without docker, and it strips the WS stanza and says so. Two things that run in the same file (`test_ui.py` → `ui_test.mjs`) are the MQTT codec and the transform, tested under node against the very files the browser loads; `browser_check.mjs` drives a real headless Chrome over CDP against a running stack and is an operator's tool, not a CI test. +Milestone M3 of `docs/design/fleet.md`, and the end of v0. The **HTTP** wire is specified as its own versioned contract in **`docs/fleet/fleet-api.md`** (M1's MQTT one is `control-plane.md`); the operator flow is `docs/fleet/README.md` §6–9 and the measurements are `m3-verification.md`. **The two directions of the loop take different paths on purpose.** *Reads* ride MQTT: the browser subscribes to `mote/v1/+/{presence,health,pose,task/status}` over WebSockets, and because all of those are retained it has the whole fleet's state within a second of loading — no polling, no service in the middle. *Writes* ride HTTP: `POST /v1/robots//dispatch` authorizes an operator token (`fleetctl operator new --name `; the name is what the audit row records), writes the audit row, then publishes to the same `task/command` topic. **The topic tree did not change — only who publishes to it**, and `fleetctl dispatch` moved to the API too, so there is one write path rather than one per client. The command grammar is still parsed only by the robot's task layer: a parser in the server would be a second grammar to keep in step. **The browser cannot publish**: `server/ui/mqtt.mjs` is a hand-rolled subscribe-only MQTT 3.1.1 client that implements no PUBLISH packet, so the split is enforced by omission (M7 makes it structural with a subscribe-only broker credential). The UI is static ES modules — no bundler, no npm, no vendored library — served by the same stdlib `http.server`; `map.mjs` holds the Q5 world→pixel transform (`px = (wx-origin_x)/res`, `py = height - (wy-origin_y)/res`) and a pan/zoom/follow canvas, and only draws robots on the *same* site+floor as the selected one because a pose from another floor is a different map frame. **Basemaps come from site bundles on the fleet box** (`--maps-dir`, default `$MOTE_FLEET_HOME/sites`, the layout `sites.py` writes, seeded by rsync until **M4** makes the registry canonical behind the same two routes). **M1's websockets blocker is settled**: `pixi run fleet-broker` runs `eclipse-mosquitto` under docker with the repo's own `mosquitto.conf`, because conda-forge's build has none; `pixi run -e fleet fleet-broker-local` is the conda binary for a box without docker, and it strips the WS stanza and says so. Two things that run in the same file (`test_ui.py` → `ui_test.mjs`) are the MQTT codec and the transform, tested under node against the very files the browser loads; `browser_check.mjs` drives a real headless Chrome over CDP against a running stack and is an operator's tool, not a CI test. **The phone is the realistic off-LAN client**, so below 760 px the three panes become one at a time behind a bottom tab bar (`server/ui/layout.mjs`), selecting a robot in the roster navigates to the map — what the desktop layout gets for free by showing both — and the canvas gained pinch-to-zoom (`pinchSpan`/`pinchUpdate` in `map.mjs`, pure and tested, because a division by a zero span puts NaN in the view scale and blanks the map for good) plus a fingertip-sized hit target. The breakpoint is a **silent** seam — CSS decides what is displayed, JS decides when a selection navigates, and disagreement yields a tab bar over stacked panes rather than an error — so it lives in `layout.mjs` and `ui_test.mjs` reads the stylesheet and holds it there, as it does for every pane having a tab and for `touch-action: none` on the canvas (without which the browser eats the drag and the pinch before a single pointer event arrives). Dispatch gained a **zone picker that writes `goto ` into the command box rather than sending it**: the grammar stays the robot's, and the keyboard leaves the common case. Three pre-existing bugs fell out, all of which a desk hides: `hidden` does not hide an element whose class sets `display` (the empty promote picker), the canvas backing store was resized on width alone so a height change left the previous frame's scale bar under the new one, and the scale bar was drawn in the dark theme's near-white on a white basemap — a canvas gets no cascade, so it now reads `--dim` off the element. Measurements, including `browser_check.mjs`'s phone pass, are `m3-verification.md` §9; **a real device is still the acceptance** — emulation gets the viewport and the touch points right and the thumb wrong. ## Fleet: the map registry (M4) diff --git a/docs/fleet/README.md b/docs/fleet/README.md index 45c4662..189320b 100644 --- a/docs/fleet/README.md +++ b/docs/fleet/README.md @@ -625,8 +625,9 @@ broker credential that can publish**, and its MQTT client implements no PUBLISH packet at all. **The map.** A floor's PNG basemap with live robot markers on it: pan by -dragging, zoom with the wheel, click a robot to select it, `follow` to keep the -selected one centred, `fit` to see the whole floor. The scale bar is metres. +dragging, zoom with the wheel or by pinching, click a robot to select it, +`follow` to keep the selected one centred, `fit` to see the whole floor. The +scale bar is metres. Only robots on the *same* site and floor as the selected one are drawn — a pose from another floor is a different map frame, and drawing it here would place a robot somewhere it is not. @@ -646,6 +647,32 @@ Beside the map's floor label is the **canonical revision** it is showing, and, when a robot has published one, a picker to promote a candidate onto the floor (§11). Both need the operator token; without one the pane is read-only. +![The dashboard on a phone](../images/fleet-ui-phone.webp) + +**On a phone.** The realistic off-LAN client is a phone — it is what an operator +has in a corridor, and "where is the robot and what is it doing" is exactly the +question you ask from one. Below 760 px the three panes become **one at a time** +behind a tab bar at the bottom of the screen, within thumb reach, so the map +gets the whole display instead of a couple of hundred pixels between the roster +and the detail pane. Two things follow from losing the side-by-side view: + +- **Picking a robot in the roster takes you to the map.** On a desk that + happens for free, both panes being visible; on a phone it has to be done. + The third tab is labelled with the selected robot's id, so the selection is + legible without switching to it. +- **Pinch to zoom**, since there is no wheel. One finger pans, two zoom about + the point between them, and a two-finger drag carries the map along. Robot + markers get a larger hit target when the pointer is a fingertip rather than + a cursor. + +**Dispatch has a zone picker** beside the command box, listing the taught zones +of the floor on screen. It *writes* `goto ` into the box rather than +sending it — the grammar is still the robot's, parsed only by its task layer — +which on a touchscreen removes the keyboard from the common case without adding +a second command language for the fleet server to keep in step. + +Between 760 and 1100 px the panes stack and scroll, as before. + **What it does not do**, deliberately: no marker clustering, no basemap tiling, no 3D, no camera, no teleop. The first two are what `fleet.md` Q5 describes for large sites and would be unmeasured complexity at this fleet size; the last diff --git a/docs/fleet/m3-verification.md b/docs/fleet/m3-verification.md index 102776c..5ebf20f 100644 --- a/docs/fleet/m3-verification.md +++ b/docs/fleet/m3-verification.md @@ -320,7 +320,7 @@ why the bug survived M1. - **A phone-sized layout.** The panes stack below 1100 px, which is not the same as being usable one-handed on a 390 px screen — and the map canvas is the part - that suffers. Observed, not designed for. + that suffers. Observed, not designed for. **Addressed later — §9 below.** - **The Foxglove deep link.** The button is rendered from the configured template and opens `foxglove://…`, which needs both the Foxglove desktop app on the operator's machine and a `foxglove_bridge` on the robot. Neither exists @@ -333,3 +333,76 @@ why the bug survived M1. - **A robot dropping off while the dashboard watches.** The Last Will is tested against a real broker (`m1-verification.md` §2) and the UI renders `offline` from the same retained payload, but the two have not been observed together. + +--- + +## 9. The phone layout (2026-07-31) + +Closing §6's "the one thing the run did not enjoy is the small screen" and the +first bullet of §8. The network path was already proven from a phone on +cellular; what was missing was a layout for the screen it arrived on. + +### What was changed + +Below 760 px the three panes become one at a time behind a bottom tab bar; +selecting a robot in the roster navigates to the map; the map canvas gained +pinch-to-zoom and a fingertip-sized hit target; and the dispatch box gained a +zone picker that writes `goto ` rather than sending it. The operator flow +is [`README.md` §9](README.md); the breakpoint lives in `server/ui/layout.mjs` +and the stylesheet is held to it by `ui_test.mjs`, because the two failing to +agree produces a tab bar over stacked panes rather than an error. + +### How it was measured + +`ui_test.mjs` grew the pinch arithmetic (`pinchSpan`, `pinchUpdate`) and the +seams that fail silently: the CSS/JS breakpoint, every pane having a tab, and +`touch-action: none` on the canvas. 24/24 under node. + +`browser_check.mjs` grew a **phone pass** — 390x844 at device scale 3, mobile +metrics and touch emulation on, driven with real `Input.dispatchTouchEvent` +gestures rather than synthesised DOM events. Against a live stack (container +broker, fleet server, three scripted robots on the `office_world` bundle): + +``` +ok a coarse pointer is what the page thinks it has +ok one pane at a time, with a tab bar to move between them — {"tabs":"flex","shown":1} +ok no pane scrolls sideways on a phone +ok the canvas backing store follows the pane it is in — 1170x1674 for 1170x1674 +ok picking a robot in the roster shows it on the map — map +ok two fingers zoom the map +``` + +Separately, in a Playwright context with `hasTouch` (so `pointer: coarse` +actually matches, which CDP touch events alone do not cause): a tap landing +**20 px** off a robot marker selected it — inside the touch target, outside the +14 px one a mouse gets — and after a pinch centred on that marker, a tap at the +same screen point still selected it, which is what shows the zoom anchors where +the fingers are rather than drifting. A `goto dropoff` chosen from the zone +picker dispatched through the API and wrote the expected audit row. + +### Three bugs this found, all pre-existing + +- **`hidden` did not hide.** `.revisions` and `.dispatch` set `display: flex`, + which outranks the attribute's UA-stylesheet `display: none` — so the promote + picker rendered empty on every floor with no candidate. Invisible on a desk; + a wasted row of a phone. Fixed with a `[hidden] { display: none !important }` + rule, which also covers anything added later. +- **The canvas backing store was resized on width alone.** A height change + without a width change — a tab switch, the toolbar rewrapping, a mobile URL + bar sliding away — left `clearRect` unable to reach the bottom of the store, + and the previous frame's scale bar stayed on screen under the new one. +- **The scale bar was drawn in the dark theme's near-white**, on a basemap whose + free space is white. A canvas gets no cascade, so the stylesheet's light + theme could not reach it; it now reads `--dim` off the canvas element and is + legible in both. + +### Not verified here + +- **A real device.** Everything above is emulation: it gets the viewport, the + device pixel ratio and the touch points right, and the thumb wrong. Reach, + one-handed grip, the on-screen keyboard covering the dispatch box, and iOS + Safari's own chrome are what a phone in a corridor tests and this does not. + The `100dvh` height, the `env(safe-area-inset-bottom)` padding under the tab + bar and the 16 px input font (which is what stops Safari zooming the page on + focus) are all written for that device and confirmed only in Chrome. +- **Landscape**, and tablets between 760 and 1100 px, which still stack. diff --git a/docs/images/fleet-ui-phone.webp b/docs/images/fleet-ui-phone.webp new file mode 100644 index 0000000000000000000000000000000000000000..119167a031efb1b5909d6b070e44032a46c4798d GIT binary patch literal 62278 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 literal 0 HcmV?d00001 diff --git a/mote_fleet/README.md b/mote_fleet/README.md index 6bfba1a..d825d4b 100644 --- a/mote_fleet/README.md +++ b/mote_fleet/README.md @@ -63,7 +63,7 @@ pixi run -e fleet fleetctl -- dispatch mote-01 goto kitchen | [`server/registry.py`](server/registry.py) | the SQLite row store: robots, enrollment tokens, operators, the audit log, transactional id allocation | | [`server/bundle_store.py`](server/bundle_store.py) | the map registry's byte store: candidate revisions, validation on the way in, the atomic flip that publishes one | | [`server/fleetctl.py`](server/fleetctl.py) | operator CLI: tokens, roster, dispatch, audit, watch | -| [`server/ui/`](server/ui/) | the dashboard: `index.html`, `app.mjs`, `map.mjs` (basemap + the Q5 transform), `mqtt.mjs` (a subscribe-only MQTT client) | +| [`server/ui/`](server/ui/) | the dashboard: `index.html`, `app.mjs`, `map.mjs` (basemap + the Q5 transform, pan/pinch), `mqtt.mjs` (a subscribe-only MQTT client), `layout.mjs` (one pane at a time on a phone) | | [`server/mosquitto.conf`](server/mosquitto.conf), [`broker.sh`](server/broker.sh) | the broker, its WebSocket listener, and where its state goes | | [`deploy/`](deploy/) | the deployed shape: an image for the API+UI, a compose file that runs it beside the broker, and `fleet-deploy.sh` (gated update, rollback, backup, restore) | diff --git a/mote_fleet/server/ui/app.mjs b/mote_fleet/server/ui/app.mjs index 5569729..ab73dc2 100644 --- a/mote_fleet/server/ui/app.mjs +++ b/mote_fleet/server/ui/app.mjs @@ -14,6 +14,7 @@ import { BrokerReader, parseTopic } from './mqtt.mjs'; import { MapView } from './map.mjs'; +import { setupPanes } from './layout.mjs'; const TOKEN_KEY = 'mote.operator.token'; @@ -34,10 +35,12 @@ const state = { operator: null, mapKey: null, floor: null, // the registry's view of the floor on screen: revisions, candidates + zones: [], // the floor's taught places, for the map and the dispatch picker }; const dom = {}; let mapView = null; +let panes = null; let pending = false; // -- small helpers ------------------------------------------------------- @@ -175,6 +178,7 @@ async function ensureMap(record) { if (key === state.mapKey) return; state.mapKey = key; state.floor = null; + setZones([]); renderRevisions(); if (!key) { mapView.clearMap(); @@ -197,11 +201,19 @@ async function ensureMap(record) { // Taught places, in the same frame as the basemap. A floor may have none, // which is a 404 and not an error worth showing. api(`/v1/maps/${site}/${floor}/zones.json`) - .then((body) => state.mapKey === key && mapView.setZones(body.zones)) - .catch(() => state.mapKey === key && mapView.setZones([])); + .then((body) => state.mapKey === key && setZones(body.zones)) + .catch(() => state.mapKey === key && setZones([])); loadFloor(site, floor, key); } +// Taught places go two ways: onto the basemap, and into the dispatch picker. +// Both are the floor's, so they arrive and are cleared together. +function setZones(zones) { + state.zones = zones || []; + mapView.setZones(state.zones); + renderZones(); +} + // -- the map registry ---------------------------------------------------- async function loadFloor(site, floor, key) { @@ -240,6 +252,26 @@ function renderRevisions() { ); } +// The zones of the floor on screen, as a `goto` the operator does not have to +// type. It writes the command rather than sending it: the send button stays the +// one place a task leaves this page. +function renderZones() { + const names = state.zones.map((zone) => zone.name).sort((a, b) => a.localeCompare(b)); + dom.zone.hidden = names.length === 0; + dom.zone.replaceChildren( + el('option', { value: '', text: 'go to a taught zone…' }), + ...names.map((name) => el('option', { value: name, text: name })), + ); +} + +function onZone() { + const name = dom.zone.value; + if (!name) return; + dom.command.value = `goto ${name}`; + dom.dispatchNote.textContent = ''; + dom.dispatchNote.className = 'note'; +} + async function onPromote(event) { event.preventDefault(); const revision = dom.revision.value; @@ -322,6 +354,9 @@ function renderRoster(records) { onclick: () => { state.selected = record.id; state.mapKey = null; // re-resolve: the new robot may be on another floor + // Where the desktop layout has three panes at once, a phone has to + // be taken there: picking a robot means asking where it is. + panes.show('map'); scheduleRender(); }, }, @@ -358,6 +393,9 @@ function renderRoster(records) { } function renderDetail(record) { + // On a phone the detail pane is behind a tab, so the tab is where the + // selection is visible at all. + dom.tabDetail.textContent = record ? record.id : 'robot'; if (!record) { dom.detailName.textContent = 'no robot selected'; dom.detailMeta.replaceChildren(); @@ -515,6 +553,8 @@ function bind() { statusLog: 'status-log', dispatch: 'dispatch', command: 'command', + zone: 'zone', + tabDetail: 'tab-detail', dispatchNote: 'dispatch-note', foxglove: 'foxglove', brokerState: 'broker-state', @@ -547,6 +587,10 @@ export async function boot() { scheduleRender(); }, }); + // The canvas has no size until its pane is on screen, so the map is told when + // it becomes visible rather than fitting into a hidden 0x0 box. + panes = setupPanes({ onShow: (name) => name === 'map' && mapView.shown() }); + dom.zone.addEventListener('change', onZone); dom.fit.addEventListener('click', () => { mapView.follow(null); dom.follow.checked = false; diff --git a/mote_fleet/server/ui/index.html b/mote_fleet/server/ui/index.html index 5d9efdb..3409e2d 100644 --- a/mote_fleet/server/ui/index.html +++ b/mote_fleet/server/ui/index.html @@ -2,7 +2,15 @@ - + + mote fleet @@ -25,12 +33,12 @@

mote/fleet

-
+

roster

-
+

map

@@ -48,7 +56,7 @@

map

-
+

no robot selected

+ @@ -90,6 +106,19 @@

task status

+ + + diff --git a/mote_fleet/server/ui/layout.mjs b/mote_fleet/server/ui/layout.mjs new file mode 100644 index 0000000..f6fffb6 --- /dev/null +++ b/mote_fleet/server/ui/layout.mjs @@ -0,0 +1,51 @@ +// One pane at a time, on a viewport too narrow for three. +// +// The desktop layout puts roster, map and detail side by side, and gets one +// thing for free by doing so: picking a robot in the roster shows it on the map +// without the operator asking. A phone cannot show three panes, so this module +// supplies the missing half — a tab bar, and the rule that selecting a robot +// moves you to the map. +// +// The breakpoint is the seam. CSS decides which panes are *displayed*; this +// decides when a selection should navigate. Disagree and nothing fails: you get +// a tab bar over three stacked panes, or a phone where choosing a robot appears +// to do nothing because the map it selected on is off screen. So the number +// lives here, the stylesheet is required to match it, and `ui_test.mjs` reads +// both files and asserts they do. + +export const NARROW_MAX_PX = 760; + +export const NARROW_MEDIA = `(max-width: ${NARROW_MAX_PX}px)`; + +// Wire the tab bar to the panes. Returns `show(name)`, which is a no-op on a +// wide viewport in effect but not in fact: it still moves `.active`, so a +// window narrowed later opens on the pane the operator last chose. +export function setupPanes({ root = document, onShow = () => {} } = {}) { + const tabs = [...root.querySelectorAll('.panes [data-pane]')]; + const panes = [...root.querySelectorAll('.pane[data-pane]')]; + + function show(name) { + if (!panes.some((pane) => pane.dataset.pane === name)) return; + for (const pane of panes) pane.classList.toggle('active', pane.dataset.pane === name); + for (const tab of tabs) { + const selected = tab.dataset.pane === name; + tab.classList.toggle('active', selected); + tab.setAttribute('aria-selected', String(selected)); + } + onShow(name); + } + + for (const tab of tabs) tab.addEventListener('click', () => show(tab.dataset.pane)); + + const narrow = window.matchMedia(NARROW_MEDIA); + // Crossing the breakpoint changes which panes exist on screen. The map is the + // one that cares: its canvas has no size while hidden, so it needs telling. + narrow.addEventListener('change', () => onShow(current())); + + function current() { + const active = panes.find((pane) => pane.classList.contains('active')); + return active ? active.dataset.pane : null; + } + + return { show, current, isNarrow: () => narrow.matches }; +} diff --git a/mote_fleet/server/ui/map.mjs b/mote_fleet/server/ui/map.mjs index 9093932..0c89e86 100644 --- a/mote_fleet/server/ui/map.mjs +++ b/mote_fleet/server/ui/map.mjs @@ -48,6 +48,37 @@ export function pixelToWorld(map, px, py) { }; } +// -- pinch --------------------------------------------------------------- + +// Two touches, as one gesture: how far apart they are and where their midpoint +// is. Pure, because the alternative to testing this is discovering on a phone +// that the map flies off screen. +export function pinchSpan(a, b) { + return { + distance: Math.hypot(a.x - b.x, a.y - b.y), + centre: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }, + }; +} + +// What one move of a two-finger gesture asks for: a zoom factor, the point to +// zoom about, and how far the fingers carried the map with them. +// +// The zero guard is load-bearing rather than defensive. Two pointers reported +// at the same position give distance 0, and a factor of 0/0 or n/0 puts NaN or +// Infinity into the view scale — from which every subsequent draw is blank, +// with nothing on screen to say why. +export function pinchUpdate(before, after) { + const factor = before.distance > 0 ? after.distance / before.distance : 1; + return { + factor, + centre: after.centre, + pan: { + x: after.centre.x - before.centre.x, + y: after.centre.y - before.centre.y, + }, + }; +} + // The view that shows the whole basemap, centred, with a small margin. export function fitView(map, width, height, margin = 16) { const scale = Math.min( @@ -89,6 +120,12 @@ export class MapView { this.followId = null; this.selectedId = null; this._drag = null; + this._pointers = new Map(); + this._pinch = null; + // A fit needs a sized canvas, and on a narrow viewport this one spends its + // early life in a hidden pane with no size at all. Track whether the map on + // screen has ever actually been fitted, so `shown()` can finish the job. + this._fitted = false; this._bind(); } @@ -98,7 +135,10 @@ export class MapView { const changed = !this.map || this.map.site !== map.site || this.map.floor !== map.floor; this.map = map; this.image = image; - if (changed) this.fit(); + if (changed) { + this._fitted = false; + this.fit(); + } this.draw(); } @@ -106,6 +146,7 @@ export class MapView { this.map = null; this.image = null; this.zones = []; + this._fitted = false; this.draw(); } @@ -137,7 +178,19 @@ export class MapView { fit() { if (!this.map) return; const { width, height } = this._size(); + // A hidden pane's canvas measures 0x0, and fitting into that yields a scale + // of 0 or NaN — a blank map that stays blank once the pane is shown. Leave + // the view alone and let `shown()` fit when there is something to fit into. + if (!width || !height) return; this.view = fitView(this.map, width, height); + this._fitted = true; + } + + // The map pane has become visible (a tab switch, or the viewport crossing the + // breakpoint). This is the first moment its canvas has a size. + shown() { + if (!this._fitted) this.fit(); + this.draw(); } zoomBy(factor, screenX, screenY) { @@ -160,10 +213,31 @@ export class MapView { return this._screenOf(worldToPixel(this.map, worldX, worldY)); } + // Canvas-relative coordinates. Not `offsetX`: during a pointer capture — and + // for the second finger of a pinch — that is measured against whatever the + // event happens to be over, which is not always this canvas. + _local(event) { + const rect = this.canvas.getBoundingClientRect(); + return { x: event.clientX - rect.left, y: event.clientY - rect.top }; + } + _bind() { const canvas = this.canvas; canvas.addEventListener('pointerdown', (event) => { - const hit = this._robotAt(event.offsetX, event.offsetY); + canvas.setPointerCapture(event.pointerId); + this._pointers.set(event.pointerId, this._local(event)); + + // A second finger means a pinch, whatever the first one had started. + if (this._pointers.size === 2) { + this._drag = null; + canvas.classList.remove('dragging'); + this._pinch = this._span(); + return; + } + if (this._pointers.size > 2) return; + + const point = this._local(event); + const hit = this._robotAt(point.x, point.y); if (hit) { this.onSelect(hit.id); return; @@ -171,23 +245,51 @@ export class MapView { // Dragging the map is a deliberate pan, so it stops following: an // operator who grabs the canvas wants to look somewhere else. this.followId = null; - this._drag = { x: event.clientX, y: event.clientY }; - canvas.setPointerCapture(event.pointerId); + this._drag = point; canvas.classList.add('dragging'); }); canvas.addEventListener('pointermove', (event) => { + if (!this._pointers.has(event.pointerId)) return; + const point = this._local(event); + this._pointers.set(event.pointerId, point); + + if (this._pinch) { + const span = this._span(); + if (!span) return; + const { factor, centre, pan } = pinchUpdate(this._pinch, span); + this._pinch = span; + // Fingers carry the map with them as well as scaling it, so the pan + // lands before the zoom is taken about where they now are. + this.view.tx += pan.x; + this.view.ty += pan.y; + this.zoomBy(factor, centre.x, centre.y); + return; + } + if (!this._drag) return; - this.view.tx += event.clientX - this._drag.x; - this.view.ty += event.clientY - this._drag.y; - this._drag = { x: event.clientX, y: event.clientY }; + this.view.tx += point.x - this._drag.x; + this.view.ty += point.y - this._drag.y; + this._drag = point; this.draw(); }); const release = (event) => { - this._drag = null; - canvas.classList.remove('dragging'); + this._pointers.delete(event.pointerId); if (canvas.hasPointerCapture(event.pointerId)) { canvas.releasePointerCapture(event.pointerId); } + if (this._pointers.size < 2 && this._pinch) { + this._pinch = null; + // One finger left: hand it the pan rather than freezing until it lifts. + // Its position is where the drag resumes, so the map does not jump. + const [remaining] = [...this._pointers.values()]; + this._drag = remaining || null; + if (this._drag) canvas.classList.add('dragging'); + return; + } + if (!this._pointers.size) { + this._drag = null; + canvas.classList.remove('dragging'); + } }; canvas.addEventListener('pointerup', release); canvas.addEventListener('pointercancel', release); @@ -199,15 +301,27 @@ export class MapView { }, { passive: false }, ); - window.addEventListener('resize', () => this.draw()); + window.addEventListener('resize', () => { + if (!this._fitted) this.fit(); + this.draw(); + }); + } + + _span() { + const [a, b] = [...this._pointers.values()]; + return a && b ? pinchSpan(a, b) : null; } _robotAt(screenX, screenY) { if (!this.map) return null; + // A fingertip is not a mouse pointer: the same 14 px target that is + // comfortable with a cursor is most of the way to unhittable by touch. + const reach = + typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches ? 24 : 14; for (const robot of this.robots) { if (robot.x === null || robot.x === undefined) continue; const point = this._toScreen(robot.x, robot.y); - if (Math.hypot(point.x - screenX, point.y - screenY) <= 14) return robot; + if (Math.hypot(point.x - screenX, point.y - screenY) <= reach) return robot; } return null; } @@ -217,9 +331,16 @@ export class MapView { draw() { const { width, height } = this._size(); const ratio = window.devicePixelRatio || 1; - if (this.canvas.width !== Math.round(width * ratio)) { - this.canvas.width = Math.round(width * ratio); - this.canvas.height = Math.round(height * ratio); + // Height as well as width: the two change independently, and on a phone the + // height changes often — a tab switch, the toolbar rewrapping, the URL bar + // sliding away. Resizing on width alone leaves the backing store at its old + // height, and `clearRect` (which works in CSS pixels) then cannot reach the + // bottom of it: the previous frame's scale bar stays on screen under the + // new one. + const backing = { w: Math.round(width * ratio), h: Math.round(height * ratio) }; + if (this.canvas.width !== backing.w || this.canvas.height !== backing.h) { + this.canvas.width = backing.w; + this.canvas.height = backing.h; } const ctx = this.context; ctx.setTransform(ratio, 0, 0, ratio, 0, 0); @@ -366,7 +487,12 @@ export class MapView { const length = metres * pixelsPerMetre; const x = 16; const y = height - 18; - ctx.strokeStyle = 'rgba(230, 237, 243, 0.8)'; + // A canvas gets no cascade, so the stylesheet's light theme cannot reach in + // here: drawn in the dark theme's near-white this is invisible on a phone + // that has asked for light, over a map whose free space is white. `--dim` + // is chosen for legibility against either background. + const ink = getComputedStyle(this.canvas).getPropertyValue('--dim').trim() || '#8b949e'; + ctx.strokeStyle = ink; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(x, y - 5); @@ -376,7 +502,7 @@ export class MapView { ctx.stroke(); ctx.font = '11px ui-monospace, monospace'; ctx.textAlign = 'left'; - ctx.fillStyle = 'rgba(230, 237, 243, 0.8)'; + ctx.fillStyle = ink; ctx.fillText(`${metres} m`, x + 4, y - 8); } } diff --git a/mote_fleet/server/ui/style.css b/mote_fleet/server/ui/style.css index e9e53b8..19205b6 100644 --- a/mote_fleet/server/ui/style.css +++ b/mote_fleet/server/ui/style.css @@ -35,9 +35,24 @@ box-sizing: border-box; } +/* The `hidden` attribute is how this page hides things it has nothing to say + with — the promote picker on a floor with no candidates, the dispatch form + with no robot selected. It is only a UA-stylesheet `display: none`, so any + rule of ours that sets `display` silently outranks it and the element stays + on screen: empty, but taking a row. This is the standard way to give the + attribute the last word. */ +[hidden] { + display: none !important; +} + body { margin: 0; height: 100vh; + /* Mobile browsers count `vh` against the viewport with the URL bar hidden, so + a 100vh page is taller than the window and the bottom tab bar starts off + screen. `dvh` is the visible height, and the declaration order leaves the + fallback for anything that does not know it. */ + height: 100dvh; display: flex; flex-direction: column; background: var(--bg); @@ -213,6 +228,10 @@ main { min-height: 0; display: block; cursor: grab; + /* The canvas handles its own pan and pinch. Without this the browser claims + both gestures first — a drag scrolls the page and a pinch zooms the whole + document — and the pointer events the map is listening for never arrive. */ + touch-action: none; /* Everything outside the basemap is unmapped, not empty: a flat backdrop distinct from both the panel and the map's own free space. */ background: var(--void); @@ -225,6 +244,11 @@ main { .check { color: var(--dim); font-size: 12px; + /* A label wrapping between its box and its word reads as two controls. */ + display: inline-flex; + align-items: center; + gap: 4px; + white-space: nowrap; } /* -- roster ------------------------------------------------------------ */ @@ -375,6 +399,11 @@ main { min-width: 160px; } +.zone-picker { + flex: 1 1 100%; + min-width: 0; +} + .note { width: 100%; margin: 4px 0 0; @@ -386,6 +415,43 @@ main { color: var(--fault); } +/* -- the tab bar -------------------------------------------------------- */ + +/* Hidden until the viewport is too narrow for three panes. It is defined here, + *before* that media query, deliberately: `display: none` and the query's + `display: flex` have the same specificity, so the one written later wins and + a bar defined below the query would never appear. */ +.panes { + display: none; + border-top: 1px solid var(--line); + background: var(--panel); + /* Below the home indicator on a notched phone, which the page reaches under + thanks to `viewport-fit=cover`. */ + padding: 6px 6px calc(6px + env(safe-area-inset-bottom, 0px)); + gap: 6px; +} + +.panes button { + flex: 1; + background: transparent; + border-color: transparent; + color: var(--dim); + padding: 10px 6px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.panes button.active { + color: var(--accent); + border-color: var(--accent); + background: var(--bg); +} + +/* -- narrower than a desk ---------------------------------------------- */ + +/* Between a phone and three columns: stack the panes and scroll. A pane is + still wide enough to read here, so all three stay on screen. */ @media (max-width: 1100px) { main { grid-template-columns: 1fr; @@ -398,6 +464,111 @@ main { } } +/* -- one pane at a time ------------------------------------------------- */ + +/* A phone. Three stacked panes here means a map a couple of hundred pixels tall + with the roster above it and the detail below the fold, so instead the tab + bar appears and exactly one pane is on screen — the map getting the whole of + it, which is the point of having one. + + `.active` is set by layout.mjs, which must switch at the same width this does + (NARROW_MAX_PX; ui_test.mjs holds the two together). Note that `.pane` sets + `display: flex`, which beats the `hidden` attribute — hiding a pane has to go + through this class, not through `hidden`. */ +@media (max-width: 760px) { + main { + grid-template-columns: 1fr; + grid-auto-rows: auto; + grid-template-rows: minmax(0, 1fr); + } + + .pane { + display: none; + border: none; + } + + .pane.active { + display: flex; + } + + .panes { + display: flex; + } + + header { + padding: 8px 12px; + gap: 8px; + } + + /* The token form is the widest thing in the header and the least often used + — a token is pasted once and kept in localStorage — so it goes last and + takes its own row, leaving the name, the broker light and the operator + state to share the one above it. */ + .token-form { + order: 3; + flex: 1 1 100%; + margin-left: 0; + } + + .token-form input { + flex: 1; + min-width: 0; + } + + /* The map's controls are the pane's whole toolbar: promote, follow, fit. + Unwrapped they run off the side of a phone, and `overflow: auto` turns + that into a horizontal scrollbar with the buttons hidden beyond it. */ + .pane-head { + flex-wrap: wrap; + } + + .revisions { + flex: 1 1 100%; + } + + .revisions select { + flex: 1; + max-width: none; + min-width: 0; + } + + /* Safari zooms the page when a text field under 16px takes focus, and never + zooms back out. */ + input, + select, + button { + font-size: 16px; + } + + .fact-key, + .subsystem-name { + width: 72px; + } +} + +/* -- touch -------------------------------------------------------------- */ + +/* Sized for a fingertip rather than a cursor, on any device driven by one — + which is the pointer, not the screen width: a tablet is neither narrow nor + a mouse. The map's own hit target follows the same rule in map.mjs. */ +@media (pointer: coarse) { + button, + .button, + input, + select { + min-height: 40px; + } + + .robot { + padding: 12px; + } + + .check input { + width: 20px; + height: 20px; + } +} + /* -- state that is no longer current ----------------------------------- */ /* Retained state is the last thing a robot said, not a claim about now. An diff --git a/mote_fleet/test/browser_check.mjs b/mote_fleet/test/browser_check.mjs index d4164e0..19ef9e1 100644 --- a/mote_fleet/test/browser_check.mjs +++ b/mote_fleet/test/browser_check.mjs @@ -172,6 +172,121 @@ try { writeFileSync(shot, Buffer.from(screenshot.data, 'base64')); console.log(`screenshot: ${shot}`); + // -- the phone ---------------------------------------------------------- + // + // The off-LAN client is a phone, and a phone is not a narrow desktop: it has + // one pane's worth of screen and no wheel to zoom the map with. Emulated + // here — a real device is still the acceptance (README §9), because emulation + // gets the viewport and the touch points right and the thumb wrong. + + await session.send('Emulation.setDeviceMetricsOverride', { + width: 390, + height: 844, + deviceScaleFactor: 3, + mobile: true, + }); + await session.send('Emulation.setTouchEmulationEnabled', { + enabled: true, + maxTouchPoints: 5, + }); + await session.send('Page.navigate', { url }); + await sleep(4000); + + check( + 'a coarse pointer is what the page thinks it has', + await session.evaluate(`window.matchMedia('(pointer: coarse)').matches`), + ); + + const onePane = await session.evaluate(`(() => ({ + tabs: getComputedStyle(document.querySelector('.panes')).display, + shown: [...document.querySelectorAll('.pane')] + .filter(p => getComputedStyle(p).display !== 'none').length, + }))()`); + check( + 'one pane at a time, with a tab bar to move between them', + onePane.tabs === 'flex' && onePane.shown === 1, + JSON.stringify(onePane), + ); + + // Nothing may scroll sideways: a pane wider than the screen hides whatever is + // off its right edge — which is where the map's follow and fit buttons are. + const sideways = await session.evaluate(`(() => { + const out = []; + for (const tab of document.querySelectorAll('.panes [data-pane]')) { + tab.click(); + const pane = document.querySelector('.pane.active'); + if (pane.scrollWidth > pane.clientWidth || + document.documentElement.scrollWidth > window.innerWidth) out.push(tab.dataset.pane); + } + return out.join(','); + })()`); + check('no pane scrolls sideways on a phone', sideways === '', sideways); + + // Switching panes changes the canvas's height but not its width. Resizing on + // width alone leaves a backing store `clearRect` cannot fully reach, and the + // previous frame stays visible along the bottom. + const backing = await session.evaluate(`(() => { + document.querySelector('.panes [data-pane="detail"]').click(); + document.querySelector('.panes [data-pane="map"]').click(); + const c = document.getElementById('map-canvas'); + const r = c.getBoundingClientRect(); + const ratio = window.devicePixelRatio || 1; + return { w: c.width, h: c.height, want: [Math.round(r.width * ratio), Math.round(r.height * ratio)] }; + })()`); + check( + 'the canvas backing store follows the pane it is in', + backing.w === backing.want[0] && backing.h === backing.want[1], + `${backing.w}x${backing.h} for ${backing.want.join('x')}`, + ); + + const jumped = await session.evaluate(`(() => { + document.querySelector('.panes [data-pane="roster"]').click(); + document.querySelector('.robot').click(); + return document.querySelector('.pane.active').dataset.pane; + })()`); + check('picking a robot in the roster shows it on the map', jumped === 'map', jumped); + + // Pinch. The wheel handler has no touch equivalent, so this is the gesture + // that decides whether the map is usable at all on a phone. + const canvas = await session.evaluate(`(() => { + const r = document.getElementById('map-canvas').getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; + })()`); + const paint = () => + session.evaluate(`(() => { + const c = document.getElementById('map-canvas'); + const d = c.getContext('2d').getImageData(0, 0, c.width, c.height).data; + let h = 0; + for (let i = 0; i < d.length; i += 997) h = (h * 31 + d[i]) >>> 0; + return h; + })()`); + const touch = (type, points) => + session.send('Input.dispatchTouchEvent', { + type, + touchPoints: points.map(([x, y], id) => ({ x, y, id })), + }); + const before = await paint(); + await touch('touchStart', [ + [canvas.x - 40, canvas.y], + [canvas.x + 40, canvas.y], + ]); + await touch('touchMove', [ + [canvas.x - 100, canvas.y], + [canvas.x + 100, canvas.y], + ]); + await touch('touchMove', [ + [canvas.x - 160, canvas.y], + [canvas.x + 160, canvas.y], + ]); + await touch('touchEnd', []); + await sleep(400); + check('two fingers zoom the map', (await paint()) !== before); + + const phoneShot = shot.replace(/(\.png)?$/, '-phone.png'); + const phonePng = await session.send('Page.captureScreenshot', { format: 'png' }); + writeFileSync(phoneShot, Buffer.from(phonePng.data, 'base64')); + console.log(`screenshot: ${phoneShot}`); + const errors = await session.evaluate(`window.__errors ? window.__errors.length : 0`); check('no uncaught page errors', errors === 0, String(errors)); } finally { diff --git a/mote_fleet/test/ui_test.mjs b/mote_fleet/test/ui_test.mjs index e5e379d..797a4fc 100644 --- a/mote_fleet/test/ui_test.mjs +++ b/mote_fleet/test/ui_test.mjs @@ -10,6 +10,7 @@ // node --test mote_fleet/test/ui_test.mjs import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; import { test } from 'node:test'; import { @@ -22,7 +23,17 @@ import { parsePackets, parseTopic, } from '../server/ui/mqtt.mjs'; -import { fitView, pixelToWorld, worldToPixel, zoneOutline } from '../server/ui/map.mjs'; +import { + fitView, + pinchSpan, + pinchUpdate, + pixelToWorld, + worldToPixel, + zoneOutline, +} from '../server/ui/map.mjs'; +import { NARROW_MAX_PX } from '../server/ui/layout.mjs'; + +const read = (name) => readFileSync(new URL(`../server/ui/${name}`, import.meta.url), 'utf8'); // -- the MQTT codec ------------------------------------------------------ @@ -184,3 +195,76 @@ test('a bare waypoint has no outline to draw', () => { const map = { resolution: 0.05, origin: [0, 0, 0], width: 100, height: 100 }; assert.equal(zoneOutline(map, { name: 'pickup', x: 1, y: 1 }), null); }); + +// -- pinch to zoom ------------------------------------------------------- + +// The touch gesture the wheel handler has no equivalent for. Its arithmetic is +// pure for the same reason the transform above is: on a phone, a sign or a +// division wrong here is a map that leaps off screen with no way to tell why. + +test('fingers moving apart zoom in, and together zoom out', () => { + const before = pinchSpan({ x: 100, y: 100 }, { x: 200, y: 100 }); + const wider = pinchSpan({ x: 50, y: 100 }, { x: 250, y: 100 }); + assert.equal(pinchUpdate(before, wider).factor, 2); + assert.equal(pinchUpdate(wider, before).factor, 0.5); +}); + +test('the zoom is taken about the midpoint of the two fingers', () => { + const span = pinchSpan({ x: 10, y: 20 }, { x: 30, y: 60 }); + assert.deepEqual(span.centre, { x: 20, y: 40 }); + assert.equal(span.distance, Math.hypot(20, 40)); +}); + +test('fingers that move together carry the map with them', () => { + const before = pinchSpan({ x: 0, y: 0 }, { x: 100, y: 0 }); + const after = pinchSpan({ x: 30, y: 12 }, { x: 130, y: 12 }); + const update = pinchUpdate(before, after); + assert.equal(update.factor, 1); // same span: a pan, not a zoom + assert.deepEqual(update.pan, { x: 30, y: 12 }); +}); + +test('two pointers reported at one place leave the scale alone', () => { + // Not defensive: a factor of n/0 is Infinity and 0/0 is NaN, and either one + // in the view scale blanks the map permanently. + const together = pinchSpan({ x: 5, y: 5 }, { x: 5, y: 5 }); + assert.equal(together.distance, 0); + assert.equal(pinchUpdate(together, pinchSpan({ x: 0, y: 0 }, { x: 40, y: 0 })).factor, 1); +}); + +// -- the narrow layout --------------------------------------------------- + +// Below the breakpoint the stylesheet shows one pane at a time and app.mjs +// navigates to the map on a selection. Neither half fails loudly if they +// disagree about where "narrow" starts, so the seams are checked here. + +test('the stylesheet switches to one pane at the width layout.mjs uses', () => { + const css = read('style.css'); + const widths = [...css.matchAll(/@media \(max-width: (\d+)px\)/g)].map((m) => Number(m[1])); + assert.ok( + widths.includes(NARROW_MAX_PX), + `style.css has no @media (max-width: ${NARROW_MAX_PX}px); found ${widths.join(', ')}`, + ); +}); + +test('the tab bar and the panes address each other by the same names', () => { + const html = read('index.html'); + const panes = [...html.matchAll(/class="pane[^"]*" data-pane="([^"]+)"/g)].map((m) => m[1]); + const tabs = [...html.matchAll(/]*data-pane="([^"]+)"/g)].map((m) => m[1]); + assert.deepEqual(panes, ['roster', 'map', 'detail']); + // A pane with no tab is simply unreachable on a phone, and nothing says so. + assert.deepEqual([...tabs].sort(), [...panes].sort()); +}); + +test('exactly one pane starts active, so a phone opens on something', () => { + const html = read('index.html'); + const active = [...html.matchAll(/class="pane [^"]*\bactive\b[^"]*"/g)]; + assert.equal(active.length, 1); +}); + +test('the map canvas takes its own touch gestures', () => { + // Without `touch-action: none` the browser consumes the drag and the pinch + // before the canvas sees a single pointer event. + const css = read('style.css'); + const canvas = css.slice(css.indexOf('#map-canvas {')); + assert.match(canvas.slice(0, canvas.indexOf('}')), /touch-action:\s*none/); +});