): void => {
// The press stops here: the panel's own press-to-focus would record a focus
// entry before the drag's, and the release below decides which one it is.
event.stopPropagation()
- // A press during a return takes the panel from where it is drawn.
+ // A press during its own return takes the panel from where it is drawn;
+ // another panel's return ends at its target.
const drawn = settling.current?.paneId === paneId ? preview?.rect : undefined
- cancelSettling()
- const area = floatBounds()
+ if (cancelSettling() && drawn === undefined) setPreview(undefined)
+ area.current = floatBounds()
const reduce = reducedMotion()
- const stored = floatRect(getPane(state, paneId))
- const shown = drawn ?? legalFloatRect(stored, area)
- const rect = mode === 'move' && !reduce ? unresistedRect(shown, area) : legalFloatRect(shown, area)
+ const shown = drawn ?? legalFloatRect(floatRect(getPane(state, paneId)), area.current)
+ const rect = mode === 'move' && !reduce ? unresistedRect(shown, area.current) : legalFloatRect(shown, area.current)
const start: FloatDrag = { mode, originX: event.clientX, originY: event.clientY, rect }
- const samples: PointerSample[] = [{ t: performance.now(), x: event.clientX, y: event.clientY }]
/** The rectangle to draw for a pointer position, and the legal one a release there records. */
const reached = (x: number, y: number): { readonly drawn: FloatRect; readonly legal: FloatRect } => {
const desired = draggedRect(start, x, y)
if (mode === 'resize') {
- const resized = boundedResize(desired, area, FLOAT_MIN_SIZE)
+ const resized = boundedResize(desired, area.current, FLOAT_MIN_SIZE)
return { drawn: resized, legal: resized }
}
- const legal = legalFloatRect(desired, area)
- return { drawn: reduce ? legal : resistedRect(desired, area), legal }
+ const legal = legalFloatRect(desired, area.current)
+ return { drawn: reduce ? legal : resistedRect(desired, area.current), legal }
}
+ const pressed = reached(event.clientX, event.clientY)
+ const samples: DrawnSample[] = []
+ sampleDrawn(samples, performance.now(), pressed.drawn)
begin(event.currentTarget, event.pointerId, {
move: (moved) => {
- samples.push({ t: performance.now(), x: moved.clientX, y: moved.clientY })
- setPreview({ paneId, rect: reached(moved.clientX, moved.clientY).drawn })
+ const { drawn } = reached(moved.clientX, moved.clientY)
+ sampleDrawn(samples, performance.now(), drawn)
+ setPreview({ paneId, rect: drawn })
},
up: (released) => {
- samples.push({ t: performance.now(), x: released.clientX, y: released.clientY })
const { drawn, legal } = reached(released.clientX, released.clientY)
- if (sameRect(legal, stored)) raise(paneId)
+ sampleDrawn(samples, performance.now(), drawn)
+ if (sameRect(legal, pressed.legal)) raise(paneId)
else if (mode === 'move') intents.moveFloat(paneId, legal.x, legal.y)
else intents.resizeFloat(paneId, legal)
if (!sameRect(drawn, legal)) settle(paneId, drawn, legal, releaseVelocity(samples))
@@ -311,10 +362,7 @@ export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle,
tabIndex={-1}
data-dockkit-float-active={state.activePaneId === paneId || undefined}
style={{
- left: live.x,
- top: `max(var(--dsh-dockkit-float-top, 0px), ${live.y}px)`,
- width: live.width,
- height: live.height,
+ ...floatStyle(live, lifted !== undefined),
zIndex: lifted === undefined ? depth + 1 : state.floats.length + 1,
}}
onPointerDown={() => { raise(paneId) }}
diff --git a/packages/client/ui-dockkit/src/components/TabLayout.tsx b/packages/client/ui-dockkit/src/components/TabLayout.tsx
index 7d3fa26562..0444a69063 100644
--- a/packages/client/ui-dockkit/src/components/TabLayout.tsx
+++ b/packages/client/ui-dockkit/src/components/TabLayout.tsx
@@ -7,7 +7,7 @@ import type { LayoutState, PaneId, PaneNode, TabId, TabRecord } from '../contrac
import { findTabPane, floatRect, getNode, getPane } from '../engine/tree.ts'
import type { SizePreview } from './PaneTree.tsx'
import type { PaneCallbacks } from './render.ts'
-import { FloatHeader, useFloatGestures } from './FloatLayer.tsx'
+import { FloatHeader, floatStyle, useFloatGestures } from './FloatLayer.tsx'
import { PaneDropHints, TabPanel, TabStrip } from './TabPanel.tsx'
import css from './dockkit.module.css'
@@ -89,9 +89,7 @@ function TabHost({ state, callbacks, intents, tab, pane, column, floats, focusRe
data-dockkit-float={floating ? pane.id : undefined}
data-dockkit-float-active={floating && state.activePaneId === pane.id || undefined}
data-dockkit-column={!floating ? column : undefined}
- style={rect === undefined ? undefined : {
- left: rect.x, top: `max(var(--dsh-dockkit-float-top, 0px), ${rect.y}px)`, width: rect.width, height: rect.height,
- }}
+ style={rect === undefined ? undefined : floatStyle(rect, lifted !== undefined)}
onPointerDown={() => { if (floating) floats.raise(pane.id) }}
onClick={() => { if (!floating && state.activePaneId !== pane.id) callbacks.onFocusPane(pane.id) }}>
diff --git a/packages/client/ui-dockkit/src/components/dockkit.module.css b/packages/client/ui-dockkit/src/components/dockkit.module.css
index 9aec2d8faa..71150cc086 100644
--- a/packages/client/ui-dockkit/src/components/dockkit.module.css
+++ b/packages/client/ui-dockkit/src/components/dockkit.module.css
@@ -785,6 +785,14 @@
.float {
position: fixed;
+ /* A resting panel publishes its rectangle in these variables (`floatStyle`).
+ The clamp keeps it inside the viewport and below the caption floor even
+ when the gesture hook read a stale viewport; a gesture or return writes
+ left, top and the size inline instead. */
+ left: max(0px, min(var(--dsh-dockkit-float-x), 100vw - var(--dsh-dockkit-float-width)));
+ top: max(var(--dsh-dockkit-float-top, 0px), min(var(--dsh-dockkit-float-y), 100vh - var(--dsh-dockkit-float-height)));
+ width: var(--dsh-dockkit-float-width);
+ height: var(--dsh-dockkit-float-height);
display: flex;
flex-direction: column;
/* The body inside is unpadded and reaches the edges, so the frame clips it
diff --git a/packages/client/ui-dockkit/src/engine/geometry.ts b/packages/client/ui-dockkit/src/engine/geometry.ts
index b3bf3bd202..1fa74b3f20 100644
--- a/packages/client/ui-dockkit/src/engine/geometry.ts
+++ b/packages/client/ui-dockkit/src/engine/geometry.ts
@@ -219,23 +219,27 @@ export interface FloatBounds {
/** How far past an edge the resistance lets a dragged panel travel, at most, in CSS pixels. */
export const FLOAT_RESISTANCE = 120
-/** One axis clamped into `[min, max]`; a span too small for the panel pins it at `min`. */
+/** One axis clamped into `[min, max]`; an empty span pins it at `min`. */
function clampAxis(value: number, min: number, max: number): number {
return Math.max(min, Math.min(value, max))
}
/**
* The nearest rectangle that lies inside the bounds, per axis. A panel larger
- * than the bounds on an axis pins its leading edge, so its header stays reachable.
+ * than the bounds on an axis takes the bounds' size on it, so its header
+ * controls and resize corner stay reachable.
* @param rect - any rectangle, possibly outside the bounds.
* @param bounds - the viewport region.
- * @returns the rectangle moved inside the bounds; the size is unchanged.
+ * @returns the rectangle moved inside the bounds, its size capped by them.
*/
export function legalFloatRect(rect: FloatRect, bounds: FloatBounds): FloatRect {
+ const width = Math.min(rect.width, bounds.width)
+ const height = Math.min(rect.height, bounds.height - bounds.top)
return {
- ...rect,
- x: clampAxis(rect.x, 0, bounds.width - rect.width),
- y: clampAxis(rect.y, bounds.top, bounds.height - rect.height),
+ x: clampAxis(rect.x, 0, bounds.width - width),
+ y: clampAxis(rect.y, bounds.top, bounds.height - height),
+ width,
+ height,
}
}
@@ -259,7 +263,11 @@ export function resistedRect(desired: FloatRect, bounds: FloatBounds): FloatRect
/** The largest displacement `unresistedRect` inverts; `resist` only approaches `FLOAT_RESISTANCE`. */
const MAX_INVERTIBLE = FLOAT_RESISTANCE * 0.99
-/** Pointer overflow that `resist` maps to a displacement, the displacement capped at `MAX_INVERTIBLE`. */
+/**
+ * Pointer overflow that `resist` maps to a displacement, the displacement capped
+ * at `MAX_INVERTIBLE`. A panel drawn further out than that is picked up at the
+ * cap, which moves it inward by less than 1.2 px.
+ */
function unresist(displacement: number): number {
const shown = Math.min(Math.abs(displacement), MAX_INVERTIBLE)
return Math.sign(displacement) * FLOAT_RESISTANCE * shown / (FLOAT_RESISTANCE - shown)
diff --git a/packages/client/ui-dockkit/src/engine/spring.ts b/packages/client/ui-dockkit/src/engine/spring.ts
index a0934c12a1..94f72dd70d 100644
--- a/packages/client/ui-dockkit/src/engine/spring.ts
+++ b/packages/client/ui-dockkit/src/engine/spring.ts
@@ -49,6 +49,24 @@ export function stepSpring(axis: SpringAxis, target: number, dtSeconds: number):
return { position, velocity }
}
+/**
+ * The velocity an axis starts its return with. An axis already at its target
+ * keeps still. Outward speed is capped at `omega0 * (reach - |offset|)`: the
+ * spring carries a start offset `d` and speed `v` at most `d + 0.46 * v / omega0`
+ * from its target, so the whole return stays within `reach`.
+ * @param position - where the axis is drawn at release.
+ * @param target - the position the spring pulls toward.
+ * @param velocity - the drawn release velocity, in pixels per second.
+ * @param reach - the farthest the return may be from the target.
+ * @returns the starting velocity.
+ */
+export function launchVelocity(position: number, target: number, velocity: number, reach: number): number {
+ const offset = position - target
+ if (offset === 0) return 0
+ if (Math.sign(velocity) !== Math.sign(offset)) return velocity
+ return Math.sign(offset) * Math.min(Math.abs(velocity), OMEGA * Math.max(0, reach - Math.abs(offset)))
+}
+
/**
* Whether an axis has come to rest at its target.
* @param axis - the axis state.
diff --git a/packages/client/ui-dockkit/tests/app-region-styles.client.spec.ts b/packages/client/ui-dockkit/tests/app-region-styles.client.spec.ts
index f0e1428afc..280ecb226d 100644
--- a/packages/client/ui-dockkit/tests/app-region-styles.client.spec.ts
+++ b/packages/client/ui-dockkit/tests/app-region-styles.client.spec.ts
@@ -31,6 +31,14 @@ it('keeps Windows floating headers 20px below the caption, including restored po
expect(css).toMatch(/:global\(html\[data-windows-titlebar\]\[data-fullscreen\]\) \.float\s*\{\s*--dsh-dockkit-float-top: 20px;/)
})
+it('clamps a resting floating panel into the viewport and below the caption floor', () => {
+ const floatRule = /(?:^|\})\s*\.float\s*\{(?[^}]*)\}/.exec(css)?.groups?.['body']
+ expect(floatRule).toContain('left: max(0px, min(var(--dsh-dockkit-float-x), 100vw - var(--dsh-dockkit-float-width)));')
+ expect(floatRule).toContain(
+ 'top: max(var(--dsh-dockkit-float-top, 0px), min(var(--dsh-dockkit-float-y), 100vh - var(--dsh-dockkit-float-height)));',
+ )
+})
+
describe('macOS app-region ownership', () => {
it('subtracts the chip run, strip-end chrome, pane body, and divider, and declares no drag itself', () => {
// The strip row's drag is the markup mark's job: a sheet that declares it
diff --git a/packages/client/ui-dockkit/tests/components.client.spec.tsx b/packages/client/ui-dockkit/tests/components.client.spec.tsx
index 9a44d625f4..d0c24d0384 100644
--- a/packages/client/ui-dockkit/tests/components.client.spec.tsx
+++ b/packages/client/ui-dockkit/tests/components.client.spec.tsx
@@ -1518,7 +1518,7 @@ describe('FloatLayer', () => {
drag(grip, [150, 90], [180, 110], false)
expect(panel.style.left).toBe('130px')
- expect(panel.style.top).toBe('max(var(--dsh-dockkit-float-top, 0px), 100px)')
+ expect(panel.style.top).toBe('100px')
// Mid-gesture the panel draws on top, as the move it records will leave it.
expect(panel.style.zIndex).toBe('3')
fireEvent.pointerUp(window, { pointerId: 7, clientX: 180, clientY: 110 })
@@ -1526,7 +1526,8 @@ describe('FloatLayer', () => {
expect(intents.moveFloat).toHaveBeenCalledTimes(1)
expect(intents.focusPane).not.toHaveBeenCalled()
// The recorded rectangle and depth are drawn again once the preview clears.
- expect(panel.style.left).toBe('100px')
+ expect(panel.style.left).toBe('')
+ expect(panel.style.getPropertyValue('--dsh-dockkit-float-x')).toBe('100px')
expect(panel.style.zIndex).toBe('1')
})
diff --git a/packages/client/ui-dockkit/tests/float-bounds.client.spec.ts b/packages/client/ui-dockkit/tests/float-bounds.client.spec.ts
index 6a7f0d55ba..4444d14489 100644
--- a/packages/client/ui-dockkit/tests/float-bounds.client.spec.ts
+++ b/packages/client/ui-dockkit/tests/float-bounds.client.spec.ts
@@ -32,9 +32,13 @@ describe('legalFloatRect', () => {
expect(legalFloatRect({ ...PANEL, y: 10 }, { ...VIEWPORT, top: 60 }).y).toBe(60)
})
- it('pins the leading edge of a panel larger than the viewport, so its header stays reachable', () => {
+ it('draws a panel larger than the viewport at the viewport size, so its header controls and corner stay reachable', () => {
const big = { x: 300, y: 300, width: 1200, height: 900 }
- expect(legalFloatRect(big, { ...VIEWPORT, top: 20 })).toEqual({ ...big, x: 0, y: 20 })
+ expect(legalFloatRect(big, { ...VIEWPORT, top: 20 })).toEqual({ x: 0, y: 20, width: 1000, height: 780 })
+ })
+
+ it('caps only the axis that does not fit', () => {
+ expect(legalFloatRect({ ...PANEL, x: 900, width: 1200 }, VIEWPORT)).toEqual({ ...PANEL, x: 0, width: 1000 })
})
})
diff --git a/packages/client/ui-dockkit/tests/float-recovery.client.spec.tsx b/packages/client/ui-dockkit/tests/float-recovery.client.spec.tsx
index ae8787eadf..7e60747cd0 100644
--- a/packages/client/ui-dockkit/tests/float-recovery.client.spec.tsx
+++ b/packages/client/ui-dockkit/tests/float-recovery.client.spec.tsx
@@ -70,19 +70,24 @@ function mounted(rect: FloatRect = { x: 100, y: 80, width: 300, height: 200 }) {
view.rerender( null} />)
}
const moveFloat = vi.fn((id: PaneId, x: number, y: number) => { controller.moveFloat(id, x, y); redraw() })
+ const resizeFloat = vi.fn((id: PaneId, next: FloatRect) => { controller.resizeFloat(id, next); redraw() })
const focusPane = vi.fn((id: PaneId) => { controller.focusPane(id); redraw() })
const intents: DockIntents = {
focusTab: vi.fn(), focusPane, splitPane: vi.fn(), addTab: vi.fn(), closeTab: vi.fn(), duplicateTab: vi.fn(),
- floatTab: vi.fn(), unfloatPane: vi.fn(), placeTab: vi.fn(), dropTab: vi.fn(), moveFloat, resizeFloat: vi.fn(),
+ floatTab: vi.fn(), unfloatPane: vi.fn(), placeTab: vi.fn(), dropTab: vi.fn(), moveFloat, resizeFloat,
resizeSplit: vi.fn(),
}
const view = render( null} />)
const panel = (): HTMLElement => view.container.querySelector(`[data-dockkit-float="${paneId}"]`) as HTMLElement
const grip = (): HTMLElement => panel().querySelector('[data-dockkit-float-grip]') as HTMLElement
- const left = (): number => Number.parseFloat(panel().style.left)
- const top = (): number => Number.parseFloat(/(-?[\d.]+)px\)$/.exec(panel().style.top)?.[1] ?? 'NaN')
+ // A lifted panel writes left and top inline; a resting one publishes them to the `.float` rule.
+ const drawn = (side: 'left' | 'top', axis: 'x' | 'y'): number =>
+ Number.parseFloat(panel().style.getPropertyValue(side) || panel().style.getPropertyValue(`--dsh-dockkit-float-${axis}`))
+ const left = (): number => drawn('left', 'x')
+ const top = (): number => drawn('top', 'y')
const stored = (): FloatRect => floatRect(getPane(controller.getSnapshot().state, paneId))
- return { controller, paneId, view, moveFloat, focusPane, left, top, grip, stored }
+ const corner = (): HTMLElement => panel().querySelector('[data-dockkit-float-resize]') as HTMLElement
+ return { controller, tabId, paneId, view, redraw, moveFloat, resizeFloat, focusPane, left, top, grip, corner, stored }
}
/** Press the grip at `from`, move to `to`, and optionally release there. */
@@ -141,6 +146,36 @@ describe('dragging past an edge', () => {
expect(fast.left()).toBe(724)
})
+ it('starts a fling at the drawn speed, so even the fastest one stays within the resistance limit', () => {
+ const h = mounted()
+ fireEvent.pointerDown(h.grip(), { clientX: 150, clientY: 90, pointerId: 7, button: 0 })
+ // A long push that ends in a fast flick: the samples older than the velocity window drop out.
+ for (let x = 150; x <= 850; x += 50) {
+ now += 50
+ fireEvent.pointerMove(window, { pointerId: 7, clientX: x, clientY: 90 })
+ }
+ now += 10
+ fireEvent.pointerMove(window, { pointerId: 7, clientX: 3000, clientY: 90 })
+ fireEvent.pointerUp(window, { pointerId: 7, clientX: 3000, clientY: 90 })
+ const trail = [h.left()]
+ for (let guard = 0; queued.size > 0 && guard < 100; guard++) {
+ frame()
+ trail.push(h.left())
+ }
+ expect(Math.max(...trail)).toBeLessThan(724 + 120)
+ expect(h.left()).toBe(724)
+ })
+
+ it('draws above the top edge while dragged and springs back below it', () => {
+ const h = mounted()
+ drag(h.grip(), [150, 90], [150, 90 - 200], false)
+ expect(h.top()).toBeLessThan(0)
+ fireEvent.pointerUp(window, { pointerId: 7, clientX: 150, clientY: -110 })
+ expect(h.moveFloat).toHaveBeenCalledWith(h.paneId, 100, 0)
+ runOut()
+ expect(h.top()).toBe(0)
+ })
+
it('springs straight back after a slow push, which has no release velocity', () => {
const h = mounted()
drag(h.grip(), [150, 90], [150 + 744, 90])
@@ -205,6 +240,47 @@ describe('interrupting a return', () => {
expect(h.left()).toBe(724)
})
+ it('yields to an undo: the panel draws at the restored record at once', () => {
+ const h = mounted()
+ drag(h.grip(), [150, 90], [150 + 744, 90])
+ frame()
+ act(() => {
+ h.controller.undo()
+ h.redraw()
+ })
+ frame()
+ expect(queued.size).toBe(0)
+ expect(h.left()).toBe(100)
+ })
+
+ it('yields when its panel closes', () => {
+ const h = mounted()
+ drag(h.grip(), [150, 90], [150 + 744, 90])
+ act(() => {
+ h.controller.closeTab(h.tabId)
+ h.redraw()
+ })
+ frame()
+ expect(queued.size).toBe(0)
+ })
+
+ it('ends at its target when another panel is pressed', () => {
+ const h = mounted()
+ const other = h.controller.floatTab(
+ h.controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'file' }),
+ { x: 100, y: 400, width: 300, height: 200 },
+ )
+ act(() => { h.redraw() })
+ drag(h.grip(), [150, 90], [150 + 744, 90])
+ frame()
+ expect(h.left()).toBeGreaterThan(724)
+ const otherGrip = h.view.container.querySelector(`[data-dockkit-float="${other}"] [data-dockkit-float-grip]`) as HTMLElement
+ fireEvent.pointerDown(otherGrip, { clientX: 150, clientY: 410, pointerId: 8, button: 0 })
+ expect(queued.size).toBe(0)
+ expect(h.left()).toBe(724)
+ fireEvent.pointerUp(window, { pointerId: 8, clientX: 150, clientY: 410 })
+ })
+
it('cancels its frame when the layer unmounts', () => {
const h = mounted()
drag(h.grip(), [150, 90], [150 + 744, 90])
@@ -255,6 +331,30 @@ describe('the viewport projection', () => {
expect(h.left()).toBe(724)
})
+ it('treats a click on a projected panel as a raise, keeping its record for a larger window', () => {
+ const h = mounted()
+ viewport(350, 768)
+ act(() => { window.dispatchEvent(new Event('resize')) })
+ expect(h.left()).toBe(50)
+ drag(h.grip(), [100, 90], [100, 90])
+ drag(h.corner(), [340, 270], [340, 270])
+ expect(h.moveFloat).not.toHaveBeenCalled()
+ expect(h.resizeFloat).not.toHaveBeenCalled()
+ viewport(1024, 768)
+ act(() => { window.dispatchEvent(new Event('resize')) })
+ expect(h.left()).toBe(100)
+ })
+
+ it('bounds a gesture by the viewport the window resized to mid-gesture', () => {
+ const h = mounted()
+ fireEvent.pointerDown(h.corner(), { clientX: 400, clientY: 280, pointerId: 7, button: 0 })
+ fireEvent.pointerMove(window, { pointerId: 7, clientX: 800, clientY: 280 })
+ viewport(500, 768)
+ act(() => { window.dispatchEvent(new Event('resize')) })
+ fireEvent.pointerUp(window, { pointerId: 7, clientX: 800, clientY: 280 })
+ expect(h.resizeFloat).toHaveBeenCalledWith(h.paneId, { x: 100, y: 80, width: 400, height: 200 })
+ })
+
it('draws a stored rectangle from outside the viewport inside it', () => {
const h = mounted({ x: 2000, y: 900, width: 300, height: 200 })
expect(h.left()).toBe(724)
diff --git a/packages/client/ui-dockkit/tests/spring.client.spec.ts b/packages/client/ui-dockkit/tests/spring.client.spec.ts
index e1dc2fa096..5959a1fd27 100644
--- a/packages/client/ui-dockkit/tests/spring.client.spec.ts
+++ b/packages/client/ui-dockkit/tests/spring.client.spec.ts
@@ -3,7 +3,7 @@
* independence, and determinism, all with fixed frame steps.
*/
import { describe, expect, it } from 'vitest'
-import { SPRING_DEADLINE_MS, springSettled, stepSpring } from '../src/engine/spring.ts'
+import { launchVelocity, SPRING_DEADLINE_MS, SPRING_MAX_VELOCITY, springSettled, stepSpring } from '../src/engine/spring.ts'
import type { SpringAxis } from '../src/engine/spring.ts'
/** Analytic overshoot bound for the shipped damping ratio, exp(-pi*zeta/sqrt(1-zeta^2)). */
@@ -54,6 +54,31 @@ describe('stepSpring', () => {
})
})
+describe('launchVelocity', () => {
+ it('keeps an axis that is already at its target still', () => {
+ expect(launchVelocity(40, 40, 900, 120)).toBe(0)
+ })
+
+ it('passes an inward velocity through unchanged', () => {
+ expect(launchVelocity(60, 0, -900, 120)).toBe(-900)
+ expect(launchVelocity(-60, 0, 900, 120)).toBe(900)
+ })
+
+ it('caps an outward velocity on either side, to nothing once the axis is at the reach', () => {
+ expect(launchVelocity(119, 0, 1500, 120)).toBeLessThan(1500)
+ expect(launchVelocity(-119, 0, -1500, 120)).toBeGreaterThan(-1500)
+ expect(launchVelocity(130, 0, 1500, 120)).toBe(0)
+ })
+
+ it('keeps every return within the reach of its target, however fast the fling', () => {
+ for (let offset = 1; offset < 120; offset += 1) {
+ const velocity = launchVelocity(offset, 0, SPRING_MAX_VELOCITY, 120)
+ const { positions } = settle({ position: offset, velocity }, 0, 60)
+ expect(Math.max(offset, ...positions)).toBeLessThan(120)
+ }
+ })
+})
+
describe('springSettled', () => {
it('needs both closeness and slowness', () => {
expect(springSettled({ position: 0.5, velocity: 10 }, 0)).toBe(true)