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` /
`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
cancels it. EXPAND is the browser's Fullscreen API on the viewer element
(hidden where the API is missing). At phone width the viewer height comes from
the rail band so the whole photo fits.
cancels it. EXPAND is the browser's Fullscreen API on the viewer element; on
iPhone, which has no element fullscreen, the viewer opens as a top-layer
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
Mapillary with a token; `npm run qa:street-level:fixtures` answers every
+86 -19
View File
@@ -5,6 +5,8 @@ import {
} from './streetLevelPresentation.js';
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({
ArrowRight: 1,
ArrowDown: 1,
@@ -108,10 +110,24 @@ export class StreetLevelControls {
);
this.listen(el.viewerClose, 'click', () => this.layer.closeViewer?.());
// 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.
if (typeof el.viewerWrap?.requestFullscreen === 'function') {
this.listen(el.viewerExpand, 'click', () => this._toggleFullscreen());
this.listen(document, 'fullscreenchange', () => this._syncFullscreen());
// rest of the page. iPhone Safari has no element fullscreen, so there the
// viewer opens as a top-layer popover over the page instead (Esc and the
// button close it). With neither, the button is not offered.
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);
for (const button of el.renderButtons) {
this.listen(button, 'click', () => {
@@ -180,10 +196,13 @@ export class StreetLevelControls {
async _toggleEnabled() {
const enabled = this.actions.isEnabled?.() === true;
// The pill is the only switch: switching on also brings back Mapillary
// if a share link or a tool turned it off, or the layer would draw nothing.
if (!enabled && this._state?.providerOn === false)
// The pill is the only switch. A share link or tool can switch Mapillary
// off under a layer that is on; the pill then reads OFF, and switching on
// brings Mapillary back (and the layer, if it is off too).
if (this._state?.providerOn === false) {
this._setParams({ mapillary: true });
if (enabled) return;
}
try {
await this.actions.setEnabled?.(!enabled);
} catch (error) {
@@ -198,27 +217,74 @@ export class StreetLevelControls {
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;
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 {
if (this.isViewerFullscreen()) await document.exitFullscreen();
else await this._elements.viewerWrap.requestFullscreen();
if (this.isViewerExpanded()) this._exitExpanded();
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 {
/* refused (no user gesture, policy): the panel view stays */
}
}
_exitFullscreen() {
if (this.isViewerFullscreen()) document.exitFullscreen().catch(() => {});
_exitExpanded() {
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. */
_syncFullscreen() {
const on = this.isViewerFullscreen();
_onPopoverToggle(event) {
this._popoverOpen = event.newState === 'open';
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;
if (button) {
setProp(
@@ -344,7 +410,7 @@ export class StreetLevelControls {
const el = this._elements;
const { viewer } = view;
const wrap = el.viewerWrap;
if (!viewer.open) this._exitFullscreen();
if (!viewer.open) this._exitExpanded();
if (wrap) {
// Closing the image (its × button) hides the wrap: move focus out
// first, or it drops to <body>.
@@ -398,7 +464,8 @@ export class StreetLevelControls {
destroy() {
if (this.destroyed) return;
this.destroyed = true;
this._exitFullscreen();
this._exitExpanded();
this._watchPanelHidingModes(false);
this.listeners.abort();
this._resizeObserver?.disconnect();
this._resizeObserver = null;
+114 -3
View File
@@ -73,6 +73,10 @@ class FakeNode {
getAttribute(key) {
return this.attributes.get(key) ?? null;
}
removeAttribute(key) {
mutations.count++;
this.attributes.delete(key);
}
appendChild(child) {
child.parent = this;
this.children.push(child);
@@ -131,6 +135,7 @@ class FakeNode {
type: event.type,
target: this,
key: event.key,
newState: event.newState,
preventDefault() {},
};
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
* 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');
Object.assign(document, {
ownerDocument: document,
@@ -170,6 +176,19 @@ function panelDom({ fullscreen = true } = {}) {
add(root, 'button', { id: 'sl-status' });
const wrap = add(root, 'div', { id: 'sl-viewer-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' });
add(expand, 'span', { classes: ['sl-btn-icon'] });
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();
}));
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', () =>
withDom(async (dom) => {
const { controls } = stubPanel(dom, uiState({ keyRequired: true }));
@@ -511,7 +549,7 @@ test('an image closed while full screen leaves full screen', () =>
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(
async (dom) => {
const { controls } = stubPanel(dom, uiState({ open: true }));
@@ -521,6 +559,79 @@ test('without the Fullscreen API there is no EXPAND button', () =>
{ 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 ───────────────────────────────────────── */
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) {
const pressed = state.enabled === true;
const pressed = drawing(state);
const title = pressed ? 'Turn Street Level off' : 'Turn Street Level on';
if (state.keyRejected)
return { text: 'KEY REJECTED', tone: 'warn', pressed, title };
@@ -93,6 +96,8 @@ function presentViewer(state) {
function presentMeta(state) {
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)
return state.sequence.loading
? 'Loading this sequence…'
+18 -3
View File
@@ -415,14 +415,29 @@
}
/* ── 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 {
border: 0;
border-radius: 0;
}
.sl-viewer-wrap:fullscreen .sl-viewer-stage,
.sl-viewer-wrap:fullscreen .sl-viewer {
.sl-viewer-wrap:popover-open {
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;
height: auto;
min-height: 0;