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
@@ -52,6 +52,85 @@ describe('BaseBottomSheet', () => {
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
})
it('emits update:modelValue=false when the handle is dragged down past the threshold', async () => {
const wrapper = mount(BaseBottomSheet, {
props: { modelValue: true, label: 'X' },
attachTo: document.body,
})
await new Promise(r => setTimeout(r, 0))
const handle = document.querySelector('.sheet__handle-target') as HTMLElement
function touchEvent(type: string, y: number) {
const e = new Event(type, { bubbles: true }) as TouchEvent
Object.defineProperty(e, 'touches', { value: [{ clientY: y }] })
Object.defineProperty(e, 'changedTouches', { value: [{ clientY: y }] })
return e
}
handle.dispatchEvent(touchEvent('touchstart', 100))
handle.dispatchEvent(touchEvent('touchmove', 220))
handle.dispatchEvent(touchEvent('touchend', 220))
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
})
it('closes via pointer drag (mouse / desktop) past the threshold', async () => {
const wrapper = mount(BaseBottomSheet, {
props: { modelValue: true, label: 'X' },
attachTo: document.body,
})
await new Promise(r => setTimeout(r, 0))
const handle = document.querySelector('.sheet__handle-target') as HTMLElement
;(handle as any).setPointerCapture = () => {}
function pointerEvent(type: string, y: number, id = 1, pointerType = 'mouse') {
const e = new Event(type, { bubbles: true }) as PointerEvent
Object.defineProperty(e, 'clientY', { value: y })
Object.defineProperty(e, 'pointerId', { value: id })
Object.defineProperty(e, 'pointerType', { value: pointerType })
return e
}
handle.dispatchEvent(pointerEvent('pointerdown', 50))
window.dispatchEvent(pointerEvent('pointermove', 200))
window.dispatchEvent(pointerEvent('pointerup', 200))
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
})
it('ignores pointerdown for touch pointers (touch handlers cover it)', async () => {
const wrapper = mount(BaseBottomSheet, {
props: { modelValue: true, label: 'X' },
attachTo: document.body,
})
await new Promise(r => setTimeout(r, 0))
const handle = document.querySelector('.sheet__handle-target') as HTMLElement
;(handle as any).setPointerCapture = () => {}
function pointerEvent(type: string, y: number) {
const e = new Event(type, { bubbles: true }) as PointerEvent
Object.defineProperty(e, 'clientY', { value: y })
Object.defineProperty(e, 'pointerId', { value: 1 })
Object.defineProperty(e, 'pointerType', { value: 'touch' })
return e
}
handle.dispatchEvent(pointerEvent('pointerdown', 50))
window.dispatchEvent(pointerEvent('pointermove', 200))
window.dispatchEvent(pointerEvent('pointerup', 200))
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('does not close when the handle is dragged less than the dismiss threshold', async () => {
const wrapper = mount(BaseBottomSheet, {
props: { modelValue: true, label: 'X' },
attachTo: document.body,
})
await new Promise(r => setTimeout(r, 0))
const handle = document.querySelector('.sheet__handle-target') as HTMLElement
function touchEvent(type: string, y: number) {
const e = new Event(type, { bubbles: true }) as TouchEvent
Object.defineProperty(e, 'touches', { value: [{ clientY: y }] })
return e
}
handle.dispatchEvent(touchEvent('touchstart', 100))
handle.dispatchEvent(touchEvent('touchmove', 150)) // 50px — below 80 threshold
handle.dispatchEvent(touchEvent('touchend', 150))
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('focuses the sheet when opened and restores focus to the trigger when closed', async () => {
const trigger = document.createElement('button')
document.body.appendChild(trigger)
+21 -10
View File
@@ -19,27 +19,38 @@ describe('FrameCard', () => {
expect(wrapper.text()).toContain('Living Room')
})
it('does not show status badge when status is ok', () => {
it('shows "Online" status when status is ok', () => {
const wrapper = mount(FrameCard, { props: defaultProps })
expect(wrapper.find('.frame-card__status-badge').exists()).toBe(false)
expect(wrapper.find('.frame-card__status-line').text()).toContain('Online')
})
it('shows "Offline" badge when status is offline', () => {
it('shows "Offline" status when status is offline', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, status: 'offline' },
})
const badge = wrapper.find('.frame-card__status-badge')
expect(badge.exists()).toBe(true)
expect(badge.text()).toContain('Offline')
expect(wrapper.find('.frame-card__status-line').text()).toContain('Offline')
})
it('shows "Sync issue" badge when status is sync-fail', () => {
it('shows "Sync issue" status when status is sync-fail', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, status: 'sync-fail' },
})
const badge = wrapper.find('.frame-card__status-badge')
expect(badge.exists()).toBe(true)
expect(badge.text()).toContain('Sync issue')
expect(wrapper.find('.frame-card__status-line').text()).toContain('Sync issue')
})
it('renders lastSync and nextSync lines on the large card', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, lastSync: '2h ago', nextSync: 'next sync in 4h' },
})
const sync = wrapper.find('.frame-card__sync-line')
expect(sync.exists()).toBe(true)
expect(sync.text()).toContain('synced 2h ago')
expect(sync.text()).toContain('next sync in 4h')
})
it('omits the sync line on the large card when no sync info is provided', () => {
const wrapper = mount(FrameCard, { props: defaultProps })
expect(wrapper.find('.frame-card__sync-line').exists()).toBe(false)
})
it('applies offline modifier class when status is offline', () => {
+131 -1
View File
@@ -13,7 +13,7 @@ vi.mock('@/components/FrameCard.vue', () => ({
default: {
name: 'FrameCard',
template: '<div class="frame-card-stub" :data-device-id="deviceId" :data-name="name" />',
props: ['deviceId', 'name', 'size', 'status', 'orientation', 'thumbnailUrl'],
props: ['deviceId', 'name', 'size', 'status', 'orientation', 'thumbnailUrl', 'lastSync', 'nextSync'],
emits: ['add-photo', 'edit'],
},
}))
@@ -315,6 +315,136 @@ describe('HomeView', () => {
expect(wrapper.findComponent({ name: 'FrameCard' }).props('status')).toBe('offline')
})
it('passes status="sync-fail" when one sync window has been missed but not two', async () => {
const devicesStore = useDevicesStore()
// 90 minutes since last seen, interval = 60 — between 1× and 2× → sync-fail
devicesStore.devices = [makeDevice({
id: 1,
rotationIntervalMinutes: 60,
lastSeenAt: new Date(Date.now() - 1000 * 60 * 90).toISOString(),
})]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('status')).toBe('sync-fail')
})
it('uses a 24h window for devices configured with a daily wakeHour', async () => {
const devicesStore = useDevicesStore()
// wakeHour set, last seen 30h ago — between 1×24h and 2×24h → sync-fail
devicesStore.devices = [makeDevice({
id: 1,
wakeHour: 4,
rotationIntervalMinutes: 5, // ignored when wakeHour is set
lastSeenAt: new Date(Date.now() - 1000 * 60 * 60 * 30).toISOString(),
})]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('status')).toBe('sync-fail')
})
it('passes a relative lastSync label and a nextSync label to the FrameCard', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({
id: 1,
rotationIntervalMinutes: 60,
lastSeenAt: new Date(Date.now() - 1000 * 60 * 30).toISOString(), // 30m ago
})]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
const props = wrapper.findComponent({ name: 'FrameCard' }).props()
expect(props.lastSync).toMatch(/m ago/)
expect(props.nextSync).toMatch(/next sync in/)
})
it('passes a wakeHour-based nextSync label when the device wakes daily', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({
id: 1,
wakeHour: 4,
timezone: 'UTC',
lastSeenAt: new Date(Date.now() - 1000 * 60 * 60).toISOString(),
})]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toMatch(/4 AM/)
})
it('formats lastSync as "yesterday" / "N days ago" / "just now"', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({
id: 1,
lastSeenAt: new Date(Date.now() - 1000 * 60 * 60 * 26).toISOString(), // ~26h
})]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
let wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('lastSync')).toBe('yesterday')
devicesStore.devices = [makeDevice({
id: 1,
lastSeenAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 4).toISOString(), // ~4 days
})]
wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('lastSync')).toMatch(/4 days ago/)
devicesStore.devices = [makeDevice({
id: 1,
lastSeenAt: new Date(Date.now() - 5_000).toISOString(), // 5 seconds
})]
wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('lastSync')).toBe('just now')
})
it('omits nextSync when an interval-based device is already past its next expected sync', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({
id: 1,
rotationIntervalMinutes: 60,
lastSeenAt: new Date(Date.now() - 1000 * 60 * 90).toISOString(), // 90m ago, already late
})]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toBeNull()
})
it('formats wakeHour 12 PM, 12 AM, and 8 PM correctly', async () => {
const devicesStore = useDevicesStore()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
devicesStore.devices = [makeDevice({ id: 1, wakeHour: 12, timezone: 'UTC' })]
let wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toMatch(/12 PM/)
devicesStore.devices = [makeDevice({ id: 1, wakeHour: 0, timezone: 'UTC' })]
wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toMatch(/12 AM/)
devicesStore.devices = [makeDevice({ id: 1, wakeHour: 20, timezone: 'UTC' })]
wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toMatch(/8 PM/)
})
it('returns null lastSync when the device has no recorded last-seen time', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 1, lastSeenAt: null })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
const props = wrapper.findComponent({ name: 'FrameCard' }).props()
expect(props.lastSync).toBeNull()
expect(props.nextSync).toBeNull()
})
it('builds a thumbnail URL when the device has a current image', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 1, currentImageId: 42 })]