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:
parent
92d8a1aabf
commit
02ecc1c214
|
|
@ -69,6 +69,7 @@ export const ROUTES = {
|
||||||
export const STORAGE_KEYS = {
|
export const STORAGE_KEYS = {
|
||||||
AUTH: 'auth-storage',
|
AUTH: 'auth-storage',
|
||||||
UI: 'ui-storage',
|
UI: 'ui-storage',
|
||||||
|
PRESENTATION_MODE: 'monitor:presentation-mode',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export const QUERY_CONFIG = {
|
export const QUERY_CONFIG = {
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,17 @@
|
||||||
// BottomTicker.tsx
|
// BottomTicker.tsx
|
||||||
|
|
||||||
import { useTicker } from '@/stores/ticker'
|
import { useTicker } from '@/stores/ticker'
|
||||||
|
import { useUIStore } from '@/stores/ui'
|
||||||
import styles from './BottomTicker.module.scss'
|
import styles from './BottomTicker.module.scss'
|
||||||
|
|
||||||
const MS_PER_SECOND = 1000
|
const MS_PER_SECOND = 1000
|
||||||
const SECONDS_PER_MINUTE = 60
|
const SECONDS_PER_MINUTE = 60
|
||||||
const SECONDS_PER_HOUR = 3600
|
const SECONDS_PER_HOUR = 3600
|
||||||
|
|
||||||
export function BottomTicker(): React.ReactElement {
|
export function BottomTicker(): React.ReactElement | null {
|
||||||
const items = useTicker((s) => s.items)
|
const items = useTicker((s) => s.items)
|
||||||
|
const isPresentation = useUIStore((s) => s.presentationMode)
|
||||||
|
if (isPresentation) return null
|
||||||
return (
|
return (
|
||||||
<div className={styles.ticker}>
|
<div className={styles.ticker}>
|
||||||
{items.length > 0 && (
|
{items.length > 0 && (
|
||||||
|
|
|
||||||
|
|
@ -41,3 +41,17 @@
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
border-left: 1px solid var(--fg-4);
|
border-left: 1px solid var(--fg-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.root.presentation {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: 280px 1fr 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right {
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,23 @@
|
||||||
// ©AngelaMos | 2026
|
// ©AngelaMos | 2026
|
||||||
// Dashboard.tsx
|
// Dashboard.tsx
|
||||||
|
|
||||||
|
import { useUIStore } from '@/stores/ui'
|
||||||
import { AlertBanner } from './AlertBanner'
|
import { AlertBanner } from './AlertBanner'
|
||||||
import { BottomTicker } from './BottomTicker'
|
import { BottomTicker } from './BottomTicker'
|
||||||
import styles from './Dashboard.module.scss'
|
import styles from './Dashboard.module.scss'
|
||||||
|
import { presentationMode } from './presentationMode'
|
||||||
import { TopStrip } from './TopStrip'
|
import { TopStrip } from './TopStrip'
|
||||||
|
|
||||||
export function Dashboard(): React.ReactElement {
|
export function Dashboard(): React.ReactElement {
|
||||||
|
presentationMode.useGlobalShortcut()
|
||||||
|
const isPresentation = useUIStore((s) => s.presentationMode)
|
||||||
|
|
||||||
|
const rootClass = isPresentation
|
||||||
|
? `${styles.root} ${styles.presentation}`
|
||||||
|
: styles.root
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={rootClass}>
|
||||||
<TopStrip />
|
<TopStrip />
|
||||||
<AlertBanner />
|
<AlertBanner />
|
||||||
<main className={styles.grid}>
|
<main className={styles.grid}>
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,11 @@ import styles from './TopStrip.module.scss'
|
||||||
|
|
||||||
const CLOCK_TICK_MS = 1000
|
const CLOCK_TICK_MS = 1000
|
||||||
|
|
||||||
export function TopStrip(): React.ReactElement {
|
export function TopStrip(): React.ReactElement | null {
|
||||||
const [now, setNow] = useState(() => new Date())
|
const [now, setNow] = useState(() => new Date())
|
||||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
||||||
const openAbout = useUIStore((s) => s.openAbout)
|
const openAbout = useUIStore((s) => s.openAbout)
|
||||||
|
const isPresentation = useUIStore((s) => s.presentationMode)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -22,6 +23,8 @@ export function TopStrip(): React.ReactElement {
|
||||||
return () => clearInterval(id)
|
return () => clearInterval(id)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
if (isPresentation) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className={styles.strip}>
|
<header className={styles.strip}>
|
||||||
<div className={styles.left}>
|
<div className={styles.left}>
|
||||||
|
|
|
||||||
|
|
@ -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])
|
||||||
|
},
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue