mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
better alertfeed!
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -78,8 +78,8 @@
|
|||||||
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
|
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
|
||||||
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
||||||
<title>Roomba Rover</title>
|
<title>Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-CsI4p8Hs.js"></script>
|
<script type="module" crossorigin src="/assets/index-B0U7t0up.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-B_VrMPCY.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CdgtactY.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
1. fix rover request spam queue cheat
|
1. add camera filter indicator for when youre changing it
|
||||||
2. fix this:
|
2. make alert feed better with fadeout and stuff and it stays when you hover over one
|
||||||
|
3. fix rover request spam queue cheat
|
||||||
|
4. fix this:
|
||||||
`Jun 18 15:14:18 roombaserver.local node[216731]: /home/daniel/MultiRoombaRover/server/src/services/roverManager/socketHandlers.js:92
|
`Jun 18 15:14:18 roombaserver.local node[216731]: /home/daniel/MultiRoombaRover/server/src/services/roverManager/socketHandlers.js:92
|
||||||
Jun 18 15:14:18 roombaserver.local node[216731]: cb({ error: err.message });
|
Jun 18 15:14:18 roombaserver.local node[216731]: cb({ error: err.message });
|
||||||
Jun 18 15:14:18 roombaserver.local node[216731]: ^
|
Jun 18 15:14:18 roombaserver.local node[216731]: ^
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Alert Feed
|
// Alert Feed
|
||||||
// Purpose: Defines the Alert Feed module and the local helpers/components used in this file.
|
// Purpose: Defines the Alert Feed module and the local helpers/components used in this file.
|
||||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
import { useSocket } from '../../context/SocketContext.jsx';
|
import { useSocket } from '../../context/SocketContext.jsx';
|
||||||
import ChatMessageRow from '../ChatMessageRow/index.jsx';
|
import ChatMessageRow from '../ChatMessageRow/index.jsx';
|
||||||
@@ -10,6 +10,8 @@ import ButtonBoxTile from '../ButtonBoxTile/index.jsx';
|
|||||||
const LIFETIME_MS = 3000;
|
const LIFETIME_MS = 3000;
|
||||||
const BUTTONBOX_LIFETIME_MS = 5000;
|
const BUTTONBOX_LIFETIME_MS = 5000;
|
||||||
const BARCODE_SCAN_LIFETIME_MS = 10 * 1000;
|
const BARCODE_SCAN_LIFETIME_MS = 10 * 1000;
|
||||||
|
const MAX_VISIBLE_TOASTS = 6;
|
||||||
|
const RECENT_ALERT_LOOKBACK = 12;
|
||||||
const DEFAULT_COLOR = '#2196f3';
|
const DEFAULT_COLOR = '#2196f3';
|
||||||
|
|
||||||
function buildKey(alert) {
|
function buildKey(alert) {
|
||||||
@@ -18,12 +20,105 @@ function buildKey(alert) {
|
|||||||
return `${alert.title || 'alert'}-${alert.message}`;
|
return `${alert.title || 'alert'}-${alert.message}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAlertLifetime(alert) {
|
||||||
|
return Number.isFinite(alert?.lifetimeMs) ? alert.lifetimeMs : LIFETIME_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAlertToken(alert, key) {
|
||||||
|
/*
|
||||||
|
A keyed alert such as a barcode scan intentionally reuses the same id so the
|
||||||
|
visual slot can be refreshed in place. The token must therefore include the
|
||||||
|
arrival timestamp too; otherwise a new scan with the same id would look like
|
||||||
|
the same toast and would not get a fresh timer.
|
||||||
|
*/
|
||||||
|
const receivedAt = alert?.receivedAt ?? '';
|
||||||
|
const timestamp = alert?.timestamp ?? '';
|
||||||
|
const title = alert?.title ?? '';
|
||||||
|
const message = alert?.message ?? '';
|
||||||
|
return `${key}:${receivedAt}:${timestamp}:${title}:${message}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildToastRecord(item) {
|
||||||
|
const lifetimeMs = getAlertLifetime(item.alert);
|
||||||
|
const expiresAt = Date.now() + lifetimeMs;
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: item.key,
|
||||||
|
alert: item.alert,
|
||||||
|
expiresAt,
|
||||||
|
remainingMs: lifetimeMs,
|
||||||
|
paused: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToastRgb(alert) {
|
||||||
|
return hexToRgb(alert?.color) || hexToRgb(DEFAULT_COLOR);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToastAccent(alert) {
|
||||||
|
const rgb = getToastRgb(alert);
|
||||||
|
return rgb ? `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})` : DEFAULT_COLOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGenericAlert(alert) {
|
||||||
|
/*
|
||||||
|
The shell needs to know which alerts use the fallback title/message renderer
|
||||||
|
because those alerts historically carried their own color-tinted surface.
|
||||||
|
Matching the same payload guards used by AlertToast keeps the shell styling
|
||||||
|
aligned with the renderer that will actually be selected below.
|
||||||
|
*/
|
||||||
|
if (alert?.kind === 'buttonbox-active' && alert.payload) return false;
|
||||||
|
if (alert?.kind === 'chat' && alert.payload) return false;
|
||||||
|
if (alert?.kind === 'chat-typing' && alert.payload) return false;
|
||||||
|
if (alert?.kind === 'barcode-scan' && alert.payload) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export default function AlertFeed({ scale = 1 }) {
|
export default function AlertFeed({ scale = 1 }) {
|
||||||
const alerts = useSessionSelector((state) => state.alerts);
|
const alerts = useSessionSelector((state) => state.alerts);
|
||||||
const buttonBoxButtons = useSessionSelector((state) => state.session?.buttonBox?.buttons ?? []);
|
const buttonBoxButtons = useSessionSelector((state) => state.session?.buttonBox?.buttons ?? []);
|
||||||
const { pushAlert } = useSessionActions();
|
const { pushAlert } = useSessionActions();
|
||||||
const socket = useSocket();
|
const socket = useSocket();
|
||||||
const [now, setNow] = useState(() => Date.now());
|
const [toasts, setToasts] = useState([]);
|
||||||
|
const latestTokensRef = useRef(new Set());
|
||||||
|
const timersRef = useRef(new Map());
|
||||||
|
|
||||||
|
const clearToastTimer = useCallback((key) => {
|
||||||
|
const timer = timersRef.current.get(key);
|
||||||
|
if (!timer) return;
|
||||||
|
clearTimeout(timer);
|
||||||
|
timersRef.current.delete(key);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeToast = useCallback(
|
||||||
|
(key) => {
|
||||||
|
clearToastTimer(key);
|
||||||
|
setToasts((currentToasts) => currentToasts.filter((toast) => toast.key !== key));
|
||||||
|
},
|
||||||
|
[clearToastTimer],
|
||||||
|
);
|
||||||
|
|
||||||
|
const scheduleToastRemoval = useCallback(
|
||||||
|
(key, delayMs) => {
|
||||||
|
clearToastTimer(key);
|
||||||
|
/*
|
||||||
|
Each toast owns exactly one removal timeout. This is cheaper and simpler
|
||||||
|
than polling all visible alerts for progress/fade state, and it still
|
||||||
|
lets hover/focus pause by clearing the one timer for the interacted row.
|
||||||
|
*/
|
||||||
|
const timer = setTimeout(() => removeToast(key), Math.max(0, delayMs));
|
||||||
|
timersRef.current.set(key, timer);
|
||||||
|
},
|
||||||
|
[clearToastTimer, removeToast],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
timersRef.current.forEach((timer) => clearTimeout(timer));
|
||||||
|
timersRef.current.clear();
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onButtonIncrement(payload = {}) {
|
function onButtonIncrement(payload = {}) {
|
||||||
@@ -79,48 +174,115 @@ export default function AlertFeed({ scale = 1 }) {
|
|||||||
};
|
};
|
||||||
}, [pushAlert, socket]);
|
}, [pushAlert, socket]);
|
||||||
|
|
||||||
const latest = useMemo(() => alerts.slice(-12).map((alert) => ({ alert, key: buildKey(alert) })), [alerts]);
|
const latest = useMemo(
|
||||||
|
() =>
|
||||||
const { visible, nextExpiryAt } = useMemo(() => {
|
alerts.slice(-RECENT_ALERT_LOOKBACK).map((alert) => {
|
||||||
const visibleByKey = new Map();
|
const key = buildKey(alert);
|
||||||
let soonestExpiryAt = null;
|
|
||||||
|
|
||||||
latest.forEach((item) => {
|
|
||||||
const receivedAt = item.alert.receivedAt ?? item.alert.timestamp ?? 0;
|
|
||||||
const age = now - receivedAt;
|
|
||||||
const lifetimeMs = Number.isFinite(item.alert.lifetimeMs) ? item.alert.lifetimeMs : LIFETIME_MS;
|
|
||||||
const expiryAt = receivedAt + lifetimeMs;
|
|
||||||
|
|
||||||
if (age <= lifetimeMs) {
|
|
||||||
visibleByKey.set(item.key, { ...item, age });
|
|
||||||
/*
|
|
||||||
Alerts do not render a live progress value, so polling every 200ms is
|
|
||||||
unnecessary. Tracking the earliest expiry lets this component sleep
|
|
||||||
until one visible toast actually needs to disappear.
|
|
||||||
*/
|
|
||||||
soonestExpiryAt = soonestExpiryAt == null ? expiryAt : Math.min(soonestExpiryAt, expiryAt);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
visible: Array.from(visibleByKey.values()).slice(-6),
|
alert,
|
||||||
nextExpiryAt: soonestExpiryAt,
|
key,
|
||||||
|
token: buildAlertToken(alert, key),
|
||||||
};
|
};
|
||||||
}, [latest, now]);
|
}),
|
||||||
|
[alerts],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (nextExpiryAt == null) return undefined;
|
|
||||||
/*
|
/*
|
||||||
The small padding avoids waking a few milliseconds before the browser's
|
The processed-token set is captured before scheduling the state update
|
||||||
current Date.now value crosses the expiry boundary. Without it, React can
|
because React may run the functional updater after this effect continues.
|
||||||
render the same alert once more and schedule a second near-immediate timer.
|
Reading latestTokensRef.current inside the updater would race with the ref
|
||||||
|
assignment below and could make brand-new alerts look already processed,
|
||||||
|
leaving the local toast list empty.
|
||||||
*/
|
*/
|
||||||
const delayMs = Math.max(0, nextExpiryAt - Date.now()) + 25;
|
const previousTokens = latestTokensRef.current;
|
||||||
const timer = setTimeout(() => setNow(Date.now()), delayMs);
|
const newItems = latest.filter((item) => !previousTokens.has(item.token));
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [nextExpiryAt]);
|
|
||||||
|
|
||||||
if (!visible.length) return null;
|
if (newItems.length) {
|
||||||
|
setToasts((currentToasts) => {
|
||||||
|
const nextByKey = new Map(currentToasts.map((toast) => [toast.key, toast]));
|
||||||
|
|
||||||
|
newItems.forEach((item) => {
|
||||||
|
/*
|
||||||
|
Setting an existing Map key updates its value but keeps its original
|
||||||
|
insertion slot. Deleting first makes refreshed keyed alerts behave
|
||||||
|
like new notifications visually, which is what operators expect when
|
||||||
|
the barcode lane or a button-box lane receives a fresh event.
|
||||||
|
*/
|
||||||
|
nextByKey.delete(item.key);
|
||||||
|
nextByKey.set(item.key, buildToastRecord(item));
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
The newest records are the only ones that should consume feed slots.
|
||||||
|
Older active records are dropped immediately so a burst of alerts
|
||||||
|
cannot cover the rest of the rover interface.
|
||||||
|
*/
|
||||||
|
const nextToasts = Array.from(nextByKey.values());
|
||||||
|
const activeKeys = nextToasts.slice(-MAX_VISIBLE_TOASTS).map((toast) => toast.key);
|
||||||
|
const activeKeySet = new Set(activeKeys);
|
||||||
|
|
||||||
|
return nextToasts.filter((toast) => activeKeySet.has(toast.key));
|
||||||
|
});
|
||||||
|
|
||||||
|
newItems.forEach((item) => {
|
||||||
|
scheduleToastRemoval(item.key, getAlertLifetime(item.alert));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
latestTokensRef.current = new Set(latest.map((item) => item.token));
|
||||||
|
}, [latest, scheduleToastRemoval]);
|
||||||
|
|
||||||
|
const pauseToast = useCallback((key) => {
|
||||||
|
const toast = toasts.find((entry) => entry.key === key);
|
||||||
|
if (!toast || toast.paused) return;
|
||||||
|
const remainingMs = Math.max(0, toast.expiresAt - Date.now());
|
||||||
|
|
||||||
|
clearToastTimer(key);
|
||||||
|
setToasts((currentToasts) =>
|
||||||
|
currentToasts.map((toast) => {
|
||||||
|
if (toast.key !== key || toast.paused) return toast;
|
||||||
|
|
||||||
|
/*
|
||||||
|
Remaining time is captured at the moment interaction begins, not at the
|
||||||
|
previous render. That makes hover/focus pauses exact enough that a
|
||||||
|
toast cannot expire while the user is moving toward a link or dismiss
|
||||||
|
button inside it.
|
||||||
|
*/
|
||||||
|
return {
|
||||||
|
...toast,
|
||||||
|
paused: true,
|
||||||
|
remainingMs,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [clearToastTimer, toasts]);
|
||||||
|
|
||||||
|
const resumeToast = useCallback((key) => {
|
||||||
|
const toast = toasts.find((entry) => entry.key === key);
|
||||||
|
if (!toast || !toast.paused) return;
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const remainingMs = Math.max(0, toast.remainingMs);
|
||||||
|
|
||||||
|
setToasts((currentToasts) =>
|
||||||
|
currentToasts.map((toast) => {
|
||||||
|
if (toast.key !== key || !toast.paused) return toast;
|
||||||
|
return {
|
||||||
|
...toast,
|
||||||
|
paused: false,
|
||||||
|
expiresAt: startedAt + remainingMs,
|
||||||
|
remainingMs,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
scheduleToastRemoval(key, remainingMs);
|
||||||
|
}, [scheduleToastRemoval, toasts]);
|
||||||
|
|
||||||
|
const dismissToast = useCallback((key) => {
|
||||||
|
removeToast(key);
|
||||||
|
}, [removeToast]);
|
||||||
|
|
||||||
|
if (!toasts.length) return null;
|
||||||
|
|
||||||
const containerStyle =
|
const containerStyle =
|
||||||
scale === 1
|
scale === 1
|
||||||
@@ -135,9 +297,15 @@ export default function AlertFeed({ scale = 1 }) {
|
|||||||
: 'pointer-events-none fixed top-0.5 left-1/2 z-50 flex flex-col gap-0.5';
|
: 'pointer-events-none fixed top-0.5 left-1/2 z-50 flex flex-col gap-0.5';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={containerClass} style={containerStyle}>
|
<div className={containerClass} style={containerStyle} aria-live="polite">
|
||||||
{visible.map((toast) => (
|
{toasts.map((toast) => (
|
||||||
<AlertToast key={toast.key} alert={toast.alert} />
|
<AlertToastShell
|
||||||
|
key={toast.key}
|
||||||
|
toast={toast}
|
||||||
|
onDismiss={dismissToast}
|
||||||
|
onPause={pauseToast}
|
||||||
|
onResume={resumeToast}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -174,6 +342,72 @@ function getSafeWikiUrl(rawUrl) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AlertToastShell({ toast, onDismiss, onPause, onResume }) {
|
||||||
|
const accent = getToastAccent(toast.alert);
|
||||||
|
const generic = isGenericAlert(toast.alert);
|
||||||
|
const rgb = getToastRgb(toast.alert);
|
||||||
|
const genericBackgroundColor = rgb ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.18)` : 'rgba(33, 150, 243, 0.18)';
|
||||||
|
const genericBorderColor = rgb ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.45)` : 'rgba(33, 150, 243, 0.45)';
|
||||||
|
const shellClass = [
|
||||||
|
'pointer-events-auto w-fit max-w-[80vw] overflow-hidden rounded-md shadow-sm shadow-black/40',
|
||||||
|
generic ? '' : 'border border-white/10 bg-black/75',
|
||||||
|
].join(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={shellClass}
|
||||||
|
onMouseEnter={() => onPause(toast.key)}
|
||||||
|
onMouseLeave={() => onResume(toast.key)}
|
||||||
|
onFocusCapture={() => onPause(toast.key)}
|
||||||
|
onBlurCapture={() => onResume(toast.key)}
|
||||||
|
style={
|
||||||
|
generic
|
||||||
|
? {
|
||||||
|
/*
|
||||||
|
Generic alerts keep the same color math they used before the shared
|
||||||
|
shell was introduced. Putting it on the shell restores the original
|
||||||
|
brightness while still leaving room for the side dismiss column.
|
||||||
|
*/
|
||||||
|
backgroundColor: genericBackgroundColor,
|
||||||
|
border: `1px solid ${genericBorderColor}`,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-stretch">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<AlertToast alert={toast.alert} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'flex w-5 shrink-0 flex-col items-center justify-start gap-0.5 border-l py-0.5',
|
||||||
|
generic ? '' : 'border-white/10 bg-black/20',
|
||||||
|
].join(' ')}
|
||||||
|
style={
|
||||||
|
generic
|
||||||
|
? {
|
||||||
|
borderLeftColor: genericBorderColor,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.08)',
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-4 w-4 items-center justify-center rounded-sm text-xs leading-none text-slate-300 hover:bg-white/10 hover:text-white focus:bg-white/10 focus:text-white focus:outline-none focus:ring-1"
|
||||||
|
style={{ '--tw-ring-color': accent }}
|
||||||
|
onClick={() => onDismiss(toast.key)}
|
||||||
|
title="Dismiss alert"
|
||||||
|
aria-label="Dismiss alert"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function BarcodeScanToast({ payload }) {
|
function BarcodeScanToast({ payload }) {
|
||||||
const wikiUrl = getSafeWikiUrl(payload?.wikiUrl);
|
const wikiUrl = getSafeWikiUrl(payload?.wikiUrl);
|
||||||
const label = payload?.label || payload?.code || 'unknown barcode';
|
const label = payload?.label || payload?.code || 'unknown barcode';
|
||||||
@@ -238,16 +472,19 @@ function AlertToast({ alert }) {
|
|||||||
if (alert.kind === 'barcode-scan' && alert.payload) {
|
if (alert.kind === 'barcode-scan' && alert.payload) {
|
||||||
return <BarcodeScanToast payload={alert.payload} />;
|
return <BarcodeScanToast payload={alert.payload} />;
|
||||||
}
|
}
|
||||||
const rgb = hexToRgb(alert.color) || hexToRgb(DEFAULT_COLOR);
|
|
||||||
const backgroundColor = rgb ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.18)` : 'rgba(33, 150, 243, 0.18)';
|
|
||||||
const borderColor = rgb ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.45)` : 'rgba(33, 150, 243, 0.45)';
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="pointer-events-auto max-w-[80vw] rounded-full border px-2 py-0.5 text-[0.7rem] leading-tight text-slate-100 shadow-sm shadow-black/30"
|
/*
|
||||||
style={{ backgroundColor, border: `1px solid ${borderColor}` }}
|
Generic alerts are the payloads that directly provide title/message/color
|
||||||
|
instead of a richer custom renderer. The surrounding shell owns their
|
||||||
|
color-tinted background so the dismiss column and content share one
|
||||||
|
continuous surface while this renderer stays responsible for one line of
|
||||||
|
compact title/message text.
|
||||||
|
*/
|
||||||
|
className="pointer-events-auto min-w-0 max-w-[24rem] px-1.5 py-0.5 text-left text-[0.72rem] leading-tight text-slate-100"
|
||||||
>
|
>
|
||||||
<p className="truncate text-slate-100">
|
<p className="truncate text-slate-100">
|
||||||
<span className="text-slate-300">{alert.title || 'Alert'}</span>
|
<span className="font-semibold text-slate-300">{alert.title || 'Alert'}</span>
|
||||||
<span className="text-slate-500"> · </span>
|
<span className="text-slate-500"> · </span>
|
||||||
<span className="text-white">{alert.message}</span>
|
<span className="text-white">{alert.message}</span>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user