From 7d3a3b7b388f51b9c8655de4e5f8536bf1817aa2 Mon Sep 17 00:00:00 2001 From: DerEchteAlec Date: Mon, 17 Aug 2026 05:16:28 +0200 Subject: [PATCH] ENH - rework homescreen editing and widgets --- frontend/src/App.vue | 387 ++++++------ .../AppDevelopmentPreview.contract.test.ts | 31 +- frontend/src/assets/main.css | 80 ++- frontend/src/components/AppIcon.vue | 65 +- frontend/src/components/HomeFolderIcon.vue | 77 ++- .../HomeFolderInteraction.contract.test.ts | 104 ++++ frontend/src/components/HomeFolderOverlay.vue | 313 ++++++---- .../components/NotificationPhonePreview.vue | 78 +-- frontend/src/components/SpringboardWidget.vue | 33 +- .../WidgetConfigSheet.contract.test.ts | 55 ++ frontend/src/components/WidgetConfigSheet.vue | 373 ++++++----- .../WidgetPickerSheet.contract.test.ts | 49 ++ frontend/src/components/WidgetPickerSheet.vue | 589 +++++++++++++----- frontend/src/stores/app-store.test.ts | 63 +- frontend/src/stores/app-store.ts | 25 +- frontend/src/stores/widgets.ts | 13 +- frontend/src/utils/homeLayout.test.ts | 96 ++- frontend/src/utils/homeLayout.ts | 101 ++- frontend/src/utils/springboardDrag.test.ts | 81 ++- frontend/src/utils/springboardDrag.ts | 58 +- .../views/SpringboardView.contract.test.ts | 91 ++- frontend/src/views/SpringboardView.vue | 532 +++++++--------- .../SpringboardWidgetActions.contract.test.ts | 39 ++ 23 files changed, 2218 insertions(+), 1115 deletions(-) create mode 100644 frontend/src/components/HomeFolderInteraction.contract.test.ts create mode 100644 frontend/src/components/WidgetConfigSheet.contract.test.ts create mode 100644 frontend/src/components/WidgetPickerSheet.contract.test.ts create mode 100644 frontend/src/views/SpringboardWidgetActions.contract.test.ts diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 461feba..ba1707a 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -257,8 +257,7 @@ type CrewLinkNotificationData = { } const REFERENCE_VIEWPORT_WIDTH = 1920 const REFERENCE_VIEWPORT_HEIGHT = 1080 -const PHONE_BASE_SCALE = 0.69 -const DEVELOPMENT_PHONE_SCALE = 1.25 +const PHONE_BASE_SCALE = 0.69 * 1.2 const PHONE_PORTRAIT_WIDTH = 390 const PHONE_PORTRAIT_HEIGHT = 844 const MIN_PRODUCTION_PHONE_ZOOM = 260 / PHONE_PORTRAIT_WIDTH @@ -295,6 +294,7 @@ const easyShare = useEasyShareStore() const notifications = useNotificationsStore() const route = useRoute() const router = useRouter() +const isHomeRoute = computed(() => route.name === 'home') const isAppRoute = computed(() => route.name === 'app') const activeAppId = computed(() => typeof route.params.appId === 'string' ? route.params.appId : '', @@ -323,6 +323,7 @@ const appTransitionName = computed(() => route.query.transition === 'app-switch' ? 'app-switch' : 'app-window', ) const isLocked = ref(false) +const springboardEditing = ref(false) const isUnlocking = ref(false) const passcodeBusy = ref(false) const passcodeError = ref('') @@ -363,11 +364,7 @@ const hardwareVolumeHudStyle = computed( const systemColorScheme = window.matchMedia('(prefers-color-scheme: dark)') const viewportScale = ref(getViewportScale()) const browserDevicePixelRatio = ref(window.devicePixelRatio || 1) -const phoneBaseZoom = computed( - () => - viewportScale.value * - (isDevelopment ? DEVELOPMENT_PHONE_SCALE : PHONE_BASE_SCALE), -) +const phoneBaseZoom = computed(() => viewportScale.value * PHONE_BASE_SCALE) const phoneZoom = computed(() => { const preferred = phoneBaseZoom.value * (phone.preferences.settings.phoneScale / 100) @@ -395,6 +392,14 @@ const phoneZoom = computed(() => { const phoneResolutionStyle = computed(() => ({ ...getHairlinePixelStyle(phoneZoom.value, browserDevicePixelRatio.value), '--phone-edge-gap': `${24 * viewportScale.value}px`, + '--phone-rendered-height': `${ + (phone.cameraLandscape ? PHONE_PORTRAIT_WIDTH : PHONE_PORTRAIT_HEIGHT) * + phoneZoom.value + }px`, + '--phone-rendered-width': `${ + (phone.cameraLandscape ? PHONE_PORTRAIT_HEIGHT : PHONE_PORTRAIT_WIDTH) * + phoneZoom.value + }px`, '--phone-stack-gap': `${16 * viewportScale.value}px`, '--phone-zoom': phoneZoom.value, })) @@ -1535,7 +1540,6 @@ onBeforeUnmount(() => { " class="phone-stage" :class="{ - 'phone-stage--dev': isDevelopment, 'phone-stage--landscape': phone.cameraLandscape, 'phone-stage--peek': notifications.isPeeking, }" @@ -1559,200 +1563,209 @@ onBeforeUnmount(() => { v-if="phone.isOpen || notifications.current" class="phone-resolution-wrapper phone-resolution-wrapper--primary" > -
- - - - -
+
- -
- - -
-
- + + + +
- - - +
+ + +
+ + - - - - - -
- - - - - - - - - - - - - -
- -
+ + + + + + + + + + + + + + + + + + + + +
+ +
+ diff --git a/frontend/src/AppDevelopmentPreview.contract.test.ts b/frontend/src/AppDevelopmentPreview.contract.test.ts index bded901..af0d7eb 100644 --- a/frontend/src/AppDevelopmentPreview.contract.test.ts +++ b/frontend/src/AppDevelopmentPreview.contract.test.ts @@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' const source = readFileSync(new URL('./App.vue', import.meta.url), 'utf8') -const styles = readFileSync( +const mainCss = readFileSync( new URL('./assets/main.css', import.meta.url), 'utf8', ) @@ -53,12 +53,35 @@ describe('browser development preview contract', () => { expect(source).toMatch( /function changeHardwareAlertVolume\(delta: number\): void \{[\s\S]*?phone\.setAlertVolumes\(volume\)/, ) - expect(styles).toMatch( + expect(mainCss).toMatch( /\.phone-hardware-button\s*\{[\s\S]*?position:\s*absolute;[\s\S]*?z-index:\s*101;/, ) - expect(styles).toContain('.phone-hardware-button--power') + expect(mainCss).toContain('.phone-hardware-button--power') expect(source).toContain('class="phone-volume-hud"') expect(source).toContain('showHardwareVolumeHud()') - expect(styles).toContain('.phone-volume-hud__level') + expect(mainCss).toContain('.phone-volume-hud__level') + }) + + it('uses layout zoom so the fixed-resolution phone stays sharply rasterized', () => { + expect(source).toContain('phone-resolution-canvas--primary') + expect(source).toContain("'--phone-rendered-height'") + expect(source).toContain("'--phone-rendered-width'") + expect(mainCss).toMatch( + /\.phone-resolution-canvas\s*\{[^}]*zoom:\s*var\(--phone-zoom, 1\);/s, + ) + expect(mainCss).not.toMatch( + /\.phone-resolution-canvas\s*\{[^}]*transform:\s*scale\(/s, + ) + }) + + it('matches the production phone size and bottom-right stage in development', () => { + expect(source).toContain('const PHONE_BASE_SCALE = 0.69 * 1.2') + expect(source).toContain('() => viewportScale.value * PHONE_BASE_SCALE') + expect(source).not.toContain('DEVELOPMENT_PHONE_SCALE') + expect(source).not.toContain("'phone-stage--dev': isDevelopment") + expect(mainCss).toMatch( + /\.phone-stage\s*\{[^}]*place-items:\s*end;[^}]*padding:\s*0 var\(--phone-edge-gap, 24px\) var\(--phone-edge-gap, 24px\) 0;/s, + ) + expect(mainCss).not.toContain('.phone-stage--dev') }) }) diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 6094308..7414079 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -248,10 +248,6 @@ button { padding: 0 var(--phone-edge-gap, 24px) var(--phone-edge-gap, 24px) 0; pointer-events: none; } -.phone-stage--dev { - place-items: center; - padding: 0; -} .phone-stage--peek .phone-resolution-wrapper--primary .phone-device { transform: translateY(calc(100% - 190px)); transform-origin: right bottom; @@ -273,11 +269,21 @@ button { transform: translateY(calc(100% + var(--phone-edge-gap, 24px))); } .phone-resolution-wrapper { + position: relative; + width: var(--phone-rendered-width, 390px); + height: var(--phone-rendered-height, 844px); +} + +.phone-resolution-canvas { + position: absolute; + top: 0; + left: 0; width: 390px; height: 844px; zoom: var(--phone-zoom, 1); } -.phone-stage--landscape .phone-resolution-wrapper--primary { + +.phone-stage--landscape .phone-resolution-canvas--primary { width: 844px; height: 390px; } @@ -2890,25 +2896,8 @@ button { .springboard--widget-dragging .springboard-widget-page-scroll { overflow: visible; } -.home-drag-layer { - position: absolute; - z-index: 70; - inset: 0; - overflow: hidden; - pointer-events: none; -} -.home-drag-layer * { - pointer-events: none !important; -} -.home-drag-ghost { - position: absolute; - top: 0; - left: 0; - margin: 0; - opacity: 1; - transition: none !important; - animation: none !important; - will-change: transform; +.springboard--home-dragging .springboard-page--apps { + overflow: visible; } .app-grid { display: grid; @@ -2943,9 +2932,6 @@ button { transition: transform var(--springboard-page-duration) var(--springboard-page-easing); } -.app-icon-item--drag-source { - opacity: 0; -} .app-icon-remove { position: absolute; z-index: 4; @@ -2957,22 +2943,30 @@ button { border: 0; border-radius: 50%; background: none; - color: black; + color: #111; display: grid; place-items: center; cursor: pointer; text-shadow: none; } .app-icon-remove__badge { + box-sizing: border-box; width: 22px; height: 22px; min-width: 22px; min-height: 22px; padding: 0; - border: 1px solid #ffffff73; - box-shadow: 0 1px 4px #0009; - background: #8e8e93; - color: #000; + border: 1px solid rgb(255 255 255 / 46%); + border-radius: 50%; + display: grid; + place-items: center; + box-shadow: 0 1px 4px rgb(0 0 0 / 46%); + background: rgb(174 174 178 / 88%); + color: #111; +} +.app-icon-remove__badge svg { + display: block; + stroke: currentcolor; } .app-icon-item--editing:not(.app-icon-item--dragging) .app-icon-button { animation: app-icon-wobble 0.15s ease-in-out infinite alternate; @@ -3123,11 +3117,13 @@ button { .springboard-edit-done { position: absolute; z-index: 12; - top: 45px; - right: 18px; - min-width: 51px; - min-height: 30px; - padding: 0 13px; + top: 14px; + right: 28px; + display: grid; + width: 58px; + height: 30px; + padding: 0; + place-items: center; border: 1px solid #ffffff38; border-radius: 999px; color: white; @@ -7777,15 +7773,15 @@ button { .springboard-edit-add { position: absolute; z-index: 12; - top: 45px; - left: 18px; + top: 14px; + left: 28px; display: grid; - width: 34px; - height: 34px; + width: 58px; + height: 30px; padding: 0; place-items: center; border: 1px solid #ffffff38; - border-radius: 50%; + border-radius: 999px; color: white; text-shadow: 0 1px 3px #000a; cursor: pointer; diff --git a/frontend/src/components/AppIcon.vue b/frontend/src/components/AppIcon.vue index 3284262..230fb3a 100644 --- a/frontend/src/components/AppIcon.vue +++ b/frontend/src/components/AppIcon.vue @@ -17,8 +17,11 @@ import { type ReorderDirection, } from '@/utils/keyboard' import { + readSpringboardDragMetrics, springboardPageDragCompensation, springboardSwipeIntent, + springboardViewportToLocal, + type SpringboardDragMetrics, } from '@/utils/springboardDrag' import { bindPointerDragSession } from '@/utils/pointerDragSession' @@ -27,13 +30,11 @@ const props = withDefaults( app: PhoneAppDefinition compact?: boolean editMode?: boolean - externalDragVisual?: boolean showLabel?: boolean }>(), { compact: false, editMode: false, - externalDragVisual: false, showLabel: true, }, ) @@ -60,15 +61,16 @@ const calendarToday = ref(new Date()) const dragOffset = ref({ x: 0, y: 0 }) let dragStartPage = 0 let dragPageWidth = 0 +let dragPageMetrics: SpringboardDragMetrics | null = null const dragStyle = computed(() => - isDragging.value && !props.externalDragVisual + isDragging.value ? { transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`, } : undefined, ) const dragPointerStyle = computed(() => - isDragging.value && !props.externalDragVisual + isDragging.value ? { transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`, } @@ -173,9 +175,36 @@ function onPointerDown(event: PointerEvent): void { function onPointerMove(event: PointerEvent): void { if (isDragging.value) { - dragOffset.value = { - x: event.clientX - pointerStart.x, - y: event.clientY - pointerStart.y, + if (dragPageMetrics) { + const pointer = springboardViewportToLocal( + event.clientX, + event.clientY, + dragPageMetrics.viewportLeft, + dragPageMetrics.viewportTop, + dragPageMetrics.viewportWidth, + dragPageMetrics.viewportHeight, + dragPageMetrics.layoutWidth, + dragPageMetrics.layoutHeight, + ) + const start = springboardViewportToLocal( + pointerStart.x, + pointerStart.y, + dragPageMetrics.viewportLeft, + dragPageMetrics.viewportTop, + dragPageMetrics.viewportWidth, + dragPageMetrics.viewportHeight, + dragPageMetrics.layoutWidth, + dragPageMetrics.layoutHeight, + ) + dragOffset.value = { + x: pointer.x - start.x, + y: pointer.y - start.y, + } + } else { + dragOffset.value = { + x: event.clientX - pointerStart.x, + y: event.clientY - pointerStart.y, + } } emit('dragmove', event) return @@ -193,10 +222,15 @@ function onPointerMove(event: PointerEvent): void { function beginPointerDrag(event: PointerEvent): void { dragStartPage = phone.currentPage - dragPageWidth = - (event.target as HTMLElement) - .closest('.springboard-page') - ?.getBoundingClientRect().width ?? 0 + const element = + pointerTarget ?? + (event.currentTarget instanceof Element + ? event.currentTarget + : event.target instanceof Element + ? event.target + : null) + dragPageMetrics = readSpringboardDragMetrics(element) + dragPageWidth = dragPageMetrics?.layoutWidth ?? 0 isDragging.value = true emit('dragstart', event) } @@ -235,6 +269,7 @@ function releasePointerCapture(): void { } pointerTarget = null pointerId = null + dragPageMetrics = null } function onKeydown(event: KeyboardEvent): void { @@ -265,7 +300,6 @@ onBeforeUnmount(() => { class="app-icon-item" :class="{ 'app-icon-item--compact': compact, - 'app-icon-item--drag-source': isDragging && externalDragVisual, 'app-icon-item--dragging': isDragging, 'app-icon-item--editing': editMode, }" @@ -285,7 +319,6 @@ onBeforeUnmount(() => { @contextmenu.prevent @keydown="onKeydown" @pointerdown="onPointerDown" - @pointerleave="isDragging || clearHold()" >