shopfloor dashboard: fit-to-viewport scaling for TV kiosks

The board was a fixed-pixel layout with an internal overflow-y scroll, so on a
TV (no scrolling) content past the fold was unreachable. Wrap it in a fixed
1920-wide surface and scale it to fill the viewport (ResizeObserver + resize),
so the whole board is visible edge-to-edge at any resolution. Drop the
.dashboard-content max-height/overflow scroll.
This commit is contained in:
cproudlock
2026-07-20 14:49:33 -04:00
parent a50e5b0ec1
commit 482d6d4bfe

View File

@@ -1,5 +1,6 @@
<template>
<div class="shopfloor-dashboard">
<div class="dash-fit" ref="fitEl">
<header class="dashboard-header">
<div class="logo-container">
<img :src="siteLogo" alt="Site logo" class="logo" />
@@ -179,11 +180,12 @@
<footer class="dashboard-footer">
Auto-refreshes every 30 seconds
</footer>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { notificationsApi, businessUnitsApi, dashboardDefaultsApi } from '@/api'
import { getFacilityName, getSiteLogo, getServicenowUrls } from '@/utils/siteSettings'
import { withBase } from '@/utils/basePath'
@@ -204,6 +206,20 @@ let refreshInterval = null
let recognitionInterval = null
let recertPageInterval = null
// Fit-to-viewport: the board is a fixed 1920-wide design; scale the whole thing
// to fill the screen so a TV shows everything with no scrolling, at any size.
const fitEl = ref(null)
let resizeObserver = null
function fitToScreen() {
const el = fitEl.value
if (!el) return
const w = el.offsetWidth || 1
const h = el.offsetHeight || 1
const scale = Math.min(window.innerWidth / w, window.innerHeight / h)
el.style.transform = `scale(${scale})`
el.style.left = `${Math.max(0, (window.innerWidth - w * scale) / 2)}px`
}
// The board groups cards by each type's configured display style, so any custom
// type set to carousel/grid/banner renders that way - not just the built-ins.
const SPECIAL_STYLES = ['carousel', 'grid', 'banner']
@@ -318,12 +334,22 @@ onMounted(async () => {
currentRecertPage.value = (currentRecertPage.value + 1) % recertPageCount.value
}
}, 7000)
// Scale to fit once rendered, and re-fit whenever the content or window
// changes size (new data, banner cycling, resolution change).
await nextTick()
fitToScreen()
resizeObserver = new ResizeObserver(() => fitToScreen())
if (fitEl.value) resizeObserver.observe(fitEl.value)
window.addEventListener('resize', fitToScreen)
})
onUnmounted(() => {
if (refreshInterval) clearInterval(refreshInterval)
if (recognitionInterval) clearInterval(recognitionInterval)
if (recertPageInterval) clearInterval(recertPageInterval)
resizeObserver?.disconnect()
window.removeEventListener('resize', fitToScreen)
})
async function loadData() {
@@ -417,13 +443,25 @@ function handlePhotoError(e) {
<style scoped>
.shopfloor-dashboard {
min-height: 100vh;
width: 100vw;
height: 100vh;
position: relative;
background: #00003d;
color: #fff;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
overflow: hidden;
}
/* Fixed 1920-wide design surface; JS scales it to fill the viewport (TV) so
the whole board is visible with no scrolling. */
.dash-fit {
position: absolute;
top: 0;
left: 0;
width: 1920px;
transform-origin: top left;
}
.dashboard-header {
display: grid;
grid-template-columns: auto 1fr auto;
@@ -485,8 +523,6 @@ function handlePhotoError(e) {
.dashboard-content {
padding: 20px 40px;
max-height: calc(100vh - 180px);
overflow-y: auto;
}
.section-title {