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:
CarterPerez-dev 2026-05-03 07:00:27 -04:00
parent 0ba29226a2
commit 92d8a1aabf
3 changed files with 91 additions and 0 deletions

View File

@ -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;
}

View File

@ -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`
}

View File

@ -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>
)
}