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/editors/Explorer.svelte b/src/components/editors/Explorer.svelte index 27a2467cb..35b36ddb1 100644 --- a/src/components/editors/Explorer.svelte +++ b/src/components/editors/Explorer.svelte @@ -345,6 +345,9 @@ // 24-C1: the in-app clipboard for files and folders import { explorerClipboard, setClipboard, clearClipboard, isCutPending, clipboardLabel } from '$lib/explorerClipboard'; import { focusStack } from '$lib/windowFocus'; + import ScrollStrip from '../ui/ScrollStrip.svelte'; + import { phoneShellActive } from '$lib/ui/phoneShell.js'; + import { touchHold, holdEvent, HOLD_MS } from '$lib/ui/touchHold.js'; import { tabbable, resizeGroup, tabGroups } from '$lib/windowTabs'; import { dockable } from '$lib/docking'; import ContextMenu from '../ContextMenu.svelte'; @@ -5904,6 +5907,9 @@ let tSuppressClick = false; /** 39: this touch drag belongs to placeDrag (it reads the pointer from the window) */ let tPlace = false; + /** 40 F3: a HOLD opened the card's menu (the phone action sheet); moving on from there + * closes it and picks the card up instead (`pickUp`), lifting keeps the menu */ + let tHeld: { pickUp: () => void } | null = null; function onCardPointerDown(e: PointerEvent, item: any) { if (e.pointerType === 'mouse') return; // desktop uses native HTML5 drag @@ -5919,7 +5925,8 @@ const carried = payload.items?.length ?? 1; const label = carried > 1 ? `${item.name} + ${carried - 1} more` : item.name; clearTimeout(tPressTimer); - tPressTimer = window.setTimeout(() => { + tHeld = null; + const pickUp = () => { // 39 P1: an object card picked up by touch is a PLACE drag — the ghost on the viewport, // the sheet collapsed out of the way; the controller owns the gesture from here if (isPlaceable(item)) { @@ -5944,15 +5951,32 @@ try { navigator.vibrate?.(15); } catch {} - }, 300); + }; + // 40 F3: a still HOLD opens the card's menu (long-press-explorer-file); the drag is the + // hold that then MOVES (onCardPointerMove) — hold-and-lift keeps the menu + tPressTimer = window.setTimeout(() => { + tHeld = { pickUp }; + try { + navigator.vibrate?.(12); + } catch {} + itemMenu(holdEvent(tStartX, tStartY), item); + }, HOLD_MS); } function onCardPointerMove(e: PointerEvent) { if (e.pointerType === 'mouse') return; if (tPlace) return void e.preventDefault(); if (!tDragging) { + const travelled = Math.abs(e.clientX - tStartX) > 10 || Math.abs(e.clientY - tStartY) > 10; + // 40 F3: held (the menu is up) and now moving: the menu gives way to the pick-up + if (tHeld && travelled) { + const { pickUp } = tHeld; + tHeld = null; + menu = null; + pickUp(); + return; + } // moved before the long-press fired -> it's a scroll, not a pick-up - if (Math.abs(e.clientX - tStartX) > 10 || Math.abs(e.clientY - tStartY) > 10) - clearTimeout(tPressTimer); + if (travelled) clearTimeout(tPressTimer); return; } e.preventDefault(); @@ -5961,6 +5985,12 @@ } function onCardPointerUp(e: PointerEvent) { clearTimeout(tPressTimer); + if (tHeld) { + // 40 F3: hold-and-lift — the menu stays; the lift is not a tap-select + tHeld = null; + tSuppressClick = true; + return; + } if (tPlace) { // placeDrag already took the release (its window listener runs first) tPlace = false; @@ -6507,7 +6537,7 @@ {#snippet editRow(depth: number)}
(editing = { ...editing, value: e.currentTarget.value })} @@ -6525,7 +6555,7 @@ (editing = { ...editing, value: e.currentTarget.value })} @@ -6679,6 +6709,8 @@ onpointerdown={(e) => !isFolder && onCardPointerDown(e, item)} onpointermove={(e) => !isFolder && onCardPointerMove(e)} onpointerup={(e) => !isFolder && onCardPointerUp(e)} + onpointercancel={() => clearTimeout(tPressTimer)} + use:touchHold={{ onhold: isFolder ? (x, y) => folderMenu(holdEvent(x, y), folder, false) : undefined }} oncontextmenu={(e) => (isFolder ? folderMenu(e, folder, false) : itemMenu(e, item))} onclick={(e) => (isFolder ? onFolderCardClick(e, folder) : onCardClick(e, item))} ondblclick={() => (isFolder ? openFolder(folder.id) : openItem(item))} @@ -7709,6 +7741,7 @@ ondragover={(e) => dragOverInto(e, folder.id)} ondragleave={() => (dropFolder = null)} ondrop={(e) => dropInto(e, folder.id)} + use:touchHold={{ onhold: (x, y) => folderMenu(holdEvent(x, y), folder, false) }} oncontextmenu={(e) => folderMenu(e, folder, false)} onclick={(e) => onFolderCardClick(e, folder)} ondblclick={() => openFolder(folder.id)} @@ -7763,6 +7796,8 @@ onpointerdown={(e) => onCardPointerDown(e, item)} onpointermove={onCardPointerMove} onpointerup={onCardPointerUp} + onpointercancel={() => clearTimeout(tPressTimer)} + use:touchHold data-cut={!!$explorerClipboard && isCutPending(item.id) ? '1' : undefined} class:opacity-50={!!$explorerClipboard && isCutPending(item.id)} oncontextmenu={(e) => itemMenu(e, item)} @@ -8310,7 +8345,7 @@ onpointerup={endResize} >
-
+ {#snippet dockRow()} Explorer +
+ {@render dockRow()} +
+ {:else} +
+ {@render dockRow()} +
+ {/if}
{@render content()}
@@ -8436,6 +8482,7 @@ {#if tDragging && tDrag}
@@ -8901,4 +8948,14 @@ flex: 1; } } + /* 40 F3: the rename field's SELECTED text is readable in every theme — the browser default + selection (and the phone's) fought the field's own colours; the kit's accent pair is the + one guaranteed to contrast. The field inside a card keeps the platform text callout. */ + .ex-edit::selection { + background: var(--accent-fill); + color: var(--on-accent); + } + .ex-edit { + -webkit-touch-callout: default; + } 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}