From 58db817acbb5bc67316b7a85149bb038f29b6fc5 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 8 Oct 2026 14:30:44 +0300 Subject: [PATCH 1/5] [feat] phone sheets: one gesture for every drawer (40 F1) + profile menu taps (F2) - F1: $lib/ui/sheetDrag (the shared drag/flick/keyboard gesture) + sheetSnap.settleSheet (free-height rest rule: snap to min/max, swipe down to the end closes) + ui/SheetGrip (the sticky grab bar). Wired into the shell's framed sheets, ui/Sheet, the Inspector / Configure Scene, the main menu, the profile menu, every phone context menu (Add), the notes sheet, toolboxes, Customize character and the dock (grab bar drawn by PhoneShell; swipe-away minimizes the dock). Heights remembered under each sheet's existing key. - phoneSheetMax / --ps-sheet-max: no sheet rises into the top bar or pushes the selection strip under it; the bar + strip rise over every open sheet so Play is never covered (cover-play-and-handle). The notification centre's desktop click-catcher stands down on the phone. - F2: the profile menu inherited pointer-events:none from the top-right corner chrome on the phone, so Customize character / Profile settings never got the tap. On the phone it is a plain sheet above the bar (no top layer), tappable. - e2e phone-sheets (104, real touch events, F2 counterfactual); mobile-map and mobile-ui-fixes assert the new above-the-bar contract; vitest sheetSnap +4. - svelte-check 300/40 (baseline ratcheted from 300/41). Co-Authored-By: Claude Opus 5.5 (1M context) --- check-baseline.json | 4 +- src/components/ContextMenu.svelte | 18 ++ src/components/menu/CharacterPanel.svelte | 28 ++- src/components/menu/Inspector.svelte | 52 ++-- src/components/menu/NotesDrawer.svelte | 58 +---- src/components/menu/NotificationCenter.svelte | 2 +- src/components/menu/Sidebar.svelte | 17 +- src/components/menu/Users.svelte | 21 +- src/components/phone/PhoneShell.svelte | 150 ++++++----- src/components/ui/ModuleToolboxLayer.svelte | 1 + src/components/ui/Sheet.svelte | 87 ++----- src/components/ui/SheetGrip.svelte | 91 +++++++ src/components/ui/ToolboxWindow.svelte | 73 ++---- src/lib/ui/phoneShell.js | 7 + src/lib/ui/sheetDrag.js | 167 +++++++++++++ src/lib/ui/sheetSnap.js | 47 ++++ src/styles/phone.css | 99 +++++--- src/styles/ui.css | 29 +-- tests/e2e/mobile-map.test.cjs | 5 +- tests/e2e/mobile-ui-fixes.test.cjs | 8 +- tests/e2e/phone-sheets.test.cjs | 235 ++++++++++++++++++ tests/unit/sheetSnap.test.js | 28 +++ 22 files changed, 902 insertions(+), 325 deletions(-) create mode 100644 src/components/ui/SheetGrip.svelte create mode 100644 src/lib/ui/sheetDrag.js create mode 100644 tests/e2e/phone-sheets.test.cjs diff --git a/check-baseline.json b/check-baseline.json index a59e9f8b1..c48f64861 100644 --- a/check-baseline.json +++ b/check-baseline.json @@ -1,6 +1,6 @@ { "comment": "27-I: the svelte-check floor, read ONLY by scripts/check-ratchet.cjs. It used to be hardcoded in release.yml's shell block, where it went stale (362 while the tree measured 359). Ratchet it DOWN whenever a change legitimately removes errors - that is the project convention, and --update does it in one command.", "errors": 300, - "warnings": 41, - "measured": "2026-10-07" + "warnings": 40, + "measured": "2026-10-08" } diff --git a/src/components/ContextMenu.svelte b/src/components/ContextMenu.svelte index 4fed80151..f7debe4cd 100644 --- a/src/components/ContextMenu.svelte +++ b/src/components/ContextMenu.svelte @@ -6,6 +6,9 @@ import { minimalScroll } from '$lib/ui/minimalScroll.js'; import { autofocusOk, typeToFocus } from '$lib/inputDevice'; import { safeStorage } from '$lib/safeStorage'; + import SheetGrip from './ui/SheetGrip.svelte'; + import { readSheetH, saveSheetH } from '$lib/ui/sheetDrag.js'; + import { phoneShellActive, phoneSheetMaxH } from '$lib/ui/phoneShell.js'; // Generic context menu. items: [{ label, action?, disabled?, tooltip?, danger?, // icon?, hint?, checked?, keepOpen?, rowActions?, children?: items[] } | @@ -59,6 +62,18 @@ * Absent = byte-identical to before. */ export let startSearch: boolean = false; + // 40 F1: on the phone every menu is a bottom sheet (phone.css), and the Add list is the + // one opened from the bar — so it wears the shared grab bar: drag to resize (remembered, + // `menuSheetH`), swipe down to the end to close. The height is ONE root CSS var, so a + // submenu laid over its parent (drill in place) takes the same height. + let menuSheetH = readSheetH('menuSheetH', 0); + $: menuShownH = Math.min(menuSheetH || Math.round((typeof window === 'undefined' ? 800 : window.innerHeight) * 0.6), $phoneSheetMaxH || 9999); + $: if ($phoneShellActive && typeof document !== 'undefined') document.documentElement.style.setProperty('--ctx-sheet-h', menuShownH + 'px'); + function menuSheetResize(h: number, done: boolean) { + menuSheetH = Math.round(h); + if (done) saveSheetH('menuSheetH', menuSheetH); + } + const rawDispatch = createEventDispatcher(); const dispatch = (name: string) => { if (name === 'close') onclose?.(); @@ -425,6 +440,9 @@ on:click|capture={guardLift} on:contextmenu|preventDefault > + {#if $phoneShellActive} + dispatch('close')} /> + {/if} {#if headerItem} {/if} diff --git a/src/components/menu/CharacterPanel.svelte b/src/components/menu/CharacterPanel.svelte index f30127274..20f96a367 100644 --- a/src/components/menu/CharacterPanel.svelte +++ b/src/components/menu/CharacterPanel.svelte @@ -17,6 +17,8 @@ import { pingColor, pingSound, previewPing } from '$lib/ping'; import { PING_SOUNDS, playPing } from '$lib/pingAudio'; import { safeStorage } from '$lib/safeStorage'; + import { settleSheet } from '$lib/ui/sheetSnap.js'; + import { phoneSheetMaxH } from '$lib/ui/phoneShell.js'; import { flyTo } from '$lib/objectActions'; import Icon from '../ui/Icon.svelte'; // 38 R7 + NOTES-38 #2: a resizable DRAWER on the redesign kit — WindowChrome size="modal" header, @@ -113,7 +115,9 @@ const MIN_W = 300; const MIN_H = 220; const maxW = () => Math.max(MIN_W, Math.round(window.innerWidth * 0.6)); - const maxH = () => Math.max(MIN_H, Math.round(window.innerHeight * 0.8)); + // 40 F1: on the phone the sheet's ceiling is the shell's sheet room (clear of the top bar and the + // selection strip), like every other phone sheet + const maxH = () => Math.max(MIN_H, Math.round(Math.min(window.innerHeight * 0.8, get(phoneSheetMaxH) || Infinity))); const sheetH = () => (drawerH > 0 ? Math.min(Math.max(drawerH, MIN_H), maxH()) : Math.min(window.innerHeight * 0.52, 460)); const panelW = () => Math.min(Math.max(drawerW, MIN_W), maxW()); @@ -162,16 +166,34 @@ const y0 = e.clientY; const w0 = panelW(); const h0 = sheetH(); + // 40 F1: the phone sheet closes like every other one — swiped down to the end, or flicked + // down from its min (the shared rule, sheetSnap.settleSheet) + let rawH = h0; + /** @type {[number, number][]} */ + let samples = [[e.timeStamp, e.clientY]]; /** @param {PointerEvent} ev */ const move = (ev) => { - if (narrow) drawerH = Math.min(Math.max(h0 - (ev.clientY - y0), MIN_H), maxH()); + rawH = h0 - (ev.clientY - y0); + samples.push([ev.timeStamp, ev.clientY]); + if (samples.length > 6) samples.shift(); + if (narrow) drawerH = Math.min(Math.max(rawH, MIN_H), maxH()); else drawerW = Math.min(Math.max(w0 - (ev.clientX - x0), MIN_W), maxW()); }; - const up = () => { + /** @param {PointerEvent} ev */ + const up = (ev) => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); window.removeEventListener('pointercancel', up); resizing = false; + if (narrow && ev.type === 'pointerup') { + const first = samples[0]; + const velocity = (ev.clientY - first[1]) / Math.max(1, ev.timeStamp - first[0]); + if (settleSheet({ height: rawH, velocity, min: MIN_H, max: maxH() }) === 'closed') { + drawerH = h0; // the height it had: a swipe-away is not a resize + end(false); + return; + } + } safeStorage.setItem(SIZE_KEY, JSON.stringify({ w: drawerW, h: drawerH })); aim(300); }; diff --git a/src/components/menu/Inspector.svelte b/src/components/menu/Inspector.svelte index 6d1105e9d..f04bdc7e7 100644 --- a/src/components/menu/Inspector.svelte +++ b/src/components/menu/Inspector.svelte @@ -29,6 +29,9 @@ import FlowPathSection from '../sim/FlowPathSection.svelte'; // 36-fb F24 import FluidTankSection from '../sim/FluidTankSection.svelte'; // 36-sim U2b import DragRow from '../ui/DragRow.svelte'; + import SheetGrip from '../ui/SheetGrip.svelte'; + import { saveSheetH } from '$lib/ui/sheetDrag.js'; + import { phoneSheetMaxH } from '$lib/ui/phoneShell.js'; import Segmented from '../ui/Segmented.svelte'; import ParticleMotionRows from '../fx/ParticleMotionRows.svelte'; import ColorPicker, { ChromeVariant } from 'svelte-awesome-color-picker'; @@ -339,30 +342,12 @@ const saved = parseInt(safeStorage.getItem('inspectorSheetH') || ''); inspectorH = !saved || Number.isNaN(saved) ? Math.round(window.innerHeight * 0.45) : saved; }); - let insResizing = $state(false); - /** @param {PointerEvent} e */ - function insStartResize(e) { - insResizing = true; - /** @type {HTMLElement} */ (e.currentTarget).setPointerCapture?.(e.pointerId); - e.preventDefault(); - } - /** @param {PointerEvent} e */ - function insDoResize(e) { - if (!insResizing) return; - // sheet is bottom:0, so height = viewport height - finger y; cap the top below - // the Connect bar + top-right chrome (same limit as the Flow/Explorer dock) - const cb = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--connect-bottom')) || 54; - const maxH = Math.max(200, window.innerHeight - cb - 56); - inspectorH = Math.min(Math.max(160, window.innerHeight - e.clientY), maxH); - } - /** @param {PointerEvent} e */ - function insEndResize(e) { - if (!insResizing) return; - insResizing = false; - /** @type {HTMLElement} */ (e.currentTarget).releasePointerCapture?.(e.pointerId); - try { - safeStorage.setItem('inspectorSheetH', String(inspectorH)); - } catch {} + // 40 F1: the grab bar is ui/SheetGrip (sticky, swipe down to the end closes). The height + // stays this drawer's own, under the same key. + /** @param {number} h @param {boolean} done */ + function insResize(h, done) { + inspectorH = Math.round(h); + if (done) saveSheetH('inspectorSheetH', inspectorH); } // C1 (roadmap #13): scene-mode physics-objects list. Recomputes on scene/graph @@ -1638,16 +1623,17 @@ role="region" aria-label="Properties" > - -
+ - -
+ label={$inspectorKind === 'scene' ? 'Configure Scene' : 'Properties'} + height={Math.min(inspectorH, $phoneSheetMaxH || inspectorH)} + min={160} + inset={16} + onresize={insResize} + onclose={() => inspectorClose.set(true)} + /> {#if $inspectorKind === 'file'} inspectorClose.set(true)} /> diff --git a/src/components/menu/NotesDrawer.svelte b/src/components/menu/NotesDrawer.svelte index f64eb3249..71f4475e6 100644 --- a/src/components/menu/NotesDrawer.svelte +++ b/src/components/menu/NotesDrawer.svelte @@ -8,6 +8,9 @@ // a header toggle for the in-scene pins. import WindowChrome from '../ui/WindowChrome.svelte'; import Icon from '../ui/Icon.svelte'; + import SheetGrip from '../ui/SheetGrip.svelte'; + import { saveSheetH } from '$lib/ui/sheetDrag.js'; + import { phoneSheetMaxH } from '$lib/ui/phoneShell.js'; import EmptyState from '../ui/EmptyState.svelte'; import { notesDrawerOpen, inspectorClose, noteDoubleClickToOpen, showToast } from '../../stores/appStore.js'; import { @@ -48,30 +51,12 @@ ? Math.round((typeof window !== 'undefined' ? window.innerHeight : 800) * 0.45) : stored ); - let resizing = false; - /** @param {PointerEvent} e */ - function startResize(e) { - resizing = true; - /** @type {HTMLElement} */ (e.currentTarget).setPointerCapture?.(e.pointerId); - e.preventDefault(); - } - /** @param {PointerEvent} e */ - function doResize(e) { - if (!resizing) return; - // sheet is bottom:0, so height = viewport height - finger y; cap the top below - // the Connect bar + top-right chrome (same limit as the Flow/Explorer dock) - const cb = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--connect-bottom')) || 54; - const maxH = Math.max(200, window.innerHeight - cb - 56); - sheetH = Math.min(Math.max(160, window.innerHeight - e.clientY), maxH); - } - /** @param {PointerEvent} e */ - function endResize(e) { - if (!resizing) return; - resizing = false; - /** @type {HTMLElement} */ (e.currentTarget).releasePointerCapture?.(e.pointerId); - try { - safeStorage.setItem('notesSheetH', String(sheetH)); - } catch {} + // 40 F1: the grab bar is ui/SheetGrip — swipe down to the end closes the sheet; the height + // keeps its own key + /** @param {number} h @param {boolean} done */ + function notesResize(h, done) { + sheetH = Math.round(h); + if (done) saveSheetH('notesSheetH', sheetH); } /** @param {string} uuid */ @@ -136,14 +121,8 @@ {#if $notesDrawerOpen}