fix(street-level): the panel respects restores, modes, focus and keys

From gekh's review of #768, each with a test that fails before:

- P2: the panel no longer reopens on every page load. The shell reports
  who asked for Street Level to switch on; only a user, voice or tool
  request (isExplicitLayerStateOrigin) or an opening photo opens it, and
  those automatic opens are never stored. Without the hook, a stored or
  shared "collapsed" choice wins during the restore window.
- P2: the expanded photo hides in Clean View, recording and the cockpit,
  and holds neither Esc nor Tab while hidden.
- Esc/Tab are taken only from the viewer, body or globe, not app-wide;
  FIT/FILL show the right choice while expanded; a 0x0 viewer is never
  resized (no z=NaN tile request); the expanded viewer keeps the panel's
  CSS tokens; KEY REQUIRED hides the raw "no_key" code.
- Share links carry the panel's collapsed state (ui token t); the Cyber
  theme frames the panel like its rail peers (the pinned Cyber selector
  tests now list it).
- Accessibility: radiogroups move with arrow keys and are one tab stop,
  their focus ring is no longer clipped, and the provider chips sit
  outside the disabled fieldset so the "how to add the key" hint stays
  reachable (a gated chip explains instead of toggling).
- Renders write only changed attributes (the rail's MutationObserver
  relaid out on every write); one coalesced resize per open; the legacy
  render-mode key migration is gone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-05 18:35:30 +01:00
1 parent bbe7aff242
commit 48276450ec
16 files changed
+690 -140

No files matched your search

+4 -2
View File
@@ -2935,8 +2935,10 @@ its criteria cannot be silently ignored.
Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment.
Street Level lives in the right context rail next to CCTV as an ordinary
collapsible panel (`#street-level-panel`, share token `0`, option owner
`street-level`) and starts collapsed. It is modelled on the iD editor's photo
collapsible panel (`#street-level-panel`, layer share token `0`, option owner
`street-level`, panel `ui` token `t`) and starts collapsed. It opens itself
only when the user (or voice, or a tool) switches the layer on or a photo
opens, never on a saved-state restore, and never stores those automatic opens. It is modelled on the iD editor's photo
overlay: one panel, a PROVIDERS chip per registered imagery provider, shared
360°/flat and captured-since filters (stored as relative days so a link keeps
its meaning), one viewer host, and one on-globe credit per active provider.
+18
View File
@@ -184,6 +184,24 @@ test('a collapsed Recent Imagery panel survives the share-link round trip beside
] });
});
test('a collapsed Street Level panel survives the share-link round trip (review P3)', () => {
const manager = makeManager();
manager.setPanelStateProvider(() => ({ specs: [
{ id: 'cctv-panel', collapsed: false },
{ id: 'street-level-panel', collapsed: true },
] }));
clearTimeout(manager._debounceTimer);
manager._updateHash();
const params = new URLSearchParams(window.location.hash.slice(1));
assert.equal(params.get('ui'), 'v.c.0_t.c.1');
const restored = makeManager(`#v=2&lat=10&lon=20&ui=${params.get('ui')}`)
.parseInitialHash();
assert.deepEqual(restored.panelState, { specs: [
{ id: 'cctv-panel', collapsed: false, pinned: null },
{ id: 'street-level-panel', collapsed: true, pinned: null },
] });
});
test('camera-only, partial, and malformed panel shares remain valid incoming state', () => {
const cameraOnly = makeManager('#lat=10&lon=20').parseInitialHash();
assert.ok(cameraOnly);
+1
View File
@@ -34,6 +34,7 @@ const SHARE_PANEL_STATE_REGISTRY = Object.freeze([
{ id: 'data-panel', token: 'd', pinnable: false },
{ id: 'cctv-panel', token: 'v', pinnable: false },
{ id: 'recent-imagery-panel', token: 'i', pinnable: false },
{ id: 'street-level-panel', token: 't', pinnable: false },
{ id: 'radio-panel', token: 'r', pinnable: false },
{ id: 'scene-panel', token: 's', pinnable: false },
{ id: 'global-context-panel', token: 'g', pinnable: false },
+77 -1
View File
@@ -83,6 +83,15 @@ test('the keyless state gates the controls rather than leaving dead buttons', ()
);
assert.match(html, /<fieldset id="sl-controls"/);
assert.match(html, /<div id="sl-provider-chips" class="sl-chips"><\/div>/);
// The gate holds the filters only: the provider chips stay reachable, so
// their tooltip can say how to add the key (gekh P3).
const gate = html.slice(
html.indexOf('<fieldset id="sl-controls"'),
html.indexOf('</fieldset>', html.indexOf('<fieldset id="sl-controls"')),
);
assert.doesNotMatch(gate, /sl-provider-chips/);
assert.match(gate, /data-sl-pano="all"/);
assert.match(gate, /id="sl-since"/);
assert.match(
html,
/<input id="sl-since" class="sl-range" type="range" min="0" max="8" step="1"/,
@@ -136,7 +145,7 @@ test('on phones the viewer is sized from the rail band, not its aspect ratio', (
});
test('the viewer comes first and the panel is a portable, resizable window', () => {
const controlsBlock = html.slice(html.indexOf('<fieldset id="sl-controls"'));
const controlsBlock = html.slice(html.indexOf('<div class="sl-main">'));
assert.ok(
controlsBlock.indexOf('id="sl-viewer-wrap"') <
controlsBlock.indexOf('class="sl-settings"'),
@@ -178,3 +187,70 @@ test('the viewer comes first and the panel is a portable, resizable window', ()
/panelId === 'street-level-panel'\)\s*this\._streetLevelControls\?\.onPanelResized\(\)/,
);
});
/* ── gekh review on #768 ───────────────────────────────────────────────── */
test('Clean View, recording and the cockpit hide the expanded viewer on <body> (P2-5)', () => {
// EXPAND moves the viewer out of every panel, past the panel-hiding rules.
assert.match(
read('src/ui/styles/controls.css'),
/body\.ui-clean-view \.sl-viewer-wrap-expanded \{\s*opacity: 0 !important;\s*visibility: hidden !important;/,
);
assert.match(
read('src/ui/styles/recording.css'),
/body\.recording-mode \.sl-viewer-wrap-expanded \{\s*opacity: 0 !important;\s*visibility: hidden !important;/,
);
assert.match(
read('src/ui/styles/cockpit.css'),
/body\.cockpit-mode\s*:is\([^)]*\.sl-viewer-wrap-expanded\s*\)\s*\{\s*display: none !important;/,
);
// Hidden, it must not keep holding Esc and Tab.
assert.match(controls, /_isDialogShown\(\)/);
});
test('the expanded viewer keeps the panel tokens (P3)', () => {
assert.match(
panelCss,
/#street-level-panel,\s*\.sl-viewer-wrap \{\s*--sl-green:[^}]*--sl-text:[^}]*--sl-text-small:/,
);
});
test('radiogroups are one tab stop and their focus ring is not clipped (P3)', () => {
const seg = /\.sl-seg \{([^}]*)\}/.exec(panelCss)[1];
assert.doesNotMatch(seg, /overflow/);
assert.match(
panelCss,
/\.sl-seg-btn:focus-visible \{[^}]*outline: 1px solid/,
);
for (const [group, checked] of [
['data-sl-render', 'letterbox'],
['data-sl-pano', 'all'],
]) {
const radios = [
...html.matchAll(
new RegExp(`<button [^>]*${group}="([a-z]+)"[^>]*>`, 'g'),
),
];
assert.ok(radios.length >= 2, group);
for (const [tag, value] of radios)
assert.equal(
/tabindex="-1"/.test(tag),
value !== checked,
`${group}="${value}" roving tab stop`,
);
}
});
test('the Cyber theme frames the Street Level panel like its rail peers (P3)', () => {
const cyber = read('src/ui/styles/cyber.css');
const inner = (cyber.match(/\.recent-imagery-panel-inner,/g) || []).length;
const panel = (cyber.match(/#recent-imagery-panel,/g) || []).length;
assert.ok(inner > 0 && panel > 0);
assert.equal(
(cyber.match(/\.street-level-panel-inner,/g) || []).length,
inner,
);
assert.equal((cyber.match(/#street-level-panel,/g) || []).length, panel);
// A theme header rule must be able to outrank the panel's own.
assert.doesNotMatch(panelCss, /#street-level-panel \.panel-header/);
});
+7
View File
@@ -954,6 +954,13 @@ export class StyleManager extends ShellFacade {
this.setPanelCollapsed('street-level-panel', collapsed, options),
dockPanel: () => this._panelChrome.dockPanel('street-level-panel'),
showToast: (message) => this._showToast(message),
// Who asked for Street Level to switch on: only a user, voice or tool
// request opens the panel, never a saved-state restore.
subscribeEnableRequests: (listener) =>
this._dataManager?.subscribeVisibilityRequests?.((change) => {
if (change?.layerId === 'street-level' && change.enabled)
listener(change.origin);
}) || null,
},
});
this._streetLevelControls.connect();
+2 -2
View File
@@ -520,11 +520,11 @@ test('Cyber side panels share one width and one framed surface material', () =>
);
assert.match(
cyberStyles,
/#right-context-rail\s+:is\(\s*\.cctv-panel-inner,\s*\.weather-panel-inner,\s*\.recent-imagery-panel-inner,\s*\.global-context-panel-inner\s*\) \{\s*padding-right: 8px;/,
/#right-context-rail\s+:is\(\s*\.cctv-panel-inner,\s*\.weather-panel-inner,\s*\.recent-imagery-panel-inner,\s*\.street-level-panel-inner,\s*\.global-context-panel-inner\s*\) \{\s*padding-right: 8px;/,
);
assert.match(
cyberStyles,
/#right-context-rail[\s\S]*?:is\(\s*#cctv-panel,\s*#weather-panel,\s*#recent-imagery-panel,\s*#global-context-panel\s*\)\.collapsed[\s\S]*?> \.panel-header \{[\s\S]*?height: 48px;[\s\S]*?padding: 0 10px 0 14px;/,
/#right-context-rail[\s\S]*?:is\(\s*#cctv-panel,\s*#weather-panel,\s*#recent-imagery-panel,\s*#street-level-panel,\s*#global-context-panel\s*\)\.collapsed[\s\S]*?> \.panel-header \{[\s\S]*?height: 48px;[\s\S]*?padding: 0 10px 0 14px;/,
);
assert.doesNotMatch(
cyberStyles,
+208 -69
View File
@@ -1,14 +1,29 @@
import { syncChipGroup } from './chipGroup.js';
import { isExplicitLayerStateOrigin } from '../data/layerState.js';
import {
presentStreetLevelPanel,
SINCE_STOPS,
} from './streetLevelPresentation.js';
const RENDER_MODE_KEY = 'gev:street-level:render-mode';
/** Key the panel used before it became provider-neutral. */
const LEGACY_RENDER_MODE_KEY = 'gev:mapillary:render-mode';
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
/** Arrow keys inside a radiogroup: the step to the next radio. */
const RADIO_STEPS = Object.freeze({
ArrowRight: 1,
ArrowDown: 1,
ArrowLeft: -1,
ArrowUp: -1,
});
// Renders run on every layer notification, and the rail's MutationObserver
// relays out on each attribute write: write only what changed.
const setProp = (node, key, value) => {
if (node && node[key] !== value) node[key] = value;
};
const setAttr = (node, key, value) => {
if (node && node.getAttribute(key) !== value) node.setAttribute(key, value);
};
/**
* Own the Street Level panel: the provider chips, the imagery filters, the
@@ -29,6 +44,17 @@ export class StreetLevelControls {
this._view = null;
this._wasEnabled = null;
this._wasStreetOpen = false;
// Until the user first touches the app, an off → on is the saved layer
// state being restored, not a user switching the layer on.
this._restoreWindow = true;
/**
* Whether the last request to switch the layer on came from the user
* (or voice, or a tool) rather than a restore; null when the shell does
* not report requests, and the restore window decides instead.
*/
this._explicitEnable = null;
this._unsubscribeEnableRequests = null;
this._resizeQueued = false;
this._wrapHome = null;
this._expandReturnFocus = null;
this._elements = this._collect();
@@ -37,6 +63,9 @@ export class StreetLevelControls {
_collect() {
const byId = (id) => this.root?.querySelector(`#${id}`) || null;
const all = (selector) => [
...(this.root?.querySelectorAll(selector) || []),
];
return {
status: byId('sl-status'),
controls: byId('sl-controls'),
@@ -56,6 +85,9 @@ export class StreetLevelControls {
imageWhen: byId('sl-image-when'),
imageLink: byId('sl-image-link'),
coverageMeta: byId('sl-coverage-meta'),
panoButtons: all('[data-sl-pano]'),
// Held, not re-queried: EXPAND moves them to <body> with the viewer.
renderButtons: all('[data-sl-render]'),
};
}
@@ -76,13 +108,21 @@ export class StreetLevelControls {
this.listen(el.providerChips, 'click', (event) => {
const button = event.target?.closest?.('.data-toggle-chip');
if (!button || button.disabled) return;
// Without a key the chips stay focusable for their tooltip; a click
// says the same thing rather than switching on a layer that cannot draw.
if (this._view?.controlsDisabled) {
if (button.title) this.actions.showToast?.(button.title);
return;
}
this._toggleProvider(button.dataset.chipId);
});
for (const button of this.root.querySelectorAll('[data-sl-pano]')) {
for (const button of el.panoButtons) {
this.listen(button, 'click', () =>
this._setParams({ pano: button.dataset.slPano }),
);
}
this._bindRadioGroup(el.panoButtons);
this._bindRadioGroup(el.renderButtons);
// The readout follows the thumb; coverage is rebuilt once, on release.
const sinceDays = () =>
SINCE_STOPS[Number(el.sinceRange?.value) || 0]?.days ?? 0;
@@ -101,7 +141,7 @@ export class StreetLevelControls {
this.listen(el.viewerExpand, 'click', () =>
this.setViewerExpanded(!this.isViewerExpanded(), { dock: true }),
);
for (const button of this.root.querySelectorAll('[data-sl-render]')) {
for (const button of el.renderButtons) {
this.listen(button, 'click', () => {
const mode = button.dataset.slRender;
this.layer.setViewerRenderMode?.(mode);
@@ -113,9 +153,7 @@ export class StreetLevelControls {
});
}
try {
const stored =
localStorage.getItem(RENDER_MODE_KEY) ??
localStorage.getItem(LEGACY_RENDER_MODE_KEY);
const stored = localStorage.getItem(RENDER_MODE_KEY);
if (stored === 'fill' || stored === 'letterbox')
this.layer.setViewerRenderMode?.(stored);
} catch {
@@ -128,21 +166,58 @@ export class StreetLevelControls {
this.listen(document, 'keydown', (event) => this._onDialogKey(event), {
capture: true,
});
const endRestoreWindow = () => {
this._restoreWindow = false;
};
for (const type of ['pointerdown', 'keydown'])
this.listen(document, type, endRestoreWindow, { capture: true });
// MapillaryJS only tracks window resizes; the panel resizes on its own.
if (typeof ResizeObserver === 'function' && el.viewer) {
let queued = false;
this._resizeObserver = new ResizeObserver(() => {
if (queued) return;
queued = true;
requestAnimationFrame(() => {
queued = false;
this.layer.resizeViewer?.();
});
});
this._resizeObserver = new ResizeObserver(() => this._requestResize());
this._resizeObserver.observe(el.viewer);
}
}
/**
* One radiogroup (ARIA radio pattern): a single tab stop, arrow keys move
* the selection, Home and End jump to the ends. Selecting is the radio's
* own click, so the keyboard takes the same path as the pointer.
*/
_bindRadioGroup(buttons) {
buttons.forEach((button, index) => {
this.listen(button, 'keydown', (event) => {
let next = null;
if (event.key in RADIO_STEPS) {
const count = buttons.length;
next = buttons[(index + RADIO_STEPS[event.key] + count) % count];
} else if (event.key === 'Home') next = buttons[0];
else if (event.key === 'End') next = buttons[buttons.length - 1];
if (!next) return;
event.preventDefault();
next.focus();
if (next !== button) next.click();
});
});
}
/**
* Refit the viewer once, on the next frame, however many asked. A hidden
* viewer measures 0×0 and would ask for tiles at z=NaN; the observer fires
* again once it has a size.
*/
_requestResize() {
if (this._resizeQueued) return;
this._resizeQueued = true;
requestAnimationFrame(() => {
this._resizeQueued = false;
const viewer = this._elements.viewer;
if (this.destroyed) return;
if (viewer && (viewer.clientWidth === 0 || viewer.clientHeight === 0))
return;
this.layer.resizeViewer?.();
});
}
async _ensureEnabled() {
if (this.actions.isEnabled?.()) return true;
try {
@@ -207,19 +282,24 @@ export class StreetLevelControls {
this._unsubscribe?.();
this._unsubscribe = null;
if (this.destroyed || !this.root) return;
this._unsubscribeEnableRequests?.();
this._unsubscribeEnableRequests =
this.actions.subscribeEnableRequests?.((origin) => {
this._explicitEnable = isExplicitLayerStateOrigin(origin);
}) || null;
this._unsubscribe = this.layer.subscribe?.((state) => this.render(state));
if (this.layer.getUIState) this.render(this.layer.getUIState());
}
/** The panel window was resized or docked again: refit the viewer. */
onPanelResized() {
requestAnimationFrame(() => this.layer.resizeViewer?.());
this._requestResize();
}
/** Ask the shell to open (or close) the rail panel. */
setCollapsed(collapsed, options = {}) {
this.actions.setPanelCollapsed?.(collapsed, options);
if (!collapsed) requestAnimationFrame(() => this.layer.resizeViewer?.());
if (!collapsed) this._requestResize();
}
isViewerExpanded() {
@@ -283,7 +363,7 @@ export class StreetLevelControls {
button.setAttribute('aria-label', on ? 'Shrink' : 'Expand');
}
if (!on && dock) this.actions.dockPanel?.();
requestAnimationFrame(() => this.layer.resizeViewer?.());
this._requestResize();
}
/** Park focus on a control that stays on screen while the viewer is hidden. */
@@ -294,18 +374,48 @@ export class StreetLevelControls {
target?.focus?.({ preventScroll: true });
}
/**
* The expanded viewer holds Esc and Tab only while it is on screen: Clean
* View, recording and the cockpit hide it with CSS.
*/
_isDialogShown() {
if (!this.isViewerExpanded()) return false;
const wrap = this._elements.viewerWrap;
return (
typeof wrap.checkVisibility !== 'function' ||
// `checkVisibilityCSS` is the option's name before Chrome 121.
wrap.checkVisibility({
visibilityProperty: true,
checkVisibilityCSS: true,
})
);
}
_onDialogKey(event) {
if (!this.isViewerExpanded()) return;
if (event.key !== 'Escape' && event.key !== 'Tab') return;
if (!this._isDialogShown()) return;
// Only keys meant for the viewer: from inside it, from nowhere (<body>)
// or from the globe. A field elsewhere (search) keeps its Esc and Tab.
const target = event.target;
const wrap = this._elements.viewerWrap;
if (
!wrap.contains(target) &&
target !== document.body &&
target !== document.documentElement &&
!target?.closest?.('#cesiumContainer')
)
return;
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
this.setViewerExpanded(false, { dock: true });
return;
}
if (event.key !== 'Tab') return;
const wrap = this._elements.viewerWrap;
// A radio off the roving tab stop (tabindex -1) is not a stop either.
const focusable = [...wrap.querySelectorAll(FOCUSABLE)].filter(
(node) => node.offsetParent !== null || node === document.activeElement,
(node) =>
node.getAttribute('tabindex') !== '-1' &&
(node.offsetParent !== null || node === document.activeElement),
);
if (!focusable.length) return;
const first = focusable[0];
@@ -332,25 +442,28 @@ export class StreetLevelControls {
this._renderProviders(view);
this._renderError(view);
this._renderFilters(view);
this._renderViewer(view, state);
this._renderViewer(view);
this._renderMeta(view);
this._reactToTransitions(view, state);
}
_renderHeader(view) {
const el = this._elements;
this.root.dataset.slEnabled = String(view.enabled);
setProp(this.root.dataset, 'slEnabled', String(view.enabled));
if (el.status) {
el.status.textContent = view.status.text;
el.status.className = `sl-status${view.status.tone ? ` is-${view.status.tone}` : ''}`;
el.status.setAttribute('aria-pressed', String(view.status.pressed));
el.status.title = view.status.title;
setProp(el.status, 'textContent', view.status.text);
setProp(
el.status,
'className',
`sl-status${view.status.tone ? ` is-${view.status.tone}` : ''}`,
);
setAttr(el.status, 'aria-pressed', String(view.status.pressed));
setProp(el.status, 'title', view.status.title);
}
}
_renderGate(view) {
const el = this._elements;
if (el.controls) el.controls.disabled = view.controlsDisabled;
setProp(this._elements.controls, 'disabled', view.controlsDisabled);
}
_renderProviders(view) {
@@ -360,17 +473,26 @@ export class StreetLevelControls {
_renderError(view) {
const el = this._elements;
if (!el.error) return;
el.error.hidden = !view.error;
if (el.errorText) el.errorText.textContent = view.error || '';
setProp(el.error, 'hidden', !view.error);
setProp(el.errorText, 'textContent', view.error || '');
}
/** Mark the checked radio of a group and make it the group's tab stop. */
_renderRadios(buttons, isChecked) {
for (const button of buttons) {
const checked = isChecked(button);
button.classList.toggle('is-active', checked);
setAttr(button, 'aria-checked', String(checked));
setAttr(button, 'tabindex', checked ? '0' : '-1');
}
}
_renderFilters(view) {
const el = this._elements;
for (const button of this.root.querySelectorAll('[data-sl-pano]')) {
const active = button.dataset.slPano === view.filter.pano;
button.classList.toggle('is-active', active);
button.setAttribute('aria-checked', String(active));
}
this._renderRadios(
el.panoButtons,
(button) => button.dataset.slPano === view.filter.pano,
);
if (el.sinceRange) {
// Never move the thumb under the user's hand; once the committed value
// matches the thumb, show the full readout with its cut-off date.
@@ -381,8 +503,8 @@ export class StreetLevelControls {
)
el.sinceRange.value = index;
if (el.sinceRange.value === index) {
if (el.sinceLabel) el.sinceLabel.textContent = view.since.label;
el.sinceRange.setAttribute('aria-valuetext', view.since.label);
setProp(el.sinceLabel, 'textContent', view.since.label);
setAttr(el.sinceRange, 'aria-valuetext', view.since.label);
}
}
// Rebuild when the swatches change, not only their count: one provider
@@ -407,7 +529,7 @@ export class StreetLevelControls {
}
}
_renderViewer(view, state) {
_renderViewer(view) {
const el = this._elements;
const { viewer } = view;
const wrap = el.viewerWrap;
@@ -421,49 +543,64 @@ export class StreetLevelControls {
wrap.contains(document.activeElement)
)
this._focusPanelControl();
wrap.hidden = !viewer.open;
setProp(wrap, 'hidden', !viewer.open);
}
if (!viewer.open && this.isViewerExpanded()) this.setViewerExpanded(false);
if (el.viewerPlaceholder) el.viewerPlaceholder.hidden = !viewer.loading;
setProp(el.viewerPlaceholder, 'hidden', !viewer.loading);
if (el.followBtn) {
el.followBtn.setAttribute('aria-pressed', String(viewer.follow.pressed));
el.followBtn.disabled = viewer.follow.disabled;
el.followBtn.title = viewer.follow.title;
setAttr(el.followBtn, 'aria-pressed', String(viewer.follow.pressed));
setProp(el.followBtn, 'disabled', viewer.follow.disabled);
setProp(el.followBtn, 'title', viewer.follow.title);
}
for (const button of this.root.querySelectorAll('[data-sl-render]')) {
const active = button.dataset.slRender === viewer.renderMode;
button.classList.toggle('is-active', active);
button.setAttribute('aria-checked', String(active));
}
// Track open → closed here too, so the next photo gets its resize.
const wasOpen = this._viewerWasOpen;
this._viewerWasOpen = state.street.open === true;
this._renderRadios(
el.renderButtons,
(button) => button.dataset.slRender === viewer.renderMode,
);
if (!viewer.open) return;
if (el.imageBy) el.imageBy.textContent = viewer.captionLeft;
if (el.imageWhen) el.imageWhen.textContent = viewer.captionRight;
setProp(el.imageBy, 'textContent', viewer.captionLeft);
setProp(el.imageWhen, 'textContent', viewer.captionRight);
if (el.imageLink) {
el.imageLink.hidden = !viewer.link;
if (viewer.link) el.imageLink.href = viewer.link;
if (viewer.linkLabel) el.imageLink.textContent = viewer.linkLabel;
setProp(el.imageLink, 'hidden', !viewer.link);
if (viewer.link) setAttr(el.imageLink, 'href', viewer.link);
if (viewer.linkLabel)
setProp(el.imageLink, 'textContent', viewer.linkLabel);
}
// Size changes reach the viewer through the ResizeObserver; resizing on
// every render ran each frame while a panorama was dragged. Opening is the
// one moment the element may not have reported a size yet.
if (state.street.open && !wasOpen) this.layer.resizeViewer?.();
}
_renderMeta(view) {
const el = this._elements;
if (el.coverageMeta) el.coverageMeta.textContent = view.meta;
setProp(this._elements.coverageMeta, 'textContent', view.meta);
}
/** Open the panel at the moments a user would look for it. */
/**
* Open the panel at the moments a user would look for it. These opens are
* never persisted: the stored collapse state stays the user's own choice.
*/
_reactToTransitions(view, state) {
const enabled = view.enabled;
if (enabled && this._wasEnabled === false) this.setCollapsed(false);
if (enabled && this._wasEnabled === false) {
// A restore that turns the saved layer back on must not reopen a panel
// the user (or a share link) chose to keep collapsed. With the shell's
// request origins the panel opens only for a user, voice or tool
// switch-on; without them, the restore window stands in.
const preference = this.root.dataset?.collapsedPreference;
const restoring =
this._explicitEnable === null
? this._restoreWindow &&
(preference === 'stored' || preference === 'share')
: !this._explicitEnable;
if (!restoring) this.setCollapsed(false, { persist: false });
this._restoreWindow = false;
this._explicitEnable = this._explicitEnable === null ? null : false;
}
this._wasEnabled = enabled;
if (state.street.open && !this._wasStreetOpen) this.setCollapsed(false);
this._wasStreetOpen = state.street.open === true;
// Size changes reach the viewer through the ResizeObserver; resizing on
// every render ran each frame while a panorama was dragged. Opening is the
// one moment the element may not have reported a size yet, and opening
// the panel asks for that one (coalesced) resize.
const open = state.street.open === true;
if (open && !this._wasStreetOpen)
this.setCollapsed(false, { persist: false });
this._wasStreetOpen = open;
}
destroy() {
@@ -475,6 +612,8 @@ export class StreetLevelControls {
this._resizeObserver = null;
this._unsubscribe?.();
this._unsubscribe = null;
this._unsubscribeEnableRequests?.();
this._unsubscribeEnableRequests = null;
this.layer.attachViewerHost?.(null);
}
}
+281 -42
View File
@@ -12,6 +12,13 @@ import {
/* ── A small DOM: just what the panel controls touch ───────────────────── */
/**
* Every write the page would see as a mutation: an attribute set or removed,
* or a reflected property assigned (even to the value it already has).
*/
const mutations = { count: 0 };
const REFLECTED = ['hidden', 'disabled', 'textContent', 'title', 'value'];
class FakeNode {
constructor(document, tag, { id = null, dataset = {}, classes = [] } = {}) {
this.listeners = new Map();
@@ -21,11 +28,21 @@ class FakeNode {
this.dataset = { ...dataset };
this.children = [];
this.parent = null;
this.hidden = false;
this.disabled = false;
this.textContent = '';
this.title = '';
this.value = '';
this.props = {
hidden: false,
disabled: false,
textContent: '',
title: '',
value: '',
};
for (const key of REFLECTED)
Object.defineProperty(this, key, {
get: () => this.props[key],
set: (value) => {
mutations.count++;
this.props[key] = value;
},
});
this.attributes = new Map();
const names = new Set(classes);
this.classList = {
@@ -51,6 +68,7 @@ class FakeNode {
Object.defineProperty(this, 'className', {
get: () => [...names].join(' '),
set: (value) => {
mutations.count++;
names.clear();
for (const name of String(value).split(/\s+/).filter(Boolean))
names.add(name);
@@ -61,13 +79,14 @@ class FakeNode {
return this.children.length;
}
setAttribute(key, value) {
mutations.count++;
this.attributes.set(key, String(value));
}
getAttribute(key) {
return this.attributes.get(key) ?? null;
}
removeAttribute(key) {
this.attributes.delete(key);
if (this.attributes.delete(key)) mutations.count++;
}
appendChild(child) {
return this.insertBefore(child, null);
@@ -104,10 +123,13 @@ class FakeNode {
focus() {
this.ownerDocument.activeElement = this;
}
/** `.class` or `#id`. */
closest(selector) {
const byId = /^#(.+)$/.exec(selector);
const name = selector.replace(/^\./, '');
for (let node = this; node; node = node.parent)
if (node.classList?.contains(name)) return node;
if (byId ? node.id === byId[1] : node.classList?.contains(name))
return node;
return null;
}
*walk() {
@@ -214,22 +236,13 @@ function panelDom() {
const add = (parent, tag, options) =>
parent.appendChild(new FakeNode(document, tag, options));
add(root, 'button', { id: 'sl-status' });
for (const id of [
'sl-provider-chips',
'sl-error',
'sl-error-text',
'sl-since',
'sl-since-label',
'sl-legend',
'sl-coverage-meta',
])
add(root, id === 'sl-legend' ? 'ul' : 'div', { id });
const controls = add(root, 'fieldset', { id: 'sl-controls' });
for (const pano of ['all', 'pano', 'flat'])
add(controls, 'button', { dataset: { slPano: pano } });
const main = add(root, 'div', { classes: ['sl-main'] });
// The viewer's own tool bar lives inside the wrap, as in the markup.
const wrap = add(controls, 'div', { id: 'sl-viewer-wrap' });
for (const id of ['sl-viewer-expand', 'sl-follow-btn', 'sl-viewer-close'])
const wrap = add(main, 'div', { id: 'sl-viewer-wrap' });
add(wrap, 'button', { id: 'sl-viewer-expand' });
for (const mode of ['letterbox', 'fill'])
add(wrap, 'button', { dataset: { slRender: mode } });
for (const id of ['sl-follow-btn', 'sl-viewer-close'])
add(wrap, 'button', { id });
for (const id of [
'sl-viewer-placeholder',
@@ -239,10 +252,26 @@ function panelDom() {
'sl-image-link',
])
add(wrap, id === 'sl-image-link' ? 'a' : 'div', { id });
const settings = add(main, 'div', { classes: ['sl-settings'] });
for (const id of ['sl-provider-chips', 'sl-error', 'sl-error-text'])
add(settings, 'div', { id });
// A missing key gates the filters only; the chips stay outside the gate.
const controls = add(settings, 'fieldset', { id: 'sl-controls' });
for (const pano of ['all', 'pano', 'flat'])
add(controls, 'button', { dataset: { slPano: pano } });
add(controls, 'input', { id: 'sl-since' });
add(controls, 'output', { id: 'sl-since-label' });
add(settings, 'ul', { id: 'sl-legend' });
add(settings, 'div', { id: 'sl-coverage-meta' });
// The globe's canvas is focusable too (tabindex=0).
const globe = add(document.body, 'canvas');
const globe = add(
add(document.body, 'div', { id: 'cesiumContainer' }),
'canvas',
);
globe.setAttribute('tabindex', '0');
return { document, root, globe };
// A field in another panel (the location search).
const search = add(document.body, 'input', { id: 'location-search' });
return { document, root, globe, search };
}
/** Run `fn` with the fake document and an immediate animation frame. */
@@ -251,11 +280,11 @@ async function withDom(fn) {
document: globalThis.document,
requestAnimationFrame: globalThis.requestAnimationFrame,
};
const dom = panelDom();
globalThis.document = dom.document;
const page = panelDom();
globalThis.document = page.document;
globalThis.requestAnimationFrame = (task) => setTimeout(task, 0);
try {
return await fn(dom);
return await fn(page);
} finally {
globalThis.document = saved.document;
globalThis.requestAnimationFrame = saved.requestAnimationFrame;
@@ -277,9 +306,7 @@ function standInProvider() {
requiresKeyId: null,
pickPrefix: 'mly:',
colors: { coverage: '#05cb63' },
credit: { key: 'mapillary', html: 'Mapillary' },
capabilities: { coverage: 'tiles', sequences: true, pano: true },
externalUrl: (id) => `https://example.test/${id}`,
credit: { html: 'Mapillary' },
create: () => ({
status: async () => ({ configured: true }),
init() {},
@@ -446,11 +473,19 @@ function stubLayer() {
};
}
function uiState({ enabled = true, on = true, open = false, legend } = {}) {
function uiState({
enabled = true,
on = true,
open = false,
legend,
pano = 'all',
renderMode = 'letterbox',
keyRequired = false,
} = {}) {
return {
enabled,
keyRequired: false,
filter: { pano: 'all', sinceDays: 0 },
keyRequired,
filter: { pano, sinceDays: 0 },
providers: [
{
id: 'mapillary',
@@ -458,9 +493,9 @@ function uiState({ enabled = true, on = true, open = false, legend } = {}) {
label: 'MAPILLARY',
color: '#05cb63',
on,
configured: true,
keyRequired: false,
requiresKeyId: null,
configured: !keyRequired,
keyRequired,
requiresKeyId: keyRequired ? 'mapillary' : null,
loading: false,
count: 3,
hint: '',
@@ -478,7 +513,7 @@ function uiState({ enabled = true, on = true, open = false, legend } = {}) {
loading: false,
follow: false,
followAvailable: false,
renderMode: 'letterbox',
renderMode,
providerId: open ? 'mapillary' : null,
imageId: open ? 'img-1' : null,
error: null,
@@ -487,9 +522,9 @@ function uiState({ enabled = true, on = true, open = false, legend } = {}) {
};
}
function stubPanel(dom, state) {
function stubPanel(dom, state, extraActions = {}) {
const layer = stubLayer();
const calls = { setParams: [], setEnabled: [] };
const calls = { setParams: [], setEnabled: [], collapsed: [], toasts: [] };
let enabled = state.enabled;
layer.publish(state);
const controls = new StreetLevelControls({
@@ -502,9 +537,11 @@ function stubPanel(dom, state) {
enabled = on;
},
setParams: (params, options) => calls.setParams.push([params, options]),
setPanelCollapsed() {},
setPanelCollapsed: (collapsed, options) =>
calls.collapsed.push([collapsed, options]),
dockPanel() {},
showToast() {},
showToast: (message) => calls.toasts.push(message),
...extraActions,
},
});
controls.connect();
@@ -554,16 +591,21 @@ test('the legend rebuilds when a swatch changes, even at the same count (review
assert.equal(swatch(), '#a66bff');
}));
test('the viewer is resized when it opens, not on every render (review #8)', () =>
test('the viewer is resized once when it opens, not on every render (review #8, gekh P3)', () =>
withDom(async (dom) => {
const { layer } = stubPanel(dom, uiState());
await settle();
assert.equal(layer.calls.resize, 0);
layer.publish(uiState({ open: true }));
await settle();
// Opening also opens the panel; the two share one coalesced resize.
assert.equal(layer.calls.resize, 1, 'once on open');
for (let i = 0; i < 5; i++) layer.publish(uiState({ open: true }));
await settle();
assert.equal(layer.calls.resize, 1, 'not again while it stays open');
layer.publish(uiState({ open: false }));
layer.publish(uiState({ open: true }));
await settle();
assert.equal(layer.calls.resize, 2, 'the next photo resizes again');
}));
@@ -641,3 +683,200 @@ test('an image closed while expanded does not return focus into the hidden viewe
);
controls.destroy();
}));
/* ── gekh review on #768 ───────────────────────────────────────────────── */
test('a restored layer does not reopen a panel the user collapsed, and automatic opens are never stored (P2-1)', () =>
withDom(async (dom) => {
// The panel chrome restored the user's stored "collapsed" before connect.
dom.root.dataset.collapsedPreference = 'stored';
const { layer, calls, controls } = stubPanel(
dom,
uiState({ enabled: false }),
);
layer.publish(uiState({ enabled: true })); // saved layer state restored
assert.deepEqual(calls.collapsed, [], 'the collapsed panel stays shut');
// Later, a user switches the layer off and on: that opens the panel,
// without overwriting the stored preference.
dom.document.body.dispatchEvent({ type: 'pointerdown', bubbles: true });
layer.publish(uiState({ enabled: false }));
layer.publish(uiState({ enabled: true }));
assert.deepEqual(calls.collapsed, [[false, { persist: false }]]);
layer.publish(uiState({ enabled: true, open: true }));
assert.deepEqual(calls.collapsed.at(-1), [false, { persist: false }]);
controls.destroy();
}));
test('without a stored choice, a restored layer still opens its panel, unstored (P2-1)', () =>
withDom(async (dom) => {
dom.root.dataset.collapsedPreference = 'default';
const { layer, calls, controls } = stubPanel(
dom,
uiState({ enabled: false }),
);
layer.publish(uiState({ enabled: true }));
assert.deepEqual(calls.collapsed, [[false, { persist: false }]]);
controls.destroy();
}));
test('with request origins, only a user switch-on opens the panel; a restore never does (P2-1)', () =>
withDom(async (dom) => {
// No stored choice: the restore window alone would open the panel.
dom.root.dataset.collapsedPreference = 'default';
let announce = null;
const { layer, calls, controls } = stubPanel(
dom,
uiState({ enabled: false }),
{
subscribeEnableRequests: (listener) => {
announce = listener;
return () => {
announce = null;
};
},
},
);
announce('restore');
layer.publish(uiState({ enabled: true }));
assert.deepEqual(calls.collapsed, [], 'a restore leaves the panel be');
layer.publish(uiState({ enabled: false }));
announce('user');
layer.publish(uiState({ enabled: true }));
assert.deepEqual(calls.collapsed, [[false, { persist: false }]]);
controls.destroy();
assert.equal(announce, null, 'destroy unsubscribes');
}));
test('Esc and Tab from a field outside the expanded viewer stay with that field (P3)', () =>
withDom(async (dom) => {
const { controls } = stubPanel(dom, uiState({ open: true }));
const reached = mapEscape(dom);
controls.setViewerExpanded(true);
dom.search.focus();
keydown(dom.search, 'Escape');
assert.equal(controls.isViewerExpanded(), true, 'the search keeps its Esc');
assert.equal(reached.length, 1, 'and the event was not swallowed');
keydown(dom.search, 'Tab');
assert.equal(dom.document.activeElement, dom.search, 'Tab stays put');
// From <body> (focus nowhere), Esc is still the viewer's.
keydown(dom.document.body, 'Escape');
assert.equal(controls.isViewerExpanded(), false);
controls.destroy();
}));
test('a hidden expanded viewer (Clean View, recording, cockpit) holds neither Esc nor Tab (P2-5)', () =>
withDom(async (dom) => {
const { controls } = stubPanel(dom, uiState({ open: true }));
const reached = mapEscape(dom);
controls.setViewerExpanded(true);
const wrap = dom.document.body.querySelector('#sl-viewer-wrap');
wrap.checkVisibility = () => false; // the mode's CSS hides it
dom.globe.focus();
keydown(dom.globe, 'Tab');
assert.equal(dom.document.activeElement, dom.globe);
keydown(dom.globe, 'Escape');
assert.equal(controls.isViewerExpanded(), true);
assert.equal(reached.length, 1, 'Esc went on to the map');
controls.destroy();
}));
test('FIT / FILL show the selection while the viewer is expanded (P3)', () =>
withDom(async (dom) => {
const { layer, controls } = stubPanel(dom, uiState({ open: true }));
controls.setViewerExpanded(true);
layer.publish(uiState({ open: true, renderMode: 'fill' }));
const wrap = dom.document.body.querySelector('#sl-viewer-wrap');
const [fit, fill] = wrap.querySelectorAll('[data-sl-render]');
assert.equal(fill.getAttribute('aria-checked'), 'true');
assert.equal(fit.getAttribute('aria-checked'), 'false');
assert.equal(fill.classList.contains('is-active'), true);
controls.destroy();
}));
test('a hidden (0×0) viewer is not resized, so no z=NaN tile request (P3)', () =>
withDom(async (dom) => {
const saved = globalThis.ResizeObserver;
const observers = [];
globalThis.ResizeObserver = class {
constructor(callback) {
observers.push(callback);
}
observe() {}
disconnect() {}
};
try {
const { layer, controls } = stubPanel(dom, uiState({ open: true }));
await settle();
const resizes = layer.calls.resize;
const viewer = dom.root.querySelector('#sl-viewer');
Object.assign(viewer, { clientWidth: 0, clientHeight: 0 });
observers[0]();
await settle();
assert.equal(layer.calls.resize, resizes, 'skipped while 0×0');
Object.assign(viewer, { clientWidth: 640, clientHeight: 400 });
observers[0]();
await settle();
assert.equal(layer.calls.resize, resizes + 1, 'resized once it shows');
controls.destroy();
} finally {
globalThis.ResizeObserver = saved;
}
}));
test('identical renders write nothing to the DOM (P3: rail MutationObserver)', () =>
withDom(async (dom) => {
const { layer, controls } = stubPanel(dom, uiState({ open: true }));
layer.publish(uiState({ open: true }));
const before = mutations.count;
for (let i = 0; i < 10; i++) layer.publish(uiState({ open: true }));
assert.equal(mutations.count - before, 0);
controls.destroy();
}));
test('arrow keys move the selection within a radiogroup, which has one tab stop (P3)', () =>
withDom(async (dom) => {
const { layer, calls, controls } = stubPanel(dom, uiState());
const [all, pano, flat] = dom.root.querySelectorAll('[data-sl-pano]');
assert.deepEqual(
[all, pano, flat].map((button) => button.getAttribute('tabindex')),
['0', '-1', '-1'],
);
all.focus();
keydown(all, 'ArrowRight');
assert.equal(dom.document.activeElement, pano);
assert.deepEqual(calls.setParams.at(-1), [
{ pano: 'pano' },
{ origin: 'user' },
]);
keydown(all, 'ArrowLeft'); // wraps to the end
assert.equal(dom.document.activeElement, flat);
keydown(flat, 'Home');
assert.equal(dom.document.activeElement, all);
layer.publish(uiState({ pano: 'flat' }));
assert.deepEqual(
[all, pano, flat].map((button) => button.getAttribute('tabindex')),
['-1', '-1', '0'],
);
controls.destroy();
}));
test('under KEY REQUIRED the provider chip stays live and explains the key instead of toggling (P3)', () =>
withDom(async (dom) => {
const { calls, chip, controls } = stubPanel(
dom,
uiState({ keyRequired: true }),
);
assert.equal(dom.root.querySelector('#sl-controls').disabled, true);
assert.equal(
dom.root.querySelector('#sl-controls').contains(chip()),
false,
'the chip is outside the disabled fieldset',
);
chip().click();
await settle();
assert.deepEqual(calls.setEnabled, []);
assert.deepEqual(calls.setParams, []);
assert.equal(calls.toasts.length, 1);
assert.match(calls.toasts[0], /^Mapillary: Needs /);
controls.destroy();
}));
+7 -1
View File
@@ -157,13 +157,19 @@ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
const enabled = state.enabled === true;
const keyRequired = state.keyRequired === true;
const filter = state.filter || { pano: 'all', sinceDays: 0 };
// KEY REQUIRED already says it, and the chip's tooltip says how to fix it;
// the error under it would only repeat a raw code (`no_key`). A rejected
// key keeps its line: that message names the fix.
const keyMissing = keyRequired && state.keyRejected !== true;
return {
enabled,
keyRequired,
status: presentStatus(state),
controlsDisabled: keyRequired,
providers: presentProviders(state),
error: state.street.error || state.coverage.error || null,
error: keyMissing
? null
: state.street.error || state.coverage.error || null,
filter: { pano: filter.pano, sinceDays: Number(filter.sinceDays) || 0 },
since: presentSince(filter.sinceDays, now),
legend: state.legend || [],
+15
View File
@@ -78,6 +78,21 @@ test('a key-gated layer disables every control and flags KEY REQUIRED', () => {
assert.equal(view.status.tone, 'warn');
});
test('KEY REQUIRED shows no raw error code under the pill (review P3)', () => {
const view = presentStreetLevelPanel(
snapshot({
enabled: true,
keyRequired: true,
coverage: { error: 'no_key' },
providers: [provider({ keyRequired: true, error: 'no_key' })],
}),
);
assert.equal(view.status.text, 'KEY REQUIRED');
assert.equal(view.error, null);
// The chip still says how to add the key.
assert.match(view.providers[0].title, /^Mapillary: Needs /);
});
test('a key Mapillary rejected reads KEY REJECTED and names the fix (review IC8 P2)', () => {
const error =
'Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings';
+2 -1
View File
@@ -1942,7 +1942,8 @@ body.cockpit-mode
#command-dock,
#military-awareness-panel,
#military-awareness-direction-overlay,
#cockpit-entry
#cockpit-entry,
.sl-viewer-wrap-expanded
) {
display: none !important;
}
+3 -1
View File
@@ -1109,7 +1109,9 @@ body.ui-clean-view #data-panel,
body.ui-clean-view #cctv-panel,
body.ui-clean-view #radio-panel,
body.ui-clean-view #global-context-panel,
body.ui-clean-view #scene-panel {
body.ui-clean-view #scene-panel,
/* The expanded Street Level viewer lives on <body>, outside every panel. */
body.ui-clean-view .sl-viewer-wrap-expanded {
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
+11
View File
@@ -198,6 +198,7 @@
.cctv-panel-inner,
.weather-panel-inner,
.recent-imagery-panel-inner,
.street-level-panel-inner,
.scene-panel-inner,
.global-context-panel-inner,
.radio-panel-inner,
@@ -218,6 +219,7 @@
.cctv-panel-inner,
.weather-panel-inner,
.recent-imagery-panel-inner,
.street-level-panel-inner,
.scene-panel-inner,
.global-context-panel-inner,
.radio-panel-inner
@@ -239,6 +241,7 @@
.cctv-panel-inner,
.weather-panel-inner,
.recent-imagery-panel-inner,
.street-level-panel-inner,
.global-context-panel-inner
) {
padding-right: 8px;
@@ -253,12 +256,14 @@
#cctv-panel,
#weather-panel,
#recent-imagery-panel,
#street-level-panel,
#global-context-panel
).collapsed
> :is(
.cctv-panel-inner,
.weather-panel-inner,
.recent-imagery-panel-inner,
.street-level-panel-inner,
.global-context-panel-inner
) {
padding: 0;
@@ -270,12 +275,14 @@
#cctv-panel,
#weather-panel,
#recent-imagery-panel,
#street-level-panel,
#global-context-panel
).collapsed
> :is(
.cctv-panel-inner,
.weather-panel-inner,
.recent-imagery-panel-inner,
.street-level-panel-inner,
.global-context-panel-inner
)
> .panel-header {
@@ -294,6 +301,7 @@
.cctv-panel-inner,
.weather-panel-inner,
.recent-imagery-panel-inner,
.street-level-panel-inner,
.scene-panel-inner,
.global-context-panel-inner,
.radio-panel-inner
@@ -309,6 +317,7 @@
.cctv-panel-inner,
.weather-panel-inner,
.recent-imagery-panel-inner,
.street-level-panel-inner,
.scene-panel-inner,
.global-context-panel-inner,
.radio-panel-inner
@@ -332,6 +341,7 @@
.cctv-panel-inner,
.weather-panel-inner,
.recent-imagery-panel-inner,
.street-level-panel-inner,
.scene-panel-inner,
.global-context-panel-inner,
.radio-panel-inner
@@ -1718,6 +1728,7 @@
#cctv-panel,
#weather-panel,
#recent-imagery-panel,
#street-level-panel,
#global-context-panel
):not(.collapsed) {
width: var(--cyber-right-panel-width);
+3 -1
View File
@@ -15,7 +15,9 @@ body.recording-mode #cctv-panel,
body.recording-mode #radio-panel,
body.recording-mode #global-context-panel,
body.recording-mode #gev-voice-control,
body.recording-mode #scene-panel {
body.recording-mode #scene-panel,
/* The expanded Street Level viewer lives on <body>, outside every panel. */
body.recording-mode .sl-viewer-wrap-expanded {
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
+32 -5
View File
@@ -1,7 +1,9 @@
/* ── Street Level panel ─────────────────────────────────────────
Lives in the right context rail next to CCTV; the rail owns position and
width (see layers.css). Everything here is panel-internal. */
#street-level-panel {
width (see layers.css). Everything here is panel-internal. The expanded
viewer is re-parented to <body>, so it carries the tokens too. */
#street-level-panel,
.sl-viewer-wrap {
--sl-green: #05cb63;
--sl-text: 0.62rem;
--sl-text-small: 0.6rem;
@@ -39,7 +41,8 @@
scrollbar-color: rgba(0, 212, 255, 0.46) rgba(255, 255, 255, 0.06);
}
#street-level-panel .panel-header {
/* Class-qualified, not id-qualified, so a theme's header rule still wins. */
.street-level-panel-inner > .panel-header {
gap: 8px;
}
@@ -101,6 +104,15 @@ button.sl-status:focus-visible {
min-height: 0;
}
.sl-main {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
/* The filters a missing key gates. The provider chips stay outside it, so
their tooltip (how to add the key) is still reachable. */
.sl-controls {
display: flex;
flex-direction: column;
@@ -192,11 +204,12 @@ button.sl-status:focus-visible {
flex-wrap: wrap;
}
/* No overflow clipping: it would cut off the buttons' focus ring. The end
buttons round themselves instead. */
.sl-seg {
display: inline-flex;
border: 1px solid var(--glass-border-hover);
border-radius: 999px;
overflow: hidden;
}
.sl-seg-btn {
@@ -210,10 +223,24 @@ button.sl-status:focus-visible {
cursor: pointer;
}
.sl-seg-btn:first-child {
border-radius: 999px 0 0 999px;
}
.sl-seg-btn:last-child {
border-radius: 0 999px 999px 0;
}
.sl-seg-btn + .sl-seg-btn {
border-left: 1px solid var(--glass-border);
}
.sl-seg-btn:focus-visible {
position: relative;
outline: 1px solid var(--accent);
outline-offset: 2px;
}
.sl-seg-btn.is-active {
background: rgba(5, 203, 99, 0.22);
color: var(--text-primary);
@@ -476,7 +503,7 @@ button.sl-status:focus-visible {
}
.panel-floating[style*='height'] .sl-body,
.panel-floating[style*='height'] .sl-controls {
.panel-floating[style*='height'] .sl-main {
flex: 1 1 auto;
min-height: 0;
}
+19 -15
View File
@@ -83,14 +83,14 @@
<button class="panel-collapse-btn" data-collapse-target="street-level-panel" title="Expand panel">◀</button>
</div>
<div id="sl-body" class="sl-body">
<fieldset id="sl-controls" class="sl-controls">
<div class="sl-main">
<div id="sl-viewer-wrap" class="sl-viewer-wrap" hidden>
<div class="sl-viewer-bar">
<div class="sl-viewer-tools">
<button id="sl-viewer-expand" class="scene-btn" type="button" aria-pressed="false" title="Expand the street-level view (Esc shrinks it)"><span class="sl-btn-icon" aria-hidden="true">⤢</span><span class="sl-btn-text">EXPAND</span></button>
<div class="sl-seg" role="radiogroup" aria-label="Image fit">
<button type="button" class="sl-seg-btn is-active" data-sl-render="letterbox" role="radio" aria-checked="true" title="Fit: show the whole photo, with dark bars where its shape differs from the frame">FIT</button>
<button type="button" class="sl-seg-btn" data-sl-render="fill" role="radio" aria-checked="false" title="Fill: fill the frame edge to edge, cropping the photo">FILL</button>
<button type="button" class="sl-seg-btn" data-sl-render="fill" role="radio" aria-checked="false" tabindex="-1" title="Fill: fill the frame edge to edge, cropping the photo">FILL</button>
</div>
<button id="sl-follow-btn" class="scene-btn" type="button" aria-pressed="false" aria-label="Camera follows view" title="Camera follows view: move the globe camera wherever the street-level view looks"><span class="sl-btn-icon" aria-hidden="true">⌖</span><span class="sl-btn-text">FOLLOW</span></button>
</div>
@@ -116,23 +116,27 @@
<div id="sl-provider-chips" class="sl-chips"></div>
</div>
<div id="sl-error" class="sl-error" role="alert" hidden><span aria-hidden="true">⚠</span> <span id="sl-error-text"></span></div>
<div class="sl-filters" role="group" aria-label="Imagery filters">
<span class="cctv-summary-label">IMAGERY</span>
<div class="sl-seg" role="radiogroup" aria-label="Panorama filter">
<button type="button" class="sl-seg-btn is-active" data-sl-pano="all" role="radio" aria-checked="true">ALL</button>
<button type="button" class="sl-seg-btn" data-sl-pano="pano" role="radio" aria-checked="false">360°</button>
<button type="button" class="sl-seg-btn" data-sl-pano="flat" role="radio" aria-checked="false">FLAT</button>
<!-- KEY REQUIRED gates the filters only: the provider chips above
stay focusable so their tooltip can say how to add the key. -->
<fieldset id="sl-controls" class="sl-controls">
<div class="sl-filters" role="group" aria-label="Imagery filters">
<span class="cctv-summary-label">IMAGERY</span>
<div class="sl-seg" role="radiogroup" aria-label="Panorama filter">
<button type="button" class="sl-seg-btn is-active" data-sl-pano="all" role="radio" aria-checked="true">ALL</button>
<button type="button" class="sl-seg-btn" data-sl-pano="pano" role="radio" aria-checked="false" tabindex="-1">360°</button>
<button type="button" class="sl-seg-btn" data-sl-pano="flat" role="radio" aria-checked="false" tabindex="-1">FLAT</button>
</div>
</div>
</div>
<div class="sl-since">
<label class="cctv-summary-label" for="sl-since">SINCE</label>
<input id="sl-since" class="sl-range" type="range" min="0" max="8" step="1" value="0" aria-valuetext="ANY DATE" aria-describedby="sl-since-label" />
<output id="sl-since-label" class="sl-since-label" for="sl-since">ANY DATE</output>
</div>
<div class="sl-since">
<label class="cctv-summary-label" for="sl-since">SINCE</label>
<input id="sl-since" class="sl-range" type="range" min="0" max="8" step="1" value="0" aria-valuetext="ANY DATE" aria-describedby="sl-since-label" />
<output id="sl-since-label" class="sl-since-label" for="sl-since">ANY DATE</output>
</div>
</fieldset>
<ul id="sl-legend" class="sl-legend" aria-label="Coverage colours"></ul>
<div id="sl-coverage-meta" class="sl-meta"></div>
</div>
</fieldset>
</div>
</div>
</div>
</div>