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 = {
|
||||
AUTH: 'auth-storage',
|
||||
UI: 'ui-storage',
|
||||
PRESENTATION_MODE: 'monitor:presentation-mode',
|
||||
} as const
|
||||
|
||||
export const QUERY_CONFIG = {
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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