From 78ff21fb98628fc74da02099c013676617a7b494 Mon Sep 17 00:00:00 2001 From: Matt Edholm Date: Wed, 6 May 2026 18:23:35 -0400 Subject: [PATCH] fix(home): shrink frame card, three-state status, draggable sheet, label overlap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HomeView clears the bottom nav so + Add Photo isn't covered. - Cap large frame-card preview to min(240px, 30dvh) so portrait frames no longer dominate the screen at full mobile width. - Three-state device status — green/Online (recent sync), yellow/Sync issue (one window missed), red/Offline (two+ windows missed). Window is rotationIntervalMinutes for interval-mode devices, 24h for daily wakeHour-mode devices. - Show last-sync ("synced 2h ago") and next-expected-sync line on the large card. wakeHour devices show local-hour ("next sync ~4 AM tomorrow") in the device's configured timezone. - BaseBottomSheet drag-to-dismiss on the handle. Touch and pointer events; releases past 80px close the sheet. Snaps back below. - BaseInput floating label rewrite — taller field, label re-anchors to top: 8px when filled/focused so it sits cleanly above the value instead of overlapping it. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/components/BaseBottomSheet.vue | 90 +++++++++++- frontend/src/components/BaseInput.vue | 11 +- frontend/src/components/FrameCard.vue | 119 ++++++++++++---- .../test/components/BaseBottomSheet.test.ts | 79 +++++++++++ .../src/test/components/FrameCard.test.ts | 31 ++-- frontend/src/test/views/HomeView.test.ts | 132 +++++++++++++++++- frontend/src/views/HomeView.vue | 61 +++++++- ...uubxf.css => BaseBottomSheet-9_gNUOjo.css} | 2 +- .../build/assets/BaseBottomSheet-CO3Iefke.js | 1 - .../build/assets/BaseBottomSheet-CaSppT-T.js | 1 + ...r-CfQ8y-l0.js => DevicePicker-Ds3dUuHV.js} | 2 +- public/build/assets/HomeView--2ilxFCK.css | 1 + public/build/assets/HomeView-3fATGLq0.js | 1 - public/build/assets/HomeView-B0-v2mFc.css | 1 - public/build/assets/HomeView-CmHDPCOU.js | 1 + ...ew-BvFubSso.js => LibraryView-BeFjTKGe.js} | 2 +- ...w-CYdyGgyj.js => SettingsView-CpFtvVz9.js} | 2 +- ...iew-lwth3Nb9.js => UploadView-Cy3DCB70.js} | 2 +- .../{index-D13oAsTG.js => index-Dtb3F_Km.js} | 4 +- public/build/index.html | 2 +- 20 files changed, 486 insertions(+), 59 deletions(-) rename public/build/assets/{BaseBottomSheet-DYtuubxf.css => BaseBottomSheet-9_gNUOjo.css} (53%) delete mode 100644 public/build/assets/BaseBottomSheet-CO3Iefke.js create mode 100644 public/build/assets/BaseBottomSheet-CaSppT-T.js rename public/build/assets/{DevicePicker-CfQ8y-l0.js => DevicePicker-Ds3dUuHV.js} (96%) create mode 100644 public/build/assets/HomeView--2ilxFCK.css delete mode 100644 public/build/assets/HomeView-3fATGLq0.js delete mode 100644 public/build/assets/HomeView-B0-v2mFc.css create mode 100644 public/build/assets/HomeView-CmHDPCOU.js rename public/build/assets/{LibraryView-BvFubSso.js => LibraryView-BeFjTKGe.js} (98%) rename public/build/assets/{SettingsView-CYdyGgyj.js => SettingsView-CpFtvVz9.js} (92%) rename public/build/assets/{UploadView-lwth3Nb9.js => UploadView-Cy3DCB70.js} (98%) rename public/build/assets/{index-D13oAsTG.js => index-Dtb3F_Km.js} (99%) diff --git a/frontend/src/components/BaseBottomSheet.vue b/frontend/src/components/BaseBottomSheet.vue index 0075bba..f1ce6b1 100644 --- a/frontend/src/components/BaseBottomSheet.vue +++ b/frontend/src/components/BaseBottomSheet.vue @@ -13,9 +13,21 @@
- @@ -36,12 +48,67 @@ const emit = defineEmits<{ }>() const sheetRef = ref(null) +const dragY = ref(0) +const isDragging = ref(false) +let dragStartY = 0 +let pointerId: number | null = null let triggerEl: HTMLElement | null = null +const DISMISS_THRESHOLD = 80 + function close() { emit('update:modelValue', false) } +function onDragStart(e: TouchEvent) { + dragStartY = e.touches[0].clientY + isDragging.value = true + dragY.value = 0 +} + +function onDragMove(e: TouchEvent) { + if (!isDragging.value) return + const delta = e.touches[0].clientY - dragStartY + dragY.value = delta > 0 ? delta : 0 +} + +function onDragEnd() { + if (!isDragging.value) return + isDragging.value = false + if (dragY.value > DISMISS_THRESHOLD) { + close() + } + dragY.value = 0 +} + +// Pointer events let desktop / non-touch testing exercise the drag too. +function onPointerStart(e: PointerEvent) { + if (e.pointerType === 'touch') return // touch events handle this + dragStartY = e.clientY + isDragging.value = true + dragY.value = 0 + pointerId = e.pointerId + ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId) + window.addEventListener('pointermove', onPointerMove) + window.addEventListener('pointerup', onPointerEnd) + window.addEventListener('pointercancel', onPointerEnd) +} + +function onPointerMove(e: PointerEvent) { + if (!isDragging.value || e.pointerId !== pointerId) return + const delta = e.clientY - dragStartY + dragY.value = delta > 0 ? delta : 0 +} + +function onPointerEnd(e: PointerEvent) { + if (e.pointerId !== pointerId) return + pointerId = null + window.removeEventListener('pointermove', onPointerMove) + window.removeEventListener('pointerup', onPointerEnd) + window.removeEventListener('pointercancel', onPointerEnd) + onDragEnd() +} + watch(() => props.modelValue, async (open) => { if (open) { triggerEl = document.activeElement as HTMLElement @@ -50,6 +117,8 @@ watch(() => props.modelValue, async (open) => { } else { triggerEl?.focus() triggerEl = null + dragY.value = 0 + isDragging.value = false } }) @@ -68,17 +137,32 @@ watch(() => props.modelValue, async (open) => { width: 100%; background: var(--color-surface); border-radius: var(--radius-lg) var(--radius-lg) 0 0; - padding: var(--space-3) var(--space-4) calc(var(--space-6) + env(safe-area-inset-bottom)); + padding: 0 var(--space-4) calc(var(--space-6) + env(safe-area-inset-bottom)); max-height: 90dvh; overflow-y: auto; outline: none; + transition: transform 200ms var(--ease-out); + + &--dragging { + transition: none; + } + + &__handle-target { + padding: var(--space-3) 0 var(--space-4); + margin: 0 calc(-1 * var(--space-4)); + display: flex; + justify-content: center; + cursor: grab; + touch-action: none; + + &:active { cursor: grabbing; } + } &__handle { width: 36px; height: 4px; border-radius: var(--radius-full); background: var(--color-border); - margin: 0 auto var(--space-4); } } diff --git a/frontend/src/components/BaseInput.vue b/frontend/src/components/BaseInput.vue index a01125b..05718d1 100644 --- a/frontend/src/components/BaseInput.vue +++ b/frontend/src/components/BaseInput.vue @@ -46,8 +46,8 @@ const inputId = computed(() => props.id ?? `input-${Math.random().toString(36).s &__field { width: 100%; - min-height: var(--touch-min); - padding: var(--space-4) var(--space-4) var(--space-2); + min-height: 56px; + padding: 22px var(--space-4) 8px; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-surface); @@ -67,8 +67,10 @@ const inputId = computed(() => props.id ?? `input-${Math.random().toString(36).s &:not(:placeholder-shown) ~ .input-wrap__label, &:focus ~ .input-wrap__label { - transform: translateY(-10px) scale(0.78); + top: 8px; + font-size: var(--text-xs); color: var(--color-primary); + transform: none; } } @@ -80,8 +82,7 @@ const inputId = computed(() => props.id ?? `input-${Math.random().toString(36).s color: var(--color-text-muted); font-size: var(--text-base); pointer-events: none; - transform-origin: left center; - transition: transform var(--duration-fast), color var(--duration-fast); + transition: top var(--duration-fast), font-size var(--duration-fast), color var(--duration-fast), transform var(--duration-fast); } &--error &__field { diff --git a/frontend/src/components/FrameCard.vue b/frontend/src/components/FrameCard.vue index a620f7a..38ea644 100644 --- a/frontend/src/components/FrameCard.vue +++ b/frontend/src/components/FrameCard.vue @@ -3,15 +3,9 @@ :class="[ 'frame-card', `frame-card--${size}`, - status !== 'ok' && `frame-card--${status}`, + `frame-card--${status}`, ]" > - -
-
-