feat(monitor/frontend): bottom ticker (world events marquee, 60s loop)
32px strip in the bottomticker grid-area that renders the ticker Zustand store as a horizontally scrolling marquee — one of the few places motion is allowed because new entries pushing older ones along IS data changing. Source label uppercase + letter-spaced --fg-3, headline body --fg-2, relative timestamp mono tabular --fg-3. Empty state is a bare strip (no "no events" message, no illustration) — operator UI doesn't onboard. The track only renders when items.length > 0 so the marquee animation isn't running over an empty list. formatRel uses module-scope SECONDS_PER_MINUTE / SECONDS_PER_HOUR / MS_PER_SECOND constants (no inline magic numbers). Animation uses kebab keyframe name (stylelint keyframes-name-pattern).
This commit is contained in:
parent
0ba29226a2
commit
92d8a1aabf
|
|
@ -0,0 +1,48 @@
|
|||
// ©AngelaMos | 2026
|
||||
// BottomTicker.module.scss
|
||||
|
||||
.ticker {
|
||||
grid-area: bottomticker;
|
||||
height: 32px;
|
||||
border-top: 1px solid var(--fg-4);
|
||||
background: var(--bg);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.track {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
gap: 24px;
|
||||
padding-left: 100%;
|
||||
white-space: nowrap;
|
||||
animation: ticker-scroll 60s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ticker-scroll {
|
||||
to {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
}
|
||||
|
||||
.item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: var(--type-body);
|
||||
color: var(--fg-2);
|
||||
}
|
||||
|
||||
.source {
|
||||
font-size: var(--type-label);
|
||||
letter-spacing: var(--letter-spacing-label);
|
||||
color: var(--fg-3);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ts {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--fg-3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
// ©AngelaMos | 2026
|
||||
// BottomTicker.tsx
|
||||
|
||||
import { useTicker } from '@/stores/ticker'
|
||||
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 {
|
||||
const items = useTicker((s) => s.items)
|
||||
return (
|
||||
<div className={styles.ticker}>
|
||||
{items.length > 0 && (
|
||||
<div className={styles.track}>
|
||||
{items.map((item) => (
|
||||
<span key={item.id} className={styles.item}>
|
||||
<span className={styles.source}>{item.source}</span>
|
||||
<span>{item.headline}</span>
|
||||
<span className={styles.ts}>{formatRel(item.ts)}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
BottomTicker.displayName = 'BottomTicker'
|
||||
|
||||
function formatRel(ts: number): string {
|
||||
const diff = (Date.now() - ts) / MS_PER_SECOND
|
||||
if (diff < SECONDS_PER_MINUTE) {
|
||||
return `${Math.floor(diff)}s ago`
|
||||
}
|
||||
if (diff < SECONDS_PER_HOUR) {
|
||||
return `${Math.floor(diff / SECONDS_PER_MINUTE)}m ago`
|
||||
}
|
||||
return `${Math.floor(diff / SECONDS_PER_HOUR)}h ago`
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
// Dashboard.tsx
|
||||
|
||||
import { AlertBanner } from './AlertBanner'
|
||||
import { BottomTicker } from './BottomTicker'
|
||||
import styles from './Dashboard.module.scss'
|
||||
import { TopStrip } from './TopStrip'
|
||||
|
||||
|
|
@ -15,6 +16,7 @@ export function Dashboard(): React.ReactElement {
|
|||
<section className={styles.center} />
|
||||
<aside className={styles.right} />
|
||||
</main>
|
||||
<BottomTicker />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue