fix(home): shrink frame card, three-state status, draggable sheet, label overlap
CI / test (push) Has been cancelled

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 18:23:35 -04:00
parent 5fcfb806be
commit 78ff21fb98
20 changed files with 486 additions and 59 deletions
+87 -3
View File
@@ -13,9 +13,21 @@
<div
ref="sheetRef"
class="sheet"
:class="{ 'sheet--dragging': isDragging }"
:style="dragY > 0 ? { transform: `translateY(${dragY}px)` } : undefined"
tabindex="-1"
>
<div class="sheet__handle" aria-hidden="true" />
<div
class="sheet__handle-target"
@touchstart.passive="onDragStart"
@touchmove.passive="onDragMove"
@touchend="onDragEnd"
@touchcancel="onDragEnd"
@pointerdown="onPointerStart"
aria-hidden="true"
>
<div class="sheet__handle" />
</div>
<slot />
</div>
</div>
@@ -36,12 +48,67 @@ const emit = defineEmits<{
}>()
const sheetRef = ref<HTMLElement | null>(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
}
})
</script>
@@ -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);
}
}
+6 -5
View File
@@ -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 {
+95 -24
View File
@@ -3,15 +3,9 @@
:class="[
'frame-card',
`frame-card--${size}`,
status !== 'ok' && `frame-card--${status}`,
`frame-card--${status}`,
]"
>
<!-- Status badge (color + text never color alone) -->
<div v-if="status !== 'ok'" class="frame-card__status-badge" aria-live="polite">
<span class="frame-card__status-dot" aria-hidden="true" />
{{ status === 'offline' ? 'Offline' : 'Sync issue' }}
</div>
<!-- Settings button (large card only) -->
<button
v-if="size === 'large'"
@@ -44,14 +38,25 @@
</div>
<div class="frame-card__body">
<p class="frame-card__name">{{ name }}</p>
<p v-if="size === 'compact'" class="frame-card__count">
{{ photoCount }} {{ photoCount === 1 ? 'photo' : 'photos' }}
</p>
<div class="frame-card__info">
<p class="frame-card__name">{{ name }}</p>
<p class="frame-card__status-line" aria-live="polite">
<span class="frame-card__status-dot" aria-hidden="true" />
<span class="frame-card__status-text">{{ statusText }}</span>
</p>
<p v-if="size === 'large' && (lastSync || nextSync)" class="frame-card__sync-line">
<span v-if="lastSync">synced {{ lastSync }}</span>
<span v-if="lastSync && nextSync" class="frame-card__sync-sep" aria-hidden="true">·</span>
<span v-if="nextSync">{{ nextSync }}</span>
</p>
<p v-else-if="size === 'compact' && photoCount !== undefined" class="frame-card__count">
{{ photoCount }} {{ photoCount === 1 ? 'photo' : 'photos' }}
</p>
</div>
<BaseButton
:variant="size === 'large' ? 'primary' : 'icon-pill'"
:aria-label="size === 'large' ? `Add photo to ${name}` : `Add photo to ${name}`"
:aria-label="`Add photo to ${name}`"
class="frame-card__add-btn"
@click="$emit('add-photo', deviceId)"
>
@@ -74,6 +79,8 @@ const props = defineProps<{
orientation: 'landscape' | 'portrait'
thumbnailUrl?: string
photoCount?: number
lastSync?: string | null
nextSync?: string | null
}>()
defineEmits<{ 'add-photo': [deviceId: number]; edit: [deviceId: number] }>()
@@ -83,30 +90,58 @@ const previewStyle = computed(() =>
? { aspectRatio: props.orientation === 'portrait' ? '3/5' : '5/3' }
: {}
)
const statusText = computed(() => {
switch (props.status) {
case 'ok': return 'Online'
case 'sync-fail': return 'Sync issue'
case 'offline': return 'Offline'
}
})
</script>
<style scoped lang="scss">
.frame-card {
position: relative;
background: var(--color-surface);
border: 2px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
transition: border-color var(--duration-fast);
&--offline { border-color: #c0392b; }
&--ok { border-color: var(--color-border); }
&--sync-fail { border-color: #c49a20; }
&--offline { border-color: #c0392b; }
&__status-badge {
&__settings-btn {
position: absolute;
top: var(--space-2);
right: var(--space-2);
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(4px);
color: var(--color-text);
cursor: pointer;
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
font-weight: 700;
background: var(--color-surface-2);
justify-content: center;
z-index: 1;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
}
.frame-card--offline & { color: #c0392b; }
&__status-line {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--text-sm);
font-weight: 600;
.frame-card--ok & { color: #1a7f4b; }
.frame-card--sync-fail & { color: #8a6a00; }
.frame-card--offline & { color: #c0392b; }
}
&__status-dot {
@@ -115,22 +150,41 @@ const previewStyle = computed(() =>
border-radius: 50%;
flex-shrink: 0;
.frame-card--offline & { background: #c0392b; }
.frame-card--ok & { background: #1a7f4b; }
.frame-card--sync-fail & { background: #c49a20; }
.frame-card--offline & { background: #c0392b; }
}
&__sync-line {
margin-top: 2px;
font-size: var(--text-xs);
color: var(--color-text-muted);
display: flex;
flex-wrap: wrap;
gap: 0 6px;
}
&__sync-sep {
opacity: 0.6;
}
// ── Large (single device) ────────────────────────────────────────────────
// Portrait frames have aspect 3:5 — at full mobile width (~360px) that would
// be 600px tall and totally dominate the screen. Cap so the card stays
// phone-friendly while still showing the photo at the frame's real shape.
&--large &__preview {
background: var(--color-surface-2);
display: flex;
align-items: center;
justify-content: center;
max-height: min(240px, 30dvh);
overflow: hidden;
}
&--large &__img {
width: 100%;
height: 100%;
object-fit: cover;
object-fit: contain;
}
&--large &__empty-preview {
@@ -141,11 +195,19 @@ const previewStyle = computed(() =>
&--large &__body {
padding: var(--space-4);
display: flex;
align-items: center;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
}
&--large &__info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
&--large &__name {
font-size: var(--text-md);
font-weight: 700;
@@ -188,6 +250,15 @@ const previewStyle = computed(() =>
align-items: center;
justify-content: space-between;
gap: var(--space-2);
min-width: 0;
}
&--compact &__info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
&--compact &__name {
@@ -196,7 +267,7 @@ const previewStyle = computed(() =>
}
&--compact &__count {
font-size: var(--text-sm);
font-size: var(--text-xs);
color: var(--color-text-muted);
}