feat(monitor/frontend): F-key presentation mode + localStorage persistence

Press F → hide TopStrip + BottomTicker, drop the panel column borders, and
shrink panel columns from 320 to 280 so the globe gets more room. Esc →
exit. Mode persists to localStorage at STORAGE_KEYS.PRESENTATION_MODE so
refresh maintains it (use case: second-monitor wall).

Implementation: each chrome component (TopStrip, BottomTicker) reads
presentationMode from the ui store and early-returns null. Dashboard
applies a .presentation modifier to .root that re-templates the inner
.grid columns and zeroes the aside borders. CSS Modules-scoped — no
:global escape, no clsx dep.

isTypingInForm guards F so it doesn't fire when typing in inputs / textareas
/ contentEditable. Escape always exits — universal "get me out" semantics.
useUIStore.getState() inside the keydown handler avoids stale closure.

Plan 5 Task 11 Design QA: F hides chrome cleanly without animation flash,
globe area expands, panels stay readable, Esc restores, state persists.
This commit is contained in:
CarterPerez-dev 2026-05-03 07:02:56 -04:00
parent 92d8a1aabf
commit 02ecc1c214
6 changed files with 85 additions and 3 deletions

View File

@ -69,6 +69,7 @@ export const ROUTES = {
export const STORAGE_KEYS = {
AUTH: 'auth-storage',
UI: 'ui-storage',
PRESENTATION_MODE: 'monitor:presentation-mode',
} as const
export const QUERY_CONFIG = {

View File

@ -2,14 +2,17 @@
// BottomTicker.tsx
import { useTicker } from '@/stores/ticker'
import { useUIStore } from '@/stores/ui'
import styles from './BottomTicker.module.scss'
const MS_PER_SECOND = 1000
const SECONDS_PER_MINUTE = 60
const SECONDS_PER_HOUR = 3600
export function BottomTicker(): React.ReactElement {
export function BottomTicker(): React.ReactElement | null {
const items = useTicker((s) => s.items)
const isPresentation = useUIStore((s) => s.presentationMode)
if (isPresentation) return null
return (
<div className={styles.ticker}>
{items.length > 0 && (

View File

@ -41,3 +41,17 @@
overflow-y: auto;
border-left: 1px solid var(--fg-4);
}
.root.presentation {
.grid {
grid-template-columns: 280px 1fr 280px;
}
.left {
border-right: 0;
}
.right {
border-left: 0;
}
}

View File

@ -1,14 +1,23 @@
// ©AngelaMos | 2026
// Dashboard.tsx
import { useUIStore } from '@/stores/ui'
import { AlertBanner } from './AlertBanner'
import { BottomTicker } from './BottomTicker'
import styles from './Dashboard.module.scss'
import { presentationMode } from './presentationMode'
import { TopStrip } from './TopStrip'
export function Dashboard(): React.ReactElement {
presentationMode.useGlobalShortcut()
const isPresentation = useUIStore((s) => s.presentationMode)
const rootClass = isPresentation
? `${styles.root} ${styles.presentation}`
: styles.root
return (
<div className={styles.root}>
<div className={rootClass}>
<TopStrip />
<AlertBanner />
<main className={styles.grid}>

View File

@ -11,10 +11,11 @@ import styles from './TopStrip.module.scss'
const CLOCK_TICK_MS = 1000
export function TopStrip(): React.ReactElement {
export function TopStrip(): React.ReactElement | null {
const [now, setNow] = useState(() => new Date())
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
const openAbout = useUIStore((s) => s.openAbout)
const isPresentation = useUIStore((s) => s.presentationMode)
const navigate = useNavigate()
useEffect(() => {
@ -22,6 +23,8 @@ export function TopStrip(): React.ReactElement {
return () => clearInterval(id)
}, [])
if (isPresentation) return null
return (
<header className={styles.strip}>
<div className={styles.left}>

View File

@ -0,0 +1,52 @@
// ©AngelaMos | 2026
// presentationMode.ts
import { useEffect } from 'react'
import { STORAGE_KEYS } from '@/config'
import { useUIStore } from '@/stores/ui'
function readInitial(): boolean {
return localStorage.getItem(STORAGE_KEYS.PRESENTATION_MODE) === '1'
}
function persist(on: boolean): void {
if (on) {
localStorage.setItem(STORAGE_KEYS.PRESENTATION_MODE, '1')
} else {
localStorage.removeItem(STORAGE_KEYS.PRESENTATION_MODE)
}
}
function isTypingInForm(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false
if (target.isContentEditable) return true
return (
target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement
)
}
export const presentationMode = {
readInitial,
useGlobalShortcut(): void {
const setMode = useUIStore((s) => s.setPresentationMode)
useEffect(() => {
setMode(readInitial())
function onKey(e: KeyboardEvent): void {
if (e.key === 'f' || e.key === 'F') {
if (isTypingInForm(e.target)) return
const next = !useUIStore.getState().presentationMode
setMode(next)
persist(next)
} else if (e.key === 'Escape') {
setMode(false)
persist(false)
}
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [setMode])
},
}