fix(street-level): expanded photo leaves with panel-hiding modes; EXPAND on iPhone; pill when Mapillary is off

Three regressions from the slimming, found in review:

- Clean View, recording and the cockpit hid the old expanded viewer with
  CSS; native fullscreen sits above all of that, so the photo stayed over the
  globe (and in recordings). While expanded, the panel now watches the
  <body> classes of those modes and leaves full screen when one starts.
- iPhone Safari has no element fullscreen, so EXPAND had disappeared there.
  Where requestFullscreen is missing but popovers exist, EXPAND opens the
  viewer as a top-layer popover sized to the viewport (it stays in the
  panel's DOM; the popover attribute is set only while open, since a closed
  popover is hidden). Checked in Chrome: the popover fills the viewport over
  the panel's backdrop filter.
- Without the provider chips, a layer that was on with Mapillary switched off
  by a share link or tool (`0.m.0`) showed ON and drew nothing. The pill now
  reads OFF with a hint, and pressing it brings Mapillary back without
  switching the layer off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-07 23:57:06 +04:00
1 parent 8706cafdd1
commit 07a239fd82
5 files changed
+231 -29

No files matched your search

+7 -3
View File
@@ -2963,9 +2963,13 @@ MapillaryJS loads on the first photo. Opening one frames it with a single
camera flight through the application's deferred navigation (`begin` / camera flight through the application's deferred navigation (`begin` /
`reassert`): it releases tracking, is refused in the cockpit, and is skipped `reassert`): it releases tracking, is refused in the cockpit, and is skipped
if newer navigation took the camera while the photo loaded; closing the photo if newer navigation took the camera while the photo loaded; closing the photo
cancels it. EXPAND is the browser's Fullscreen API on the viewer element cancels it. EXPAND is the browser's Fullscreen API on the viewer element; on
(hidden where the API is missing). At phone width the viewer height comes from iPhone, which has no element fullscreen, the viewer opens as a top-layer
the rail band so the whole photo fits. popover instead (hidden where neither API exists). Clean View, recording and
the cockpit take an expanded photo out of full screen. With the layer on but
Mapillary switched off by a link or tool, the header pill reads OFF and turns
Mapillary back on. At phone width the viewer height comes from the rail band
so the whole photo fits.
Browser gates: `npm run qa:street-level -- --url <server>` runs against real Browser gates: `npm run qa:street-level -- --url <server>` runs against real
Mapillary with a token; `npm run qa:street-level:fixtures` answers every Mapillary with a token; `npm run qa:street-level:fixtures` answers every
+86 -19
View File
@@ -5,6 +5,8 @@ import {
} from './streetLevelPresentation.js'; } from './streetLevelPresentation.js';
const RENDER_MODE_KEY = 'gev:street-level:render-mode'; const RENDER_MODE_KEY = 'gev:street-level:render-mode';
/** <body> classes of the modes that hide every panel; an expanded photo leaves with them. */
const PANEL_HIDING_MODES = ['ui-clean-view', 'recording-mode', 'cockpit-mode'];
const RADIO_STEPS = Object.freeze({ const RADIO_STEPS = Object.freeze({
ArrowRight: 1, ArrowRight: 1,
ArrowDown: 1, ArrowDown: 1,
@@ -108,10 +110,24 @@ export class StreetLevelControls {
); );
this.listen(el.viewerClose, 'click', () => this.layer.closeViewer?.()); this.listen(el.viewerClose, 'click', () => this.layer.closeViewer?.());
// EXPAND is the browser's own fullscreen: it handles Esc, focus and the // EXPAND is the browser's own fullscreen: it handles Esc, focus and the
// rest of the page. Without the API the button is simply not offered. // rest of the page. iPhone Safari has no element fullscreen, so there the
if (typeof el.viewerWrap?.requestFullscreen === 'function') { // viewer opens as a top-layer popover over the page instead (Esc and the
this.listen(el.viewerExpand, 'click', () => this._toggleFullscreen()); // button close it). With neither, the button is not offered.
this.listen(document, 'fullscreenchange', () => this._syncFullscreen()); this._expandMode =
typeof el.viewerWrap?.requestFullscreen === 'function'
? 'fullscreen'
: typeof el.viewerWrap?.showPopover === 'function'
? 'popover'
: null;
this._popoverOpen = false;
if (this._expandMode) {
this.listen(el.viewerExpand, 'click', () => this._toggleExpanded());
if (this._expandMode === 'fullscreen')
this.listen(document, 'fullscreenchange', () => this._syncExpanded());
else
this.listen(el.viewerWrap, 'toggle', (event) =>
this._onPopoverToggle(event),
);
} else setProp(el.viewerExpand, 'hidden', true); } else setProp(el.viewerExpand, 'hidden', true);
for (const button of el.renderButtons) { for (const button of el.renderButtons) {
this.listen(button, 'click', () => { this.listen(button, 'click', () => {
@@ -180,10 +196,13 @@ export class StreetLevelControls {
async _toggleEnabled() { async _toggleEnabled() {
const enabled = this.actions.isEnabled?.() === true; const enabled = this.actions.isEnabled?.() === true;
// The pill is the only switch: switching on also brings back Mapillary // The pill is the only switch. A share link or tool can switch Mapillary
// if a share link or a tool turned it off, or the layer would draw nothing. // off under a layer that is on; the pill then reads OFF, and switching on
if (!enabled && this._state?.providerOn === false) // brings Mapillary back (and the layer, if it is off too).
if (this._state?.providerOn === false) {
this._setParams({ mapillary: true }); this._setParams({ mapillary: true });
if (enabled) return;
}
try { try {
await this.actions.setEnabled?.(!enabled); await this.actions.setEnabled?.(!enabled);
} catch (error) { } catch (error) {
@@ -198,27 +217,74 @@ export class StreetLevelControls {
else this.layer.setParams?.(params); else this.layer.setParams?.(params);
} }
isViewerFullscreen() { /** Whether EXPAND has the viewer full screen (or, on iPhone, open over the page). */
isViewerExpanded() {
const wrap = this._elements.viewerWrap; const wrap = this._elements.viewerWrap;
return Boolean(wrap) && document.fullscreenElement === wrap; if (!wrap) return false;
return this._expandMode === 'popover'
? this._popoverOpen
: document.fullscreenElement === wrap;
} }
async _toggleFullscreen() { async _toggleExpanded() {
const wrap = this._elements.viewerWrap;
try { try {
if (this.isViewerFullscreen()) await document.exitFullscreen(); if (this.isViewerExpanded()) this._exitExpanded();
else await this._elements.viewerWrap.requestFullscreen(); else if (this._expandMode === 'fullscreen')
await wrap.requestFullscreen();
else {
// Only while open: a closed popover is hidden even inside the panel.
wrap.popover = 'auto';
wrap.showPopover();
}
} catch { } catch {
/* refused (no user gesture, policy): the panel view stays */ /* refused (no user gesture, policy): the panel view stays */
} }
} }
_exitFullscreen() { _exitExpanded() {
if (this.isViewerFullscreen()) document.exitFullscreen().catch(() => {}); if (!this.isViewerExpanded()) return;
if (this._expandMode === 'popover') this._elements.viewerWrap.hidePopover();
else document.exitFullscreen().catch(() => {});
} }
/** Mirror the browser's fullscreen state on EXPAND and refit the viewer. */ _onPopoverToggle(event) {
_syncFullscreen() { this._popoverOpen = event.newState === 'open';
const on = this.isViewerFullscreen(); if (!this._popoverOpen)
this._elements.viewerWrap.removeAttribute('popover');
this._syncExpanded();
}
/**
* While expanded, leave when Clean View, recording or the cockpit starts:
* they hide every panel, and the photo would stay over the globe (and in a
* recording).
*/
_watchPanelHidingModes(on) {
const body = document.body;
if (!on || !body) {
this._modesObserver?.disconnect();
this._modesObserver = null;
return;
}
const leaveIfHiding = () => {
if (PANEL_HIDING_MODES.some((mode) => body.classList.contains(mode)))
this._exitExpanded();
};
if (!this._modesObserver && typeof MutationObserver === 'function') {
this._modesObserver = new MutationObserver(leaveIfHiding);
this._modesObserver.observe(body, {
attributes: true,
attributeFilter: ['class'],
});
}
leaveIfHiding();
}
/** Mirror the expanded state on EXPAND, watch the panel-hiding modes, refit. */
_syncExpanded() {
const on = this.isViewerExpanded();
this._watchPanelHidingModes(on);
const button = this._elements.viewerExpand; const button = this._elements.viewerExpand;
if (button) { if (button) {
setProp( setProp(
@@ -344,7 +410,7 @@ export class StreetLevelControls {
const el = this._elements; const el = this._elements;
const { viewer } = view; const { viewer } = view;
const wrap = el.viewerWrap; const wrap = el.viewerWrap;
if (!viewer.open) this._exitFullscreen(); if (!viewer.open) this._exitExpanded();
if (wrap) { if (wrap) {
// Closing the image (its × button) hides the wrap: move focus out // Closing the image (its × button) hides the wrap: move focus out
// first, or it drops to <body>. // first, or it drops to <body>.
@@ -398,7 +464,8 @@ export class StreetLevelControls {
destroy() { destroy() {
if (this.destroyed) return; if (this.destroyed) return;
this.destroyed = true; this.destroyed = true;
this._exitFullscreen(); this._exitExpanded();
this._watchPanelHidingModes(false);
this.listeners.abort(); this.listeners.abort();
this._resizeObserver?.disconnect(); this._resizeObserver?.disconnect();
this._resizeObserver = null; this._resizeObserver = null;
+114 -3
View File
@@ -73,6 +73,10 @@ class FakeNode {
getAttribute(key) { getAttribute(key) {
return this.attributes.get(key) ?? null; return this.attributes.get(key) ?? null;
} }
removeAttribute(key) {
mutations.count++;
this.attributes.delete(key);
}
appendChild(child) { appendChild(child) {
child.parent = this; child.parent = this;
this.children.push(child); this.children.push(child);
@@ -131,6 +135,7 @@ class FakeNode {
type: event.type, type: event.type,
target: this, target: this,
key: event.key, key: event.key,
newState: event.newState,
preventDefault() {}, preventDefault() {},
}; };
for (let node = this; node; node = event.bubbles ? node.parent : null) for (let node = this; node; node = event.bubbles ? node.parent : null)
@@ -145,9 +150,10 @@ class FakeNode {
/** /**
* The Street Level panel's body, as layer-panels.html lays it out, on a page * The Street Level panel's body, as layer-panels.html lays it out, on a page
* with the Fullscreen API unless `fullscreen` is false. * with the Fullscreen API unless `fullscreen` is false, and the Popover API
* if `popover` is true (iPhone Safari: popovers, no element fullscreen).
*/ */
function panelDom({ fullscreen = true } = {}) { function panelDom({ fullscreen = true, popover = false } = {}) {
const document = new FakeNode(null, '#document'); const document = new FakeNode(null, '#document');
Object.assign(document, { Object.assign(document, {
ownerDocument: document, ownerDocument: document,
@@ -170,6 +176,19 @@ function panelDom({ fullscreen = true } = {}) {
add(root, 'button', { id: 'sl-status' }); add(root, 'button', { id: 'sl-status' });
const wrap = add(root, 'div', { id: 'sl-viewer-wrap' }); const wrap = add(root, 'div', { id: 'sl-viewer-wrap' });
if (fullscreen) wrap.requestFullscreen = async () => fullscreenTo(wrap); if (fullscreen) wrap.requestFullscreen = async () => fullscreenTo(wrap);
if (popover) {
// The browser fires `toggle` after the popover opens or closes.
const toggleTo = (newState) =>
setTimeout(() => wrap.dispatchEvent({ type: 'toggle', newState }), 0);
wrap.showPopover = () => {
wrap.popoverOpen = true;
toggleTo('open');
};
wrap.hidePopover = () => {
wrap.popoverOpen = false;
toggleTo('closed');
};
}
const expand = add(wrap, 'button', { id: 'sl-viewer-expand' }); const expand = add(wrap, 'button', { id: 'sl-viewer-expand' });
add(expand, 'span', { classes: ['sl-btn-icon'] }); add(expand, 'span', { classes: ['sl-btn-icon'] });
add(expand, 'span', { classes: ['sl-btn-text'] }); add(expand, 'span', { classes: ['sl-btn-text'] });
@@ -423,6 +442,25 @@ test('the header pill switches the layer, bringing back Mapillary switched off e
dark.controls.destroy(); dark.controls.destroy();
})); }));
test('a layer that is on with Mapillary switched off reads OFF, and the pill brings Mapillary back', () =>
withDom(async (dom) => {
const status = dom.root.querySelector('#sl-status');
const { calls, controls } = stubPanel(dom, uiState({ on: false }));
assert.equal(status.textContent, 'OFF');
assert.equal(status.getAttribute('aria-pressed'), 'false');
assert.match(
dom.root.querySelector('#sl-coverage-meta').textContent,
/Mapillary is off in this view/,
);
status.click();
await settle();
assert.deepEqual(calls.setParams, [
[{ mapillary: true }, { origin: 'user' }],
]);
assert.deepEqual(calls.setEnabled, [], 'the layer itself stays on');
controls.destroy();
}));
test('under KEY REQUIRED the filters are gated and the error line says how to add the key', () => test('under KEY REQUIRED the filters are gated and the error line says how to add the key', () =>
withDom(async (dom) => { withDom(async (dom) => {
const { controls } = stubPanel(dom, uiState({ keyRequired: true })); const { controls } = stubPanel(dom, uiState({ keyRequired: true }));
@@ -511,7 +549,7 @@ test('an image closed while full screen leaves full screen', () =>
controls.destroy(); controls.destroy();
})); }));
test('without the Fullscreen API there is no EXPAND button', () => test('without the Fullscreen or Popover API there is no EXPAND button', () =>
withDom( withDom(
async (dom) => { async (dom) => {
const { controls } = stubPanel(dom, uiState({ open: true })); const { controls } = stubPanel(dom, uiState({ open: true }));
@@ -521,6 +559,79 @@ test('without the Fullscreen API there is no EXPAND button', () =>
{ fullscreen: false }, { fullscreen: false },
)); ));
test('on iPhone (no element fullscreen) EXPAND opens the viewer as a popover over the page', () =>
withDom(
async (dom) => {
const { controls } = stubPanel(dom, uiState({ open: true }));
const expand = dom.root.querySelector('#sl-viewer-expand');
assert.equal(expand.hidden, false, 'offered');
expand.click();
await settle();
await settle();
assert.equal(dom.wrap.popoverOpen, true);
assert.equal(dom.root.contains(dom.wrap), true, 'never leaves the panel');
assert.deepEqual(expandLabel(dom), ['SHRINK', 'true']);
expand.click();
await settle();
await settle();
assert.equal(dom.wrap.popoverOpen, false);
assert.equal(
dom.wrap.getAttribute('popover'),
null,
'a closed popover would be hidden inside the panel',
);
assert.deepEqual(expandLabel(dom), ['EXPAND', 'false']);
controls.destroy();
},
{ fullscreen: false, popover: true },
));
/** A MutationObserver stand-in whose callback the test fires. */
function fakeMutationObserver() {
const observers = new Set();
class FakeObserver {
constructor(callback) {
this.callback = callback;
}
observe() {
observers.add(this);
}
disconnect() {
observers.delete(this);
}
}
return {
FakeObserver,
observers,
fire: () => [...observers].forEach((o) => o.callback([])),
};
}
for (const mode of ['ui-clean-view', 'recording-mode', 'cockpit-mode'])
test(`entering ${mode} takes the photo out of full screen`, () =>
withDom(async (dom) => {
const saved = globalThis.MutationObserver;
const watch = fakeMutationObserver();
globalThis.MutationObserver = watch.FakeObserver;
try {
const { controls } = stubPanel(dom, uiState({ open: true }));
dom.root.querySelector('#sl-viewer-expand').click();
await settle();
await settle();
assert.equal(dom.document.fullscreenElement, dom.wrap);
assert.equal(watch.observers.size, 1, 'watching while expanded');
dom.document.body.classList.toggle(mode, true);
watch.fire();
await settle();
await settle();
assert.equal(dom.document.fullscreenElement, null);
assert.equal(watch.observers.size, 0, 'and not after');
controls.destroy();
} finally {
globalThis.MutationObserver = saved;
}
}));
/* ── Restores and keyboard rules ───────────────────────────────────────── */ /* ── Restores and keyboard rules ───────────────────────────────────────── */
test('a restored layer does not reopen a panel the user collapsed, and automatic opens are never stored (P2-1)', () => test('a restored layer does not reopen a panel the user collapsed, and automatic opens are never stored (P2-1)', () =>
+6 -1
View File
@@ -53,8 +53,11 @@ function formatDate(ms) {
} }
} }
/** Whether the layer draws: on, and Mapillary not switched off by a link or tool. */
const drawing = (state) => state.enabled === true && state.providerOn !== false;
function presentStatus(state) { function presentStatus(state) {
const pressed = state.enabled === true; const pressed = drawing(state);
const title = pressed ? 'Turn Street Level off' : 'Turn Street Level on'; const title = pressed ? 'Turn Street Level off' : 'Turn Street Level on';
if (state.keyRejected) if (state.keyRejected)
return { text: 'KEY REJECTED', tone: 'warn', pressed, title }; return { text: 'KEY REJECTED', tone: 'warn', pressed, title };
@@ -93,6 +96,8 @@ function presentViewer(state) {
function presentMeta(state) { function presentMeta(state) {
if (!state.enabled) return 'Switch Street Level on to draw its coverage.'; if (!state.enabled) return 'Switch Street Level on to draw its coverage.';
if (!drawing(state))
return 'Mapillary is off in this view. Switch Street Level on to show it.';
if (state.sequence.selectedId) if (state.sequence.selectedId)
return state.sequence.loading return state.sequence.loading
? 'Loading this sequence…' ? 'Loading this sequence…'
+18 -3
View File
@@ -415,14 +415,29 @@
} }
/* ── Full screen (EXPAND) ────────────────────────────────────────────────── */ /* ── Full screen (EXPAND) ────────────────────────────────────────────────── */
/* The browser lifts the viewer into the top layer; the photo takes the rest. */ /* The browser lifts the viewer into the top layer; the photo takes the rest.
iPhone Safari has no element fullscreen, so there the viewer is a popover
sized to the viewport instead. */
.sl-viewer-wrap:fullscreen { .sl-viewer-wrap:fullscreen {
border: 0; border: 0;
border-radius: 0; border-radius: 0;
} }
.sl-viewer-wrap:fullscreen .sl-viewer-stage, .sl-viewer-wrap:popover-open {
.sl-viewer-wrap:fullscreen .sl-viewer { position: fixed;
inset: 0;
width: 100vw;
height: 100dvh;
max-width: none;
max-height: none;
margin: 0;
padding: 0;
border: 0;
border-radius: 0;
}
.sl-viewer-wrap:is(:fullscreen, :popover-open) .sl-viewer-stage,
.sl-viewer-wrap:is(:fullscreen, :popover-open) .sl-viewer {
flex: 1 1 auto; flex: 1 1 auto;
height: auto; height: auto;
min-height: 0; min-height: 0;