fix(street-level): /revmain cleanup after the slim rebuild

- Google 3D: the OPEN button re-measures a ground whose tiles are still
  loading (a few times, a second apart) and on a map change, not only
  after the next camera move.
- Mapillary: two mounts on one host during the first download share one
  build instead of leaking a second WebGL viewer.
- Retired floating-panel keys (v8 panelPos, panelFloatHintShown) are
  dropped from localStorage; dead inline-position resets, the drag JSDoc
  and the dead qa-ui-disposal observer branch are gone.
- Docs: z ladder, panel chrome, position storage and the fixture gate's
  actual coverage; changelog fragment for #974 (old .gev-cache/mapillary
  can be deleted).
- ru/es: the layer is named as everywhere else in the UI.
- Fixtures and test fakes: unused exports removed; a fake credit removal
  of an absent credit no longer drops another one; the tile test reset
  frees upstream slots.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-20 14:02:09 +04:00
1 parent 00e8e3b997
commit 28e0ba7104
22 files changed
+220 -74

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
- Street Level is rebuilt around one small provider registry: Mapillary,
KartaView, Panoramax and Google Street View share one coverage drawer and
one photo viewer, one source lit at a time. Floating and docked panels,
FOLLOW, surface modes, the legend and the Mapillary proxy's disk cache are
gone; `.gev-cache/mapillary/` from earlier runs is no longer read and can be
deleted (gekh, #974).
+2 -2
View File
@@ -356,8 +356,8 @@ drag or final change. Feature adapters own data mapping and service subscription
generic modules own DOM/listeners/timers and release them in `destroy()`.
Keep persistence with panel chrome: collapse uses
`godsEyeView.v6.panelCollapsed.<panelId>`, position uses
`godsEyeView.v8.panelPos.<panelId>`, and share state uses the registered panel id.
`godsEyeView.v6.panelCollapsed.<panelId>`, positions are
not stored (`godsEyeView.v8.panelPos.<panelId>` stays reserved), and share state uses the registered panel id.
Do not rename existing
ids or reset persistence versions when adding a body. The WEATHER adapter is
`src/ui/weatherPanel.js`; its observed-history selection remains transient.
+7 -6
View File
@@ -651,7 +651,7 @@ The UI facade composes separate owners for navigation authority, share restorati
visual settings and panel chrome. Navigation owns request generations and follow
handoff; restoration owns its coordinator, deferred notices, gestures and timer;
visual settings own style/detection overrides and sensor cleanup; panel chrome
owns disclosure, docking and Cockpit panel snapshots. Each takes explicit
owns disclosure and Cockpit panel snapshots. Each takes explicit
operations and reads instead of the complete shell. Existing controls, share
formats and one-application-per-page behavior are preserved.
@@ -977,10 +977,9 @@ start with current state, isolate listener failures and stop on disposal.
The standalone entry composes the UI with the application's existing layer,
terrain, navigation and rendering operations. The shell receives those instances
and assembles the controls. Panel layout scheduling, position preferences and
active drags, loading notices, recording presentation and DOM lookup have focused
and assembles the controls. Panel layout scheduling, loading notices, recording presentation and DOM lookup have focused
owners. Disposal revokes queued presentation and listeners before asynchronous
Context restoration, cancels an unfinished drag without saving it, restores the
Context restoration, restores the
recording HUD and releases status decoration without replacing accessible text.
The ordinary control snapshot includes the current 3D model toggle and mode.
@@ -3771,7 +3770,9 @@ runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run
qa:street-level:fixtures -- --url <server>` answers every Mapillary request
from fixtures (`providers/mapillary/coverageFixture.mjs`,
`scripts/fixtures/street-level/`), and KartaView's and Panoramax's too, so it
needs no network and covers the whole photo flow. CI runs the fixture gate against a production build served by `vite preview` with a dummy token.
needs no network. It opens and closes a photo from Mapillary, KartaView and
Panoramax; stepping, EXPAND, FIT/FILL, the filters and Street View stay with
the unit tests. CI runs the fixture gate against a production build served by `vite preview` with a dummy token.
Directions is a keyless front end to the routing the voice agent already
uses. Its row chips are the whole interface: DRIVE / WALK / BIKE pick the
@@ -5064,7 +5065,7 @@ pins and share links already supply complete boxes.
Pixel-mode pinch deltas receive bounded amplification before reaching the
canvas; ordinary wheel, line/page-mode and native touch-pinch paths are left
unchanged. The application scene owns and removes the relay listener.
- Z ladder: panels promote within 100–139 (renormalized on wrap), voice pill 150, toast 200, clean-view exit 300.
- Z ladder: panels around 100, voice pill 150, toast 200, clean-view exit 300.
- Panel POSITION keys are versioned `v8` (`godsEyeView.v8.panelPos.<id>`); collapsed-state keys remain `v6`. The one-time position reset clears stale DISPLAY placements that could overlap the Context rail.
- Map Source lives in the bottom Visual Presets tray. The left accordion contains no MAP STACK panel, and the `k` panel token that addressed it is gone from the share registry, so legacy `ui=k...` state takes the ordinary unknown-token skip.
- A dock popover (Visual Presets, Location) auto-dismisses on mouse-away unless pinned. Focus inside the tray defers that dismissal only when the browser reports `:focus-visible` — keyboard focus and typed-into fields hold the tray open; a mouse-clicked tile does not, because Chromium focuses a `<button>` on press.
@@ -49,11 +49,6 @@ export function resetKartaViewFixture() {
nearestLookups = 0;
}
/** Nearest lookups answered since the last reset (the first got a 429). */
export function kartaViewLookups() {
return nearestLookups;
}
/** The drive's photos, south to north; every tenth is 360°, every odd one archived. */
export function kartaViewPhotos() {
const step = KV_SPACING_M / METRES_PER_DEG_LAT;
+1 -1
View File
@@ -28,7 +28,7 @@ export const PNX_PARK = Object.freeze({
height: 700,
pitch: -1.4,
});
export const PNX_CREATOR = 'Fixture Mapper';
const PNX_CREATOR = 'Fixture Mapper';
const PNX_SPACING_M = 10;
const METRES_PER_DEG_LON = 111_320 * Math.cos((PNX_LINE.lat * Math.PI) / 180);
const API = 'https://api.panoramax.xyz/api';
+1
View File
@@ -281,6 +281,7 @@
"src/ui/applicationShell.js",
"src/ui/panelLayoutController.js",
"src/ui/panelPositionControls.js",
"src/ui/panelPositionControls.test.mjs",
"src/ui/recordingControls.js",
"src/ui/shellElements.js",
"src/ui/shellFeedback.js",
+2 -11
View File
@@ -43,11 +43,7 @@ try {
const fpsReadout = document.querySelector('.frame-rate-readout');
const fpsWasVisible = fpsReadout && !fpsReadout.hidden;
const watchObserver = (name) => {
const owner =
name === '_draggableResizeObserver'
? ui._panelPosition
: ui._panelLayout;
const observer = owner[name];
const observer = ui._panelLayout[name];
if (!observer) return false;
const disconnect = observer.disconnect.bind(observer);
counts[name] = 0;
@@ -59,7 +55,6 @@ try {
};
const observed = [
'_commandDockTrayObserver',
'_draggableResizeObserver',
'_leftStackResizeObserver',
'_rightStackResizeObserver',
'_leftStackMutationObserver',
@@ -193,11 +188,7 @@ try {
counts[field] === 1 && ui._contextControls[field] === null,
),
observersReleased: observed.every(
(name) =>
counts[name] === 1 &&
(name === '_draggableResizeObserver'
? ui._panelPosition
: ui._panelLayout)[name] === null,
(name) => counts[name] === 1 && ui._panelLayout[name] === null,
),
subscriptionReleased:
counts.cctv === 1 && ui._cctvControls._cctvUnsubscribe === null,
+3 -1
View File
@@ -11,7 +11,7 @@ const MAX_ZOOM = 14;
* (positions come from the Graph API), so it is dropped in transit.
*/
const DROP_LAYERS = ['image'];
/** A z14 tile with its image layer is ~11 MB; anything past this is wrong. */
/** A z14 tile with its image layer is ~12 MB; anything past this is wrong. */
export const TILE_MAX_BYTES = 48 * 1024 * 1024;
const FETCH_TIMEOUT_MS = 60_000;
/** Upstream fetches at once; more wait their turn. */
@@ -260,4 +260,6 @@ export function _resetTileCacheForTest() {
memory.clear();
inFlight.clear();
refusal = null;
upstreamActive = 0;
upstreamWaiting.length = 0;
}
+1 -1
View File
@@ -2882,7 +2882,7 @@
"Zoom in to see street-level coverage": "Acércate para ver la cobertura a pie de calle",
"Point the camera at the ground for street-level coverage": "Apunta la cámara al suelo para ver la cobertura a pie de calle",
"Sequence images unavailable": "Las imágenes de la secuencia no están disponibles",
"Switch Street Level on to draw its coverage.": "Activa Street Level para dibujar su cobertura.",
"Switch Street Level on to draw its coverage.": "Activa A pie de calle para dibujar su cobertura.",
"Every provider is switched off: light a chip to draw its coverage.": "Todos los proveedores están apagados: enciende uno para dibujar su cobertura.",
"No sequences in view match the filter": "Ninguna secuencia a la vista coincide con el filtro",
"No coverage in view": "No hay cobertura a la vista",
+1 -1
View File
@@ -2949,7 +2949,7 @@
"Zoom in to see street-level coverage": "Приблизьтесь, чтобы увидеть покрытие уличных снимков",
"Point the camera at the ground for street-level coverage": "Направьте камеру на землю, чтобы увидеть покрытие уличных снимков",
"Sequence images unavailable": "Снимки последовательности недоступны",
"Switch Street Level on to draw its coverage.": "Включите Street Level, чтобы показать покрытие.",
"Switch Street Level on to draw its coverage.": "Включите «Уличные снимки», чтобы показать покрытие.",
"Every provider is switched off: light a chip to draw its coverage.": "Все источники выключены: включите один, чтобы показать его покрытие.",
"No sequences in view match the filter": "Ни одна последовательность в кадре не подходит под фильтр",
"No coverage in view": "В кадре нет покрытия",
+36 -4
View File
@@ -14,7 +14,7 @@ import {
keyAlternatives,
summarizeCoverage,
} from './uiState.js';
import { viewCentre } from './view.js';
import { groundUnderCamera, viewCentre } from './view.js';
import { createGroundClick, groundClickHeightOk } from './groundClick.js';
import { msg, t, translateKnown } from '../../i18n/index.js';
import {
@@ -29,6 +29,9 @@ export { STREET_LEVEL_LAYER_ID } from './policy.js';
/** Longest wait for a provider's key status; past it, the provider decides. */
const KEY_STATUS_TIMEOUT_MS = 10_000;
/** Re-measures of a ground not loaded yet (Google 3D), a second apart. */
const GROUND_RETRY_LIMIT = 5;
const GROUND_RETRY_MS = 1000;
/**
* The Street Level layer over imagery providers (contract in registry.js).
@@ -163,22 +166,50 @@ export function createStreetLevelLayer({
parts.viewerHost.unmount(entry.def.id);
}
}
remeasureGround();
notify();
}
/** Whether a ground click can open an image: the camera is at street zoom. */
let groundRetry = null;
let groundRetries = 0;
function cancelGroundRetry() {
clearTimeout(groundRetry);
groundRetry = null;
}
/**
* Whether a ground click can open an image: the camera is at street zoom.
* Under Google 3D the ground is unknown until its tiles load, so an unknown
* ground is measured again a few times instead of waiting for the next move.
*/
function syncGroundClickReady() {
cancelGroundRetry();
const ready = state.enabled && groundClickHeightOk(state.viewer);
if (
state.enabled &&
!ready &&
groundRetries < GROUND_RETRY_LIMIT &&
groundUnderCamera(state.viewer) === null
) {
groundRetries++;
groundRetry = setTimeout(syncGroundClickReady, GROUND_RETRY_MS);
}
if (ready === state.groundClickReady) return;
state.groundClickReady = ready;
notify();
}
/** A camera move or a map change measures afresh. */
function remeasureGround() {
groundRetries = 0;
syncGroundClickReady();
}
let removeCameraListener = null;
function watchCamera(viewer) {
removeCameraListener?.();
removeCameraListener =
viewer?.camera?.moveEnd?.addEventListener(syncGroundClickReady) || null;
viewer?.camera?.moveEnd?.addEventListener(remeasureGround) || null;
}
const activeEntries = () =>
@@ -463,7 +494,7 @@ export function createStreetLevelLayer({
parts.selection.install(viewer);
for (const entry of activeEntries()) activate(entry);
watchCamera(viewer);
syncGroundClickReady();
remeasureGround();
notify();
},
@@ -472,6 +503,7 @@ export function createStreetLevelLayer({
abortNearest();
removeCameraListener?.();
removeCameraListener = null;
cancelGroundRetry();
parts.viewerHost.unmount();
for (const entry of state.providers.values()) entry.instance.deactivate();
syncGroundClickReady();
+61 -2
View File
@@ -352,8 +352,11 @@ function fakeViewer() {
},
creditDisplay: {
addStaticCredit: (credit) => credits.push(credit),
removeStaticCredit: (credit) =>
credits.splice(credits.indexOf(credit), 1),
removeStaticCredit: (credit) => {
// Cesium ignores a credit it does not hold.
const at = credits.indexOf(credit);
if (at !== -1) credits.splice(at, 1);
},
},
};
}
@@ -786,6 +789,62 @@ test('Street View appears only on the Google 3D map: chip, credit, source and im
assert.deepEqual(listed(), ['mapillary']);
});
test('under Google 3D the ground is measured again once its tiles load, without a move', async (t) => {
const saved = globalThis.document;
globalThis.document = new EventTarget();
t.after(() => {
globalThis.document = saved;
});
t.mock.timers.enable({ apis: ['setTimeout'] });
const viewer = fakeViewer();
// 4 km up the ellipsoid, 2 km over a city whose tiles are still loading.
let ground;
viewer.camera.positionCartographic = { height: 4000 };
viewer.scene.globe = { show: false };
viewer.scene.sampleHeightSupported = true;
viewer.scene.sampleHeight = () => ground;
const layer = createStreetLevelLayer({ providers: [fakeProvider()] });
layer.init(viewer);
layer.enable(viewer);
t.after(() => layer.destroy());
assert.equal(layer.getUIState().groundClickReady, false);
ground = 2000;
t.mock.timers.tick(1000);
assert.equal(layer.getUIState().groundClickReady, true);
});
test('an unknown ground is measured only a few times, and not after the layer is off', (t) => {
const saved = globalThis.document;
globalThis.document = new EventTarget();
t.after(() => {
globalThis.document = saved;
});
t.mock.timers.enable({ apis: ['setTimeout'] });
const viewer = fakeViewer();
let samples = 0;
viewer.camera.positionCartographic = { height: 4000 };
viewer.scene.globe = { show: false };
viewer.scene.sampleHeightSupported = true;
viewer.scene.sampleHeight = () => {
samples++;
return undefined;
};
const layer = createStreetLevelLayer({ providers: [fakeProvider()] });
layer.init(viewer);
layer.enable(viewer);
t.after(() => layer.destroy());
t.mock.timers.tick(60_000);
const measured = samples;
assert.ok(measured > 1 && measured <= 12, `measured ${measured} times`);
viewer.camera.moveEnd.raiseEvent();
layer.disable();
const atDisable = samples;
t.mock.timers.tick(60_000);
assert.equal(samples, atDisable, 'no measuring once the layer is off');
});
test('the UI state says when a click (or the button) can open Street View here', async (t) => {
const { layer } = await withStreetView(t);
assert.equal(typeof layer.getUIState().groundClickReady, 'boolean');
@@ -21,6 +21,8 @@ export function createMapillaryViewer({ source, render } = {}) {
let viewer = null;
let Library = null;
let container = null;
/** The viewer being built for a host: a second mount joins it, not a twin. */
let building = null;
let pendingOpen = null;
/** Bumped by `unmount`, so a construction it overtook builds nothing. */
let generation = 0;
@@ -100,8 +102,17 @@ export function createMapillaryViewer({ source, render } = {}) {
}
}
async function ensureViewer(host) {
if (viewer && container === host) return viewer;
function ensureViewer(host) {
if (viewer && container === host) return Promise.resolve(viewer);
if (building?.host === host) return building.promise;
const promise = buildViewer(host).finally(() => {
if (building?.promise === promise) building = null;
});
building = { host, promise };
return promise;
}
async function buildViewer(host) {
const built = generation;
destroyViewer();
const { Viewer } = await ensureLibrary();
@@ -177,6 +188,7 @@ export function createMapillaryViewer({ source, render } = {}) {
pendingOpen = null;
// A build still waiting for the library gives up when it arrives.
generation++;
building = null;
destroyViewer();
},
+13
View File
@@ -180,6 +180,19 @@ test('Esc in a text field or rich-text editor keeps the selection (M61)', () =>
}
});
test('a click on the position marker neither selects nor opens at the ground', () => {
const h = harness();
try {
h.scene.under = 'sl:pos';
h.click();
assert.deepEqual(h.groundClicks, []);
assert.deepEqual(h.opened, []);
assert.deepEqual(h.sequences.selects, []);
} finally {
h.restore();
}
});
test('a click on nothing of ours goes to the ground handler with the pick, which decides', () => {
const h = harness();
try {
@@ -385,6 +385,19 @@ test('a Mapillary viewer unmounted mid-download is never built; the next mount b
assert.equal(viewers.live, 0, 'nothing left holding a WebGL context');
});
test('two mounts on one host during the first download build one Mapillary viewer', async () => {
const { gate, viewers } = fakeLibrary();
const adapter = createMapillaryViewer({ source: { token: 't' } });
const element = {};
const first = adapter.mount(element);
const second = adapter.mount(element);
gate.resolve();
await Promise.all([first, second]);
assert.equal(viewers.created, 1);
adapter.unmount();
assert.equal(viewers.live, 0, 'nothing left holding a WebGL context');
});
test('closing the photo stops sequence playback in the hidden viewer', async () => {
const { gate, viewers } = fakeLibrary();
gate.resolve();
+5 -2
View File
@@ -205,8 +205,11 @@ export function fakeCesiumViewer({ view = false } = {}) {
camera,
creditDisplay: {
addStaticCredit: (credit) => credits.push(credit),
removeStaticCredit: (credit) =>
credits.splice(credits.indexOf(credit), 1),
removeStaticCredit: (credit) => {
// Cesium ignores a credit it does not hold.
const at = credits.indexOf(credit);
if (at !== -1) credits.splice(at, 1);
},
},
};
}
+1 -15
View File
@@ -53,8 +53,7 @@ import { applyLocaleChoice, mountLocalePicker } from './localePicker.js';
* Responsibilities:
* - Visual controls and presets backed by the VisualEffects controller.
* - Bloom and sharpen post-processing toggle/intensity control.
* - Draggable/collapsible panel system with localStorage persistence,
* z-order stacking, and viewport-clamped positioning.
* - Collapsible rail panels with their collapsed state kept in localStorage.
* - CCTV panel: camera selection, coverage toggle, projection, calibration
* sliders, auto-hop, and summary typewriter effect.
* - Location bar with city/POI preset pills, QWERTY key navigation,
@@ -1204,19 +1203,6 @@ export class StyleManager extends ShellFacade {
return true;
}
/**
* Makes a panel draggable via its handle element. Implements:
* - Z-order promotion: each pointerdown increments the global z-counter
* so the clicked panel floats above siblings.
* - Viewport clamping: drag moves are clamped to a 6px inset from all edges.
* - Right-rail pinning: pp-toggles panel is re-anchored right after drag.
* - CCTV viewport sync: cctv-panel recalculates scroll height after drag.
* @param {string} panelId - DOM id of the panel.
* @param {HTMLElement} panelEl - The panel DOM element.
* @param {HTMLElement} handleEl - The drag handle element within the panel.
* @returns {void}
*/
/**
* Programmatically collapses or expands a panel, persists the state,
* and triggers layout recalculation for dependent panels.
-8
View File
@@ -189,12 +189,6 @@ export class PanelLayoutController {
const stack = this._rightPanelStack;
if (!stack || !this._ppToggles) return;
this._ppToggles.style.removeProperty('top');
this._ppToggles.style.removeProperty('right');
this._ppToggles.style.removeProperty('bottom');
this._ppToggles.style.removeProperty('left');
this._ppToggles.style.removeProperty('z-index');
this._ppToggles.querySelector('.pp-header-row')?.removeAttribute('title');
stack.prepend(this._ppToggles);
const globalContextPanel = document.getElementById('global-context-panel');
for (const panel of [
@@ -204,8 +198,6 @@ export class PanelLayoutController {
this._streetLevelPanel,
]) {
if (!panel) continue;
for (const property of ['top', 'right', 'bottom', 'left', 'z-index'])
panel.style.removeProperty(property);
stack.insertBefore(panel, globalContextPanel);
this._syncPanelCollapseButton(panel);
}
+16
View File
@@ -8,6 +8,11 @@ const PANEL_LAYOUT_STORAGE_VERSION = 'v6';
* the layout-reset notice and the documented key.
*/
const PANEL_POSITION_STORAGE_VERSION = 'v8';
/** Floating panels' stored positions and hint flag: nothing reads them now. */
const RETIRED_PANEL_KEY_PREFIXES = [
`godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelPos.`,
`godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelFloatHintShown`,
];
export class PanelPositionControls {
constructor({ syncPanelCollapseButton, showToast }) {
this._syncPanelCollapseButton = syncPanelCollapseButton;
@@ -15,6 +20,7 @@ export class PanelPositionControls {
}
_maybeNotifyLayoutReset() {
this._dropRetiredPanelKeys();
try {
const marker = `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.layoutResetNotified`;
if (localStorage.getItem(marker)) return;
@@ -32,6 +38,16 @@ export class PanelPositionControls {
}
}
_dropRetiredPanelKeys() {
try {
for (const key of Object.keys(localStorage))
if (RETIRED_PANEL_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)))
localStorage.removeItem(key);
} catch {
// storage unavailable
}
}
_panelCollapseStorageKey(panelId) {
return `godsEyeView.${PANEL_LAYOUT_STORAGE_VERSION}.panelCollapsed.${panelId}`;
}
+34
View File
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { PanelPositionControls } from './panelPositionControls.js';
/** A localStorage stand-in whose keys enumerate like the real one. */
function fakeStorage(entries) {
const storage = {};
Object.defineProperties(storage, {
getItem: { value: (key) => (key in storage ? storage[key] : null) },
setItem: { value: (key, value) => (storage[key] = String(value)) },
removeItem: { value: (key) => delete storage[key] },
});
Object.assign(storage, entries);
return storage;
}
test('the floating panels’ stored positions and hint flag are dropped; collapse state stays', (t) => {
const saved = globalThis.localStorage;
globalThis.localStorage = fakeStorage({
'godsEyeView.v8.panelPos.cctv-panel': '{"x":1}',
'godsEyeView.v8.panelFloatHintShown': '1',
'godsEyeView.v6.panelCollapsed.cctv-panel': '1',
'godsEyeView.v8.layoutResetNotified': '1',
});
t.after(() => {
globalThis.localStorage = saved;
});
const controls = new PanelPositionControls({ showToast() {} });
controls._maybeNotifyLayoutReset();
assert.deepEqual(Object.keys(globalThis.localStorage).sort(), [
'godsEyeView.v6.panelCollapsed.cctv-panel',
'godsEyeView.v8.layoutResetNotified',
]);
});
+2 -12
View File
@@ -228,7 +228,7 @@ test('recording restores the original HUD after repeated entry and revokes retai
}
});
test('weather rail orders between CCTV and Context, resets positions, observes and syncs chrome', (t) => {
test('weather rail orders between CCTV and Context, observes and syncs chrome', (t) => {
const f = fixture();
const observed = new Set();
const previousResizeObserver = globalThis.ResizeObserver;
@@ -263,16 +263,7 @@ test('weather rail orders between CCTV and Context, resets positions, observes a
]) {
const panel = f.nodes.get(id) || f.element();
panel.id = id;
const positions = new Map(
['top', 'right', 'bottom', 'left', 'z-index'].map((key) => [
key,
'20px',
]),
);
panel.style = {
removeProperty: (key) => positions.delete(key),
positions,
};
panel.style = {};
panel.classList.add('collapsed');
const button = f.element();
button.closest = () => panel;
@@ -307,7 +298,6 @@ test('weather rail orders between CCTV and Context, resets positions, observes a
);
assert.ok(synced.includes('weather-panel'));
assert.ok(observed.has(weather));
assert.equal(weather.style.positions.size, 0);
assert.equal(weather.button.textContent, '◀');
assert.equal(weather.button.getAttribute('aria-expanded'), 'false');
assert.equal(weather.button.title, 'Expand WEATHER');
+1 -1
View File
@@ -381,7 +381,7 @@ body.cockpit-mode #cockpit-cloud-effects.active {
position: fixed;
right: 36px;
bottom: 36px;
/* Z ladder: panels 100-139, voice pill 150, toast 200, clean-view-exit 300 */
/* Z ladder: panels ~100, voice pill 150, toast 200, clean-view-exit 300 */
z-index: 150;
display: flex;
align-items: center;