From 48276450ec338c73036a82c78bdd039dc7877b44 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Mon, 5 Oct 2026 18:35:30 +0100 Subject: [PATCH] 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 --- docs/CURRENT-STATE.md | 6 +- src/sharelink.celestial.test.mjs | 18 ++ src/sharelink.js | 1 + src/streetLevelMarkup.test.mjs | 78 +++++- src/ui/applicationShell.js | 7 + src/ui/cyberTheme.test.mjs | 4 +- src/ui/streetLevelControls.js | 277 +++++++++++++++----- src/ui/streetLevelControls.test.mjs | 323 +++++++++++++++++++++--- src/ui/streetLevelPresentation.js | 8 +- src/ui/streetLevelPresentation.test.mjs | 15 ++ src/ui/styles/cockpit.css | 3 +- src/ui/styles/controls.css | 4 +- src/ui/styles/cyber.css | 11 + src/ui/styles/recording.css | 4 +- src/ui/styles/street-level.css | 37 ++- src/ui/templates/layer-panels.html | 34 +-- 16 files changed, 690 insertions(+), 140 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9329e4122..aceebe477 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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. diff --git a/src/sharelink.celestial.test.mjs b/src/sharelink.celestial.test.mjs index 567bb09dd..d9e3395d2 100644 --- a/src/sharelink.celestial.test.mjs +++ b/src/sharelink.celestial.test.mjs @@ -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); diff --git a/src/sharelink.js b/src/sharelink.js index 36862dcbf..e2a9e41b2 100644 --- a/src/sharelink.js +++ b/src/sharelink.js @@ -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 }, diff --git a/src/streetLevelMarkup.test.mjs b/src/streetLevelMarkup.test.mjs index 9e327bec7..3ace37ca2 100644 --- a/src/streetLevelMarkup.test.mjs +++ b/src/streetLevelMarkup.test.mjs @@ -83,6 +83,15 @@ test('the keyless state gates the controls rather than leaving dead buttons', () ); assert.match(html, /
<\/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('
', html.indexOf('
{ - const controlsBlock = html.slice(html.indexOf('
')); 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 (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(`
-
+
    -
    +