From 477b61e0cba0ad563a2e22be905991707a89b7c0 Mon Sep 17 00:00:00 2001 From: DerEchteAlec Date: Thu, 13 Aug 2026 21:21:14 +0200 Subject: [PATCH] FIX - enable cross-page Home Screen dragging --- frontend/src/assets/main.css | 5 +- frontend/src/components/AppIcon.vue | 28 +- frontend/src/components/HomeFolderIcon.vue | 27 +- frontend/src/stores/app-store.test.ts | 91 +++- frontend/src/stores/app-store.ts | 47 +- frontend/src/utils/homeLayout.test.ts | 387 ++++++++++---- frontend/src/utils/homeLayout.ts | 399 ++++++++++++-- frontend/src/utils/springboardDrag.test.ts | 60 +++ frontend/src/utils/springboardDrag.ts | 91 ++++ frontend/src/utils/springboardLayout.test.ts | 116 +++++ frontend/src/utils/springboardLayout.ts | 97 ++++ frontend/src/utils/widgetLayout.test.ts | 37 ++ frontend/src/utils/widgetLayout.ts | 32 +- .../views/SpringboardView.contract.test.ts | 66 +++ frontend/src/views/SpringboardView.vue | 486 ++++++++++++------ 15 files changed, 1635 insertions(+), 334 deletions(-) create mode 100644 frontend/src/utils/springboardDrag.test.ts create mode 100644 frontend/src/utils/springboardDrag.ts create mode 100644 frontend/src/utils/springboardLayout.test.ts create mode 100644 frontend/src/utils/springboardLayout.ts create mode 100644 frontend/src/views/SpringboardView.contract.test.ts diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 7380881..729c436 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -6555,7 +6555,10 @@ button { display: none; } .app-grid { - grid-template-rows: repeat(6, var(--springboard-grid-row)); + grid-template-rows: repeat( + var(--home-grid-rows, 6), + var(--springboard-grid-row) + ); gap: var(--springboard-grid-row-gap) var(--springboard-grid-column-gap); } .app-grid-slot { diff --git a/frontend/src/components/AppIcon.vue b/frontend/src/components/AppIcon.vue index 8e2da12..52050f8 100644 --- a/frontend/src/components/AppIcon.vue +++ b/frontend/src/components/AppIcon.vue @@ -16,6 +16,10 @@ import { reorderDirectionFromKeyboard, type ReorderDirection, } from '@/utils/keyboard' +import { + springboardPageDragCompensation, + springboardSwipeIntent, +} from '@/utils/springboardDrag' const props = withDefaults( defineProps<{ @@ -56,8 +60,14 @@ let dragPageWidth = 0 const dragStyle = computed(() => isDragging.value ? { - transform: `translateX(${(phone.currentPage - dragStartPage) * dragPageWidth}px)`, - translate: `${dragOffset.value.x}px ${dragOffset.value.y}px`, + transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`, + } + : undefined, +) +const dragPointerStyle = computed(() => + isDragging.value + ? { + transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`, } : undefined, ) @@ -142,6 +152,7 @@ function clearHold(): void { function onPointerDown(event: PointerEvent): void { if (props.compact || event.button !== 0) return + suppressClick.value = false pointerTarget = event.currentTarget as HTMLElement pointerId = event.pointerId pointerTarget.setPointerCapture(pointerId) @@ -169,9 +180,12 @@ function onPointerMove(event: PointerEvent): void { return } if ( - Math.hypot(event.clientX - pointerStart.x, event.clientY - pointerStart.y) > - 8 + springboardSwipeIntent( + event.clientX - pointerStart.x, + event.clientY - pointerStart.y, + ) !== 'pending' ) { + suppressClick.value = true clearHold() } } @@ -190,9 +204,11 @@ function onPointerUp(event: PointerEvent): void { clearHold() if (isDragging.value) { suppressClick.value = true - emit('dragend', event) isDragging.value = false dragOffset.value = { x: 0, y: 0 } + releasePointerCapture() + emit('dragend', event) + return } releasePointerCapture() } @@ -260,6 +276,7 @@ onBeforeUnmount(() => { :aria-keyshortcuts=" editMode ? 'ArrowLeft ArrowRight ArrowUp ArrowDown' : undefined " + :style="dragPointerStyle" @click="launch" @contextmenu.prevent @keydown="onKeydown" @@ -319,6 +336,7 @@ onBeforeUnmount(() => { app: getPhoneAppLabel(app, phone.t), }) " + :style="dragPointerStyle" @click.stop="emit('remove')" @pointerdown.stop > diff --git a/frontend/src/components/HomeFolderIcon.vue b/frontend/src/components/HomeFolderIcon.vue index ddcfc35..adf3cb7 100644 --- a/frontend/src/components/HomeFolderIcon.vue +++ b/frontend/src/components/HomeFolderIcon.vue @@ -8,6 +8,10 @@ import { reorderDirectionFromKeyboard, type ReorderDirection, } from '@/utils/keyboard' +import { + springboardPageDragCompensation, + springboardSwipeIntent, +} from '@/utils/springboardDrag' const props = withDefaults( defineProps<{ @@ -47,8 +51,14 @@ const folderName = computed(() => props.folder.name || props.defaultName) const dragStyle = computed(() => isDragging.value ? { - transform: `translateX(${(phone.currentPage - dragStartPage) * dragPageWidth}px)`, - translate: `${dragOffset.value.x}px ${dragOffset.value.y}px`, + transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`, + } + : undefined, +) +const dragPointerStyle = computed(() => + isDragging.value + ? { + transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`, } : undefined, ) @@ -82,6 +92,7 @@ function beginPointerDrag(event: PointerEvent): void { function onPointerDown(event: PointerEvent): void { if (event.button !== 0) return + suppressClick.value = false pointerTarget = event.currentTarget as HTMLElement pointerId = event.pointerId pointerTarget.setPointerCapture(pointerId) @@ -109,9 +120,12 @@ function onPointerMove(event: PointerEvent): void { return } if ( - Math.hypot(event.clientX - pointerStart.x, event.clientY - pointerStart.y) > - 8 + springboardSwipeIntent( + event.clientX - pointerStart.x, + event.clientY - pointerStart.y, + ) !== 'pending' ) { + suppressClick.value = true clearHold() } } @@ -120,9 +134,11 @@ function onPointerUp(event: PointerEvent): void { clearHold() if (isDragging.value) { suppressClick.value = true - emit('dragend', event) isDragging.value = false dragOffset.value = { x: 0, y: 0 } + releasePointerCapture() + emit('dragend', event) + return } releasePointerCapture() } @@ -176,6 +192,7 @@ onBeforeUnmount(() => { :aria-keyshortcuts=" editMode ? 'ArrowLeft ArrowRight ArrowUp ArrowDown' : undefined " + :style="dragPointerStyle" @click="openFolder" @contextmenu.prevent @keydown="onKeydown" diff --git a/frontend/src/stores/app-store.test.ts b/frontend/src/stores/app-store.test.ts index 90efc2f..21b1510 100644 --- a/frontend/src/stores/app-store.test.ts +++ b/frontend/src/stores/app-store.test.ts @@ -3,7 +3,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { NON_REMOVABLE_PHONE_APP_IDS } from '@/config/apps' import { useAppStoreStore } from '@/stores/app-store' -import { getHomeFolder, removeHomeApp } from '@/utils/homeLayout' +import { + getHomeFolder, + HOME_GRID_PAGE_SIZE, + removeHomeApp, +} from '@/utils/homeLayout' const mocks = vi.hoisted(() => ({ phone: { @@ -45,6 +49,64 @@ describe('app store', () => { }) }) + it('keeps external app state while migrating version 4 layouts', () => { + const apps = useAppStoreStore() + const grid = Array.from({ length: 24 }, () => null as string | null) + grid[20] = 'external-radio' + + apps.hydrate({ + claimedApps: ['external-radio'], + homeLayout: { dock: [], grid, hidden: [], version: 4 }, + launchCounts: { 'external-radio': 3 }, + }) + + expect(apps.claimedApps).toEqual(['external-radio']) + expect(apps.homeLayout.version).toBe(5) + expect(apps.homeLayout.grid).toContain('external-radio') + const pageAppCount = apps.homeLayout.grid + .slice(0, HOME_GRID_PAGE_SIZE) + .filter((appId) => appId !== null).length + expect(apps.homeLayout.grid.slice(0, pageAppCount)).not.toContain(null) + expect(apps.launchCounts).toEqual({ 'external-radio': 3 }) + expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1) + }) + + it('keeps external app state from current version 5 layouts', () => { + const apps = useAppStoreStore() + + apps.hydrate({ + claimedApps: ['external-radio'], + homeLayout: { + dock: [], + grid: ['external-radio'], + hidden: [], + version: 5, + }, + launchCounts: { 'external-radio': 3 }, + }) + + expect(apps.claimedApps).toEqual(['external-radio']) + expect(apps.homeLayout.grid).toContain('external-radio') + expect(apps.launchCounts).toEqual({ 'external-radio': 3 }) + expect(mocks.phone.saveDeviceNamespace).not.toHaveBeenCalled() + }) + + it('persists a page-aware version 3 to version 5 migration once', () => { + const apps = useAppStoreStore() + const grid = Array.from({ length: 40 }, () => null as string | null) + grid[20] = 'external-page-two' + + apps.hydrate({ + claimedApps: ['external-page-two'], + homeLayout: { dock: [], grid, hidden: [], version: 3 }, + }) + + expect(apps.homeLayout.version).toBe(5) + expect(apps.homeLayout.grid[20]).not.toBe('external-page-two') + expect(apps.homeLayout.grid[24]).toBe('external-page-two') + expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1) + }) + it('installs an app after showing a three second loading state', () => { vi.useFakeTimers() const apps = useAppStoreStore() @@ -160,6 +222,23 @@ describe('app store', () => { }) }) + it('persists a cross-page drop exactly once', () => { + const apps = useAppStoreStore() + apps.hydrate(null) + mocks.phone.saveDeviceNamespace.mockClear() + const sourceIndex = apps.homeLayout.grid.indexOf('phone') + expect(sourceIndex).toBeGreaterThanOrEqual(0) + + expect( + apps.moveHomeAppToGridPage('grid', sourceIndex, 2, 0, [ + HOME_GRID_PAGE_SIZE, + HOME_GRID_PAGE_SIZE, + ]), + ).toBe(true) + expect(apps.homeLayout.grid[HOME_GRID_PAGE_SIZE]).toBe('phone') + expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1) + }) + it('persists the complete folder lifecycle through store actions', () => { const apps = useAppStoreStore() apps.hydrate(null) @@ -167,7 +246,6 @@ describe('app store', () => { const notesIndex = apps.homeLayout.grid.indexOf('notes') const clockIndex = apps.homeLayout.grid.indexOf('clock') - const mailIndex = apps.homeLayout.grid.indexOf('mail') const folderId = apps.createHomeFolder( 'grid', notesIndex, @@ -181,6 +259,7 @@ describe('app store', () => { 'clock', 'notes', ]) + const mailIndex = apps.homeLayout.grid.indexOf('mail') expect(apps.addHomeAppToFolder('grid', mailIndex, folderId!)).toBe(true) apps.moveHomeFolderApp(folderId!, 2, 0) apps.renameHomeFolder(folderId!, 'Work') @@ -190,10 +269,10 @@ describe('app store', () => { }) const emptyIndex = apps.homeLayout.grid.indexOf(null) - expect( - apps.extractHomeFolderApp(folderId!, 0, 'grid', emptyIndex), - ).toBe(true) - expect(apps.homeLayout.grid[emptyIndex]).toBe('mail') + expect(apps.extractHomeFolderApp(folderId!, 0, 'grid', emptyIndex)).toBe( + true, + ) + expect(apps.homeLayout.grid).toContain('mail') expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(5) }) diff --git a/frontend/src/stores/app-store.ts b/frontend/src/stores/app-store.ts index 0e0c761..5a9a163 100644 --- a/frontend/src/stores/app-store.ts +++ b/frontend/src/stores/app-store.ts @@ -20,6 +20,7 @@ import { extractHomeFolderApp, moveHomeFolderApp, moveHomeApp, + moveHomeAppToGridPage, parseHomeLayout, renameHomeFolder, removeHomeApp, @@ -192,14 +193,14 @@ export const useAppStoreStore = defineStore('app-store', { data?.homeLayout && typeof data.homeLayout === 'object' ? (data.homeLayout as { version?: unknown }).version : undefined + const supportsPersistedExternalApps = + layoutVersion === 3 || layoutVersion === 4 || layoutVersion === 5 this.claimedApps = Array.isArray(data?.claimedApps) ? data.claimedApps.filter( (id): id is LaunchablePhoneAppId => typeof id === 'string' && (isPhoneAppId(id) || - ((layoutVersion === 3 || - layoutVersion === 4 || - layoutVersion === 5) && + (supportsPersistedExternalApps && isValidExternalPhoneAppId(id))), ) : [] @@ -226,9 +227,7 @@ export const useAppStoreStore = defineStore('app-store', { for (const [appId, count] of Object.entries(data.launchCounts)) { if ( (isPhoneAppId(appId) || - ((layoutVersion === 3 || - layoutVersion === 4 || - layoutVersion === 5) && + (supportsPersistedExternalApps && isValidExternalPhoneAppId(appId))) && typeof count === 'number' && Number.isFinite(count) && @@ -239,7 +238,14 @@ export const useAppStoreStore = defineStore('app-store', { } } this.hydrated = true - if (protectedHiddenAppIds.length) this.persist() + if ( + protectedHiddenAppIds.length || + layoutVersion === 2 || + layoutVersion === 3 || + layoutVersion === 4 + ) { + this.persist() + } }, isInstalled(appId: LaunchablePhoneAppId): boolean { if (this.claimedApps.includes(appId)) return true @@ -280,15 +286,38 @@ export const useAppStoreStore = defineStore('app-store', { sourceIndex: number, to: HomeArea, targetIndex: number, - ): void { - this.homeLayout = moveHomeApp( + ): boolean { + const next = moveHomeApp( this.homeLayout, from, sourceIndex, to, targetIndex, ) + if (next === this.homeLayout) return false + this.homeLayout = next this.persist() + return true + }, + moveHomeAppToGridPage( + from: HomeArea, + sourceIndex: number, + targetPage: number, + targetOffset: number, + capacities: readonly number[], + ): boolean { + const next = moveHomeAppToGridPage( + this.homeLayout, + from, + sourceIndex, + targetPage, + targetOffset, + capacities, + ) + if (next === this.homeLayout) return false + this.homeLayout = next + this.persist() + return true }, createHomeFolder( from: HomeArea, diff --git a/frontend/src/utils/homeLayout.test.ts b/frontend/src/utils/homeLayout.test.ts index 3ecf3ab..a6a8d88 100644 --- a/frontend/src/utils/homeLayout.test.ts +++ b/frontend/src/utils/homeLayout.test.ts @@ -1,22 +1,26 @@ import { describe, expect, it } from 'vitest' +import type { LaunchablePhoneAppId } from '@/types/apps' import { addHomeAppToFolder, addHomePage, - createHomeFolder, createDefaultHomeLayout, + createHomeFolder, deleteHomePage, extractHomeFolderApp, getHomeFolder, + HOME_GRID_COLUMNS, HOME_GRID_PAGE_SIZE, + HOME_GRID_ROWS, homeKeyboardTarget, isHomeFolder, MAX_HOME_GRID_PAGES, - moveHomeFolderApp, moveHomeApp, + moveHomeAppToGridPage, + moveHomeFolderApp, parseHomeLayout, - renameHomeFolder, removeHomeApp, + renameHomeFolder, restoreHomeApp, type HomeLayout, } from '@/utils/homeLayout' @@ -28,10 +32,36 @@ const defaults = createDefaultHomeLayout( ['phone', 'messages', 'clock'], ) +function generatedApp(index: number): LaunchablePhoneAppId { + return `generated-app-${index}` as LaunchablePhoneAppId +} + +function pageLayout(pageCounts: readonly number[]): HomeLayout { + const grid: HomeLayout['grid'] = Array.from( + { length: pageCounts.length * HOME_GRID_PAGE_SIZE }, + () => null, + ) + let appIndex = 0 + for (const [pageIndex, count] of pageCounts.entries()) { + for (let offset = 0; offset < count; offset += 1) { + grid[pageIndex * HOME_GRID_PAGE_SIZE + offset] = generatedApp(appIndex) + appIndex += 1 + } + } + return { + dock: [generatedApp(999), null, null, null], + grid, + hidden: [], + version: 5, + } +} + describe('home layout', () => { - it('uses fixed grid and dock slots for the registry arrangement', () => { + it('uses six-row grid pages and fixed dock slots', () => { const layout = parseHomeLayout(undefined, defaults, [...installed]) + expect(HOME_GRID_PAGE_SIZE).toBe(HOME_GRID_COLUMNS * HOME_GRID_ROWS) + expect(HOME_GRID_ROWS).toBe(6) expect(layout.grid).toHaveLength(HOME_GRID_PAGE_SIZE) expect(layout.grid.slice(0, 6)).toEqual([ 'phone', @@ -62,11 +92,8 @@ describe('home layout', () => { expect(layout.version).toBe(5) }) - it('preserves explicit gaps in versioned layouts', () => { - const grid: HomeLayout['grid'] = Array.from( - { length: 20 }, - () => null, - ) + it('closes gaps within a versioned page without moving apps between pages', () => { + const grid: HomeLayout['grid'] = Array.from({ length: 20 }, () => null) grid[0] = 'phone' grid[7] = 'mail' @@ -81,88 +108,219 @@ describe('home layout', () => { [...installed], ) - expect(layout.grid[0]).toBe('phone') - expect(layout.grid[1]).toBeNull() - expect(layout.grid[7]).toBe('mail') + expect(layout.grid.slice(0, 3)).toEqual(['phone', 'mail', null]) expect(layout.dock).toEqual(['messages', null, 'clock', null]) }) - it('keeps valid custom-app tombstones in version 3 layouts', () => { - const grid: HomeLayout['grid'] = Array.from( - { length: 20 }, - () => null, - ) + it('keeps valid custom-app tombstones while migrating version 3 layouts', () => { + const grid: HomeLayout['grid'] = Array.from({ length: 20 }, () => null) grid[6] = 'temporarily-missing' as HomeLayout['hidden'][number] const layout = parseHomeLayout( - { - dock: ['phone', null, null, null], - grid, - hidden: [], - version: 3, - }, + { dock: ['phone'], grid, hidden: [], version: 3 }, defaults, [...installed], ) - expect(layout.grid[6]).toBe('temporarily-missing') + expect(layout.grid).toContain('temporarily-missing') + const pageItemCount = layout.grid + .slice(0, HOME_GRID_PAGE_SIZE) + .filter((item) => item !== null).length + expect(layout.grid.slice(0, pageItemCount)).not.toContain(null) expect(layout.version).toBe(5) }) - it('expands version 3 capacity without compacting persisted gaps', () => { + it('preserves page membership while expanding version 3 pages', () => { const grid: HomeLayout['grid'] = Array.from({ length: 40 }, () => null) - grid[19] = 'mail' - grid[20] = 'notes' + grid[0] = 'phone' + grid[19] = 'messages' + grid[20] = 'mail' + grid[39] = 'clock' const layout = parseHomeLayout( - { - dock: ['phone', null, null, null], - grid, - hidden: [], - version: 3, - }, + { dock: [], grid, hidden: [], version: 3 }, defaults, [...installed], ) expect(layout.grid).toHaveLength(HOME_GRID_PAGE_SIZE * 2) - expect(layout.grid[19]).toBe('mail') - expect(layout.grid[20]).toBe('notes') - expect(layout.grid.slice(21, 24)).toEqual([null, null, null]) + expect(layout.grid.slice(0, 3)).toEqual(['phone', 'messages', 'notes']) + expect(layout.grid[HOME_GRID_PAGE_SIZE]).toBe('mail') + expect(layout.grid[HOME_GRID_PAGE_SIZE + 1]).toBe('clock') + expect(layout.version).toBe(5) + }) + + it('reads version 4 pages directly before migrating the schema', () => { + const grid: HomeLayout['grid'] = Array.from( + { length: HOME_GRID_PAGE_SIZE * 2 }, + () => null, + ) + grid[20] = 'phone' + grid[HOME_GRID_PAGE_SIZE] = 'messages' + + const layout = parseHomeLayout( + { dock: [], grid, hidden: [], version: 4 }, + defaults, + [...installed], + ) + + expect(layout.grid).toHaveLength(HOME_GRID_PAGE_SIZE * 2) + expect(layout.grid[0]).toBe('phone') + expect(layout.grid[HOME_GRID_PAGE_SIZE]).toBe('messages') + expect(layout.version).toBe(5) + }) + + it('preserves folders and page membership in version 5 layouts', () => { + const grid: HomeLayout['grid'] = Array.from( + { length: HOME_GRID_PAGE_SIZE * 2 }, + () => null, + ) + grid[8] = { + apps: ['mail', 'notes'], + id: 'folder-work-123456', + name: 'Work', + type: 'folder', + } + grid[HOME_GRID_PAGE_SIZE + 7] = 'clock' + + const layout = parseHomeLayout( + { dock: ['phone'], grid, hidden: [], version: 5 }, + defaults, + [...installed], + ) + + expect(getHomeFolder(layout, 'folder-work-123456')?.apps).toEqual([ + 'mail', + 'notes', + ]) + expect(layout.grid[HOME_GRID_PAGE_SIZE]).toBe('clock') expect(layout.version).toBe(5) }) it('preserves independently positioned shortcuts for the same app', () => { - const grid: HomeLayout['grid'] = Array.from( - { length: 20 }, - () => null, - ) + const grid: HomeLayout['grid'] = Array.from({ length: 20 }, () => null) grid[0] = 'phone' grid[5] = 'phone' const layout = parseHomeLayout( - { - dock: ['phone', null, null, null], - grid, - hidden: [], - version: 2, - }, + { dock: ['phone'], grid, hidden: [], version: 2 }, defaults, [...installed], ) expect(layout.grid[0]).toBe('phone') - expect(layout.grid[5]).toBe('phone') + expect(layout.grid[1]).toBe('phone') expect(layout.dock[0]).toBe('phone') }) - it('moves to an exact empty slot without compacting other apps', () => { + it('inserts into an empty area and closes the source gap', () => { const moved = moveHomeApp(defaults, 'grid', 2, 'grid', 12) - expect(moved.grid[2]).toBeNull() - expect(moved.grid[12]).toBe('mail') - expect(moved.grid[0]).toBe('phone') - expect(moved.grid[4]).toBe('notes') + expect(moved.grid.slice(0, 6)).toEqual([ + 'phone', + 'messages', + 'clock', + 'notes', + 'mail', + null, + ]) + }) + + it('extends the layout when moving an item onto an empty new page', () => { + const targetIndex = HOME_GRID_PAGE_SIZE + 20 + const moved = moveHomeApp(defaults, 'grid', 0, 'grid', targetIndex) + + expect(moved.grid).toHaveLength(HOME_GRID_PAGE_SIZE * 2) + expect(moved.grid.slice(0, 5)).toEqual([ + 'messages', + 'mail', + 'clock', + 'notes', + null, + ]) + expect(moved.grid[HOME_GRID_PAGE_SIZE]).toBe('phone') + }) + + it.each([20, 21, 23])( + 'fills a target page containing %s apps without swapping one backwards', + (targetCount) => { + const layout = pageLayout([2, targetCount]) + const dragged = layout.grid[0] + const originalTarget = layout.grid + .slice(HOME_GRID_PAGE_SIZE, HOME_GRID_PAGE_SIZE * 2) + .filter((item) => item !== null) + const moved = moveHomeAppToGridPage(layout, 'grid', 0, 2, targetCount) + + expect(moved.grid[0]).toBe(layout.grid[1]) + expect( + moved.grid + .slice(HOME_GRID_PAGE_SIZE, HOME_GRID_PAGE_SIZE * 2) + .filter((item) => item !== null), + ).toEqual([...originalTarget, dragged]) + }, + ) + + it('cascades a full target page forward into a newly created page', () => { + const layout = pageLayout([2, HOME_GRID_PAGE_SIZE]) + const dragged = layout.grid[0] + const displaced = layout.grid[HOME_GRID_PAGE_SIZE * 2 - 1] + const moved = moveHomeAppToGridPage(layout, 'grid', 0, 2, 0) + + expect(moved.grid).toHaveLength(HOME_GRID_PAGE_SIZE * 3) + expect(moved.grid.slice(0, 2)).toEqual([layout.grid[1], null]) + expect(moved.grid[HOME_GRID_PAGE_SIZE]).toBe(dragged) + expect(moved.grid[HOME_GRID_PAGE_SIZE * 2]).toBe(displaced) + }) + + it('uses a later source-page gap for forward overflow', () => { + const layout = pageLayout([HOME_GRID_PAGE_SIZE, 2]) + const draggedIndex = HOME_GRID_PAGE_SIZE + const dragged = layout.grid[draggedIndex] + const displaced = layout.grid[HOME_GRID_PAGE_SIZE - 1] + const remainingSecondPageItem = layout.grid[draggedIndex + 1] + const moved = moveHomeAppToGridPage(layout, 'grid', draggedIndex, 1, 4) + + expect(moved.grid).toHaveLength(HOME_GRID_PAGE_SIZE * 2) + expect(moved.grid[4]).toBe(dragged) + expect(moved.grid[HOME_GRID_PAGE_SIZE]).toBe(displaced) + expect(moved.grid[HOME_GRID_PAGE_SIZE + 1]).toBe(remainingSecondPageItem) + }) + + it('rejects a dock insertion atomically when all pages are full', () => { + const layout = pageLayout( + Array.from({ length: MAX_HOME_GRID_PAGES }, () => HOME_GRID_PAGE_SIZE), + ) + + expect(moveHomeAppToGridPage(layout, 'dock', 0, 1, 0)).toBe(layout) + }) + + it('moves a dock app onto another page and preserves every other item', () => { + const layout = pageLayout([3, 2]) + const dockApp = layout.dock[0] + const before = [...layout.grid.filter(Boolean), dockApp].sort() + const moved = moveHomeAppToGridPage(layout, 'dock', 0, 2, 1) + + expect(moved.dock[0]).toBeNull() + expect(moved.grid[HOME_GRID_PAGE_SIZE + 1]).toBe(dockApp) + expect(moved.grid.filter(Boolean).sort()).toEqual(before) + }) + + it('respects widget-reduced capacities while cascading forward', () => { + const layout = pageLayout([HOME_GRID_PAGE_SIZE]) + const moved = moveHomeAppToGridPage(layout, 'grid', 0, 1, 0, [ + 20, + HOME_GRID_PAGE_SIZE, + ]) + + expect(moved.grid.slice(0, 20).filter(Boolean)).toHaveLength(20) + expect( + moved.grid + .slice(HOME_GRID_PAGE_SIZE, HOME_GRID_PAGE_SIZE * 2) + .filter(Boolean), + ).toHaveLength(4) + expect(moved.grid.filter(Boolean).sort()).toEqual( + layout.grid.filter(Boolean).sort(), + ) }) it('provides bounded keyboard reorder targets without wrapping rows', () => { @@ -174,33 +332,40 @@ describe('home layout', () => { expect(homeKeyboardTarget(defaults, 'dock', 1, 'down')).toBeNull() }) - it('swaps occupied grid slots without moving unrelated apps', () => { + it('shifts occupied grid slots instead of replacing their items', () => { const reordered = moveHomeApp(defaults, 'grid', 2, 'grid', 0) + expect(reordered.grid.slice(0, 5)).toEqual([ 'mail', - 'messages', 'phone', + 'messages', 'clock', 'notes', ]) }) - it('swaps an occupied dock slot with the exact grid source', () => { + it('shifts an occupied dock slot toward its gap', () => { const docked = moveHomeApp(defaults, 'grid', 2, 'dock', 2) - expect(docked.dock).toEqual(['phone', 'messages', 'mail', null]) - expect(docked.grid[2]).toBe('clock') + expect(docked.dock).toEqual(['phone', 'messages', 'mail', 'clock']) + expect(docked.grid.slice(0, 5)).toEqual([ + 'phone', + 'messages', + 'clock', + 'notes', + null, + ]) }) - it('swaps with an app in a full dock without shifting the dock', () => { + it('moves a dock item displaced from a full dock into the source slot', () => { const layout: HomeLayout = { ...defaults, dock: ['phone', 'messages', 'clock', 'notes'], } const docked = moveHomeApp(layout, 'grid', 2, 'dock', 1) - expect(docked.dock).toEqual(['phone', 'mail', 'clock', 'notes']) - expect(docked.grid[2]).toBe('messages') + expect(docked.dock).toEqual(['phone', 'mail', 'messages', 'clock']) + expect(docked.grid[2]).toBe('notes') }) it('moves shortcuts between the dock and grid independently', () => { @@ -211,21 +376,29 @@ describe('home layout', () => { expect(movedToGrid.grid[5]).toBe('phone') const movedToDock = moveHomeApp(movedToGrid, 'grid', 1, 'dock', 3) - expect(movedToDock.grid[1]).toBeNull() + expect(movedToDock.grid.slice(0, 6)).toEqual([ + 'phone', + 'mail', + 'clock', + 'notes', + 'phone', + null, + ]) expect(movedToDock.dock[1]).toBe('messages') expect(movedToDock.dock[3]).toBe('messages') }) - it('removes shortcuts without closing gaps and restores the first gap', () => { - const layout: HomeLayout = moveHomeApp(defaults, 'grid', 0, 'grid', 10) + it('removes and restores shortcuts without leaving a page gap', () => { + const layout = moveHomeApp(defaults, 'grid', 0, 'grid', 10) const removed = removeHomeApp(layout, 'phone') - expect(removed.grid[0]).toBeNull() - expect(removed.grid[10]).toBeNull() + + expect(removed.grid).not.toContain('phone') expect(removed.dock[0]).toBeNull() expect(removed.hidden).toContain('phone') const restored = restoreHomeApp(removed, 'phone') - expect(restored.grid[0]).toBe('phone') + expect(restored.grid[4]).toBe('phone') + expect(restored.grid.slice(0, 5)).not.toContain(null) expect(restored.hidden).not.toContain('phone') }) @@ -239,7 +412,7 @@ describe('home layout', () => { expect(addHomePage(layout)).toBe(layout) }) - it('deletes a page and moves its apps into remaining empty slots', () => { + it('deletes a page and moves its items into remaining empty slots', () => { let layout = addHomePage(defaults) layout = moveHomeApp(layout, 'grid', 0, 'grid', HOME_GRID_PAGE_SIZE) const deleted = deleteHomePage(layout, 2) @@ -249,7 +422,7 @@ describe('home layout', () => { expect(deleteHomePage(deleted, 1)).toBe(deleted) }) - it('creates, renames, and persists a folder without moving other slots', () => { + it('creates, renames, and parses a folder with compact pages', () => { const folderLayout = createHomeFolder( defaults, 'grid', @@ -259,21 +432,21 @@ describe('home layout', () => { 'folder-work-123456', 'Work', ) - const folder = folderLayout.grid[4] + const folder = getHomeFolder(folderLayout, 'folder-work-123456') - expect(folderLayout.grid[2]).toBeNull() - expect(isHomeFolder(folder) && folder.apps).toEqual(['notes', 'mail']) - expect(folderLayout.grid[0]).toBe('phone') + expect(folder?.apps).toEqual(['notes', 'mail']) + expect(folderLayout.grid.slice(0, 4)).toEqual([ + 'phone', + 'messages', + 'clock', + folder, + ]) const renamed = renameHomeFolder( folderLayout, 'folder-work-123456', ' Dienstprogramme ', ) - expect(getHomeFolder(renamed, 'folder-work-123456')?.name).toBe( - 'Dienstprogramme', - ) - const parsed = parseHomeLayout(renamed, defaults, [...installed]) expect(parsed.version).toBe(5) expect(getHomeFolder(parsed, 'folder-work-123456')).toEqual({ @@ -284,7 +457,7 @@ describe('home layout', () => { }) }) - it('adds apps to a folder and swaps apps inside its 3x3 pages', () => { + it('adds apps to a folder and swaps apps inside it', () => { const folderLayout = createHomeFolder( defaults, 'grid', @@ -294,25 +467,20 @@ describe('home layout', () => { 'folder-tools-123456', 'Tools', ) + const clockIndex = folderLayout.grid.indexOf('clock') const expanded = addHomeAppToFolder( folderLayout, 'grid', - 3, + clockIndex, 'folder-tools-123456', ) - expect(expanded.grid[3]).toBeNull() + expect(getHomeFolder(expanded, 'folder-tools-123456')?.apps).toEqual([ 'notes', 'mail', 'clock', ]) - - const reordered = moveHomeFolderApp( - expanded, - 'folder-tools-123456', - 2, - 0, - ) + const reordered = moveHomeFolderApp(expanded, 'folder-tools-123456', 2, 0) expect(getHomeFolder(reordered, 'folder-tools-123456')?.apps).toEqual([ 'clock', 'mail', @@ -320,7 +488,7 @@ describe('home layout', () => { ]) }) - it('extracts a folder app into an exact gap and dissolves one-app folders', () => { + it('extracts a folder app and dissolves one-app folders', () => { const folderLayout = createHomeFolder( defaults, 'grid', @@ -338,12 +506,17 @@ describe('home layout', () => { 12, ) - expect(extracted.grid[12]).toBe('mail') - expect(extracted.grid[4]).toBe('notes') + expect(extracted.grid.slice(0, 5)).toEqual([ + 'phone', + 'messages', + 'clock', + 'notes', + 'mail', + ]) expect(getHomeFolder(extracted, 'folder-social-123456')).toBeNull() }) - it('removes hidden apps from folders and dissolves the folder when needed', () => { + it('removes hidden apps from folders and dissolves the folder', () => { const folderLayout = createHomeFolder( defaults, 'grid', @@ -355,7 +528,39 @@ describe('home layout', () => { ) const removed = removeHomeApp(folderLayout, 'mail') - expect(removed.grid[4]).toBe('notes') + expect(removed.grid.slice(0, 4)).toEqual([ + 'phone', + 'messages', + 'clock', + 'notes', + ]) expect(removed.hidden).toContain('mail') }) + + it('moves folders as one item without crossing page boundaries', () => { + const folderLayout = createHomeFolder( + defaults, + 'grid', + 2, + 'grid', + 4, + 'folder-page-123456', + 'Page two', + ) + const folderIndex = folderLayout.grid.findIndex(isHomeFolder) + const moved = moveHomeApp( + folderLayout, + 'grid', + folderIndex, + 'grid', + HOME_GRID_PAGE_SIZE + 8, + ) + + expect(moved.grid.slice(0, 4)).toEqual(['phone', 'messages', 'clock', null]) + const movedFolder = moved.grid[HOME_GRID_PAGE_SIZE] + expect(isHomeFolder(movedFolder)).toBe(true) + expect(isHomeFolder(movedFolder) && movedFolder.id).toBe( + 'folder-page-123456', + ) + }) }) diff --git a/frontend/src/utils/homeLayout.ts b/frontend/src/utils/homeLayout.ts index 261db2d..68e5e88 100644 --- a/frontend/src/utils/homeLayout.ts +++ b/frontend/src/utils/homeLayout.ts @@ -3,10 +3,12 @@ import type { ReorderDirection } from '@/utils/keyboard' export const HOME_DOCK_CAPACITY = 4 export const HOME_GRID_COLUMNS = 4 -export const HOME_GRID_PAGE_SIZE = 24 +export const HOME_GRID_ROWS = 6 +export const HOME_GRID_PAGE_SIZE = HOME_GRID_COLUMNS * HOME_GRID_ROWS export const HOME_FOLDER_PAGE_SIZE = 9 export const HOME_FOLDER_NAME_MAX_LENGTH = 32 export const MAX_HOME_GRID_PAGES = 5 +export const HOME_LAYOUT_VERSION = 5 const LEGACY_HOME_GRID_PAGE_SIZE = 20 export type HomeArea = 'dock' | 'grid' @@ -25,9 +27,11 @@ export type HomeLayout = { dock: HomeSlot[] grid: HomeSlot[] hidden: LaunchablePhoneAppId[] - version: 5 + version: typeof HOME_LAYOUT_VERSION } +export type HomeGridPageCapacities = readonly number[] + export function isHomeFolder(value: unknown): value is HomeFolder { if (!value || typeof value !== 'object') return false const folder = value as Partial @@ -77,11 +81,6 @@ function getGridCapacity(itemCount: number): number { ) } -function migrateLegacyGrid(value: unknown): unknown[] { - if (!Array.isArray(value)) return [] - return value.slice(0, LEGACY_HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES) -} - function cloneItem(item: HomeSlot): HomeSlot { return isHomeFolder(item) ? { ...item, apps: [...item.apps] } : item } @@ -91,7 +90,7 @@ function cloneLayout(layout: HomeLayout): HomeLayout { dock: layout.dock.map(cloneItem), grid: layout.grid.map(cloneItem), hidden: [...layout.hidden], - version: 5, + version: HOME_LAYOUT_VERSION, } } @@ -105,6 +104,7 @@ function readItem( value: unknown, availableIds: Set, folderIds: Set, + allowFolders: boolean, ): HomeSlot { if ( typeof value === 'string' && @@ -112,8 +112,14 @@ function readItem( ) { return value as LaunchablePhoneAppId } - if (!isHomeFolder(value) || !isPersistableFolderId(value.id)) return null - if (folderIds.has(value.id)) return null + if ( + !allowFolders || + !isHomeFolder(value) || + !isPersistableFolderId(value.id) || + folderIds.has(value.id) + ) { + return null + } const apps = readAppIds(value.apps, availableIds) if (apps.length === 0) return null @@ -132,12 +138,45 @@ function readSlots( availableIds: Set, length: number, folderIds: Set, + allowFolders: boolean, ): HomeSlot[] { const slots = createSlots(length) if (!Array.isArray(value)) return slots for (let index = 0; index < Math.min(value.length, length); index += 1) { - slots[index] = readItem(value[index], availableIds, folderIds) + slots[index] = readItem(value[index], availableIds, folderIds, allowFolders) + } + return slots +} + +function migrateLegacyGrid( + value: unknown, + availableIds: Set, +): HomeSlot[] { + if (!Array.isArray(value)) return createSlots(HOME_GRID_PAGE_SIZE) + + const legacyLength = Math.min( + value.length, + LEGACY_HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES, + ) + const pageCount = Math.max( + 1, + Math.ceil(legacyLength / LEGACY_HOME_GRID_PAGE_SIZE), + ) + const slots = createSlots(pageCount * HOME_GRID_PAGE_SIZE) + for (let index = 0; index < legacyLength; index += 1) { + const valueId = value[index] + if ( + typeof valueId !== 'string' || + !availableIds.has(valueId as LaunchablePhoneAppId) + ) { + continue + } + + const page = Math.floor(index / LEGACY_HOME_GRID_PAGE_SIZE) + const pageIndex = index % LEGACY_HOME_GRID_PAGE_SIZE + slots[page * HOME_GRID_PAGE_SIZE + pageIndex] = + valueId as LaunchablePhoneAppId } return slots } @@ -153,6 +192,42 @@ function placeInFirstEmptySlot(slots: HomeSlot[], item: HomeItem): void { slots[slots.length - HOME_GRID_PAGE_SIZE] = cloneItem(item) } +function insertIntoSlot( + slots: HomeSlot[], + targetIndex: number, + item: HomeItem, +): HomeSlot { + if (slots[targetIndex] === null) { + slots[targetIndex] = cloneItem(item) + return null + } + + const emptyAfter = slots.indexOf(null, targetIndex + 1) + if (emptyAfter !== -1) { + for (let index = emptyAfter; index > targetIndex; index -= 1) { + slots[index] = slots[index - 1] + } + slots[targetIndex] = cloneItem(item) + return null + } + + const emptyBefore = slots.lastIndexOf(null, targetIndex - 1) + if (emptyBefore !== -1) { + for (let index = emptyBefore; index < targetIndex; index += 1) { + slots[index] = slots[index + 1] + } + slots[targetIndex] = cloneItem(item) + return null + } + + const displacedItem = cloneItem(slots.at(-1) ?? null) + for (let index = slots.length - 1; index > targetIndex; index -= 1) { + slots[index] = slots[index - 1] + } + slots[targetIndex] = cloneItem(item) + return displacedItem +} + function itemContainsApp(item: HomeSlot, appId: LaunchablePhoneAppId): boolean { return isHomeFolder(item) ? item.apps.includes(appId) : item === appId } @@ -180,6 +255,177 @@ export function getHomeFolder( return isHomeFolder(item) ? item : null } +function readPageItems(slots: HomeSlot[], pageStart: number): HomeItem[] { + return slots + .slice(pageStart, pageStart + HOME_GRID_PAGE_SIZE) + .filter((item): item is HomeItem => item !== null) + .map((item) => cloneItem(item) as HomeItem) +} + +function writePageItems( + slots: HomeSlot[], + pageStart: number, + items: HomeItem[], +): void { + for (let offset = 0; offset < HOME_GRID_PAGE_SIZE; offset += 1) { + slots[pageStart + offset] = cloneItem(items[offset] ?? null) + } +} + +function compactGridPages(slots: HomeSlot[]): HomeSlot[] { + const compacted = slots.map(cloneItem) + for ( + let pageStart = 0; + pageStart < compacted.length; + pageStart += HOME_GRID_PAGE_SIZE + ) { + writePageItems(compacted, pageStart, readPageItems(compacted, pageStart)) + } + return compacted +} + +type IndexedHomeItem = { + item: HomeItem + sourceIndex: number +} + +function gridPageCapacity( + capacities: HomeGridPageCapacities, + page: number, +): number { + const capacity = capacities[page - 1] + return Number.isFinite(capacity) + ? Math.max(0, Math.min(HOME_GRID_PAGE_SIZE, Math.trunc(capacity))) + : HOME_GRID_PAGE_SIZE +} + +function distributeGridPages( + slots: readonly HomeSlot[], + capacities: HomeGridPageCapacities, + minimumPageCount: number, +): IndexedHomeItem[][] | null { + const persistedPageCount = Math.max( + 1, + Math.ceil(slots.length / HOME_GRID_PAGE_SIZE), + ) + const pages: IndexedHomeItem[][] = [] + let incoming: IndexedHomeItem[] = [] + let pageCount = Math.max(persistedPageCount, minimumPageCount) + + for (let page = 1; page <= pageCount; page += 1) { + const pageStart = (page - 1) * HOME_GRID_PAGE_SIZE + const persisted = slots + .slice(pageStart, pageStart + HOME_GRID_PAGE_SIZE) + .flatMap((item, offset) => + item === null + ? [] + : [ + { + item: cloneItem(item) as HomeItem, + sourceIndex: pageStart + offset, + }, + ], + ) + const available = [...incoming, ...persisted] + const capacity = gridPageCapacity(capacities, page) + pages.push(available.slice(0, capacity)) + incoming = available.slice(capacity) + + if ( + page === pageCount && + incoming.length && + pageCount < MAX_HOME_GRID_PAGES + ) { + pageCount += 1 + } + } + + return incoming.length ? null : pages +} + +function serializeGridPages(pages: readonly IndexedHomeItem[][]): HomeSlot[] { + const slots = createSlots(Math.max(1, pages.length) * HOME_GRID_PAGE_SIZE) + for (const [pageIndex, entries] of pages.entries()) { + const pageStart = pageIndex * HOME_GRID_PAGE_SIZE + for (const [offset, entry] of entries.entries()) { + slots[pageStart + offset] = cloneItem(entry.item) + } + } + return slots +} + +export function moveHomeAppToGridPage( + layout: HomeLayout, + from: HomeArea, + sourceIndex: number, + targetPage: number, + targetOffset: number, + capacities: HomeGridPageCapacities = [], +): HomeLayout { + const sourceSlots = layout[from] + const sourceItem = sourceSlots[sourceIndex] + if ( + sourceItem === null || + sourceItem === undefined || + sourceIndex < 0 || + sourceIndex >= sourceSlots.length || + !Number.isInteger(targetPage) || + targetPage < 1 || + targetPage > MAX_HOME_GRID_PAGES || + !Number.isFinite(targetOffset) || + gridPageCapacity(capacities, targetPage) === 0 + ) { + return layout + } + + const pages = distributeGridPages(layout.grid, capacities, targetPage) + if (!pages) return layout + + let sourcePage = -1 + if (from === 'grid') { + for (const [pageIndex, entries] of pages.entries()) { + const entryIndex = entries.findIndex( + (entry) => entry.sourceIndex === sourceIndex, + ) + if (entryIndex !== -1) { + sourcePage = pageIndex + 1 + entries.splice(entryIndex, 1) + break + } + } + if (sourcePage === -1) return layout + } + + while (pages.length < targetPage) pages.push([]) + const targetEntries = pages[targetPage - 1] + const requestedOffset = Math.max(0, Math.trunc(targetOffset)) + targetEntries.splice(Math.min(requestedOffset, targetEntries.length), 0, { + item: cloneItem(sourceItem) as HomeItem, + sourceIndex, + }) + + for (let page = targetPage; page <= pages.length; page += 1) { + const entries = pages[page - 1] + const capacity = gridPageCapacity(capacities, page) + if (entries.length <= capacity) continue + const overflow = entries.splice(capacity) + if (page >= MAX_HOME_GRID_PAGES) return layout + if (!pages[page]) pages.push([]) + pages[page].unshift(...overflow) + } + + const next = cloneLayout(layout) + next.grid = serializeGridPages(pages) + if (from === 'dock') next.dock[sourceIndex] = null + if ( + JSON.stringify(next.grid) === JSON.stringify(layout.grid) && + JSON.stringify(next.dock) === JSON.stringify(layout.dock) + ) { + return layout + } + return next +} + export function createDefaultHomeLayout( installedIds: LaunchablePhoneAppId[], defaultGridIds: LaunchablePhoneAppId[], @@ -200,7 +446,7 @@ export function createDefaultHomeLayout( dock[index] = id } - return { dock, grid, hidden: [], version: 5 } + return { dock, grid, hidden: [], version: HOME_LAYOUT_VERSION } } export function parseHomeLayout( @@ -227,12 +473,16 @@ export function parseHomeLayout( } const hidden = readAppIds(source.hidden, availableIds) const hiddenIds = new Set(hidden) - const persistedGrid = - source.version === 2 || source.version === 3 - ? migrateLegacyGrid(source.grid) - : source.grid - const persistedGridLength = Array.isArray(persistedGrid) - ? Math.min(persistedGrid.length, HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES) + const isLegacyVersionedLayout = source.version === 2 || source.version === 3 + const persistedGridLength = Array.isArray(source.grid) + ? isLegacyVersionedLayout + ? Math.ceil( + Math.min( + source.grid.length, + LEGACY_HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES, + ) / LEGACY_HOME_GRID_PAGE_SIZE, + ) * HOME_GRID_PAGE_SIZE + : Math.min(source.grid.length, HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES) : 0 const gridLength = Math.max( defaults.grid.length, @@ -241,15 +491,35 @@ export function parseHomeLayout( let grid: HomeSlot[] let dock: HomeSlot[] - if ( - source.version === 2 || - source.version === 3 || - source.version === 4 || - source.version === 5 - ) { + if (isLegacyVersionedLayout) { + grid = migrateLegacyGrid(source.grid, availableIds) + if (grid.length < gridLength) { + grid.push(...createSlots(gridLength - grid.length)) + } + dock = readSlots( + source.dock, + availableIds, + HOME_DOCK_CAPACITY, + new Set(), + false, + ) + } else if (source.version === 4 || source.version === 5) { const folderIds = new Set() - grid = readSlots(persistedGrid, availableIds, gridLength, folderIds) - dock = readSlots(source.dock, availableIds, HOME_DOCK_CAPACITY, folderIds) + const allowFolders = source.version === 5 + grid = readSlots( + source.grid, + availableIds, + gridLength, + folderIds, + allowFolders, + ) + dock = readSlots( + source.dock, + availableIds, + HOME_DOCK_CAPACITY, + folderIds, + allowFolders, + ) } else { grid = createSlots(gridLength) for (const id of readAppIds(source.grid, availableIds)) { @@ -271,8 +541,9 @@ export function parseHomeLayout( apps: item.apps.filter((appId) => !hiddenIds.has(appId)), }) } - grid = grid.map(removeHidden) + grid = compactGridPages(grid.map(removeHidden)) dock = dock.map(removeHidden) + const placedIds = new Set(hidden) for (const item of [...grid, ...dock]) { if (typeof item === 'string') placedIds.add(item) @@ -288,7 +559,12 @@ export function parseHomeLayout( } } - return { dock, grid, hidden, version: 5 } + return { + dock: dock.map(cloneItem), + grid: compactGridPages(grid), + hidden, + version: HOME_LAYOUT_VERSION, + } } export function removeHomeApp( @@ -297,7 +573,7 @@ export function removeHomeApp( ): HomeLayout { const removeFromItem = (item: HomeSlot): HomeSlot => { if (item === appId) return null - if (!isHomeFolder(item)) return item + if (!isHomeFolder(item)) return cloneItem(item) return normalizeFolder({ ...item, apps: item.apps.filter((folderAppId) => folderAppId !== appId), @@ -305,11 +581,11 @@ export function removeHomeApp( } return { dock: layout.dock.map(removeFromItem), - grid: layout.grid.map(removeFromItem), + grid: compactGridPages(layout.grid.map(removeFromItem)), hidden: layout.hidden.includes(appId) ? [...layout.hidden] : [...layout.hidden, appId], - version: 5, + version: HOME_LAYOUT_VERSION, } } @@ -327,9 +603,9 @@ export function restoreHomeApp( placeInFirstEmptySlot(grid, appId) return { dock: layout.dock.map(cloneItem), - grid, + grid: compactGridPages(grid), hidden: layout.hidden.filter((id) => id !== appId), - version: 5, + version: HOME_LAYOUT_VERSION, } } @@ -342,7 +618,7 @@ export function addHomePage(layout: HomeLayout): HomeLayout { dock: layout.dock.map(cloneItem), grid: [...layout.grid.map(cloneItem), ...createSlots(HOME_GRID_PAGE_SIZE)], hidden: [...layout.hidden], - version: 5, + version: HOME_LAYOUT_VERSION, } } @@ -362,9 +638,9 @@ export function deleteHomePage(layout: HomeLayout, page: number): HomeLayout { return { dock: layout.dock.map(cloneItem), - grid, + grid: compactGridPages(grid), hidden: [...layout.hidden], - version: 5, + version: HOME_LAYOUT_VERSION, } } @@ -375,23 +651,46 @@ export function moveHomeApp( to: HomeArea, targetIndex: number, ): HomeLayout { - const next = cloneLayout(layout) - const source = next[from] - const target = next[to] - const item = source[sourceIndex] + const sourceSlots = layout[from] + const item = sourceSlots[sourceIndex] + const maximumTargetIndex = + to === 'grid' + ? HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES + : HOME_DOCK_CAPACITY if ( - !item || + item === null || + item === undefined || sourceIndex < 0 || - sourceIndex >= source.length || + sourceIndex >= sourceSlots.length || targetIndex < 0 || - targetIndex >= target.length + targetIndex >= maximumTargetIndex || + (from === to && sourceIndex === targetIndex) ) { return layout } - if (from === to && sourceIndex === targetIndex) return layout - source[sourceIndex] = cloneItem(target[targetIndex]) - target[targetIndex] = item + if (to === 'grid') { + return moveHomeAppToGridPage( + layout, + from, + sourceIndex, + Math.floor(targetIndex / HOME_GRID_PAGE_SIZE) + 1, + targetIndex % HOME_GRID_PAGE_SIZE, + ) + } + + const next = cloneLayout(layout) + const source = next[from] + const target = next[to] + + if (from === to) { + source[sourceIndex] = null + insertIntoSlot(source, targetIndex, item) + return next + } + + source[sourceIndex] = insertIntoSlot(target, targetIndex, item) + next.grid = compactGridPages(next.grid) return next } @@ -404,8 +703,9 @@ export function createHomeFolder( folderId: string, name: string, ): HomeLayout { - if (!isPersistableFolderId(folderId)) return layout - if (folderLocation(layout, folderId)) return layout + if (!isPersistableFolderId(folderId) || folderLocation(layout, folderId)) { + return layout + } const sourceItem = layout[from][sourceIndex] const targetItem = layout[to][targetIndex] if ( @@ -424,6 +724,7 @@ export function createHomeFolder( name: name.trim().slice(0, HOME_FOLDER_NAME_MAX_LENGTH), type: 'folder', } + next.grid = compactGridPages(next.grid) return next } @@ -443,6 +744,7 @@ export function addHomeAppToFolder( if (!isHomeFolder(folder)) return layout next[from][sourceIndex] = null folder.apps.push(sourceItem) + next.grid = compactGridPages(next.grid) return next } @@ -522,6 +824,7 @@ export function extractHomeFolderApp( const [appId] = nextFolder.apps.splice(sourceIndex, 1) next[to][targetIndex] = appId next[location.area][location.index] = normalizeFolder(nextFolder) + next.grid = compactGridPages(next.grid) return next } diff --git a/frontend/src/utils/springboardDrag.test.ts b/frontend/src/utils/springboardDrag.test.ts new file mode 100644 index 0000000..0b29e41 --- /dev/null +++ b/frontend/src/utils/springboardDrag.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' + +import { + maximumRenderedWidgetPage, + resolveSpringboardEdgeTurn, + resolveSpringboardHomeEdgeTurn, + springboardEdgeDirection, + springboardPageDragCompensation, + springboardSwipeIntent, +} from '@/utils/springboardDrag' + +describe('springboard widget drag', () => { + it('turns pages from the reachable phone edge zones', () => { + expect(springboardEdgeDirection(113, 100, 468)).toBe(-1) + expect(springboardEdgeDirection(455, 100, 468)).toBe(1) + expect(springboardEdgeDirection(284, 100, 468)).toBe(0) + }) + + it('resolves reachable page destinations without crossing page bounds', () => { + expect(resolveSpringboardEdgeTurn(455, 100, 468, 1, 0, 3)).toEqual({ + destination: 2, + direction: 1, + }) + expect(resolveSpringboardEdgeTurn(113, 100, 468, 2, 0, 3)).toEqual({ + destination: 1, + direction: -1, + }) + expect(resolveSpringboardEdgeTurn(113, 100, 468, 0, 0, 3)).toBeNull() + expect(resolveSpringboardEdgeTurn(455, 100, 468, 3, 0, 3)).toBeNull() + }) + + it('previews one new trailing page without crossing the home-page limit', () => { + expect( + resolveSpringboardHomeEdgeTurn(455, 100, 468, 2, 2, 5, false), + ).toEqual({ destination: 3, direction: 1, previewsPage: true }) + expect( + resolveSpringboardHomeEdgeTurn(455, 100, 468, 2, 3, 5, true), + ).toEqual({ destination: 3, direction: 1, previewsPage: false }) + expect( + resolveSpringboardHomeEdgeTurn(455, 100, 468, 5, 5, 5, false), + ).toBeNull() + }) + + it('keeps the original widget page mounted during a drag preview', () => { + expect(maximumRenderedWidgetPage([1, 3], [1, 2])).toBe(3) + expect(maximumRenderedWidgetPage([], [])).toBe(1) + }) + + it('distinguishes a page swipe from a tap or vertical gesture', () => { + expect(springboardSwipeIntent(5, 2)).toBe('pending') + expect(springboardSwipeIntent(30, 8)).toBe('horizontal') + expect(springboardSwipeIntent(8, 30)).toBe('vertical') + }) + + it('keeps a dragged item under the pointer while the track changes pages', () => { + expect(springboardPageDragCompensation(3, 2, 368)).toBe(-368) + expect(springboardPageDragCompensation(2, 3, 368)).toBe(368) + expect(springboardPageDragCompensation(2, 2, 368)).toBe(0) + }) +}) diff --git a/frontend/src/utils/springboardDrag.ts b/frontend/src/utils/springboardDrag.ts new file mode 100644 index 0000000..53d6a6e --- /dev/null +++ b/frontend/src/utils/springboardDrag.ts @@ -0,0 +1,91 @@ +export type PageTurnDirection = -1 | 0 | 1 + +export type SpringboardEdgeTurn = { + destination: number + direction: Exclude +} + +export type SpringboardHomeEdgeTurn = SpringboardEdgeTurn & { + previewsPage: boolean +} + +export type SpringboardSwipeIntent = 'horizontal' | 'pending' | 'vertical' + +export function springboardPageDragCompensation( + startPage: number, + currentPage: number, + pageWidth: number, +): number { + return (currentPage - startPage) * Math.max(0, pageWidth) +} + +export function springboardSwipeIntent( + deltaX: number, + deltaY: number, + threshold = 8, +): SpringboardSwipeIntent { + if (Math.hypot(deltaX, deltaY) <= threshold) return 'pending' + return Math.abs(deltaX) > Math.abs(deltaY) ? 'horizontal' : 'vertical' +} + +export function springboardEdgeDirection( + clientX: number, + left: number, + right: number, +): PageTurnDirection { + const width = Math.max(0, right - left) + const edgeSize = Math.min(42, width * 0.12) + if (clientX <= left + edgeSize) return -1 + if (clientX >= right - edgeSize) return 1 + return 0 +} + +export function resolveSpringboardEdgeTurn( + clientX: number, + left: number, + right: number, + currentPage: number, + minimumPage: number, + maximumPage: number, +): SpringboardEdgeTurn | null { + const direction = springboardEdgeDirection(clientX, left, right) + if (direction === 0) return null + const destination = currentPage + direction + if (destination < minimumPage || destination > maximumPage) return null + return { destination, direction } +} + +export function resolveSpringboardHomeEdgeTurn( + clientX: number, + left: number, + right: number, + currentPage: number, + renderedPageCount: number, + maximumPageCount: number, + previewPageActive: boolean, +): SpringboardHomeEdgeTurn | null { + const canPreviewPage = + !previewPageActive && renderedPageCount < maximumPageCount + const maximumPage = Math.min( + maximumPageCount, + renderedPageCount + (canPreviewPage ? 1 : 0), + ) + const turn = resolveSpringboardEdgeTurn( + clientX, + left, + right, + currentPage, + 1, + maximumPage, + ) + return turn + ? { ...turn, previewsPage: turn.destination > renderedPageCount } + : null +} + +export function maximumRenderedWidgetPage( + persistedPages: readonly number[], + previewPages: readonly number[], +): number { + return Math.max(1, ...persistedPages, ...previewPages) +} diff --git a/frontend/src/utils/springboardLayout.test.ts b/frontend/src/utils/springboardLayout.test.ts new file mode 100644 index 0000000..1ad8013 --- /dev/null +++ b/frontend/src/utils/springboardLayout.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest' + +import type { LaunchablePhoneAppId } from '@/types/apps' +import { HOME_GRID_PAGE_SIZE, type HomeSlot } from '@/utils/homeLayout' +import { layoutSpringboardHomePages } from '@/utils/springboardLayout' + +function app(id: string): LaunchablePhoneAppId { + return id as LaunchablePhoneAppId +} + +describe('springboard app page layout', () => { + it('closes visual gaps per page while keeping page membership', () => { + const grid = new Array( + HOME_GRID_PAGE_SIZE * 2, + ).fill(null) + grid[20] = app('row-six') + grid[24] = app('page-two') + + const pages = layoutSpringboardHomePages(grid, new Map(), 2) + + expect(pages).toHaveLength(2) + expect(pages[0]?.cells[0]).toMatchObject({ + item: 'row-six', + sourceIndex: 20, + }) + expect(pages[0]?.cells[1]).toMatchObject({ item: null, sourceIndex: 1 }) + expect(pages[1]?.cells[0]).toMatchObject({ + item: 'page-two', + sourceIndex: 24, + }) + }) + + it('reflows apps forward around widgets without swapping their order', () => { + const grid = new Array( + HOME_GRID_PAGE_SIZE, + ).fill(null) + for (let index = 0; index < 8; index += 1) grid[index] = app(`app-${index}`) + + const pages = layoutSpringboardHomePages( + grid, + new Map([[1, new Set([0, 1, 4, 5])]]), + 1, + ) + + expect( + pages[0]?.cells.slice(0, 12).map((cell) => cell?.item ?? null), + ).toEqual([ + null, + null, + 'app-0', + 'app-1', + null, + null, + 'app-2', + 'app-3', + 'app-4', + 'app-5', + 'app-6', + 'app-7', + ]) + }) + + it('reflows folders as one item and keeps their source index', () => { + const grid = new Array(HOME_GRID_PAGE_SIZE).fill(null) + grid[0] = app('mail') + grid[1] = { + apps: [app('clock'), app('notes')], + id: 'folder-tools-123456', + name: 'Tools', + type: 'folder', + } + + const pages = layoutSpringboardHomePages( + grid, + new Map([[1, new Set([0, 1])]]), + 1, + ) + + expect(pages[0]?.cells[2]).toMatchObject({ + item: 'mail', + sourceIndex: 0, + }) + expect(pages[0]?.cells[3]).toMatchObject({ + item: { + apps: ['clock', 'notes'], + id: 'folder-tools-123456', + type: 'folder', + }, + sourceIndex: 1, + }) + }) + + it('maps widget overflow to the visible destination page and offset', () => { + const grid = Array.from({ length: HOME_GRID_PAGE_SIZE }, (_, index) => + app(`app-${index}`), + ) + const pages = layoutSpringboardHomePages( + grid, + new Map([[1, new Set([0, 1, 2, 3])]]), + 2, + ) + + expect(pages[1]?.cells[0]).toMatchObject({ + item: 'app-20', + sourceIndex: 20, + targetOffset: 0, + targetPage: 2, + }) + expect(pages[1]?.cells[1]).toMatchObject({ + item: 'app-21', + sourceIndex: 21, + targetOffset: 1, + targetPage: 2, + }) + }) +}) diff --git a/frontend/src/utils/springboardLayout.ts b/frontend/src/utils/springboardLayout.ts new file mode 100644 index 0000000..8858bb8 --- /dev/null +++ b/frontend/src/utils/springboardLayout.ts @@ -0,0 +1,97 @@ +import { + HOME_GRID_PAGE_SIZE, + isHomeFolder, + MAX_HOME_GRID_PAGES, + type HomeItem, + type HomeSlot, +} from '@/utils/homeLayout' + +export type SpringboardHomeCell = { + item: HomeItem | null + renderKey: string + sourceIndex: number | null + targetOffset: number + targetPage: number +} + +export type SpringboardHomePage = { + cells: Array + page: number +} + +type PendingHomeCell = Pick< + SpringboardHomeCell, + 'item' | 'renderKey' | 'sourceIndex' +> + +function homeItemRenderKey( + item: HomeItem, + appOccurrences: Map, +): string { + if (isHomeFolder(item)) return `grid-folder-${item.id}` + const occurrence = appOccurrences.get(item) ?? 0 + appOccurrences.set(item, occurrence + 1) + return `grid-app-${item}-${occurrence}` +} + +export function layoutSpringboardHomePages( + grid: readonly HomeSlot[], + occupiedByPage: ReadonlyMap>, + minimumPageCount: number, +): SpringboardHomePage[] { + const pageCount = Math.max( + 1, + Math.min( + MAX_HOME_GRID_PAGES, + Math.max(minimumPageCount, Math.ceil(grid.length / HOME_GRID_PAGE_SIZE)), + ), + ) + const pages: SpringboardHomePage[] = [] + const pending: PendingHomeCell[] = [] + const appOccurrences = new Map() + + const createPage = (page: number): SpringboardHomePage => { + const pageStart = (page - 1) * HOME_GRID_PAGE_SIZE + for (let offset = 0; offset < HOME_GRID_PAGE_SIZE; offset += 1) { + const sourceIndex = pageStart + offset + const item = grid[sourceIndex] + if (!item) continue + pending.push({ + item, + renderKey: homeItemRenderKey(item, appOccurrences), + sourceIndex, + }) + } + + const occupied = occupiedByPage.get(page) ?? new Set() + const cells = new Array( + HOME_GRID_PAGE_SIZE, + ).fill(null) + let targetOffset = 0 + for (let cell = 0; cell < HOME_GRID_PAGE_SIZE; cell += 1) { + if (occupied.has(cell)) continue + const pendingCell = pending.shift() + cells[cell] = pendingCell + ? { ...pendingCell, targetOffset, targetPage: page } + : { + item: null, + renderKey: `grid-empty-${pageStart + targetOffset}`, + sourceIndex: pageStart + targetOffset, + targetOffset, + targetPage: page, + } + targetOffset += 1 + } + return { cells, page } + } + + for (let page = 1; page <= pageCount; page += 1) { + pages.push(createPage(page)) + } + + while (pending.length && pages.length < MAX_HOME_GRID_PAGES) { + pages.push(createPage(pages.length + 1)) + } + + return pages +} diff --git a/frontend/src/utils/widgetLayout.test.ts b/frontend/src/utils/widgetLayout.test.ts index 1b7cd2e..c6674eb 100644 --- a/frontend/src/utils/widgetLayout.test.ts +++ b/frontend/src/utils/widgetLayout.test.ts @@ -4,6 +4,7 @@ import { addWidget, createDefaultWidgetLayout, deleteWidgetPage, + MAX_WIDGET_HOME_PAGES, moveWidget, parseWidgetLayout, removeWidget, @@ -75,6 +76,42 @@ describe('widget layout', () => { expect(widgetOccupiedCells(moved.instances, 2).has(7)).toBe(false) }) + it('moves a widget across pages into the sixth widget row', () => { + const layout = createDefaultWidgetLayout() + const moved = moveWidget(layout, 'home-clock', 2, 2, 4) + + expect( + moved.instances.find((instance) => instance.id === 'home-clock'), + ).toMatchObject({ column: 2, page: 2, row: 4 }) + expect([...widgetOccupiedCells(moved.instances, 2)]).toEqual([ + 18, 19, 22, 23, + ]) + }) + + it('bounds persisted and requested widget pages to the home page limit', () => { + const parsed = parseWidgetLayout({ + instances: [ + { + column: 0, + id: 'clock-too-far', + kind: 'clock', + page: 999, + row: 0, + settings: {}, + size: 'small', + }, + ], + version: 1, + }) + const moved = moveWidget(parsed, 'clock-too-far', 999, 0, 4) + + expect(parsed.instances[0]?.page).toBe(MAX_WIDGET_HOME_PAGES) + expect(moved.instances[0]).toMatchObject({ + page: MAX_WIDGET_HOME_PAGES, + row: 4, + }) + }) + it('keeps the original layout when displaced widgets cannot be reflowed', () => { const layout = createDefaultWidgetLayout() const packed = { diff --git a/frontend/src/utils/widgetLayout.ts b/frontend/src/utils/widgetLayout.ts index 6869be5..720be0d 100644 --- a/frontend/src/utils/widgetLayout.ts +++ b/frontend/src/utils/widgetLayout.ts @@ -6,12 +6,17 @@ import type { WidgetSettings, WidgetSize, } from '@/types/widgets' +import { + HOME_GRID_COLUMNS, + HOME_GRID_ROWS, + MAX_HOME_GRID_PAGES, +} from '@/utils/homeLayout' import type { ReorderDirection } from '@/utils/keyboard' -export const WIDGET_GRID_COLUMNS = 4 -export const WIDGET_HOME_ROWS = 6 +export const WIDGET_GRID_COLUMNS = HOME_GRID_COLUMNS +export const WIDGET_HOME_ROWS = HOME_GRID_ROWS export const WIDGET_PAGE_ROWS = 10 -const MAX_WIDGET_HOME_PAGES = 5 +export const MAX_WIDGET_HOME_PAGES = MAX_HOME_GRID_PAGES export const WIDGET_SPANS: Record< WidgetSize, @@ -26,6 +31,13 @@ function rowsForPage(page: number): number { return page === 0 ? WIDGET_PAGE_ROWS : WIDGET_HOME_ROWS } +function normalizePage(page: number): number { + return Math.max( + 0, + Math.min(Math.floor(Number(page) || 0), MAX_WIDGET_HOME_PAGES), + ) +} + function overlaps(left: WidgetInstance, right: WidgetInstance): boolean { if (left.page !== right.page) return false const leftSpan = WIDGET_SPANS[left.size] @@ -221,7 +233,7 @@ export function parseWidgetLayout(value: unknown): WidgetLayout { column: Math.floor(Number(candidate.column) || 0), id: candidate.id, kind: candidate.kind as WidgetKind, - page: Math.max(0, Math.floor(Number(candidate.page) || 0)), + page: normalizePage(Number(candidate.page)), row: Math.floor(Number(candidate.row) || 0), settings: normalizeSettings(candidate.settings), size, @@ -248,7 +260,7 @@ export function addWidget( column: 0, id: `${kind}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, kind, - page, + page: normalizePage(page), row: 0, settings: { ...(kind === 'clock' ? { showDate: true } : {}), @@ -256,7 +268,7 @@ export function addWidget( }, size, } - const positioned = findPosition(instance, layout.instances, page) + const positioned = findPosition(instance, layout.instances, instance.page) if (!positioned) return layout return { instances: [...layout.instances, positioned], version: 1 } } @@ -278,11 +290,12 @@ export function moveWidget( const moving = layout.instances.find((instance) => instance.id === id) if (!moving) return layout const span = WIDGET_SPANS[moving.size] + const targetPage = normalizePage(page) const requested: WidgetInstance = { ...moving, column: Math.max(0, Math.min(column, WIDGET_GRID_COLUMNS - span.columns)), - page, - row: Math.max(0, Math.min(row, rowsForPage(page) - span.rows)), + page: targetPage, + row: Math.max(0, Math.min(row, rowsForPage(targetPage) - span.rows)), } const placed: WidgetInstance[] = [requested] for (const instance of layout.instances) { @@ -309,8 +322,7 @@ export function widgetKeyboardTarget( instance.column + (direction === 'left' ? -1 : direction === 'right' ? 1 : 0), row: - instance.row + - (direction === 'up' ? -1 : direction === 'down' ? 1 : 0), + instance.row + (direction === 'up' ? -1 : direction === 'down' ? 1 : 0), } if ( target.column < 0 || diff --git a/frontend/src/views/SpringboardView.contract.test.ts b/frontend/src/views/SpringboardView.contract.test.ts new file mode 100644 index 0000000..edcc532 --- /dev/null +++ b/frontend/src/views/SpringboardView.contract.test.ts @@ -0,0 +1,66 @@ +import { readFileSync } from 'node:fs' + +import { describe, expect, it } from 'vitest' + +const viewSource = readFileSync( + new URL('./SpringboardView.vue', import.meta.url), + 'utf8', +) +const appIconSource = readFileSync( + new URL('../components/AppIcon.vue', import.meta.url), + 'utf8', +) +const folderIconSource = readFileSync( + new URL('../components/HomeFolderIcon.vue', import.meta.url), + 'utf8', +) +describe('Springboard page swipe contract', () => { + it('allows page swipes to start on home apps and widget surfaces', () => { + expect(viewSource).toContain( + '.springboard-page--apps .app-icon-button, .home-widget', + ) + expect(viewSource).toContain('springboardSwipeIntent') + expect(viewSource).not.toContain( + "target.closest('button, input, .home-widget-shell')", + ) + }) + + it('suppresses app and folder activation after pointer movement', () => { + expect(appIconSource).toContain('springboardSwipeIntent') + expect(folderIconSource).toContain('springboardSwipeIntent') + expect(appIconSource).toContain('suppressClick.value = true') + expect(folderIconSource).toContain('suppressClick.value = true') + }) + + it('separates animated page compensation from immediate pointer movement', () => { + expect(appIconSource).toContain('springboardPageDragCompensation') + expect(folderIconSource).toContain('springboardPageDragCompensation') + expect(appIconSource).toContain(':style="dragPointerStyle"') + expect(folderIconSource).toContain(':style="dragPointerStyle"') + }) + + it('targets the active visual page and lets grid or dock drags turn pages', () => { + expect(viewSource).toContain(':data-home-page="page.page"') + expect(viewSource).toContain(':data-home-target-offset="cell.targetOffset"') + expect(viewSource).toContain('nearestGridDropTarget') + expect(viewSource).toContain('moveHomeAppToGridPage') + expect(viewSource).not.toContain("draggingHomeApp.value?.area === 'grid'") + expect(viewSource).toContain('event.clientX < springboardBounds.left') + expect(viewSource).toContain('event.clientY > springboardBounds.bottom') + }) + + it('resolves occupied dock slots from the dock bounds rather than the event target', () => { + expect(viewSource).toContain("querySelector('.app-dock')") + expect(viewSource).toContain('\'[data-home-area="dock"][data-home-index]\'') + expect(viewSource).not.toContain( + '.closest(\'[data-home-area="dock"]\')', + ) + }) + + it('keeps a new trailing page temporary until a valid drop', () => { + expect(viewSource).toContain('temporaryHomePage') + expect(viewSource).toContain('clearTemporaryHomePage') + expect(viewSource).toContain('resolveSpringboardHomeEdgeTurn') + expect(viewSource).toContain('onBeforeUnmount') + }) +}) diff --git a/frontend/src/views/SpringboardView.vue b/frontend/src/views/SpringboardView.vue index be9eb61..d07358f 100644 --- a/frontend/src/views/SpringboardView.vue +++ b/frontend/src/views/SpringboardView.vue @@ -1,7 +1,7 @@