From fbbc4cda5c1ec178b75679c12008ee6809eaa94f Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 7 Oct 2026 21:39:33 +0400 Subject: [PATCH] refactor(ui): remove floating, resizable rail panels PR #768 bundled a portable-panel windowing system with the Street Level layer. Remove it so CCTV and Street Level are ordinary docked right-rail panels again, as on main before the merge. Removed: - header drag to float, 8-way edge/corner resize, double-press snap-back, dockOnCollapse, command-dock clearance, float hint toast, floating z floor, persisted floating geometry and ResizeObserver re-clamping (panelPositionControls.js restored to its pre-#768 version) - src/ui/panelResize.js and its tests, panelDock.test.mjs - onPanelResized/dockPanel wiring in PanelChrome, the application shell and StreetLevelControls (EXPAND/SHRINK no longer docks) - :not(.panel-floating) guards and .panel-floating/.panel-portable/resize CSS - qa:panel-resize, qa-panel-resize.mjs, qa-panelDrag.mjs, their tests, the CI "Portable panel gate" step, QA_FAIL_ON_RETRY, and the Street Level gate's float/resize/dock steps - floating-panel docs in README, CHANGELOG, CURRENT-STATE, KNOWN-ISSUES Kept: the Street Level panel as a docked rail panel (share/cockpit/rail lists, layout controller, rail and Cyber CSS, EXPAND viewer), and the independent right-rail alignment fix (settledLeftRailTop, 9dde7b4). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 6 - CHANGELOG.md | 4 - README.md | 2 +- docs/CURRENT-STATE.md | 15 +- docs/KNOWN-ISSUES.md | 6 +- package.json | 1 - scripts/format-scope.json | 5 - scripts/package-boundaries.json | 3 - scripts/qa-panel-resize.mjs | 456 ------------------ scripts/qa-panelDrag.mjs | 319 ------------- scripts/qa-street-level.mjs | 285 +++-------- src/rightRailPolicy.test.mjs | 6 +- src/streetLevelMarkup.test.mjs | 49 +- src/tooling/panelResizeQa.test.mjs | 57 --- src/tooling/streetLevelQa.test.mjs | 267 +---------- src/ui/applicationShell.js | 9 - src/ui/cyberPanelDisclosure.test.mjs | 13 - src/ui/panelChrome.js | 16 +- src/ui/panelDock.test.mjs | 580 ----------------------- src/ui/panelLayoutController.js | 9 +- src/ui/panelPositionControls.js | 684 +++------------------------ src/ui/panelRails.test.mjs | 27 +- src/ui/panelResize.js | 47 -- src/ui/panelResize.test.mjs | 64 --- src/ui/rightPanelRail.js | 4 +- src/ui/streetLevelControls.js | 15 +- src/ui/streetLevelControls.test.mjs | 2 - src/ui/styles/controls.css | 109 ----- src/ui/styles/layers.css | 31 +- src/ui/styles/responsive.css | 8 +- src/ui/styles/street-level.css | 47 -- 31 files changed, 201 insertions(+), 2945 deletions(-) delete mode 100644 scripts/qa-panel-resize.mjs delete mode 100644 scripts/qa-panelDrag.mjs delete mode 100644 src/tooling/panelResizeQa.test.mjs delete mode 100644 src/ui/panelDock.test.mjs delete mode 100644 src/ui/panelResize.js delete mode 100644 src/ui/panelResize.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 375bc9077..933f3291e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,8 +67,6 @@ jobs: timeout-minutes: 30 env: GEV_REQUIRE_ALLOCATION_GATE: '0' - # Fail on a missed header press unless the panel itself moved. - QA_FAIL_ON_RETRY: '1' steps: - name: Check out source uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 @@ -104,10 +102,6 @@ jobs: - name: Street Level gate with Mapillary fixtures run: npm run qa:street-level:fixtures -- --strict --url http://localhost:4173 - - name: Portable panel gate - if: success() || failure() - run: npm run qa:panel-resize -- --url http://localhost:4173 - - name: Preview server log if: failure() run: cat preview.log diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b3876f79..887a21566 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,10 +14,6 @@ refuses cross-site requests, rate-limits per IP and backs off when Mapillary rejects the token or rate-limits. -- The CCTV and Street Level panels are portable: drag the header to float the - panel, resize it from any edge or corner, and double-click the header to dock - it again. The position persists across reloads. - - Add Norway's road cameras from Statens vegvesen as a CCTV pack. The camera list is one keyless GeoJSON request to the agency's OGC API view of its DATEX 3.1 CCTV table, so no DATEX account is needed. Every working camera diff --git a/README.md b/README.md index 1c2f375b5..22437b67f 100644 --- a/README.md +++ b/README.md @@ -340,7 +340,7 @@ _The Space Missions layer replaying a Falcon 9 ascent β€” labeled `RECONSTRUCTED **Use.** Turn on **Street Level** under DATA LAYERS β†’ Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green). The **IMAGERY** (ALL / 360Β° / FLAT) and **SINCE** filters apply to every provider. Click a coverage line for its image cones and a cone to open the photo. Above the photo: **EXPAND**, **FIT / FILL**, and **FOLLOW** (the globe camera follows the photo's view; Google 3D map only). The caption links to the image on the provider's site, and the globe credits "Β© Mapillary contributors, CC BY-SA 4.0" while Mapillary is on. -Drag the panel header to float the panel, and resize it from any edge. Double-click the header or collapse it to put it back in the rail. Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`. +Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`. ## πŸŽ–οΈ Field Missions diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d51be986b..a1f7e1612 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2978,11 +2978,8 @@ and is refused in the cockpit: turning FOLLOW on claims the camera at once, and FOLLOW stops when another feature takes it; a photo takes a deferred ticket when it starts opening and frames only if nothing newer took the camera while it loaded. SINCE is a stepped slider whose readout names the -cut-off date. The panel is portable (`street-level-panel` spec, minimum -320 Γ— 280, `dockOnCollapse`): a floating window gives spare height to the -viewer, and collapsing it, double-clicking the header or SHRINK after EXPAND -docks it. At phone width (≀720 px) the viewer height is derived from the rail -band so the whole photo fits. +cut-off date. At phone width (≀720 px) the viewer height is derived from the +rail band so the whole photo fits. Providers implement the contract in `src/layers/streetLevel/registry.js`. The core routes clicks by pick prefix, swaps viewer adapters in the one host, @@ -2998,8 +2995,8 @@ runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run qa:street-level:fixtures -- --url ` answers every Mapillary request from fixtures (`providers/mapillary/coverageFixture.mjs`, `scripts/fixtures/street-level/`), so it needs no network and covers the whole -photo flow. CI runs the fixture gate and `qa:panel-resize` -against a production build served by `vite preview` with a dummy token. +photo flow. 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 @@ -4429,8 +4426,8 @@ easier to meet (detection is now on more often), but does not create it. samples and constant elevation during E/N drag; one shared-floor resolution on release; late one-shot shared-cell work is permitted during viewshed idle. The A+B harness intentionally excludes citywide LOD assertions. -- Panel positions have a versioned storage name, `godsEyeView.v8.panelPos.`. Docked panels store nothing; a portable panel (CCTV, Street Level) stores `{ left, top, width, height, floating: true }` once a header drag lifts it out of the rail, and a header double-click removes it. Double presses are detected in the header's `pointerdown` handler, because its `preventDefault()` suppresses native `dblclick`. Collapsed state persists for every panel at `godsEyeView.v6.panelCollapsed.` (`'0'` open, `'1'` closed, absent means the panel's own default). -- Legacy draggable-panel position keys may remain in local storage for backward compatibility, but the map-mode right rail ignores them unless they describe a portable panel's floating window; collapsed states still persist at `godsEyeView.v6.panelCollapsed.`. +- Panel positions have a versioned storage name, `godsEyeView.v8.panelPos.`, but the current rails lay panels out adaptively and write none. Collapsed state does persist for every panel at `godsEyeView.v6.panelCollapsed.` (`'0'` open, `'1'` closed, absent means the panel's own default). +- Legacy draggable-panel position keys may remain in local storage for backward compatibility, but the map-mode right rail ignores them; collapsed states still persist at `godsEyeView.v6.panelCollapsed.`. - Flight/military tracked entities cache dead-reckoned positions per frame to avoid callback desync flicker. - Aircraft 3D-model and tracking invariants are covered by `npm run test:track`; run this before touching `flights.js`, `militaryFlights.js`, `detection.js`, or `trackedReadout.js`. - Annotation resolver behavior is pinned by `src/annotations/annotationResolver.test.mjs`; re-run that suite before changing place-resolution scoring. diff --git a/docs/KNOWN-ISSUES.md b/docs/KNOWN-ISSUES.md index e93dd0ccf..e41623cc6 100644 --- a/docs/KNOWN-ISSUES.md +++ b/docs/KNOWN-ISSUES.md @@ -57,9 +57,9 @@ Related keys (current versions): - Panel collapsed state: `godsEyeView.v6.panelCollapsed.` β€” `'0'` open, `'1'` closed, absent means the panel's own default. A view opened from a share link ignores the stored value entirely. -- Panel positions: `godsEyeView.v8.panelPos.` β€” written only for a - floating portable panel (CCTV, Street Level). Deleting it, or double-clicking - the panel header, docks the panel again. +- Panel positions: `godsEyeView.v8.panelPos.` β€” the versioned name for a + stored position. The current layout writes none, so deleting one changes + nothing. - CCTV calibration: `godsEyeView.cctv.calibration.v2` --- diff --git a/package.json b/package.json index 9222cd6c4..cbfe75a0b 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,6 @@ "test": "node scripts/run-unit-tests.mjs", "test:track": "node scripts/track-regression.mjs", "qa:map-source-tray": "node scripts/qa-map-source-tray.mjs", - "qa:panel-resize": "node scripts/qa-panel-resize.mjs", "qa:street-level": "node scripts/qa-street-level.mjs", "qa:street-level:fixtures": "node scripts/qa-street-level.mjs --fixtures", "check:boundaries": "node scripts/check-import-directions.mjs && node scripts/check-package-boundaries.mjs", diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 7062704a7..94abd4e08 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -36,9 +36,6 @@ "src/main.js", "docs/APPLICATION.md", "scripts/qa-application.mjs", - "scripts/qa-panel-resize.mjs", - "scripts/qa-panelDrag.mjs", - "src/tooling/panelResizeQa.test.mjs", "scripts/qa-terrain-429.mjs", "src/app/application.js", "src/app/application.test.mjs", @@ -168,7 +165,6 @@ "src/ui/panelRails.js", "src/ui/leftPanelRail.js", "src/ui/rightPanelRail.js", - "src/ui/panelResize.test.mjs", "src/ui/panelMeasurement.js", "src/ui/panelRailGeometry.js", "src/ui/panelRails.test.mjs", @@ -917,7 +913,6 @@ "src/layers/streetLevel/providers/mapillary/coverageFixture.mjs", "src/layers/streetLevel/cameraFollow.test.mjs", "src/ui/streetLevelControls.test.mjs", - "src/ui/panelDock.test.mjs", "src/layers/streetLevel/marker.test.mjs", "src/layers/streetLevel/viewerHost.test.mjs", "src/layers/streetLevel/providers/mapillary/sequences.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index ce778ee95..4f53ab761 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -623,7 +623,6 @@ "src/ui/panelLayoutController.js", "src/ui/panelMeasurement.js", "src/ui/panelPositionControls.js", - "src/ui/panelResize.js", "src/ui/panelRailGeometry.js", "src/ui/panelRails.js", "src/ui/radio.js", @@ -1912,7 +1911,6 @@ "src/ui/panelLayoutController.js", "src/ui/panelMeasurement.js", "src/ui/panelPositionControls.js", - "src/ui/panelResize.js", "src/ui/panelRailGeometry.js", "src/ui/panelRails.js", "src/ui/radio.js", @@ -2809,7 +2807,6 @@ "src/ui/panelLayoutController.js", "src/ui/panelMeasurement.js", "src/ui/panelPositionControls.js", - "src/ui/panelResize.js", "src/ui/panelRailGeometry.js", "src/ui/panelRails.js", "src/ui/rightPanelRail.js", diff --git a/scripts/qa-panel-resize.mjs b/scripts/qa-panel-resize.mjs deleted file mode 100644 index 026b6f2fb..000000000 --- a/scripts/qa-panel-resize.mjs +++ /dev/null @@ -1,456 +0,0 @@ -#!/usr/bin/env node -/** - * Browser QA for the floating CCTV panel: lift, resize from every handle, - * minimum size, reload, and dock again. `--fail-on-retry` (or - * QA_FAIL_ON_RETRY=1) fails on any header-press retry. - */ -import assert from 'node:assert/strict'; -import { pathToFileURL } from 'node:url'; -import { RESIZE_DIRECTIONS, resizeBox } from '../src/ui/panelResize.js'; -import { - describePress, - dockPanelByDoubleClick, - dragPointer, - liftPanelByHeader, - nextFrames, -} from './qa-panelDrag.mjs'; -import { - hookRenderErrors, - readRenderErrors, - saveFailureArtifacts, - watchPage, -} from './qa-browserEvidence.mjs'; - -export const PANEL_ID = 'cctv-panel'; -export const STORAGE_KEY = `godsEyeView.v8.panelPos.${PANEL_ID}`; -export const MIN_SIZE = { width: 300, height: 160 }; -export const ARTIFACT_DIR = 'qa-artifacts/panel-resize'; -const VIEWPORT = { width: 1400, height: 900 }; -const EDGE_BY_SIDE = { n: 'top', s: 'bottom', e: 'right', w: 'left' }; - -/** Box edges a resize in `dir` must leave exactly where they were. */ -export function pinnedEdges(dir) { - return Object.entries(EDGE_BY_SIDE) - .filter(([side]) => !dir.includes(side)) - .map(([, edge]) => edge); -} - -/** Pointer travel that grows a panel by `amount` on every edge named in `dir`. */ -export function growthDelta(dir, amount) { - return { - dx: dir.includes('e') ? amount : dir.includes('w') ? -amount : 0, - dy: dir.includes('s') ? amount : dir.includes('n') ? -amount : 0, - }; -} - -/** - * Viewport point inside the handle for `dir`, given the panel's client rect. - * Edge strips straddle the border by 3px; corners and the grip sit inside. - */ -export function handlePoint(rect, dir) { - const inset = dir.length === 2 ? 4 : 1; - const x = dir.includes('w') - ? rect.left + inset - : dir.includes('e') - ? rect.right - inset - : rect.left + rect.width / 2; - const y = dir.includes('n') - ? rect.top + inset - : dir.includes('s') - ? rect.bottom - inset - : rect.top + rect.height / 2; - return { x, y }; -} - -/** Pinned edges that moved during a resize in `dir`. */ -export function driftedEdges(before, after, dir, tolerance = 1.5) { - const edges = (box) => ({ - left: box.left, - top: box.top, - right: box.left + box.width, - bottom: box.top + box.height, - }); - const a = edges(before); - const b = edges(after); - return pinnedEdges(dir).filter( - (edge) => Math.abs(a[edge] - b[edge]) > tolerance, - ); -} - -function near(actual, expected, tolerance, label) { - assert.ok( - Math.abs(actual - expected) <= tolerance, - `${label}: expected ${expected} Β± ${tolerance}, got ${actual}`, - ); -} - -async function main() { - const { default: puppeteer } = await import('puppeteer'); - const args = process.argv.slice(2); - const urlIndex = args.indexOf('--url'); - const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173'; - if (args.includes('--fail-on-retry')) process.env.QA_FAIL_ON_RETRY = '1'; - const browser = await puppeteer.launch({ - headless: true, - executablePath: - process.env.PUPPETEER_EXECUTABLE_PATH || - (await puppeteer.executablePath()), - args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'], - }); - const monitors = []; - try { - const page = await browser.newPage(); - await page.setViewport(VIEWPORT); - const errors = []; - monitors.push(watchPage(page, { name: 'panel', errors })); - await hookRenderErrors(page); - - const boot = async () => { - await page.goto(`${url}/`, { waitUntil: 'domcontentloaded' }); - await page.waitForFunction(() => window.__godsEyeView?.viewer, { - timeout: 90_000, - }); - await page.waitForSelector(`#${PANEL_ID} .panel-header`); - await page.evaluate(() => { - document.querySelector('.first-run-explore')?.click(); - }); - await page.keyboard.press('Escape'); - await page.evaluate(() => { - document.getElementById('first-run-launcher')?.remove(); - for (const node of document.querySelectorAll('[class*=first-run]')) - node.remove(); - }); - await page.mouse.click(VIEWPORT.width / 2, VIEWPORT.height / 2); - await nextFrames(page); - }; - const readPanel = () => - page.evaluate((id) => { - const panel = document.getElementById(id); - const rect = panel.getBoundingClientRect(); - return { - left: rect.left, - top: rect.top, - width: rect.width, - height: rect.height, - // handlePoint() reads the far edges for s/e handles. - right: rect.right, - bottom: rect.bottom, - floating: panel.classList.contains('panel-floating'), - collapsed: panel.classList.contains('collapsed'), - inRail: panel.parentElement?.id === 'right-context-rail', - allocated: panel.style.getPropertyValue( - '--right-panel-allocated-height', - ), - inlineWidth: panel.style.width, - inlineHeight: panel.style.height, - stored: localStorage.getItem(`godsEyeView.v8.panelPos.${id}`), - }; - }, PANEL_ID); - /** - * Best effort: wait for the box to hold still for half a second. The lift - * is checked against the box at pointerdown, so a timeout only costs time. - */ - const settle = async () => { - try { - await page.waitForFunction( - (id) => { - const r = document.getElementById(id).getBoundingClientRect(); - const box = [r.left, r.top, r.width, r.height] - .map(Math.round) - .join(','); - const now = performance.now(); - if (window.__qaLastBox !== box) { - window.__qaLastBox = box; - window.__qaSince = now; - } - return now - window.__qaSince >= 500; - }, - { polling: 100, timeout: 20_000 }, - PANEL_ID, - ); - } catch (error) { - if (error?.name !== 'TimeoutError') throw error; - console.log('note: the CCTV panel was still moving; lifting anyway'); - } - }; - const headerPoint = async () => { - const rect = await page.evaluate((id) => { - const title = document.querySelector( - `#${id} .panel-header .panel-title`, - ); - const r = title.getBoundingClientRect(); - return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; - }, PANEL_ID); - return rect; - }; - const drag = (from, dx, dy) => dragPointer(page, from, dx, dy); - - await page.goto(`${url}/`, { waitUntil: 'domcontentloaded' }); - await page.evaluate((key) => { - localStorage.removeItem(key); - localStorage.removeItem('godsEyeView.v8.panelFloatHintShown'); - }, STORAGE_KEY); - await boot(); - - await page.evaluate((id) => { - const panel = document.getElementById(id); - if (panel.classList.contains('collapsed')) - document - .querySelector(`.panel-collapse-btn[data-collapse-target="${id}"]`) - .click(); - }, PANEL_ID); - await page.waitForFunction( - (id) => !document.getElementById(id).classList.contains('collapsed'), - {}, - PANEL_ID, - ); - // The rail animates panel heights, and on a slow software renderer the - // panels above CCTV can still be moving after a fixed sleep. - await settle(); - const docked = await readPanel(); - assert.equal(docked.floating, false); - assert.equal(docked.inRail, true); - - // Names whatever above CCTV in the rail shifted it, for a retry note. - const railAbove = () => - page.evaluate((id) => { - const out = []; - for (const node of document.getElementById('right-context-rail') - ?.children || []) { - if (node.id === id) break; - out.push( - `${node.id || node.className}:${Math.round(node.offsetHeight)}`, - ); - } - return out.join(' '); - }, PANEL_ID); - const aboveAtDock = await railAbove(); - // On CI a panel above CCTV grows after load and can shift the header out - // from under the press; the helper retries only such a provable miss. - const attempt = await liftPanelByHeader(page, PANEL_ID, { - dx: -200, - dy: -100, - note: async () => - `rail above CCTV ${aboveAtDock} -> ${await railAbove()}`, - }); - const { pressed } = attempt; - const lifted = await readPanel(); - assert.equal( - lifted.floating, - true, - `a header drag lifts the panel out (${describePress(attempt)}; panel docked at ${docked.top}, at press ${pressed?.top})`, - ); - assert.equal( - lifted.collapsed, - false, - 'the drag-ending click must not collapse', - ); - const moved = `docked at ${docked.left},${docked.top}; pressed at ${pressed.left},${pressed.top}`; - near( - lifted.left, - Math.max(6, pressed.left - 200), - 2, - `lifted left (${moved})`, - ); - near( - lifted.top, - Math.max(6, pressed.top - 100), - 2, - `lifted top (${moved})`, - ); - near(lifted.width, pressed.width, 2, 'lifted width'); - // The rail lets go of the lifted panel on its next layout pass. - await page - .waitForFunction( - (id) => - document - .getElementById(id) - .style.getPropertyValue('--right-panel-allocated-height') === '', - { timeout: 5_000 }, - PANEL_ID, - ) - .catch(() => {}); - const afterLayout = await readPanel(); - assert.equal(afterLayout.allocated, '', 'the rail no longer allocates it'); - const railExcludes = await page.evaluate((id) => { - const rail = document.getElementById('right-context-rail'); - // Same rule as the rail: hidden panels take no room. - const counted = [...rail.children].filter((panel) => - panel.matches( - '[data-panel-id]:not(.panel-floating):not(.collapsed):not([hidden])', - ), - ); - return { - expandedCount: rail.dataset.expandedCount, - counted: counted.length, - includesPanel: counted.some((panel) => panel.id === id), - }; - }, PANEL_ID); - assert.equal(railExcludes.includesPanel, false); - if (railExcludes.expandedCount !== undefined) - assert.equal(Number(railExcludes.expandedCount), railExcludes.counted); - const storedAfterLift = JSON.parse(afterLayout.stored); - assert.equal(storedAfterLift.floating, true); - assert.equal( - 'height' in storedAfterLift, - false, - 'a drag alone must not freeze the measured height', - ); - console.log('PASS: header drag lifts CCTV out of the rail'); - - // Park near the top: the voice dock would cover the south corners once - // the loop below has grown the window. - { - const header = await headerPoint(); - await drag(header, 0, 120 - header.y); - } - - const limits = { - minWidth: MIN_SIZE.width, - minHeight: MIN_SIZE.height, - viewportWidth: VIEWPORT.width, - viewportHeight: VIEWPORT.height, - }; - for (const dir of RESIZE_DIRECTIONS) { - const before = await readPanel(); - const { dx, dy } = growthDelta(dir, 40); - const point = handlePoint(before, dir); - const hit = await page.evaluate(({ x, y }) => { - const node = document.elementFromPoint(x, y); - if (!node) return 'nothing'; - const dirAttr = node.dataset?.dir - ? `[data-dir=${node.dataset.dir}]` - : ''; - return `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}.${[...node.classList].join('.')}${dirAttr}`; - }, point); - await drag(point, dx, dy); - const after = await readPanel(); - const expected = resizeBox(before, dir, dx, dy, limits); - for (const key of ['left', 'top', 'width', 'height']) - near(after[key], expected[key], 2, `${dir} ${key} (pointer on ${hit})`); - assert.deepEqual(driftedEdges(before, after, dir, 2), [], `${dir} pins`); - } - console.log('PASS: eight resize handles keep the opposite edge pinned'); - - let before = await readPanel(); - await drag(handlePoint(before, 'se'), -2000, -2000); - let after = await readPanel(); - near(after.width, MIN_SIZE.width, 2, 'min width (se)'); - near(after.height, MIN_SIZE.height, 2, 'min height (se)'); - assert.deepEqual(driftedEdges(before, after, 'se', 2), []); - before = after; - await drag(handlePoint(before, 'nw'), 2000, 2000); - after = await readPanel(); - near(after.width, MIN_SIZE.width, 2, 'min width (nw)'); - near(after.height, MIN_SIZE.height, 2, 'min height (nw)'); - assert.deepEqual(driftedEdges(before, after, 'nw', 2), []); - console.log('PASS: minimum size holds from both corners'); - - const saved = await readPanel(); - const record = JSON.parse(saved.stored); - assert.equal(record.floating, true); - await boot(); - const restored = await readPanel(); - assert.equal(restored.floating, true, 'floating survives a reload'); - for (const key of ['left', 'top', 'width', 'height']) - near(restored[key], record[key], 2, `restored ${key}`); - console.log('PASS: reload restores position and size'); - - // A collapsed window keeps the height chosen for it, through a drag of - // its header strip and a reload. - const setCollapsed = async (collapsed) => { - await page.evaluate( - (id, want) => { - const panel = document.getElementById(id); - if (panel.classList.contains('collapsed') !== want) - document - .querySelector( - `.panel-collapse-btn[data-collapse-target="${id}"]`, - ) - .click(); - }, - PANEL_ID, - collapsed, - ); - await page.waitForFunction( - (id, want) => - document.getElementById(id).classList.contains('collapsed') === want, - {}, - PANEL_ID, - collapsed, - ); - await nextFrames(page); - }; - await drag(handlePoint(await readPanel(), 'se'), 0, 200); - const chosen = await readPanel(); - assert.ok( - chosen.height > MIN_SIZE.height + 150, - `the window grew to ${chosen.height}px`, - ); - await setCollapsed(true); - const strip = await readPanel(); - assert.ok(strip.height < chosen.height - 100, 'collapsed to its header'); - await drag(await headerPoint(), 40, 30); - const movedStrip = await readPanel(); - assert.equal(movedStrip.floating, true, 'the strip stays a window'); - near( - JSON.parse(movedStrip.stored).height, - chosen.height, - 2, - 'stored height while collapsed', - ); - await boot(); - await setCollapsed(false); - near( - (await readPanel()).height, - chosen.height, - 2, - 'height after expanding', - ); - console.log( - 'PASS: a collapsed window keeps its height through a drag and a reload', - ); - - const dock = await dockPanelByDoubleClick(page, PANEL_ID); - const snapped = await readPanel(); - assert.equal( - snapped.floating, - false, - `double-click snaps back (${describePress(dock)})`, - ); - assert.equal(snapped.inRail, true); - assert.equal(snapped.inlineWidth, ''); - assert.equal(snapped.inlineHeight, ''); - assert.equal(snapped.stored, null, 'the storage key is gone'); - console.log('PASS: header double-click returns the panel to the rail'); - - const renderErrors = await readRenderErrors(page); - assert.deepEqual( - [...errors, ...renderErrors.map((text) => `scene.renderError: ${text}`)], - [], - 'no page errors and no Cesium render-loop errors', - ); - console.log('PASS: no page errors and no render-loop errors'); - } catch (error) { - const dir = await saveFailureArtifacts({ - dir: ARTIFACT_DIR, - browser, - monitors, - error, - }).catch(() => null); - if (dir) console.error(`failure evidence saved in ${dir}/`); - throw error; - } finally { - await browser.close(); - } -} - -if ( - process.argv[1] && - import.meta.url === pathToFileURL(process.argv[1]).href -) { - main().catch((error) => { - console.error(error.stack || error.message); - process.exit(1); - }); -} diff --git a/scripts/qa-panelDrag.mjs b/scripts/qa-panelDrag.mjs deleted file mode 100644 index fa6cc0c3a..000000000 --- a/scripts/qa-panelDrag.mjs +++ /dev/null @@ -1,319 +0,0 @@ -/** - * Header press-and-verify for the QA gates that lift a rail panel into a - * floating window and dock it again. The rail animates panel heights, so a - * point read once can be under another panel by the time the pointer goes - * down. Only a provable miss (a recorded press outside the header) is - * retried, with a GitHub `::warning::`. With QA_FAIL_ON_RETRY=1 a miss the - * panel's own movement cannot explain fails the run instead. - */ - -const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - -/** Retries after the first attempt, and the pause before each. */ -export const PRESS_RETRIES = 2; -const RETRY_PAUSE_MS = 1000; - -/** Whether a retry fails the run (QA_FAIL_ON_RETRY=1 or --fail-on-retry). */ -export function failOnRetryDefault( - env = process.env, - argv = process.argv.slice(2), -) { - return env.QA_FAIL_ON_RETRY === '1' || argv.includes('--fail-on-retry'); -} - -/** - * A failed attempt whose press landed outside the header. No recorded press - * is a failure, not a miss: a retry would only hide it. - * @param {{done: boolean, pressed: {inHeader: boolean}|null}} attempt - */ -export function pressMissed(attempt) { - return !attempt.done && attempt.pressed?.inHeader === false; -} - -/** Whether the panel's box changed by more than 2 px between two reads. */ -export function panelMoved(before, after) { - if (!before || !after) return false; - return ['left', 'top', 'width', 'height'].some( - (key) => Math.abs((after[key] ?? NaN) - (before[key] ?? NaN)) > 2, - ); -} - -/** The panel's box, rounded, as `left,top widthxheight`. */ -export function formatBox(box) { - if (!box) return 'unknown'; - const r = (value) => Math.round(value ?? NaN); - return `${r(box.left)},${r(box.top)} ${r(box.width)}x${r(box.height)}`; -} - -function panelBox(page, panelId) { - return page.evaluate((id) => { - const r = document.getElementById(id).getBoundingClientRect(); - return { left: r.left, top: r.top, width: r.width, height: r.height }; - }, panelId); -} - -export function headerPoint(page, panelId) { - return page.evaluate((id) => { - const title = document.querySelector(`#${id} .panel-header .panel-title`); - const r = title.getBoundingClientRect(); - return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; - }, panelId); -} - -/** Name the element at a point, and whether it is the panel's header. */ -export function hitAt(page, point, panelId) { - return page.evaluate( - ({ x, y, id }) => { - const node = document.elementFromPoint(x, y); - if (!node) return { target: 'nothing', inHeader: false }; - return { - target: `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}.${[...node.classList].join('.')}`, - inHeader: Boolean(node.closest(`#${id} .panel-header`)), - }; - }, - { ...point, id: panelId }, - ); -} - -/** - * Re-resolve the header point until it hits the header or the wait runs out - * (the press then records the miss). - */ -export async function headerTarget( - page, - panelId, - { tries = 20, intervalMs = 500 } = {}, -) { - let point = await headerPoint(page, panelId); - let hit = await hitAt(page, point, panelId); - for (let i = 0; i < tries && !hit.inHeader; i++) { - await delay(intervalMs); - point = await headerPoint(page, panelId); - hit = await hitAt(page, point, panelId); - } - const box = await panelBox(page, panelId); - return { point, hit, timedOut: !hit.inHeader, box }; -} - -/** - * Best effort: wait until the panel's box has held still for `stillMs`, so - * the header does not move between the hit check and the press. - */ -export async function waitForStill( - page, - panelId, - { stillMs = 300, timeout = 5000 } = {}, -) { - try { - await page.waitForFunction( - (id, ms) => { - const r = document.getElementById(id).getBoundingClientRect(); - const box = [r.left, r.top, r.width, r.height].map(Math.round).join(); - const now = performance.now(); - if (window.__qaStillBox !== box) { - window.__qaStillBox = box; - window.__qaStillSince = now; - } - return now - window.__qaStillSince >= ms; - }, - { polling: 100, timeout }, - panelId, - stillMs, - ); - return true; - } catch (error) { - if (error?.name !== 'TimeoutError') throw error; - return false; - } -} - -/** Record the next pointerdown's target and the panel's box at that moment. */ -export function recordNextPress(page, panelId) { - return page.evaluate((id) => { - window.__qaPressBox = null; - window.addEventListener( - 'pointerdown', - (event) => { - const r = document.getElementById(id).getBoundingClientRect(); - const target = event.target; - window.__qaPressBox = { - left: r.left, - top: r.top, - width: r.width, - height: r.height, - target: `${target.tagName?.toLowerCase()}${target.id ? `#${target.id}` : ''}.${[...(target.classList || [])].join('.')}`, - inHeader: Boolean(target.closest?.(`#${id} .panel-header`)), - }; - }, - { capture: true, once: true }, - ); - }, panelId); -} - -function readPress(page) { - return page.evaluate(() => window.__qaPressBox ?? null); -} - -export async function waitForFloating( - page, - panelId, - floating, - { timeout = 3000 } = {}, -) { - try { - await page.waitForFunction( - (id, want) => - document.getElementById(id).classList.contains('panel-floating') === - want, - { polling: 50, timeout }, - panelId, - floating, - ); - return true; - } catch (error) { - if (error?.name !== 'TimeoutError') throw error; - return false; - } -} - -/** Two paints: by then every layout change an input caused is on screen. */ -export function nextFrames(page) { - return page.evaluate( - () => - new Promise((resolve) => - requestAnimationFrame(() => requestAnimationFrame(() => resolve())), - ), - ); -} - -export async function dragPointer(page, from, dx, dy, { steps = 6 } = {}) { - await page.mouse.move(from.x, from.y); - await page.mouse.down(); - await page.mouse.move(from.x + dx / 2, from.y + dy / 2, { steps }); - await page.mouse.move(from.x + dx, from.y + dy, { steps }); - await page.mouse.up(); - await nextFrames(page); -} - -export function describeRetry( - panelId, - attempt, - { round, retries, extra = '' }, -) { - const { hit, pressed, timedOut, still, box } = attempt; - return [ - `retry ${round + 1}/${retries}: the press missed the ${panelId} header (on ${pressed?.target ?? hit.target})`, - `pre-press hit check ${timedOut ? `timed out (on ${hit.target})` : 'passed'}`, - `stillness ${still === false ? 'timed out' : 'held'}`, - `panel box at hit check ${formatBox(box)} -> at press ${formatBox(pressed)}`, - ] - .concat(extra ? [extra] : []) - .join('; '); -} - -/** Retry `press` only while it provably misses the header. */ -async function pressUntilDecided( - page, - panelId, - press, - { - retries, - log, - note, - pauseMs = RETRY_PAUSE_MS, - failOnRetry = failOnRetryDefault(), - }, -) { - for (let round = 0; ; round++) { - const still = await waitForStill(page, panelId); - await recordNextPress(page, panelId); - const { point, hit, timedOut, box } = await headerTarget(page, panelId); - const done = await press(point); - const attempt = { - point, - hit, - timedOut, - still, - box, - done, - pressed: await readPress(page), - retries: round, - }; - if (round >= retries || !pressMissed(attempt)) return attempt; - const why = describeRetry(panelId, attempt, { - round, - retries, - extra: note ? await note() : '', - }); - if (failOnRetry && !panelMoved(box, attempt.pressed)) - throw new Error( - `QA_FAIL_ON_RETRY=1 turns this retry into a failure: ${why}`, - ); - log(`note: ${why}; retrying`); - log(`::warning title=Panel press retried::${why}`); - await delay(pauseMs); - } -} - -/** - * Lift a docked panel into a floating window with a header drag. - * @returns {Promise<{point: {x: number, y: number}, hit: {target: string, inHeader: boolean}, done: boolean, pressed: object|null}>} - */ -export function liftPanelByHeader( - page, - panelId, - { - dx, - dy, - retries = PRESS_RETRIES, - log = console.log, - note, - pauseMs, - failOnRetry, - } = {}, -) { - return pressUntilDecided( - page, - panelId, - async (point) => { - await dragPointer(page, point, dx, dy); - return waitForFloating(page, panelId, true); - }, - { retries, log, note, pauseMs, failOnRetry }, - ); -} - -/** Dock a floating panel with a header double-click; same result as lifting. */ -export function dockPanelByDoubleClick( - page, - panelId, - { - retries = PRESS_RETRIES, - log = console.log, - note, - pauseMs, - failOnRetry, - } = {}, -) { - return pressUntilDecided( - page, - panelId, - async (point) => { - await page.mouse.click(point.x, point.y, { clickCount: 1 }); - await page.mouse.click(point.x, point.y, { clickCount: 2 }); - return waitForFloating(page, panelId, false); - }, - { retries, log, note, pauseMs, failOnRetry }, - ); -} - -/** Where an attempt pressed, for an assertion message. */ -export function describePress(attempt) { - const { point, hit, pressed } = attempt; - const where = `pressed ${Math.round(point.x)},${Math.round(point.y)}`; - if (!pressed) - return `${where}: no pointerdown reached the page (hit check on ${hit.target}, in header: ${hit.inHeader})`; - const n = attempt.retries || 0; - const retried = n ? `, after ${n} ${n === 1 ? 'retry' : 'retries'}` : ''; - return `${where} on ${pressed.target}, in header: ${pressed.inHeader}${retried}`; -} diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index 59b0584da..342a4aa2b 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -8,9 +8,8 @@ * because the bundle carries it; CI uses a dummy one. Fixture runs also stage * a rejected key and a keyless second server. * - * `--strict` fails on any skip not in STRICT_ALLOWED_SKIPS. `--fail-on-retry` - * (or QA_FAIL_ON_RETRY=1) fails a missed header press unless the panel moved. - * A failed run saves evidence under qa-artifacts/street-level/. + * `--strict` fails on any skip not in STRICT_ALLOWED_SKIPS. A failed run saves + * evidence under qa-artifacts/street-level/. */ import assert from 'node:assert/strict'; import { pathToFileURL } from 'node:url'; @@ -18,12 +17,6 @@ import { LAYER_STATE_REGISTRY } from '../src/data/layerState.js'; import { tileBounds } from '../src/layers/streetLevel/tileMath.js'; import { encodeCoverageTile } from '../src/layers/streetLevel/providers/mapillary/coverageFixture.mjs'; import { COVERAGE_MOVE_DEBOUNCE_MS } from '../src/layers/streetLevel/providers/mapillary/policy.js'; -import { - describePress, - dockPanelByDoubleClick, - liftPanelByHeader, - waitForStill, -} from './qa-panelDrag.mjs'; import { hookRenderErrors, readRenderErrors, @@ -339,6 +332,38 @@ async function clearFirstRun(page) { }); } +/** + * Best effort: wait until the panel's box has held still for `stillMs`, so a + * point read from it does not move before the press. + */ +async function waitForStill( + page, + panelId, + { stillMs = 300, timeout = 5000 } = {}, +) { + try { + await page.waitForFunction( + (id, ms) => { + const r = document.getElementById(id).getBoundingClientRect(); + const box = [r.left, r.top, r.width, r.height].map(Math.round).join(); + const now = performance.now(); + if (window.__qaStillBox !== box) { + window.__qaStillBox = box; + window.__qaStillSince = now; + } + return now - window.__qaStillSince >= ms; + }, + { polling: 100, timeout }, + panelId, + stillMs, + ); + return true; + } catch (error) { + if (error?.name !== 'TimeoutError') throw error; + return false; + } +} + /** * Click a panel control once the panel holds still and the control is what * lies under its centre: the rail animates panel heights, so a point read @@ -528,7 +553,6 @@ async function main() { const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173'; const fixtures = args.includes('--fixtures'); const strict = args.includes('--strict'); - if (args.includes('--fail-on-retry')) process.env.QA_FAIL_ON_RETRY = '1'; const browser = await puppeteer.launch({ headless: true, executablePath: @@ -1425,12 +1449,6 @@ async function main() { await stacks.set(original); }, ); - const expanded = () => - page.evaluate(() => - document - .getElementById('sl-viewer-wrap') - .classList.contains('sl-viewer-wrap-expanded'), - ); await step( 'EXPAND opens a modal dialog (the rest of inert) and Esc returns focus to the button', async () => { @@ -1512,209 +1530,56 @@ async function main() { assert.equal(after.sequence.selectedId, null); }, ); - const floating = () => - page.$eval('#street-level-panel', (node) => - node.classList.contains('panel-floating'), - ); - const center = (selector) => - page.$eval(selector, (node) => { - const r = node.getBoundingClientRect(); - return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; - }); - // Right after a dock the rail is still animating, so the shared helpers - // re-resolve the header and retry only a press that provably missed it. - const floatPanel = async () => { - const attempt = await liftPanelByHeader(page, PANEL_ID, { - dx: -400, - dy: 80, - }); - assert.equal( - attempt.done, - true, - `a header drag lifts the panel out of the rail (${describePress(attempt)})`, - ); - assert.equal(await floating(), true); - }; - await step( - 'the panel floats on a header drag, resizes, and the viewer takes the room', - async () => { - await openById(firstImageId); - await floatPanel(); - const viewerHeight = () => - page.$eval('#sl-viewer', (node) => - Math.round(node.getBoundingClientRect().height), - ); - const before = await viewerHeight(); - const grip = await center('#street-level-panel .panel-resize-grip'); - await page.mouse.move(grip.x, grip.y); - await page.mouse.down(); - await page.mouse.move(grip.x + 120, grip.y + 160, { steps: 10 }); - await page.mouse.up(); - await settle( - page, - (height) => - document.getElementById('sl-viewer').getBoundingClientRect() - .height > - height + 60, - before, - ); - const after = await viewerHeight(); - assert.ok( - after > before + 60, - `viewer grew with the window (${before} β†’ ${after}px)`, - ); - assert.equal( - await page.$eval( - '.sl-settings', - (node) => getComputedStyle(node).overflowY, - ), - 'auto', - 'only the settings block scrolls', - ); - }, - ); - await step( - 'SHRINK after EXPAND docks the window back in the rail at its default size, viewer inside the panel', - async () => { - await clickPanelControl(page, '#sl-viewer-expand'); - await settle(page, () => - document - .getElementById('sl-viewer-wrap') - .classList.contains('sl-viewer-wrap-expanded'), - ); - assert.equal(await expanded(), true, 'expanded'); - await clickPanelControl(page, '#sl-viewer-expand'); - await settle( - page, - () => - !document - .getElementById('street-level-panel') - .classList.contains('panel-floating') && - !document - .getElementById('sl-viewer-wrap') - .classList.contains('sl-viewer-wrap-expanded'), - ); - assert.equal(await floating(), false, 'docked again'); - const style = await page.$eval('#street-level-panel', (node) => ({ - width: node.style.width, - height: node.style.height, - parent: node.parentElement.id, - })); - assert.deepEqual(style, { - width: '', - height: '', - parent: 'right-context-rail', - }); - const home = await page.evaluate(() => { - const wrap = document.getElementById('sl-viewer-wrap'); - return { - inPanel: Boolean(wrap.closest('#street-level-panel')), - onBody: wrap.parentElement === document.body, - }; - }); - assert.deepEqual( - home, - { inPanel: true, onBody: false }, - 'the viewer returned into the panel', - ); - }, - ); - await step('a header double-click docks a floating window', async () => { - await floatPanel(); - const attempt = await dockPanelByDoubleClick(page, PANEL_ID); - assert.equal( - attempt.done, - true, - `docked again (${describePress(attempt)})`, - ); - }); - await step( - 'collapsing a floating window docks it as the rail strip', - async () => { - // The double-click just docked a panel showing a photo: let the photo - // and its sequence finish loading before pressing the header again. - await uiUntil((u) => !u.street.loading && !u.sequence.loading); - await floatPanel(); - await clickPanelControl( - page, - '.panel-collapse-btn[data-collapse-target="street-level-panel"]', - ); - await settle(page, () => { - const node = document.getElementById('street-level-panel'); - return ( - node.classList.contains('collapsed') && - !node.classList.contains('panel-floating') - ); - }); - const state = await page.$eval('#street-level-panel', (node) => ({ - floating: node.classList.contains('panel-floating'), - collapsed: node.classList.contains('collapsed'), - height: node.style.height, - })); - assert.deepEqual(state, { - floating: false, - collapsed: true, - height: '', - }); - }, - ); - await step( - 'on a phone the whole photo fits in the docked panel', - async () => { - // Width alone drives the phone layout; toggling isMobile would reload. - await page.setViewport({ width: 390, height: 844 }); - await settle(page, () => innerWidth === 390); - if ( - await page.$eval('#street-level-panel', (node) => - node.classList.contains('collapsed'), - ) + await step('on a phone the whole photo fits in the panel', async () => { + // Width alone drives the phone layout; toggling isMobile would reload. + await page.setViewport({ width: 390, height: 844 }); + await settle(page, () => innerWidth === 390); + if ( + await page.$eval('#street-level-panel', (node) => + node.classList.contains('collapsed'), ) - await expandStrip(); - await openById(firstImageId); - const measure = () => - page.evaluate(() => { - const inner = document - .querySelector('.street-level-panel-inner') - .getBoundingClientRect(); - const meta = document - .getElementById('sl-image-meta') - .getBoundingClientRect(); - const viewer = document - .getElementById('sl-viewer') - .getBoundingClientRect(); - return { - cut: Math.round(meta.bottom - inner.bottom), - top: Math.round(viewer.top - inner.top), - height: Math.round(viewer.height), - overflowX: document.documentElement.scrollWidth > innerWidth, - }; - }); - // The panel settles its phone layout over a frame or two. - await settle(page, () => { + ) + await expandStrip(); + await openById(firstImageId); + const measure = () => + page.evaluate(() => { const inner = document .querySelector('.street-level-panel-inner') .getBoundingClientRect(); const meta = document .getElementById('sl-image-meta') .getBoundingClientRect(); - return ( - meta.bottom - inner.bottom <= 1 && - document.getElementById('sl-viewer').getBoundingClientRect() - .height >= 100 - ); + const viewer = document + .getElementById('sl-viewer') + .getBoundingClientRect(); + return { + cut: Math.round(meta.bottom - inner.bottom), + top: Math.round(viewer.top - inner.top), + height: Math.round(viewer.height), + overflowX: document.documentElement.scrollWidth > innerWidth, + }; }); - const fit = await measure(); - assert.ok(fit.cut <= 1, `photo and caption fit (${fit.cut}px cut)`); - assert.ok(fit.height >= 100, `viewer stays usable (${fit.height}px)`); - assert.equal(fit.overflowX, false, 'no sideways scroll'); - await page.setViewport(VIEWPORTS[0]); - await settle( - page, - (width) => innerWidth === width, - VIEWPORTS[0].width, + // The panel settles its phone layout over a frame or two. + await settle(page, () => { + const inner = document + .querySelector('.street-level-panel-inner') + .getBoundingClientRect(); + const meta = document + .getElementById('sl-image-meta') + .getBoundingClientRect(); + return ( + meta.bottom - inner.bottom <= 1 && + document.getElementById('sl-viewer').getBoundingClientRect() + .height >= 100 ); - }, - ); + }); + const fit = await measure(); + assert.ok(fit.cut <= 1, `photo and caption fit (${fit.cut}px cut)`); + assert.ok(fit.height >= 100, `viewer stays usable (${fit.height}px)`); + assert.equal(fit.overflowX, false, 'no sideways scroll'); + await page.setViewport(VIEWPORTS[0]); + await settle(page, (width) => innerWidth === width, VIEWPORTS[0].width); + }); await step( 'a reload with the layer on keeps a collapsed panel collapsed, and the share link carries its ui token', async () => { diff --git a/src/rightRailPolicy.test.mjs b/src/rightRailPolicy.test.mjs index b727e6d12..2254ea24e 100644 --- a/src/rightRailPolicy.test.mjs +++ b/src/rightRailPolicy.test.mjs @@ -45,11 +45,7 @@ test('desktop Display participates in Tactical exclusivity without changing mobi /panel\.id !== 'pp-toggles' && !panel\.classList\.contains\('collapsed'\)/, ); assert.match(ui, /if \(exclusive && panel\.classList\.contains\('collapsed'\)\)\s*panel\.setAttribute\('aria-hidden', 'true'\)/); - // A panel floating out of the rail (portable mode) is exempt from exclusivity. - assert.match( - css, - /#right-context-rail\.layout-exclusive\s*> \[data-panel-id\]\.collapsed:not\(\.panel-floating\) \{/, - ); + assert.match(css, /#right-context-rail\.layout-exclusive > \[data-panel-id\]\.collapsed \{/); }); test('explicit Contacts, Space Missions, and Cockpit actions expand Global Context after success', () => { diff --git a/src/streetLevelMarkup.test.mjs b/src/streetLevelMarkup.test.mjs index de1878a79..e8df49460 100644 --- a/src/streetLevelMarkup.test.mjs +++ b/src/streetLevelMarkup.test.mjs @@ -54,14 +54,13 @@ test('the panel is registered with panel chrome, cockpit entry and the right rai /for \(const panel of \[[\s\S]*?this\._streetLevelPanel,[\s\S]*?\]\) \{[\s\S]*?stack\.insertBefore\(panel, globalContextPanel\)/, ); for (const rule of [ - '#right-context-rail > #street-level-panel:not(.panel-floating)', - '#right-context-rail #street-level-panel.collapsed:not(.panel-floating)', + '#right-context-rail > #street-level-panel', + '#right-context-rail #street-level-panel.collapsed', ]) assert.ok(css.includes(rule), `layers.css names ${rule}`); - // Rail placement never applies to the panel once it floats as a window. assert.match( css, - /#right-context-rail\.layout-focus\s*>\s*#street-level-panel:not\(\.collapsed\):not\(\.panel-floating\)/, + /#right-context-rail\.layout-focus\s*>\s*#street-level-panel:not\(\.collapsed\)/, ); }); @@ -138,15 +137,14 @@ test('on phones the viewer is sized from the rail band, not its aspect ratio', ( panelCss, /@media \(max-width: 720px\) \{\s*\.sl-viewer \{[^}]*height: clamp\(/, ); - // Floating and expanded viewers must not inherit the phone height. - for (const selector of [ - ".panel-floating\\[style\\*='height'\\] \\.sl-viewer", - '\\.sl-viewer-wrap-expanded \\.sl-viewer', - ]) - assert.match(panelCss, new RegExp(`${selector} \\{[^}]*height: auto`)); + // The expanded viewer must not inherit the phone height. + assert.match( + panelCss, + /\.sl-viewer-wrap-expanded \.sl-viewer \{[^}]*height: auto/, + ); }); -test('the viewer comes first and the panel is a portable, resizable window', () => { +test('the viewer comes first, with follow in its toolbar', () => { const controlsBlock = html.slice(html.indexOf('
')); assert.ok( controlsBlock.indexOf('id="sl-viewer-wrap"') < @@ -158,35 +156,6 @@ test('the viewer comes first and the panel is a portable, resizable window', () /id="sl-follow-btn"/, 'follow lives in the viewer toolbar', ); - const position = read('src/ui/panelPositionControls.js'); - assert.match( - position, - /id: 'street-level-panel',[\s\S]*?portable: true,[\s\S]*?min: \{ width: 320, height: 280 \}/, - ); - assert.match( - panelCss, - /\.panel-floating\[style\*='height'\] \.sl-settings \{[^}]*overflow-y: auto/, - ); - // Collapsing a floating panel, or SHRINK / Esc on the expanded viewer, - // docks it rather than leaving a window over the globe. - assert.match( - position, - /id: 'street-level-panel',[\s\S]*?dockOnCollapse: true/, - ); - assert.match( - read('src/ui/panelChrome.js'), - /classList\.toggle\('collapsed', nextCollapsed\);[\s\S]{0,200}?if \(explicit && !restore\)\s*this\._panelPosition\?\.onPanelCollapsed\?\.\(panelId, nextCollapsed\)/, - ); - assert.match( - controls, - /setViewerExpanded\(!this\.isViewerExpanded\(\), \{ dock: true \}\)/, - ); - assert.match(controls, /this\.setViewerExpanded\(false, \{ dock: true \}\)/); - assert.match(controls, /if \(!on && dock\) this\.actions\.dockPanel\?\.\(\)/); - assert.match( - read('src/ui/applicationShell.js'), - /panelId === 'street-level-panel'\)\s*this\._streetLevelControls\?\.onPanelResized\(\)/, - ); }); test('Clean View, recording and the cockpit hide the expanded viewer on (P2-5)', () => { diff --git a/src/tooling/panelResizeQa.test.mjs b/src/tooling/panelResizeQa.test.mjs deleted file mode 100644 index 586f22c08..000000000 --- a/src/tooling/panelResizeQa.test.mjs +++ /dev/null @@ -1,57 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { - driftedEdges, - growthDelta, - handlePoint, - MIN_SIZE, - pinnedEdges, - STORAGE_KEY, -} from '../../scripts/qa-panel-resize.mjs'; -import { RESIZE_DIRECTIONS } from '../ui/panelResize.js'; - -test('the harness targets the key and minimums the controls use', () => { - assert.equal(STORAGE_KEY, 'godsEyeView.v8.panelPos.cctv-panel'); - assert.deepEqual(MIN_SIZE, { width: 300, height: 160 }); -}); - -test('pinned edges are exactly the sides a direction does not name', () => { - assert.deepEqual(pinnedEdges('n'), ['bottom', 'right', 'left']); - assert.deepEqual(pinnedEdges('se'), ['top', 'left']); - assert.deepEqual(pinnedEdges('nw'), ['bottom', 'right']); - for (const dir of RESIZE_DIRECTIONS) - assert.equal(pinnedEdges(dir).length, 4 - dir.length); -}); - -test('growth deltas push each named edge outward', () => { - assert.deepEqual(growthDelta('n', 40), { dx: 0, dy: -40 }); - assert.deepEqual(growthDelta('e', 40), { dx: 40, dy: 0 }); - assert.deepEqual(growthDelta('sw', 40), { dx: -40, dy: 40 }); - assert.deepEqual(growthDelta('se', 40), { dx: 40, dy: 40 }); -}); - -test('handle points land inside the strip or corner for every direction', () => { - const rect = { left: 100, top: 200, width: 400, height: 300 }; - rect.right = rect.left + rect.width; - rect.bottom = rect.top + rect.height; - assert.deepEqual(handlePoint(rect, 'n'), { x: 300, y: 201 }); - assert.deepEqual(handlePoint(rect, 'w'), { x: 101, y: 350 }); - assert.deepEqual(handlePoint(rect, 'se'), { x: 496, y: 496 }); - assert.deepEqual(handlePoint(rect, 'nw'), { x: 104, y: 204 }); - for (const dir of RESIZE_DIRECTIONS) { - const { x, y } = handlePoint(rect, dir); - assert.ok(x >= rect.left && x <= rect.right, `${dir} x inside`); - assert.ok(y >= rect.top && y <= rect.bottom, `${dir} y inside`); - } -}); - -test('drift detection reports only pinned edges that moved', () => { - const before = { left: 100, top: 100, width: 400, height: 300 }; - const grewEast = { left: 100, top: 100, width: 440, height: 300 }; - assert.deepEqual(driftedEdges(before, grewEast, 'e'), []); - const slidWest = { left: 60, top: 100, width: 440, height: 300 }; - assert.deepEqual(driftedEdges(before, slidWest, 'e'), ['left']); - assert.deepEqual(driftedEdges(before, slidWest, 'w'), []); - const wobble = { left: 101, top: 100, width: 439, height: 300 }; - assert.deepEqual(driftedEdges(before, wobble, 'e'), []); -}); diff --git a/src/tooling/streetLevelQa.test.mjs b/src/tooling/streetLevelQa.test.mjs index ec9450853..9f83b1c2b 100644 --- a/src/tooling/streetLevelQa.test.mjs +++ b/src/tooling/streetLevelQa.test.mjs @@ -124,189 +124,6 @@ test('the gate requires the server’s real Mapillary status route', async () => ); }); -/* ── Header press-and-verify shared with the panel gate ───────────────── */ - -/** - * A page stand-in that runs page functions in Node against a fake document. - * The hit check always sees the header; `presses` says where each pointerdown - * lands ('header', 'other', 'moved' or 'none'). - */ -function fakePanelPage({ presses = [], floating = false, stuck = false } = {}) { - const state = { floating, downs: 0, last: null, dragging: false, shift: 0 }; - const listeners = []; - const node = (tagName, id, classes, inHeader) => ({ - tagName, - id, - classList: classes, - closest: (selector) => - inHeader && selector.includes('.panel-header') ? {} : null, - }); - const header = node('DIV', '', ['panel-title'], true); - const other = node('SECTION', 'weather-panel', ['panel'], false); - const rect = (left, top, width, height) => () => ({ - left, - top, - width, - height, - }); - const document = { - querySelector: () => ({ getBoundingClientRect: rect(120, 210, 100, 20) }), - getElementById: () => ({ - getBoundingClientRect: () => rect(100, 200 + state.shift, 300, 400)(), - classList: { - contains: (name) => name === 'panel-floating' && state.floating, - }, - }), - elementFromPoint: () => header, - }; - const window = { - addEventListener: (type, listener) => listeners.push(listener), - }; - const inPage = (fn, ...args) => { - const saved = { document: globalThis.document, window: globalThis.window }; - Object.assign(globalThis, { document, window }); - try { - return fn(...args); - } finally { - Object.assign(globalThis, saved); - } - }; - const evaluate = async (fn, ...args) => inPage(fn, ...args); - const press = () => { - state.last = presses[state.downs++] ?? 'header'; - // 'none': the pointerdown never reaches the page. - if (state.last === 'none') return; - const target = state.last === 'header' ? header : other; - // 'moved': the panel's layout shifted just before the press landed. - if (state.last === 'moved') state.shift = -116; - for (const listener of listeners.splice(0)) inPage(listener, { target }); - state.shift = 0; - }; - return { - state, - evaluate, - async waitForFunction(fn, options, ...args) { - for (let i = 0; i < 3; i++) { - if (await evaluate(fn, ...args)) return; - await new Promise((resolve) => setTimeout(resolve, 1)); - } - throw Object.assign(new Error('timed out'), { name: 'TimeoutError' }); - }, - mouse: { - async move() {}, - async down() { - press(); - state.dragging = true; - }, - async up() { - if (state.dragging && state.last === 'header' && !stuck) - state.floating = true; - state.dragging = false; - }, - async click(x, y, { clickCount }) { - if (clickCount === 1) press(); - else if (state.last === 'header' && !stuck) state.floating = false; - }, - }, - }; -} - -test('a header press is retried only when it provably missed the header', async () => { - const { pressMissed } = await import('../../scripts/qa-panelDrag.mjs'); - assert.equal(pressMissed({ done: true, pressed: null }), false); - assert.equal( - pressMissed({ done: false, pressed: { inHeader: true } }), - false, - ); - assert.equal( - pressMissed({ done: false, pressed: { inHeader: false } }), - true, - ); - // No recorded press is a failure, not a miss to retry. - assert.equal(pressMissed({ done: false, pressed: null }), false); -}); - -test('a lift whose press the rail moved off the header is retried, then lifts', async () => { - const { liftPanelByHeader, describePress } = - await import('../../scripts/qa-panelDrag.mjs'); - const page = fakePanelPage({ presses: ['other', 'header'] }); - const notes = []; - const attempt = await liftPanelByHeader(page, 'street-level-panel', { - dx: -400, - dy: 80, - log: (line) => notes.push(line), - pauseMs: 0, - }); - assert.equal(attempt.done, true); - assert.equal(page.state.downs, 2); - assert.equal(notes.length, 2, 'a note and a GitHub warning'); - assert.match(notes[0], /missed the street-level-panel header/); - assert.match(notes[0], /section#weather-panel\.panel/, 'names what it hit'); - assert.match(notes[0], /pre-press hit check passed/); - assert.match(notes[0], /stillness (held|timed out)/); - assert.match( - notes[0], - /panel box at hit check 100,200 300x400 -> at press 100,200 300x400/, - ); - assert.match(notes[1], /^::warning title=Panel press retried::retry 1\/2/); - assert.match(describePress(attempt), /in header: true, after 1 retry$/); -}); - -test('a press in the header that does not lift fails at once, naming the target', async () => { - const { liftPanelByHeader, describePress } = - await import('../../scripts/qa-panelDrag.mjs'); - const page = fakePanelPage({ stuck: true }); - const attempt = await liftPanelByHeader(page, 'street-level-panel', { - dx: -400, - dy: 80, - log: () => assert.fail('no retry'), - pauseMs: 0, - }); - assert.equal(attempt.done, false); - assert.equal(page.state.downs, 1); - assert.match(describePress(attempt), /on div\.panel-title, in header: true/); -}); - -test('presses that keep missing stop after the retries and report the miss', async () => { - const { liftPanelByHeader, PRESS_RETRIES } = - await import('../../scripts/qa-panelDrag.mjs'); - const page = fakePanelPage({ presses: ['other', 'other', 'other', 'other'] }); - const attempt = await liftPanelByHeader(page, 'street-level-panel', { - dx: -400, - dy: 80, - log: () => {}, - pauseMs: 0, - }); - assert.equal(attempt.done, false); - assert.equal(page.state.downs, PRESS_RETRIES + 1); - assert.equal(attempt.pressed.inHeader, false); -}); - -test('a header double-click docks, waiting on the docked state', async () => { - const { dockPanelByDoubleClick } = - await import('../../scripts/qa-panelDrag.mjs'); - const page = fakePanelPage({ floating: true, presses: ['other', 'header'] }); - const attempt = await dockPanelByDoubleClick(page, 'street-level-panel', { - log: () => {}, - pauseMs: 0, - }); - assert.equal(attempt.done, true); - assert.equal(page.state.floating, false); - assert.equal(page.state.downs, 2, 'the missed first press was retried'); -}); - -test('both panel gates press the header through the shared helper', () => { - for (const script of ['qa-street-level.mjs', 'qa-panel-resize.mjs']) { - const source = fs.readFileSync( - new URL(`../../scripts/${script}`, import.meta.url), - 'utf8', - ); - assert.match(source, /from '\.\/qa-panelDrag\.mjs'/, script); - assert.match(source, /liftPanelByHeader\(page, PANEL_ID/, script); - assert.match(source, /dockPanelByDoubleClick\(page, PANEL_ID/, script); - } -}); - /* ── Hermetic photo flow: the photo line and the Graph fixtures ────────── */ test('the photo sequence runs through the parked view, clipped to each tile it crosses', async () => { @@ -527,80 +344,17 @@ test('--strict accepts only the documented skips', () => { ); }); -test('a press that records no pointerdown fails at once, without a retry', async () => { - const { liftPanelByHeader, describePress } = - await import('../../scripts/qa-panelDrag.mjs'); - const page = fakePanelPage({ presses: ['none', 'header'] }); - const attempt = await liftPanelByHeader(page, 'street-level-panel', { - dx: -400, - dy: 80, - log: () => assert.fail('no retry'), - pauseMs: 0, - }); - assert.equal(attempt.done, false); - assert.equal(attempt.pressed, null); - assert.equal(page.state.downs, 1); - assert.match(describePress(attempt), /no pointerdown reached the page/); -}); - -test('QA_FAIL_ON_RETRY turns a retry into a failure that says why', async () => { - const { liftPanelByHeader, failOnRetryDefault } = - await import('../../scripts/qa-panelDrag.mjs'); - assert.equal(failOnRetryDefault({ QA_FAIL_ON_RETRY: '1' }, []), true); - assert.equal(failOnRetryDefault({}, ['--fail-on-retry']), true); - assert.equal(failOnRetryDefault({}, []), false); - const page = fakePanelPage({ presses: ['other', 'header'] }); - await assert.rejects( - liftPanelByHeader(page, 'street-level-panel', { - dx: -400, - dy: 80, - log: () => {}, - pauseMs: 0, - failOnRetry: true, - }), - /QA_FAIL_ON_RETRY=1 .*missed the street-level-panel header.*pre-press hit check passed/, +test('the gate saves failure evidence and fails on render-loop errors', () => { + const source = fs.readFileSync( + new URL('../../scripts/qa-street-level.mjs', import.meta.url), + 'utf8', ); - assert.equal(page.state.downs, 1); + assert.match(source, /saveFailureArtifacts\(/); + assert.match(source, /hookRenderErrors\(/); + assert.match(source, /readRenderErrors\(/); }); -test('QA_FAIL_ON_RETRY retries, with a warning, a miss the panel moving explains', async () => { - const { liftPanelByHeader, panelMoved } = - await import('../../scripts/qa-panelDrag.mjs'); - assert.equal(panelMoved({ left: 0, top: 382 }, { left: 0, top: 266 }), true); - assert.equal(panelMoved({ left: 0, top: 382 }, { left: 1, top: 383 }), false); - const page = fakePanelPage({ presses: ['moved', 'header'] }); - const lines = []; - const attempt = await liftPanelByHeader(page, 'street-level-panel', { - dx: -400, - dy: 80, - log: (line) => lines.push(line), - pauseMs: 0, - failOnRetry: true, - }); - assert.equal(attempt.done, true, 'the second press lifted it'); - assert.equal(page.state.downs, 2); - assert.ok( - lines.some((line) => - /::warning title=Panel press retried::.*-> at press 100,84/.test(line), - ), - lines.join('\n'), - ); -}); - -test('both gates save failure evidence and fail on render-loop errors', () => { - for (const script of ['qa-street-level.mjs', 'qa-panel-resize.mjs']) { - const source = fs.readFileSync( - new URL(`../../scripts/${script}`, import.meta.url), - 'utf8', - ); - assert.match(source, /saveFailureArtifacts\(/, script); - assert.match(source, /hookRenderErrors\(/, script); - assert.match(source, /readRenderErrors\(/, script); - assert.match(source, /--fail-on-retry/, script); - } -}); - -test('CI runs the gates hermetically against a production build, strict, with evidence', () => { +test('CI runs the gate hermetically against a production build, strict, with evidence', () => { const ci = fs.readFileSync( new URL('../../.github/workflows/ci.yml', import.meta.url), 'utf8', @@ -628,9 +382,4 @@ test('CI runs the gates hermetically against a production build, strict, with ev /if: failure\(\)[\s\S]*actions\/upload-artifact@[0-9a-f]{40}/, ); assert.match(gate, /path: qa-artifacts\//); - assert.match( - gate, - /QA_FAIL_ON_RETRY: '1'/, - 'a missed header press fails CI now that the rail settles', - ); }); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index d400b54c2..a9ba8022d 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -105,7 +105,6 @@ export class StyleManager extends ShellFacade { _setRadioDisclosure: (...args) => this._setRadioDisclosure(...args), _syncCctvPanelViewport: (...args) => this._syncCctvPanelViewport(...args), - _onPanelResized: (...args) => this._onPanelResized(...args), _syncContextRadioLauncherState: (...args) => this._syncContextRadioLauncherState(...args), _showToast: (...args) => this._showToast(...args), @@ -960,7 +959,6 @@ export class StyleManager extends ShellFacade { this._dataManager?.setLayerParams('street-level', params, options), setPanelCollapsed: (collapsed, options) => this.setPanelCollapsed('street-level-panel', collapsed, options), - dockPanel: () => this._panelChrome.dockPanel('street-level-panel'), showToast: (message) => this._showToast(message), // Lets the panel open for a user, voice or tool switch-on, not a restore. subscribeEnableRequests: (listener) => @@ -1516,13 +1514,6 @@ export class StyleManager extends ShellFacade { this._initCockpitDisplayPortal(); } - /** A portable panel was resized or docked: refit any viewport inside it. */ - _onPanelResized(panelId) { - if (panelId === 'cctv-panel') this._syncCctvPanelViewport(); - if (panelId === 'street-level-panel') - this._streetLevelControls?.onPanelResized(); - } - /** * Recalculates the CCTV panel max-height based on its current top position * and the window height, enabling internal scroll without viewport overflow. diff --git a/src/ui/cyberPanelDisclosure.test.mjs b/src/ui/cyberPanelDisclosure.test.mjs index 911e2cab1..ae0256d4c 100644 --- a/src/ui/cyberPanelDisclosure.test.mjs +++ b/src/ui/cyberPanelDisclosure.test.mjs @@ -85,16 +85,3 @@ test('non-Cyber explicit panels and restored preferences retain their existing p } finally { globalThis.document = prior; } } }); - -test('opening a floating Cyber window leaves the docked rail panels open', () => { - const f = fixture(), prior = globalThis.document; - globalThis.document = f.doc; - try { - f.owner.setPanelCollapsed('pp-toggles', false, { explicit: true }); - f.cctv.classList.add('panel-floating'); - f.owner.setPanelCollapsed('cctv-panel', false, { explicit: true }); - assert.equal(f.cctv.classList.contains('collapsed'), false); - assert.equal(f.display.classList.contains('collapsed'), false, 'DISPLAY stays open'); - assert.ok(!f.saved.some(([id, collapsed]) => id === 'pp-toggles' && collapsed)); - } finally { globalThis.document = prior; } -}); diff --git a/src/ui/panelChrome.js b/src/ui/panelChrome.js index 284d2b1b2..5a671e453 100644 --- a/src/ui/panelChrome.js +++ b/src/ui/panelChrome.js @@ -63,7 +63,6 @@ export class PanelChrome { layoutRightPanels: () => this._layoutRightPanels(), syncCctvPanelViewport: () => this._syncCctvPanelViewport(), showToast: (message) => this._showToast(message), - onPanelResized: (panelId) => this._onPanelResized(panelId), }); this._panelLayout = new PanelLayoutController({ readHud: () => ({ @@ -147,7 +146,6 @@ export class PanelChrome { this._initCommandDockPins(); this._initCommandDockTrayMetrics(); this._maybeNotifyLayoutReset(); - this._panelPosition._initPanelDrag(); } _collapsePanelOnEscape(event, panelId) { @@ -388,11 +386,6 @@ export class PanelChrome { this.shareLinkManager?.onPanelStateChange?.(); } - /** @returns {boolean} Whether the panel was floating and is now docked. */ - dockPanel(panelId) { - return this._panelPosition?.dockPanel?.(panelId) === true; - } - setPanelCollapsed( panelId, collapsed, @@ -415,20 +408,17 @@ export class PanelChrome { panelEl.classList.contains('cyber-accordion-collapsed'); // A user opening a Cyber rail panel owns the whole accordion, including // peers that were only presentation-collapsed during a saved-state restore. - // A floating window is outside the accordion both ways. if ( explicit && !restore && !nextCollapsed && document.documentElement?.dataset.uiTheme === 'cyber' && - panelEl.parentElement === this._rightPanelStack && - !panelEl.classList.contains('panel-floating') + panelEl.parentElement === this._rightPanelStack ) { for (const peer of this._rightPanelStack.children) { if ( peer !== panelEl && peer.matches('[data-panel-id]') && - !peer.classList.contains('panel-floating') && !peer.hidden ) { this.setPanelCollapsed(peer.id, true, { @@ -556,10 +546,6 @@ export class PanelChrome { } } panelEl.classList.toggle('collapsed', nextCollapsed); - // Only a user's own collapse docks a floating window; restores, cockpit - // entry and accordion peers keep its place. - if (explicit && !restore) - this._panelPosition?.onPanelCollapsed?.(panelId, nextCollapsed); if ( nextCollapsed && this.cockpitView?.active && diff --git a/src/ui/panelDock.test.mjs b/src/ui/panelDock.test.mjs deleted file mode 100644 index 52716a54b..000000000 --- a/src/ui/panelDock.test.mjs +++ /dev/null @@ -1,580 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { PanelPositionControls } from './panelPositionControls.js'; - -/** Just enough DOM for PanelPositionControls' portable-panel paths. */ -function fixture() { - const saved = Object.fromEntries( - ['document', 'window', 'localStorage', 'performance', 'ResizeObserver'].map( - (key) => [key, globalThis[key]], - ), - ); - const store = new Map(); - const element = () => { - const classes = new Set(); - const style = { - removeProperty(name) { - // Like CSSStyleDeclaration: 'z-index' also clears style.zIndex. - delete this[name]; - delete this[name.replace(/-([a-z])/g, (_, c) => c.toUpperCase())]; - }, - getPropertyValue: () => '', - }; - return Object.assign(new EventTarget(), { - id: '', - style, - dataset: {}, - classList: { - add: (...names) => names.forEach((name) => classes.add(name)), - remove: (...names) => names.forEach((name) => classes.delete(name)), - contains: (name) => classes.has(name), - toggle: (name, on) => (on ? classes.add(name) : classes.delete(name)), - }, - querySelector: () => null, - querySelectorAll: () => [], - closest: () => null, - setAttribute() {}, - removeAttribute() {}, - appendChild() {}, - remove() {}, - getBoundingClientRect: () => ({ - left: 100, - top: 80, - width: 360, - height: 420, - right: 460, - bottom: 500, - }), - }); - }; - globalThis.document = Object.assign(new EventTarget(), { - getElementById: () => null, - querySelectorAll: () => [], - createElement: () => element(), - }); - globalThis.window = Object.assign(new EventTarget(), { - innerWidth: 1440, - innerHeight: 900, - }); - globalThis.localStorage = { - getItem: (key) => (store.has(key) ? store.get(key) : null), - setItem: (key, value) => store.set(key, String(value)), - removeItem: (key) => store.delete(key), - }; - const calls = { layout: 0, resized: [] }; - const owner = new PanelPositionControls({ - syncPanelCollapseButton() {}, - layoutRightPanels: () => calls.layout++, - syncCctvPanelViewport() {}, - showToast() {}, - onPanelResized: (id) => calls.resized.push(id), - }); - return { - owner, - element, - store, - calls, - restore() { - owner.destroy(); - Object.assign(globalThis, saved); - }, - }; -} - -function floatingPanel(f, id, { dockOnCollapse = false } = {}) { - const panel = f.element(); - const handle = f.element(); - panel.id = id; - panel.classList.add('panel-floating', 'panel-draggable'); - Object.assign(panel.style, { - left: '100px', - top: '80px', - width: '360px', - height: '420px', - }); - f.store.set(`godsEyeView.v8.panelPos.${id}`, '{"floating":true}'); - f.owner._portablePanels.set(id, { - panel, - min: { width: 320, height: 280 }, - dockOnCollapse, - }); - f.owner._makePanelDraggable(id, panel, handle); - return { panel, handle }; -} - -/** A left-button header press; `timeStamp` is a read-only getter on Event. */ -function press(handle, { timeStamp, clientX = 200, clientY = 90 }) { - const event = new Event('pointerdown', { cancelable: true }); - for (const [key, value] of Object.entries({ - button: 0, - clientX, - clientY, - timeStamp, - })) - Object.defineProperty(event, key, { value }); - handle.dispatchEvent(event); -} - -test('dockPanel returns a floating window to its rail and forgets its place', () => { - const f = fixture(); - try { - const { panel } = floatingPanel(f, 'street-level-panel'); - assert.equal(f.owner.dockPanel('street-level-panel'), true); - assert.equal(panel.classList.contains('panel-floating'), false); - assert.equal(panel.style.width, undefined); - assert.equal(panel.style.height, undefined); - assert.equal( - f.store.has('godsEyeView.v8.panelPos.street-level-panel'), - false, - ); - assert.deepEqual(f.calls.resized, ['street-level-panel']); - assert.equal( - f.owner.dockPanel('street-level-panel'), - false, - 'already docked', - ); - assert.equal(f.owner.dockPanel('unknown'), false); - } finally { - f.restore(); - } -}); - -test('only a dockOnCollapse panel docks when collapsed, and only on collapse', () => { - const f = fixture(); - try { - const street = floatingPanel(f, 'street-level-panel', { - dockOnCollapse: true, - }); - const cctv = floatingPanel(f, 'cctv-panel'); - f.owner.onPanelCollapsed('street-level-panel', false); - assert.equal( - street.panel.classList.contains('panel-floating'), - true, - 'expanding keeps it', - ); - f.owner.onPanelCollapsed('cctv-panel', true); - assert.equal( - cctv.panel.classList.contains('panel-floating'), - true, - 'CCTV did not opt in', - ); - f.owner.onPanelCollapsed('street-level-panel', true); - assert.equal(street.panel.classList.contains('panel-floating'), false); - } finally { - f.restore(); - } -}); - -test('two quick header presses snap a floating panel back; slow or distant ones do not', () => { - const f = fixture(); - try { - const { panel, handle } = floatingPanel(f, 'cctv-panel'); - press(handle, { timeStamp: 1000 }); - window.dispatchEvent(new Event('pointerup')); - press(handle, { timeStamp: 1600 }); - window.dispatchEvent(new Event('pointerup')); - assert.equal( - panel.classList.contains('panel-floating'), - true, - '600 ms apart is two clicks', - ); - press(handle, { timeStamp: 1700, clientX: 260 }); - window.dispatchEvent(new Event('pointerup')); - assert.equal( - panel.classList.contains('panel-floating'), - true, - '60 px apart is two clicks', - ); - press(handle, { timeStamp: 1900, clientX: 262 }); - assert.equal( - panel.classList.contains('panel-floating'), - false, - 'a double press docks', - ); - } finally { - f.restore(); - } -}); - -test('a restored floating window stacks above the docked DISPLAY panel (z 110)', () => { - const f = fixture(); - try { - const panel = f.element(); - panel.id = 'street-level-panel'; - f.owner._portablePanels.set('street-level-panel', { - panel, - min: { width: 320, height: 280 }, - dockOnCollapse: true, - }); - f.store.set( - 'godsEyeView.v8.panelPos.street-level-panel', - JSON.stringify({ - left: 900, - top: 200, - width: 420, - height: 600, - floating: true, - }), - ); - f.owner._restorePanelPosition('street-level-panel', panel); - assert.equal(panel.classList.contains('panel-floating'), true); - assert.ok(Number(panel.style.zIndex) > 110, `z ${panel.style.zIndex}`); - f.owner.dockPanel('street-level-panel'); - assert.equal(panel.style.zIndex, undefined, 'docking drops the promotion'); - } finally { - f.restore(); - } -}); - -test('renumbering the z ladder never drops a window below the docked panels', () => { - const f = fixture(); - try { - const windows = [f.element(), f.element(), f.element()]; - windows.forEach((node, index) => { - node.classList.add('panel-draggable'); - node.style.zIndex = String(130 + index); - }); - globalThis.document.querySelectorAll = (selector) => - selector === '.panel-draggable' ? windows : []; - f.owner._panelZCounter = 139; - const top = f.element(); - top.classList.add('panel-draggable'); - f.owner._promotePanelZ(top); - for (const node of [...windows, top]) - assert.ok(Number(node.style.zIndex) > 110, `z ${node.style.zIndex}`); - assert.ok(Number(top.style.zIndex) > Number(windows[2].style.zIndex)); - } finally { - f.restore(); - } -}); - -/** A portable panel still in its rail, with its header and resize handles. */ -function dockedPanel(f, id) { - const panel = f.element(); - const handle = f.element(); - panel.id = id; - const portable = { panel, min: { width: 320, height: 280 } }; - f.owner._portablePanels.set(id, portable); - f.owner._makePanelDraggable(id, panel, handle); - f.owner._makePanelResizable(id, panel, portable.min); - const edges = Object.fromEntries( - f.owner._resizeHandles.map((node) => [node.dataset.dir, node]), - ); - return { panel, handle, edges }; -} - -/** A pointer event; `pointerType` defaults to what a mouse reports. */ -function pointer(target, type, { x = 200, y = 90, pointerType = 'mouse' }) { - const event = new Event(type, { cancelable: true }); - for (const [key, value] of Object.entries({ - button: 0, - clientX: x, - clientY: y, - pointerType, - })) - Object.defineProperty(event, key, { value }); - target.dispatchEvent(event); - return event; -} - -const POSITION_KEY = 'godsEyeView.v8.panelPos.street-level-panel'; - -test('a click on a docked panel’s resize edge leaves it docked and stores nothing', () => { - const f = fixture(); - try { - const { panel, edges } = dockedPanel(f, 'street-level-panel'); - pointer(edges.w, 'pointerdown', { x: 101, y: 200 }); - pointer(window, 'pointermove', { x: 103, y: 201 }); // a jittery click - pointer(window, 'pointerup', { x: 103, y: 201 }); - assert.equal(panel.classList.contains('panel-floating'), false); - assert.equal(panel.style.width, undefined, 'no size was pinned'); - assert.equal(f.store.has(POSITION_KEY), false); - assert.deepEqual(f.calls.resized, []); - - // A real resize still lifts it out and remembers the window. - pointer(edges.w, 'pointerdown', { x: 101, y: 200 }); - pointer(window, 'pointermove', { x: 61, y: 200 }); - pointer(window, 'pointerup', { x: 61, y: 200 }); - assert.equal(panel.classList.contains('panel-floating'), true); - assert.equal(JSON.parse(f.store.get(POSITION_KEY)).floating, true); - } finally { - f.restore(); - } -}); - -test('a touch on the header or an edge does not lift the panel, and the rail can still scroll', () => { - const f = fixture(); - try { - const { panel, handle, edges } = dockedPanel(f, 'street-level-panel'); - const press = pointer(handle, 'pointerdown', { pointerType: 'touch' }); - assert.equal(press.defaultPrevented, false, 'the swipe can scroll'); - pointer(window, 'pointermove', { y: 190, pointerType: 'touch' }); - pointer(window, 'pointerup', { y: 190, pointerType: 'touch' }); - pointer(edges.s, 'pointerdown', { y: 500, pointerType: 'touch' }); - pointer(window, 'pointermove', { y: 600, pointerType: 'touch' }); - pointer(window, 'pointerup', { y: 600, pointerType: 'touch' }); - assert.equal(panel.classList.contains('panel-floating'), false); - assert.equal(f.store.has(POSITION_KEY), false); - } finally { - f.restore(); - } -}); - -test('a pointercancel mid-drag saves nothing and puts the panel back', () => { - const f = fixture(); - try { - const { panel, handle, edges } = dockedPanel(f, 'street-level-panel'); - pointer(handle, 'pointerdown', {}); - pointer(window, 'pointermove', { x: 260, y: 140 }); - assert.equal(panel.classList.contains('panel-floating'), true, 'lifted'); - pointer(window, 'pointercancel', { x: 260, y: 140 }); - assert.equal(panel.classList.contains('panel-floating'), false, 'docked'); - assert.equal(f.store.has(POSITION_KEY), false); - - pointer(edges.e, 'pointerdown', { x: 459 }); - pointer(window, 'pointermove', { x: 520 }); - pointer(window, 'pointercancel', { x: 520 }); - assert.equal(panel.classList.contains('panel-floating'), false); - assert.equal(f.store.has(POSITION_KEY), false); - } finally { - f.restore(); - } -}); - -test('a pointercancel while dragging a floating window returns it to where it was', () => { - const f = fixture(); - try { - const { panel, handle } = floatingPanel(f, 'cctv-panel'); - const stored = f.store.get('godsEyeView.v8.panelPos.cctv-panel'); - pointer(handle, 'pointerdown', {}); - pointer(window, 'pointermove', { x: 400, y: 300 }); - assert.notEqual(panel.style.left, '100px', 'it moved'); - pointer(window, 'pointercancel', { x: 400, y: 300 }); - assert.equal(panel.style.left, '100px'); - assert.equal(panel.style.top, '80px'); - assert.equal(panel.classList.contains('panel-floating'), true); - assert.equal(f.store.get('godsEyeView.v8.panelPos.cctv-panel'), stored); - } finally { - f.restore(); - } -}); - -test('a header press that travels under the drag threshold is a click, not a lift', () => { - const f = fixture(); - try { - const { panel, handle } = dockedPanel(f, 'street-level-panel'); - pointer(handle, 'pointerdown', { x: 200, y: 90 }); - pointer(window, 'pointermove', { x: 202, y: 90 }); - pointer(window, 'pointerup', { x: 202, y: 90 }); - assert.equal(panel.classList.contains('panel-floating'), false); - assert.equal(panel.classList.contains('panel-dragging'), false); - assert.equal(f.store.has(POSITION_KEY), false); - assert.equal(f.calls.layout, 0, 'the rail was not re-laid out'); - assert.deepEqual(f.calls.resized, []); - } finally { - f.restore(); - } -}); - -/** - * Both portable panels wired through _initPanelDrag. Headers are the top - * 36 px; `obstacles` stand in for the command dock and voice pill. - * ResizeObserver callbacks run on `observe()`. - */ -function portableShell(f, { stored = {}, box = {}, obstacles = {} } = {}) { - const observers = []; - globalThis.ResizeObserver = class { - constructor(callback) { - this.callback = callback; - this.targets = []; - observers.push(this); - } - observe(target) { - this.targets.push(target); - } - disconnect() {} - }; - const nodes = new Map(); - const panels = {}; - for (const id of ['cctv-panel', 'street-level-panel']) { - const panel = f.element(); - const handle = f.element(); - const natural = { width: 360, height: 420, ...box[id] }; - panel.id = id; - panel.querySelector = (selector) => - selector === '.panel-header' ? handle : null; - panel.getBoundingClientRect = () => { - const left = parseFloat(panel.style.left) || 0; - const top = parseFloat(panel.style.top) || 0; - const width = parseFloat(panel.style.width) || natural.width; - const height = parseFloat(panel.style.height) || natural.height; - return { - left, - top, - width, - height, - right: left + width, - bottom: top + height, - }; - }; - handle.getBoundingClientRect = () => { - const rect = panel.getBoundingClientRect(); - return { ...rect, height: 36, bottom: rect.top + 36 }; - }; - if (stored[id]) - f.store.set( - `godsEyeView.v8.panelPos.${id}`, - JSON.stringify({ floating: true, ...stored[id] }), - ); - nodes.set(id, panel); - panels[id] = { panel, handle, natural }; - } - const obstacle = (id, rect) => - nodes.set(id, { - getBoundingClientRect: () => ({ - ...rect, - width: rect.right - rect.left, - height: rect.bottom - rect.top, - }), - }); - for (const [id, rect] of Object.entries(obstacles)) obstacle(id, rect); - globalThis.document.getElementById = (id) => nodes.get(id) ?? null; - f.owner._initPanelDrag(); - return { - ...panels, - obstacle, - observed: (node) => observers.some(({ targets }) => targets.includes(node)), - contentResized: () => observers.forEach(({ callback }) => callback([])), - }; -} - -test('a floating window whose content grows is pulled back on-screen', () => { - const f = fixture(); - try { - const shell = portableShell(f, { - stored: { 'cctv-panel': { left: 900, top: 380 } }, - }); - const { panel, natural } = shell['cctv-panel']; - assert.equal(panel.style.top, '380px'); - assert.equal(shell.observed(panel), true, 'its size is watched'); - assert.equal(shell.observed(shell['street-level-panel'].panel), true); - // Expanding the collapsed strip, or an image opening in Street Level. - natural.height = 800; - shell.contentResized(); - assert.equal(panel.style.top, `${900 - 800 - 6}px`); - assert.equal(panel.style.left, '900px'); - } finally { - f.restore(); - } -}); - -test('a live window resize shrinks a floating window larger than the viewport', () => { - const f = fixture(); - try { - const shell = portableShell(f, { - stored: { - 'cctv-panel': { left: 100, top: 80, width: 1300, height: 700 }, - }, - }); - const { panel } = shell['cctv-panel']; - assert.equal(panel.style.width, '1300px'); - Object.assign(window, { innerWidth: 1000, innerHeight: 500 }); - window.dispatchEvent(new Event('resize')); - assert.equal(panel.style.width, '988px'); - assert.equal(panel.style.height, '488px'); - assert.equal(panel.style.left, '6px'); - assert.equal(panel.style.top, '6px'); - // A window that already fits keeps the size the user gave it. - Object.assign(window, { innerWidth: 1440, innerHeight: 900 }); - window.dispatchEvent(new Event('resize')); - assert.equal(panel.style.width, '988px'); - } finally { - f.restore(); - } -}); - -const DOCK = { left: 300, top: 820, right: 1140, bottom: 882 }; - -test('a floating header never lands under the command dock or voice pill', () => { - const f = fixture(); - try { - const shell = portableShell(f, { - stored: { 'cctv-panel': { left: 100, top: 80 } }, - // A collapsed CCTV window is just its header strip. - box: { 'cctv-panel': { height: 36 } }, - obstacles: { 'command-dock': DOCK }, - }); - const { panel, handle, natural } = shell['cctv-panel']; - const above = DOCK.top - 36 - 6; - - // Drag: the strip dropped at the bottom centre stops above the dock. - pointer(handle, 'pointerdown', { x: 200, y: 90 }); - pointer(window, 'pointermove', { x: 720, y: 880 }); - assert.equal(panel.style.top, `${above}px`); - pointer(window, 'pointerup', { x: 720, y: 880 }); - const key = 'godsEyeView.v8.panelPos.cctv-panel'; - assert.equal(JSON.parse(f.store.get(key)).top, above); - - // Restore: a place saved under the dock (older build) comes back clear. - f.store.set(key, JSON.stringify({ left: 620, top: 858, floating: true })); - f.owner._restorePanelPosition('cctv-panel', panel); - assert.equal(panel.style.top, `${above}px`); - - // Re-clamp: the voice pill measured when the window resizes. - Object.assign(panel.style, { left: '1000px', top: '700px' }); - shell.obstacle('gev-voice-control', { - left: 1190, - top: 690, - right: 1404, - bottom: 740, - }); - window.dispatchEvent(new Event('resize')); - assert.equal(panel.style.top, `${690 - 36 - 6}px`); - - // Only the header has to stay clear: a body over the dock is fine. - natural.height = 200; - Object.assign(panel.style, { left: '620px', top: '650px' }); - window.dispatchEvent(new Event('resize')); - assert.equal(panel.style.top, '650px'); - } finally { - f.restore(); - } -}); - -test('a collapsed floating window keeps its chosen height through a drag and a reload', () => { - const f = fixture(); - try { - const { panel, handle } = floatingPanel(f, 'cctv-panel'); - panel.style.height = '500px'; - panel.classList.add('collapsed'); - // Collapsed, the window measures only its header strip. - panel.getBoundingClientRect = () => { - const left = parseFloat(panel.style.left); - const top = parseFloat(panel.style.top); - return { - left, - top, - width: 360, - height: 50, - right: left + 360, - bottom: top + 50, - }; - }; - pointer(handle, 'pointerdown', { x: 200, y: 90 }); - pointer(window, 'pointermove', { x: 260, y: 140 }); - pointer(window, 'pointerup', { x: 260, y: 140 }); - const record = JSON.parse( - f.store.get('godsEyeView.v8.panelPos.cctv-panel'), - ); - assert.equal(record.height, 500, 'the chosen height, not the strip'); - assert.equal(record.width, 360); - - // Reload: a fresh panel restores the size it expands to. - const reloaded = f.element(); - reloaded.id = 'cctv-panel'; - f.owner._restorePanelPosition('cctv-panel', reloaded); - assert.equal(reloaded.style.height, '500px'); - } finally { - f.restore(); - } -}); diff --git a/src/ui/panelLayoutController.js b/src/ui/panelLayoutController.js index c46a8324c..fc170646e 100644 --- a/src/ui/panelLayoutController.js +++ b/src/ui/panelLayoutController.js @@ -205,12 +205,9 @@ export class PanelLayoutController { this._streetLevelPanel, ]) { if (!panel) continue; - // A floating window keeps its restored place and size. - if (!panel.classList.contains('panel-floating')) { - for (const property of ['top', 'right', 'bottom', 'left', 'z-index']) - panel.style.removeProperty(property); - panel.classList.remove('panel-draggable', 'panel-dragging'); - } + for (const property of ['top', 'right', 'bottom', 'left', 'z-index']) + panel.style.removeProperty(property); + panel.classList.remove('panel-draggable', 'panel-dragging'); stack.insertBefore(panel, globalContextPanel); this._syncPanelCollapseButton(panel); } diff --git a/src/ui/panelPositionControls.js b/src/ui/panelPositionControls.js index b40a2d378..812122abe 100644 --- a/src/ui/panelPositionControls.js +++ b/src/ui/panelPositionControls.js @@ -1,6 +1,4 @@ /** Own panel position preferences, viewport clamping and drag listeners. */ -import { RESIZE_DIRECTIONS, resizeBox } from './panelResize.js'; - /** Versioned localStorage namespace prefix to invalidate stale panel layouts. */ const PANEL_LAYOUT_STORAGE_VERSION = 'v6'; /** @@ -12,203 +10,40 @@ const PANEL_POSITION_STORAGE_VERSION = 'v8'; /** Z ladder: panels promote within [100, 139]; voice pill 150, toast 200, clean-view-exit 300. */ const PANEL_Z_BASE = 100; const PANEL_Z_MAX = 139; -/** - * Lowest z for a promoted panel: above every docked rail panel (#pp-toggles is - * 110). Keep in step with `#right-context-rail > .panel-floating` in controls.css. - */ -const PANEL_Z_FLOATING_FLOOR = PANEL_Z_BASE + 11; -/** Pointer travel before a press becomes a drag or resize. */ -const DRAG_THRESHOLD_PX = 4; -/** - * Double press that snaps a floating panel back. Detected from pointerdown - * because the drag's preventDefault() suppresses a native dblclick. - */ -const DOUBLE_PRESS_MS = 400; -const DOUBLE_PRESS_SLOP_PX = 6; -const VIEWPORT_MARGIN_PX = 6; -/** - * Fixed chrome that paints over the rail's stacking context; a floating - * window keeps its header clear of it so it can always be grabbed again. - */ -const PANEL_OBSTACLE_IDS = ['command-dock', 'gev-voice-control']; -const PANEL_FLOAT_HINT_STORAGE_KEY = `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelFloatHintShown`; -const PANEL_FLOAT_HINT = 'Double-click the header to snap the panel back'; -/** Header children whose own interaction wins over a drag or a snap-back. */ -const INTERACTIVE_SELECTOR = - 'input, select, option, textarea, button, a, [role="button"]'; -const FLOATING_STYLE_PROPERTIES = [ - 'left', - 'top', - 'right', - 'bottom', - 'width', - 'height', - 'z-index', -]; - -function clamp(value, min, max) { - return Math.max(min, Math.min(max, value)); -} - -function fitWindowExtent(value, minimum, viewportExtent) { - return clamp( - Math.round(value), - minimum, - Math.max(minimum, viewportExtent - 2 * VIEWPORT_MARGIN_PX), - ); -} - -function placeAt(panelEl, left, top) { - panelEl.style.left = `${left}px`; - panelEl.style.top = `${top}px`; - panelEl.style.right = 'auto'; - panelEl.style.bottom = 'auto'; -} - -/** On a phone the rail scrolls, so a finger on a header is a swipe, not a drag. */ -function isPrecisePointer(event) { - const pointerType = event.pointerType || 'mouse'; - return pointerType === 'mouse' || pointerType === 'pen'; -} - -function geometryOf(panelEl) { - const { left, top, width, height } = panelEl.style; - return { left, top, width, height }; -} - export class PanelPositionControls { - /** `onPanelResized(panelId)` runs after a portable panel moves, resizes or docks. */ constructor({ syncPanelCollapseButton, layoutRightPanels, syncCctvPanelViewport, showToast, - onPanelResized = null, }) { this._syncPanelCollapseButton = syncPanelCollapseButton; this._layoutRightPanels = layoutRightPanels; this._syncCctvPanelViewport = syncCctvPanelViewport; this._showToast = showToast; - this._onPanelResized = onPanelResized; this._ppToggles = document.getElementById('pp-toggles'); this._panelZCounter = PANEL_Z_BASE + 10; this._draggableResizeObserver = null; this._cancelDrag = null; - this._portablePanels = new Map(); - this._resizeHandles = []; - this._dragInitialized = false; this.removers = []; this.destroyed = false; } - listen(target, type, callback, options) { + listen(target, type, callback) { if (this.destroyed || !target) return; const listener = (event) => { if (!this.destroyed) callback(event); }; - target.addEventListener(type, listener, options); - this.removers.push(() => - target.removeEventListener(type, listener, options), - ); + target.addEventListener(type, listener); + this.removers.push(() => target.removeEventListener(type, listener)); } _reclampDraggablePanels() { if (this.destroyed) return; const el = this._ppToggles; - if (el && el.style.top && el.style.top !== 'auto') { - const top = parseInt(el.style.top, 10); - if (Number.isFinite(top)) { - el.style.top = `${this._clampToViewport(0, top, el).top}px`; - this._pinPanelToRight(el); - } - } - for (const entry of this._portablePanels.values()) { - const { classList } = entry.panel; - if (!classList.contains('panel-floating')) continue; - // A live gesture owns the box; it re-clamps when it ends. - if (classList.contains('panel-dragging')) continue; - if (classList.contains('panel-resizing')) continue; - this._reclampPortablePanel(entry); - } - } - - /** Bring a floating window on-screen; shrink it only if it exceeds the viewport. */ - _reclampPortablePanel(entry) { - const { panel, min } = entry; - const left = parseInt(panel.style.left, 10); - const top = parseInt(panel.style.top, 10); - if (!Number.isFinite(left) || !Number.isFinite(top)) return; - for (const [property, viewportExtent] of [ - ['width', window.innerWidth], - ['height', window.innerHeight], - ]) { - const extent = parseFloat(panel.style[property]); - if (!Number.isFinite(extent)) continue; - const fitted = fitWindowExtent(extent, min[property], viewportExtent); - if (fitted < extent) panel.style[property] = `${fitted}px`; - } - const next = this._clampPortableWindow(entry, left, top); - panel.style.left = `${next.left}px`; - panel.style.top = `${next.top}px`; - } - - /** - * Clamp a floating window inside the viewport margin with its header above - * the command dock and voice pill, measured each time since the dock resizes. - */ - _clampPortableWindow( - entry, - left, - top, - geometry = this._windowGeometry(entry), - ) { - const { width, height, header } = geometry; - const maxLeft = Math.max( - VIEWPORT_MARGIN_PX, - window.innerWidth - width - VIEWPORT_MARGIN_PX, - ); - const maxTop = Math.max( - VIEWPORT_MARGIN_PX, - window.innerHeight - height - VIEWPORT_MARGIN_PX, - ); - const x = clamp(left, VIEWPORT_MARGIN_PX, maxLeft); - let y = clamp(top, VIEWPORT_MARGIN_PX, maxTop); - const obstacles = this._panelObstacles(); - for (let pass = 0; pass < obstacles.length; pass++) { - const hit = obstacles.find( - (rect) => - rect.left < x + width && - rect.right > x && - rect.top < y + header && - rect.bottom > y, - ); - if (!hit) break; - const above = hit.top - header - VIEWPORT_MARGIN_PX; - y = - above >= VIEWPORT_MARGIN_PX - ? above - : Math.min(maxTop, hit.bottom + VIEWPORT_MARGIN_PX); - } - return { left: x, top: y }; - } - - _windowGeometry({ panel, handle }, rect = panel.getBoundingClientRect()) { - const handleRect = handle?.getBoundingClientRect(); - const header = handleRect ? handleRect.bottom - rect.top : 0; - return { - width: rect.width, - height: rect.height, - // Without a measurable header, keep the whole window clear. - header: header > 0 ? Math.min(header, rect.height) : rect.height, - }; - } - - _panelObstacles() { - const rects = []; - for (const id of PANEL_OBSTACLE_IDS) { - const rect = document.getElementById(id)?.getBoundingClientRect(); - // A hidden dock, or a voice UI switched off, measures empty. - if (rect && rect.width > 0 && rect.height > 0) rects.push(rect); - } - return rects; + if (!el || !el.style.top || el.style.top === 'auto') return; + const top = parseInt(el.style.top, 10); + if (!Number.isFinite(top)) return; + el.style.top = `${this._clampToViewport(0, top, el).top}px`; + this._pinPanelToRight(el); } _maybeNotifyLayoutReset() { @@ -230,71 +65,28 @@ export class PanelPositionControls { } _initPanelDrag() { - if (this.destroyed || this._dragInitialized) return; - this._dragInitialized = true; - const cctvPanel = document.getElementById('cctv-panel'); - const streetLevelPanel = document.getElementById('street-level-panel'); - // A `portable` panel lifts out of its rail on a header drag, resizes from - // every edge and remembers its window; the others only reposition. + if (this.destroyed) return; const dragSpecs = [ { id: 'pp-toggles', panel: this._ppToggles, handle: this._ppToggles?.querySelector('.panel-drag-handle.compact'), }, - { - id: 'cctv-panel', - panel: cctvPanel, - handle: cctvPanel?.querySelector('.panel-header'), - portable: true, - min: { width: 300, height: 160 }, - }, - { - id: 'street-level-panel', - panel: streetLevelPanel, - handle: streetLevelPanel?.querySelector('.panel-header'), - portable: true, - min: { width: 320, height: 280 }, - // Shrinking the window to its strip docks it, so it never sits - // over the globe as a stray header. - dockOnCollapse: true, - }, ].filter(Boolean); for (const spec of dragSpecs) { if (!spec.panel || !spec.handle) continue; - if (spec.portable) { - this._portablePanels.set(spec.id, { - panel: spec.panel, - handle: spec.handle, - min: { width: 300, height: 160, ...spec.min }, - dockOnCollapse: spec.dockOnCollapse === true, - }); - spec.panel.classList.add('panel-portable'); - } this._restorePanelPosition(spec.id, spec.panel); this._makePanelDraggable(spec.id, spec.panel, spec.handle); - if (spec.portable) { - this._makePanelResizable(spec.id, spec.panel, spec.min); - } } // Keep a positioned panel on-screen when its HEIGHT changes after restore β€” it expands to its // full row set a frame or two later, so the restore-time clamp used a stale (shorter) height and - // the panel could still hang off the bottom (audit U2). Re-clamp on every size change. Floating - // windows grow the same way when their content does. - const observed = [ - this._ppToggles, - ...[...this._portablePanels.values()].map(({ panel }) => panel), - ].filter(Boolean); - if (observed.length && typeof ResizeObserver !== 'undefined') { + // the panel could still hang off the bottom (audit U2). Re-clamp on every size change. + if (this._ppToggles && typeof ResizeObserver !== 'undefined') { this._draggableResizeObserver = new ResizeObserver(() => this._reclampDraggablePanels(), ); - for (const panel of observed) - this._draggableResizeObserver.observe(panel); - } - if (this._portablePanels.size) { - this.listen(window, 'resize', () => this._reclampDraggablePanels()); + this._draggableResizeObserver.observe(this._ppToggles); } } @@ -367,43 +159,17 @@ export class PanelPositionControls { const pos = JSON.parse(raw); if (!pos || typeof pos.left !== 'number' || typeof pos.top !== 'number') return; - // Only portable panels store, and accept, floating records. - const portable = this._portablePanels.get(panelId); - if (Boolean(pos.floating) !== Boolean(portable)) return; - if (portable) { - panelEl.classList.add('panel-floating', 'panel-draggable'); - // A restored window must stack above the docked rail panels too. - this._promotePanelZ(panelEl); - if (Number.isFinite(pos.width)) { - panelEl.style.width = `${fitWindowExtent( - pos.width, - portable.min.width, - window.innerWidth, - )}px`; - } - if (Number.isFinite(pos.height)) { - panelEl.style.height = `${fitWindowExtent( - pos.height, - portable.min.height, - window.innerHeight, - )}px`; - } - } // Clamp to the viewport: a position saved at one window size would otherwise land off-screen at - // another (audit U2 β€” observed a panel at x:-192). The drag handler clamps; restore must too, - // and a window saved under the command dock must not come back there. - const { left, top } = portable - ? this._clampPortableWindow( - portable, - Math.round(pos.left), - Math.round(pos.top), - ) - : this._clampToViewport( - Math.round(pos.left), - Math.round(pos.top), - panelEl, - ); - placeAt(panelEl, left, top); + // another (audit U2 β€” observed a panel at x:-192). The drag handler clamps; restore must too. + const { left, top } = this._clampToViewport( + Math.round(pos.left), + Math.round(pos.top), + panelEl, + ); + panelEl.style.left = `${left}px`; + panelEl.style.top = `${top}px`; + panelEl.style.right = 'auto'; + panelEl.style.bottom = 'auto'; if (panelId === 'pp-toggles') { this._pinPanelToRight(panelEl); } @@ -424,28 +190,13 @@ export class PanelPositionControls { _savePanelPosition(panelId, panelEl) { const rect = panelEl.getBoundingClientRect(); - const record = { - left: Math.round(rect.left), - top: Math.round(rect.top), - }; - if (this._portablePanels.has(panelId)) { - // Only an explicitly set size is remembered, and the inline value - // rather than the measured box: a collapsed window measures only its - // header but keeps the size the user chose for when it expands. - const chosen = (inline, measured) => { - const px = parseFloat(inline); - return Math.round(Number.isFinite(px) ? px : measured); - }; - if (panelEl.style.width) - record.width = chosen(panelEl.style.width, rect.width); - if (panelEl.style.height) - record.height = chosen(panelEl.style.height, rect.height); - record.floating = true; - } try { localStorage.setItem( this._panelStorageKey(panelId), - JSON.stringify(record), + JSON.stringify({ + left: Math.round(rect.left), + top: Math.round(rect.top), + }), ); } catch { // storage unavailable @@ -458,7 +209,7 @@ export class PanelPositionControls { const promoted = [...document.querySelectorAll('.panel-draggable')] .filter((el) => el.style.zIndex) .sort((a, b) => Number(a.style.zIndex) - Number(b.style.zIndex)); - let z = PANEL_Z_FLOATING_FLOOR; + let z = PANEL_Z_BASE + 1; for (const el of promoted) { el.style.zIndex = String(z); z += 1; @@ -468,70 +219,11 @@ export class PanelPositionControls { panelEl.style.zIndex = String(this._panelZCounter); } - _maybeShowFloatHint() { - try { - if (localStorage.getItem(PANEL_FLOAT_HINT_STORAGE_KEY)) return; - localStorage.setItem(PANEL_FLOAT_HINT_STORAGE_KEY, '1'); - } catch { - // storage unavailable: still show it once this session - if (this._floatHintShown) return; - this._floatHintShown = true; - } - this._showToast(PANEL_FLOAT_HINT); - } - - /** - * Turn a rail panel into a fixed window in place. Width is frozen so a - * `width: 100%` panel keeps its size; height stays natural until a resize. - */ - _liftPanelOut(panelId, panelEl, rect = panelEl.getBoundingClientRect()) { - if (panelEl.classList.contains('panel-floating')) return false; - placeAt(panelEl, Math.round(rect.left), Math.round(rect.top)); - if (!panelEl.classList.contains('collapsed')) { - panelEl.style.width = `${Math.round(rect.width)}px`; - } - panelEl.style.removeProperty('--right-panel-allocated-height'); - panelEl.removeAttribute('aria-hidden'); - panelEl.classList.add('panel-floating', 'panel-draggable'); - this._promotePanelZ(panelEl); - this._layoutRightPanels(); - this._maybeShowFloatHint(); - return true; - } - - /** Snap a floating panel back into its rail and forget its window. */ - _resetPanelPosition(panelId) { - const portable = this._portablePanels.get(panelId); - if (!portable) return; - const panelEl = portable.panel; - this._cancelDrag?.(); - panelEl.classList.remove( - 'panel-floating', - 'panel-draggable', - 'panel-dragging', - 'panel-resizing', - ); - for (const property of FLOATING_STYLE_PROPERTIES) { - panelEl.style.removeProperty(property); - } - try { - localStorage.removeItem(this._panelStorageKey(panelId)); - } catch { - // storage unavailable - } - this._layoutRightPanels(); - this._onPanelResized?.(panelId); - } - _makePanelDraggable(panelId, panelEl, handleEl) { // Z-order promotion: bring clicked panel to front of the stacking context this.listen(panelEl, 'pointerdown', () => { this._promotePanelZ(panelEl); }); - if (this._portablePanels.has(panelId)) { - this._makePortablePanelDraggable(panelId, panelEl, handleEl); - return; - } this.listen(handleEl, 'pointerdown', (event) => { if (event.button !== 0) return; @@ -546,296 +238,64 @@ export class PanelPositionControls { this._cancelDrag?.(); event.preventDefault(); const rect = panelEl.getBoundingClientRect(); - const offsetX = event.clientX - rect.left; - const offsetY = event.clientY - rect.top; + const startX = event.clientX; + const startY = event.clientY; + const offsetX = startX - rect.left; + const offsetY = startY - rect.top; - placeAt(panelEl, rect.left, rect.top); + panelEl.style.left = `${rect.left}px`; + panelEl.style.top = `${rect.top}px`; + panelEl.style.right = 'auto'; + panelEl.style.bottom = 'auto'; panelEl.classList.add('panel-dragging'); this._promotePanelZ(panelEl); - this._trackPointerGesture(event, { - onMove: (moveEvent) => { - const nextLeftRaw = moveEvent.clientX - offsetX; - const nextTopRaw = moveEvent.clientY - offsetY; - const maxLeft = Math.max(6, window.innerWidth - rect.width - 6); - const maxTop = Math.max(6, window.innerHeight - rect.height - 6); - const nextLeft = Math.max(6, Math.min(maxLeft, nextLeftRaw)); - const nextTop = Math.max(6, Math.min(maxTop, nextTopRaw)); - panelEl.style.left = `${nextLeft}px`; - panelEl.style.top = `${nextTop}px`; - if (panelId === 'pp-toggles') { - this._layoutRightPanels(); - } - if (panelId === 'cctv-panel') { - this._syncCctvPanelViewport(); - } - }, - onStop: () => panelEl.classList.remove('panel-dragging'), - // A cancelled press keeps the place too: there is no rail to revert to. - onEnd: () => { - if (panelId === 'pp-toggles') { - this._pinPanelToRight(panelEl); - } - this._savePanelPosition(panelId, panelEl); - if (panelId === 'cctv-panel') { - this._syncCctvPanelViewport(); - } - }, - }); - }); - } + const onMove = (moveEvent) => { + const nextLeftRaw = moveEvent.clientX - offsetX; + const nextTopRaw = moveEvent.clientY - offsetY; + const maxLeft = Math.max(6, window.innerWidth - rect.width - 6); + const maxTop = Math.max(6, window.innerHeight - rect.height - 6); + const nextLeft = Math.max(6, Math.min(maxLeft, nextLeftRaw)); + const nextTop = Math.max(6, Math.min(maxTop, nextTopRaw)); + panelEl.style.left = `${nextLeft}px`; + panelEl.style.top = `${nextTop}px`; + if (panelId === 'pp-toggles') { + this._layoutRightPanels(); + } + if (panelId === 'cctv-panel') { + this._syncCctvPanelViewport(); + } + }; - /** - * Track one pointer gesture. With a `threshold`, `onStart` and `onMove` wait - * until the pointer has travelled that far; `onEnd`/`onCancel` get whether - * it started. Stopping through `_cancelDrag` calls neither. - */ - _trackPointerGesture( - event, - { threshold = 0, onStart, onMove, onStop, onEnd, onCancel = onEnd }, - ) { - const startX = event.clientX; - const startY = event.clientY; - let started = threshold <= 0; - const move = (moveEvent) => { - const dx = moveEvent.clientX - startX; - const dy = moveEvent.clientY - startY; - if (!started) { - if (Math.hypot(dx, dy) < threshold) return; - started = true; - onStart?.(); - } - onMove(moveEvent, dx, dy); - }; - const stop = () => { - onStop?.(); - window.removeEventListener('pointermove', move); - window.removeEventListener('pointerup', up); - window.removeEventListener('pointercancel', cancel); - if (this._cancelDrag === stop) this._cancelDrag = null; - }; - const up = () => { - stop(); - onEnd?.(started); - }; - const cancel = () => { - stop(); - onCancel?.(started); - }; - this._cancelDrag = stop; - window.addEventListener('pointermove', move); - window.addEventListener('pointerup', up); - window.addEventListener('pointercancel', cancel); - } - - /** @returns {boolean} Whether the panel was floating and is now docked. */ - dockPanel(panelId) { - const portable = this._portablePanels.get(panelId); - if (!portable?.panel.classList.contains('panel-floating')) return false; - this._resetPanelPosition(panelId); - return true; - } - - /** A `dockOnCollapse` panel returns to its rail when collapsed while floating. */ - onPanelCollapsed(panelId, collapsed) { - if (!collapsed || !this._portablePanels.get(panelId)?.dockOnCollapse) - return; - this.dockPanel(panelId); - } - - /** - * The collapse button still toggles on a plain click; the click that ends a - * real drag is swallowed so it cannot toggle by accident. - */ - _makePortablePanelDraggable(panelId, panelEl, handleEl) { - let swallowNextClick = false; - this.listen( - handleEl, - 'click', - (event) => { - if (!swallowNextClick) return; - swallowNextClick = false; - event.stopImmediatePropagation(); - event.preventDefault(); - }, - { capture: true }, - ); - this.listen(handleEl, 'dblclick', (event) => { - if (event.target.closest?.(INTERACTIVE_SELECTOR)) return; - if (!panelEl.classList.contains('panel-floating')) return; - event.preventDefault(); - this._resetPanelPosition(panelId); - }); - let lastPress = null; - this.listen(handleEl, 'pointerdown', (event) => { - if (event.button !== 0 || !isPrecisePointer(event)) return; - const interactive = event.target.closest?.(INTERACTIVE_SELECTOR); - if (interactive && !interactive.matches('.panel-collapse-btn')) return; - - const now = event.timeStamp || performance.now(); - const doublePress = - !interactive && - lastPress && - now - lastPress.time <= DOUBLE_PRESS_MS && - Math.hypot(event.clientX - lastPress.x, event.clientY - lastPress.y) <= - DOUBLE_PRESS_SLOP_PX; - lastPress = doublePress - ? null - : { time: now, x: event.clientX, y: event.clientY }; - if (doublePress && panelEl.classList.contains('panel-floating')) { - event.preventDefault(); - this._resetPanelPosition(panelId); - return; - } - - this._cancelDrag?.(); - // A header press must not start a text selection across the page. - event.preventDefault(); - const entry = this._portablePanels.get(panelId); - let lifted = false; - let before = null; - let offsetX = 0; - let offsetY = 0; - let geometry = null; - - this._trackPointerGesture(event, { - threshold: DRAG_THRESHOLD_PX, - onStart: () => { - // A drag between two presses is not a double-click. - lastPress = null; - const rect = panelEl.getBoundingClientRect(); - offsetX = event.clientX - rect.left; - offsetY = event.clientY - rect.top; - geometry = this._windowGeometry(entry, rect); - before = geometryOf(panelEl); - lifted = this._liftPanelOut(panelId, panelEl, rect); - panelEl.classList.add('panel-dragging'); - this._promotePanelZ(panelEl); - }, - onMove: (moveEvent) => { - moveEvent.preventDefault(); - const next = this._clampPortableWindow( - entry, - moveEvent.clientX - offsetX, - moveEvent.clientY - offsetY, - geometry, - ); - panelEl.style.left = `${next.left}px`; - panelEl.style.top = `${next.top}px`; - }, - onStop: () => panelEl.classList.remove('panel-dragging'), - onEnd: (dragged) => { - if (!dragged) return; - swallowNextClick = true; - setTimeout(() => { - swallowNextClick = false; - }, 0); - this._savePanelPosition(panelId, panelEl); - this._onPanelResized?.(panelId); - }, - onCancel: (dragged) => { - if (dragged) - this._revertPanelGesture(panelId, panelEl, lifted, before); - }, - }); - }); - } - - /** On pointercancel, restore the panel as the gesture found it; save nothing. */ - _revertPanelGesture(panelId, panelEl, lifted, before) { - if (lifted) { - this._resetPanelPosition(panelId); - return; - } - Object.assign(panelEl.style, before); - this._onPanelResized?.(panelId); - } - - /** Resizing a docked panel lifts it out first, so handles work in both states. */ - _makePanelResizable(panelId, panelEl, min = {}) { - const limits = { width: 300, height: 160, ...min }; - for (const dir of RESIZE_DIRECTIONS) { - const handle = document.createElement('div'); - handle.className = - dir === 'se' ? 'panel-resize-grip' : 'panel-resize-edge'; - handle.dataset.dir = dir; - handle.setAttribute('aria-hidden', 'true'); - if (dir === 'se') { - handle.title = 'Drag to resize Β· double-click the header to snap back'; - } - panelEl.appendChild(handle); - this._resizeHandles.push(handle); - this.listen(handle, 'pointerdown', (event) => - this._startPanelResize(event, panelId, panelEl, dir, limits), - ); - } - } - - _startPanelResize(event, panelId, panelEl, dir, limits) { - if (event.button !== 0 || !isPrecisePointer(event)) return; - event.preventDefault(); - this._cancelDrag?.(); - // Set once the pointer has travelled: a plain click on an edge must not - // lift the panel out. - let startBox = null; - let lifted = false; - let before = null; - const apply = ({ left, top, width, height }) => { - panelEl.style.left = `${left}px`; - panelEl.style.top = `${top}px`; - panelEl.style.width = `${width}px`; - panelEl.style.height = `${height}px`; - }; - - this._trackPointerGesture(event, { - threshold: DRAG_THRESHOLD_PX, - onStart: () => { - const rect = panelEl.getBoundingClientRect(); - before = geometryOf(panelEl); - lifted = this._liftPanelOut(panelId, panelEl, rect); - startBox = { - left: Math.round(rect.left), - top: Math.round(rect.top), - width: Math.round(rect.width), - height: Math.round(rect.height), - }; - panelEl.classList.add('panel-resizing'); - this._promotePanelZ(panelEl); - apply(startBox); - }, - onMove: (moveEvent, dx, dy) => { - moveEvent.preventDefault(); - apply( - resizeBox(startBox, dir, dx, dy, { - minWidth: limits.width, - minHeight: limits.height, - viewportWidth: window.innerWidth, - viewportHeight: window.innerHeight, - }), - ); - }, - onStop: () => panelEl.classList.remove('panel-resizing'), - onEnd: (resized) => { - if (!resized) return; - // Re-clamping skipped the live gesture; a north edge may have moved - // the header under the command dock. - const entry = this._portablePanels.get(panelId); - if (entry) this._reclampPortablePanel(entry); + const cancel = () => { + panelEl.classList.remove('panel-dragging'); + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointercancel', onUp); + this._cancelDrag = null; + }; + const onUp = () => { + cancel(); + if (panelId === 'pp-toggles') { + this._pinPanelToRight(panelEl); + } this._savePanelPosition(panelId, panelEl); - this._onPanelResized?.(panelId); - }, - onCancel: (resized) => { - if (resized) this._revertPanelGesture(panelId, panelEl, lifted, before); - }, + if (panelId === 'cctv-panel') { + this._syncCctvPanelViewport(); + } + }; + + this._cancelDrag = cancel; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + window.addEventListener('pointercancel', onUp); }); } - destroy() { if (this.destroyed) return; this.destroyed = true; this._cancelDrag?.(); for (const remove of this.removers.splice(0)) remove(); - for (const handle of this._resizeHandles.splice(0)) handle.remove(); this._draggableResizeObserver?.disconnect(); this._draggableResizeObserver = null; } diff --git a/src/ui/panelRails.test.mjs b/src/ui/panelRails.test.mjs index 016de58fa..df97bf721 100644 --- a/src/ui/panelRails.test.mjs +++ b/src/ui/panelRails.test.mjs @@ -113,10 +113,7 @@ function element( getBoundingClientRect() { return this.rect; }, - matches: (selector) => - selector === '[data-panel-id]' || - (selector === '[data-panel-id]:not(.panel-floating)' && - !classes.has('panel-floating')), + matches: (selector) => selector === '[data-panel-id]', contains(target) { return ( target === this || this.children.some((child) => child.contains(target)) @@ -325,28 +322,6 @@ test('right layout retains Display allocation during measurement and caps restor ); }); -test('right rail neither measures nor allocates a panel floating out of it', () => { - const f = fixture('right'); - f.expand(f.first, 900); - f.expand(f.second, 300); - f.first.classList.add('panel-floating'); - f.first.style.setProperty('--right-panel-allocated-height', '500px'); - f.first.writes.length = 0; - f.run(); - assert.equal(f.stack.dataset.expandedCount, '1'); - assert.deepEqual( - f.first.writes, - [], - 'a floating panel keeps whatever inline style it left the rail with', - ); - assert.equal(f.first.getAttribute('aria-hidden'), undefined); - assert.equal(f.first.classList.contains('layout-auto-collapsed'), false); - assert.ok( - f.second.style.getPropertyValue('--right-panel-allocated-height'), - 'the docked panel still receives its allocation', - ); -}); - test('right rail aligns to the current left rail and excludes hidden obstacles', () => { const f = fixture('right'); f.options.leftStack.rect.top = 200; diff --git a/src/ui/panelResize.js b/src/ui/panelResize.js deleted file mode 100644 index d3ac848ed..000000000 --- a/src/ui/panelResize.js +++ /dev/null @@ -1,47 +0,0 @@ -// Pure resize geometry for floating panels; PanelPositionControls wires the DOM. - -const EDGE_MARGIN_PX = 6; -export const RESIZE_DIRECTIONS = ['n', 's', 'e', 'w', 'ne', 'nw', 'sw', 'se']; - -function clamp(value, min, max) { - return Math.max(min, Math.min(max, value)); -} - -/** - * Edges named in `dir` follow the pointer (dx, dy since gesture start) and the - * opposite edge stays put. Minimum size and the viewport margin win. - */ -export function resizeBox( - box, - dir, - dx, - dy, - { - minWidth, - minHeight, - viewportWidth = Infinity, - viewportHeight = Infinity, - margin = EDGE_MARGIN_PX, - } = {}, -) { - let { left, top, width, height } = box; - const right = box.left + box.width; - const bottom = box.top + box.height; - if (dir.includes('e')) - width = clamp(box.width + dx, minWidth, viewportWidth - margin - box.left); - if (dir.includes('s')) - height = clamp( - box.height + dy, - minHeight, - viewportHeight - margin - box.top, - ); - if (dir.includes('w')) { - width = clamp(box.width - dx, minWidth, right - margin); - left = right - width; - } - if (dir.includes('n')) { - height = clamp(box.height - dy, minHeight, bottom - margin); - top = bottom - height; - } - return { left, top, width, height }; -} diff --git a/src/ui/panelResize.test.mjs b/src/ui/panelResize.test.mjs deleted file mode 100644 index 3e1577665..000000000 --- a/src/ui/panelResize.test.mjs +++ /dev/null @@ -1,64 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { resizeBox } from './panelResize.js'; - -const box = { left: 100, top: 100, width: 400, height: 300 }; -const limits = { - minWidth: 200, - minHeight: 150, - viewportWidth: 1000, - viewportHeight: 800, -}; - -test('east and south edges grow away from the anchored corner', () => { - assert.deepEqual(resizeBox(box, 'se', 50, 20, limits), { - left: 100, - top: 100, - width: 450, - height: 320, - }); - assert.deepEqual(resizeBox(box, 'e', -30, 999, limits), { - left: 100, - top: 100, - width: 370, - height: 300, - }); -}); - -test('west and north edges keep the opposite edge pinned', () => { - assert.deepEqual(resizeBox(box, 'w', -40, 0, limits), { - left: 60, - top: 100, - width: 440, - height: 300, - }); - assert.deepEqual(resizeBox(box, 'nw', 40, 30, limits), { - left: 140, - top: 130, - width: 360, - height: 270, - }); - assert.deepEqual( - resizeBox(box, 'n', -50, 0, limits).top, - 100, - 'horizontal motion does not move a north edge', - ); -}); - -test('minimum size stops the moving edge, not the anchored one', () => { - const small = resizeBox(box, 'w', 350, 0, limits); - assert.deepEqual(small, { left: 300, top: 100, width: 200, height: 300 }); - assert.equal(resizeBox(box, 'ne', 0, 400, limits).height, 150); - assert.equal(resizeBox(box, 'ne', 0, 400, limits).top, 250); -}); - -test('the viewport margin caps growth in every direction', () => { - assert.equal(resizeBox(box, 'e', 2000, 0, limits).width, 1000 - 6 - 100); - assert.equal(resizeBox(box, 's', 0, 2000, limits).height, 800 - 6 - 100); - const west = resizeBox(box, 'w', -2000, 0, limits); - assert.equal(west.left, 6); - assert.equal(west.left + west.width, 500); - const north = resizeBox(box, 'n', 0, -2000, limits); - assert.equal(north.top, 6); - assert.equal(north.top + north.height, 400); -}); diff --git a/src/ui/rightPanelRail.js b/src/ui/rightPanelRail.js index 3b85bb896..70dc776dc 100644 --- a/src/ui/rightPanelRail.js +++ b/src/ui/rightPanelRail.js @@ -69,10 +69,8 @@ export function layoutRightPanelRail({ // A collapse may schedule one follow-up, which only measures and allocates. const isCollapseRetry = pendingCollapseRetries.delete(stack); - // A floating (lifted-out) panel is not laid out by the rail. const panels = [...stack.children].filter( - (panel) => - panel.matches('[data-panel-id]:not(.panel-floating)') && !panel.hidden, + (panel) => panel.matches('[data-panel-id]') && !panel.hidden, ); if (!hud.visible || hud.variant !== 'tactical') { for (const panel of panels.filter((item) => diff --git a/src/ui/streetLevelControls.js b/src/ui/streetLevelControls.js index 4da1570bf..4c07a84c3 100644 --- a/src/ui/streetLevelControls.js +++ b/src/ui/streetLevelControls.js @@ -134,7 +134,7 @@ export class StreetLevelControls { }); this.listen(el.viewerClose, 'click', () => this.layer.closeViewer?.()); this.listen(el.viewerExpand, 'click', () => - this.setViewerExpanded(!this.isViewerExpanded(), { dock: true }), + this.setViewerExpanded(!this.isViewerExpanded()), ); for (const button of el.renderButtons) { this.listen(button, 'click', () => { @@ -276,10 +276,6 @@ export class StreetLevelControls { if (this.layer.getUIState) this.render(this.layer.getUIState()); } - onPanelResized() { - this._requestResize(); - } - setCollapsed(collapsed, options = {}) { this.actions.setPanelCollapsed?.(collapsed, options); if (!collapsed) this._requestResize(); @@ -293,11 +289,7 @@ export class StreetLevelControls { ); } - /** - * A user's shrink (`dock`) also returns a floating panel to its rail, so the - * viewer does not land in a window over the globe. - */ - setViewerExpanded(expanded, { dock = false } = {}) { + setViewerExpanded(expanded) { const wrap = this._elements.viewerWrap; if (!wrap) return; const on = expanded === true; @@ -347,7 +339,6 @@ export class StreetLevelControls { button.setAttribute('aria-pressed', String(on)); button.setAttribute('aria-label', on ? 'Shrink' : 'Expand'); } - if (!on && dock) this.actions.dockPanel?.(); this._requestResize(); } @@ -430,7 +421,7 @@ export class StreetLevelControls { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); - this.setViewerExpanded(false, { dock: true }); + this.setViewerExpanded(false); return; } // A radio off the roving tab stop (tabindex -1) is not a stop either. diff --git a/src/ui/streetLevelControls.test.mjs b/src/ui/streetLevelControls.test.mjs index bcf39cda2..66e65f042 100644 --- a/src/ui/streetLevelControls.test.mjs +++ b/src/ui/streetLevelControls.test.mjs @@ -407,7 +407,6 @@ async function productionPanel(dom) { setParams: (params, options) => manager.setLayerParams('street-level', params, options), setPanelCollapsed() {}, - dockPanel() {}, showToast() {}, }, }); @@ -546,7 +545,6 @@ function stubPanel(dom, state, extraActions = {}) { setParams: (params, options) => calls.setParams.push([params, options]), setPanelCollapsed: (collapsed, options) => calls.collapsed.push([collapsed, options]), - dockPanel() {}, showToast: (message) => calls.toasts.push(message), ...extraActions, }, diff --git a/src/ui/styles/controls.css b/src/ui/styles/controls.css index 8607cd459..abc2012c2 100644 --- a/src/ui/styles/controls.css +++ b/src/ui/styles/controls.css @@ -103,115 +103,6 @@ opacity: 0.96; } -/* ── Portable rail panels ──────────────────── - Rail placement in layers.css is guarded with :not(.panel-floating), so - these rules only have to beat a panel's base placement (one id). */ -.panel-portable .panel-header { - cursor: grab; -} -.panel-portable.panel-dragging .panel-header { - cursor: grabbing; -} -#right-context-rail > .panel-floating { - position: fixed; - inset: auto; - /* Above every docked rail panel; keep in step with PANEL_Z_FLOATING_FLOOR. */ - z-index: 111; - max-height: calc(100vh - 12px); - margin: 0; - flex: none; - pointer-events: auto; -} -/* A collapsed window shows only its header strip; its inline height stays. */ -#right-context-rail > .panel-floating.collapsed { - max-height: max-content; -} -.panel-floating[style*='height'] > .cctv-panel-inner { - box-sizing: border-box; - height: 100%; - max-height: none; -} -.panel-resize-edge, -.panel-resize-grip { - position: absolute; - z-index: 3; - touch-action: none; -} -.panel-resize-edge[data-dir='n'], -.panel-resize-edge[data-dir='s'] { - left: 10px; - right: 10px; - height: 7px; - cursor: ns-resize; -} -.panel-resize-edge[data-dir='n'] { - top: -3px; -} -.panel-resize-edge[data-dir='s'] { - bottom: -3px; -} -.panel-resize-edge[data-dir='e'], -.panel-resize-edge[data-dir='w'] { - top: 10px; - bottom: 10px; - width: 7px; - cursor: ew-resize; -} -.panel-resize-edge[data-dir='e'] { - right: -3px; -} -.panel-resize-edge[data-dir='w'] { - left: -3px; -} -.panel-resize-edge[data-dir='ne'], -.panel-resize-edge[data-dir='nw'], -.panel-resize-edge[data-dir='sw'] { - width: 14px; - height: 14px; -} -.panel-resize-edge[data-dir='ne'] { - top: -3px; - right: -3px; - cursor: nesw-resize; -} -.panel-resize-edge[data-dir='nw'] { - top: -3px; - left: -3px; - cursor: nwse-resize; -} -.panel-resize-edge[data-dir='sw'] { - bottom: -3px; - left: -3px; - cursor: nesw-resize; -} -.panel-resize-grip { - right: 3px; - bottom: 3px; - width: 14px; - height: 14px; - cursor: nwse-resize; - opacity: 0.55; - clip-path: polygon(100% 0, 100% 100%, 0 100%); - background: repeating-linear-gradient( - 135deg, - transparent 0 3px, - rgba(0, 212, 255, 0.6) 3px 4px - ); - transition: opacity var(--transition-fast); -} -.panel-portable:hover .panel-resize-grip, -.panel-resizing .panel-resize-grip { - opacity: 1; -} -.panel-collapsible.collapsed > .panel-resize-edge, -.panel-collapsible.collapsed > .panel-resize-grip { - display: none; -} -.panel-resizing, -.panel-resizing * { - user-select: none; -} - .panel-title { font-family: var(--font-mono); font-size: 9px; diff --git a/src/ui/styles/layers.css b/src/ui/styles/layers.css index 9b2534054..ba9bfbc68 100644 --- a/src/ui/styles/layers.css +++ b/src/ui/styles/layers.css @@ -161,8 +161,8 @@ max-height var(--transition-fast); } #right-context-rail > #pp-toggles, -#right-context-rail > #cctv-panel:not(.panel-floating), -#right-context-rail > #street-level-panel:not(.panel-floating), +#right-context-rail > #cctv-panel, +#right-context-rail > #street-level-panel, #right-context-rail > #weather-panel, #right-context-rail > #recent-imagery-panel, #right-context-rail > #global-context-panel { @@ -176,8 +176,8 @@ max-height: 100%; pointer-events: auto; } -#right-context-rail #cctv-panel:not(.panel-floating), -#right-context-rail #street-level-panel:not(.panel-floating), +#right-context-rail #cctv-panel, +#right-context-rail #street-level-panel, #right-context-rail #weather-panel, #right-context-rail #recent-imagery-panel { width: 100%; @@ -192,8 +192,8 @@ flex-shrink: 1; min-height: 0; } -#right-context-rail #cctv-panel.collapsed:not(.panel-floating), -#right-context-rail #street-level-panel.collapsed:not(.panel-floating), +#right-context-rail #cctv-panel.collapsed, +#right-context-rail #street-level-panel.collapsed, #right-context-rail #weather-panel.collapsed, #right-context-rail #recent-imagery-panel.collapsed { width: var(--right-display-collapsed-width); @@ -257,24 +257,19 @@ #right-context-rail.layout-focus { height: var(--right-stack-max-height); } -#right-context-rail.layout-exclusive - > [data-panel-id].collapsed:not(.panel-floating) { +#right-context-rail.layout-exclusive > [data-panel-id].collapsed { display: none; } -#right-context-rail.layout-focus - > [data-panel-id].collapsed:not(.panel-floating) { +#right-context-rail.layout-focus > [data-panel-id].collapsed { display: block; flex: 0 0 auto; } -#right-context-rail.layout-focus.layout-exclusive - > [data-panel-id].collapsed:not(.panel-floating) { +#right-context-rail.layout-focus.layout-exclusive > [data-panel-id].collapsed { display: none; } #right-context-rail.layout-focus > #pp-toggles:not(.collapsed), -#right-context-rail.layout-focus - > #cctv-panel:not(.collapsed):not(.panel-floating), -#right-context-rail.layout-focus - > #street-level-panel:not(.collapsed):not(.panel-floating), +#right-context-rail.layout-focus > #cctv-panel:not(.collapsed), +#right-context-rail.layout-focus > #street-level-panel:not(.collapsed), #right-context-rail.layout-focus > #weather-panel:not(.collapsed), #right-context-rail.layout-focus > #recent-imagery-panel:not(.collapsed), #right-context-rail.layout-focus > #global-context-panel:not(.collapsed) { @@ -293,10 +288,10 @@ > #weather-panel:not(.collapsed) .weather-panel-inner, #right-context-rail.layout-focus - > #cctv-panel:not(.collapsed):not(.panel-floating) + > #cctv-panel:not(.collapsed) .cctv-panel-inner, #right-context-rail.layout-focus - > #street-level-panel:not(.collapsed):not(.panel-floating) + > #street-level-panel:not(.collapsed) .street-level-panel-inner, #right-context-rail.layout-focus > #recent-imagery-panel:not(.collapsed) diff --git a/src/ui/styles/responsive.css b/src/ui/styles/responsive.css index 4faccd150..8278b51a7 100644 --- a/src/ui/styles/responsive.css +++ b/src/ui/styles/responsive.css @@ -216,10 +216,10 @@ } #right-context-rail #pp-toggles.collapsed .pp-header-row, - #right-context-rail #cctv-panel:not(.panel-floating), - #right-context-rail #cctv-panel.collapsed:not(.panel-floating), - #right-context-rail #street-level-panel:not(.panel-floating), - #right-context-rail #street-level-panel.collapsed:not(.panel-floating), + #right-context-rail #cctv-panel, + #right-context-rail #cctv-panel.collapsed, + #right-context-rail #street-level-panel, + #right-context-rail #street-level-panel.collapsed, #right-context-rail #recent-imagery-panel, #right-context-rail #recent-imagery-panel.collapsed, #right-context-rail #radio-panel, diff --git a/src/ui/styles/street-level.css b/src/ui/styles/street-level.css index 3991bc2ae..e2fd98eb8 100644 --- a/src/ui/styles/street-level.css +++ b/src/ui/styles/street-level.css @@ -125,7 +125,6 @@ button.sl-status:focus-visible { pointer-events: none; } -/* In a resized window only this block scrolls; the viewer keeps its room. */ .sl-settings { display: flex; flex-direction: column; @@ -476,52 +475,6 @@ button.sl-status:focus-visible { display: none; } -/* ── Floating window ─────────────────────────────────────────────────────── */ -/* A dragged window keeps its natural height, so bound it to the viewport: - the rail's max-height: 100% does not bind an auto-height parent. - Id-qualified to outrank that rule. */ -#right-context-rail > #street-level-panel.panel-floating > .street-level-panel-inner { - max-height: calc(100vh - 12px); - overflow-y: auto; -} - -/* A resized window (inline height) gives its spare height to the viewer. */ -.panel-floating[style*='height'] > .street-level-panel-inner { - box-sizing: border-box; - height: 100%; - max-height: none; - overflow: hidden; -} - -.panel-floating[style*='height'] .sl-body, -.panel-floating[style*='height'] .sl-main { - flex: 1 1 auto; - min-height: 0; -} - -.panel-floating[style*='height'] .sl-viewer-wrap { - flex: 1 1 auto; - min-height: 0; -} - -.panel-floating[style*='height'] .sl-viewer-stage { - flex: 1 1 auto; - min-height: 140px; -} - -.panel-floating[style*='height'] .sl-viewer { - flex: 1 1 auto; - height: auto; - min-height: 0; - aspect-ratio: auto; -} - -.panel-floating[style*='height'] .sl-settings { - flex: 0 1 auto; - overflow-y: auto; - scrollbar-width: thin; -} - /* ── Expanded viewer ─────────────────────────────────────────────────────── */ /* Re-parented to : the panel's backdrop-filter would contain a fixed child. z-index sits above panels (≀139), below the voice pill (150). */