+
+
+ A
+ setFontSize(value)}
+ min={MIN_FONT_SIZE}
+ max={MAX_FONT_SIZE}
+ step={1}
+ className="w-24"
+ aria-label="Message font size"
+ />
+ A
+
+
{filtered.length} / {messages.length}
@@ -106,7 +138,9 @@ const Messages = () => {
No messages
) : (
- filtered.map((msg) =>
)
+ filtered.map((msg) => (
+
+ ))
)}
{/* Floating "scroll to latest" button */}
@@ -114,10 +148,10 @@ const Messages = () => {
)}
diff --git a/frontend/competition-view/src/pages/Messages/components/MessageItem.tsx b/frontend/competition-view/src/pages/Messages/components/MessageItem.tsx
index 0521e4088..a37f267d8 100644
--- a/frontend/competition-view/src/pages/Messages/components/MessageItem.tsx
+++ b/frontend/competition-view/src/pages/Messages/components/MessageItem.tsx
@@ -12,19 +12,30 @@ const KIND_BADGE_CLASS: Record
= {
const KIND_ROW_CLASS: Record = {
info: "",
- warning: "bg-amber-50/40 dark:bg-amber-900/10",
- fault: "bg-red-50/40 dark:bg-red-900/10",
+ warning: "bg-amber-100 dark:bg-amber-900/10",
+ fault: "bg-red-100 dark:bg-red-900/10",
ok: "",
};
interface MessageItemProps {
message: Message;
+ /** Body text size. "base" (default) is used by the dedicated Messages page; the compact Overview panel passes "sm". Ignored when `fontSizePx` is set. */
+ size?: "sm" | "base";
+ /** Overrides `size` with an exact pixel size, driven by the Messages page's font-size slider. */
+ fontSizePx?: number;
}
// Memoised: message objects are immutable, so once rendered a row never
// changes — this keeps a new message from re-rendering the whole list.
-const MessageItem = memo(({ message }: MessageItemProps) => {
+/** Badge-to-body ratio that keeps the slider's 16px default at the original 10px badge. */
+const BADGE_SIZE_RATIO = 10 / 16;
+const MIN_BADGE_SIZE_PX = 10;
+
+const MessageItem = memo(({ message, size = "base", fontSizePx }: MessageItemProps) => {
const time = formatMessageTimestamp(message.timestamp);
+ const badgeSizePx = fontSizePx
+ ? Math.max(MIN_BADGE_SIZE_PX, Math.round(fontSizePx * BADGE_SIZE_RATIO))
+ : undefined;
return (
@@ -33,11 +44,15 @@ const MessageItem = memo(({ message }: MessageItemProps) => {
{message.kind}
-
+
{messageContent(message.payload)}
diff --git a/frontend/competition-view/src/pages/Overview/Overview.tsx b/frontend/competition-view/src/pages/Overview/Overview.tsx
index 437d5ee45..d8238c430 100644
--- a/frontend/competition-view/src/pages/Overview/Overview.tsx
+++ b/frontend/competition-view/src/pages/Overview/Overview.tsx
@@ -1,5 +1,5 @@
import { Button } from "@workspace/ui/components";
-import { ChevronUp, MessageSquare } from "@workspace/ui/icons";
+import { ChevronDown, MessageSquare } from "@workspace/ui/icons";
import {
BatteryFull,
Gauge,
@@ -280,9 +280,14 @@ const MessagesPanel = () => {
const scrollRef = useRef(null);
const prevLenRef = useRef(0);
+ const scrollToLatest = (behavior: ScrollBehavior = "auto") => {
+ const el = scrollRef.current;
+ el?.scrollTo({ top: el.scrollHeight, behavior });
+ };
+
useEffect(() => {
if (messages.length > prevLenRef.current && !scrolledAway) {
- scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
+ scrollToLatest();
}
prevLenRef.current = messages.length;
}, [messages.length, scrolledAway]);
@@ -303,19 +308,22 @@ const MessagesPanel = () => {
setScrolledAway(e.currentTarget.scrollTop > 60)}
+ onScroll={(e) => {
+ const el = e.currentTarget;
+ setScrolledAway(el.scrollHeight - el.scrollTop - el.clientHeight > 60);
+ }}
className="relative min-h-0 flex-1 overflow-y-auto"
>
{messages.length === 0 ? (
No messages
) : (
- messages.map((msg) =>
)
+ messages.map((msg) =>
)
)}
{scrolledAway && (
)}
diff --git a/frontend/competition-view/src/store/slices/messagesSlice.ts b/frontend/competition-view/src/store/slices/messagesSlice.ts
index bc5e02e32..3c4f4e1be 100644
--- a/frontend/competition-view/src/store/slices/messagesSlice.ts
+++ b/frontend/competition-view/src/store/slices/messagesSlice.ts
@@ -18,9 +18,10 @@ export const createMessagesSlice: StateCreator<
> = (set) => ({
messages: [],
+ // Terminal order: newest last, so the cap drops the oldest entries.
addMessage: (message) =>
set((state) => ({
- messages: [message, ...state.messages].slice(0, MAX_MESSAGES),
+ messages: [...state.messages, message].slice(-MAX_MESSAGES),
})),
clearMessages: () => set({ messages: [] }),
diff --git a/frontend/frontend-kit/ui/src/components/shadcn/index.ts b/frontend/frontend-kit/ui/src/components/shadcn/index.ts
index f8790d0fe..c1d4dc9da 100644
--- a/frontend/frontend-kit/ui/src/components/shadcn/index.ts
+++ b/frontend/frontend-kit/ui/src/components/shadcn/index.ts
@@ -18,6 +18,7 @@ export * from "./separator";
export * from "./sheet";
export * from "./sidebar";
export * from "./skeleton";
+export * from "./slider";
export * from "./spinner";
export * from "./tabs";
export * from "./table";
diff --git a/frontend/frontend-kit/ui/src/components/shadcn/slider.tsx b/frontend/frontend-kit/ui/src/components/shadcn/slider.tsx
new file mode 100644
index 000000000..d930c32eb
--- /dev/null
+++ b/frontend/frontend-kit/ui/src/components/shadcn/slider.tsx
@@ -0,0 +1,59 @@
+"use client"
+
+import * as React from "react"
+import { Slider as SliderPrimitive } from "radix-ui"
+
+import { cn } from "@workspace/ui/lib/utils"
+
+function Slider({
+ className,
+ defaultValue,
+ value,
+ min = 0,
+ max = 100,
+ ...props
+}: React.ComponentProps) {
+ const _values = React.useMemo(
+ () =>
+ Array.isArray(value)
+ ? value
+ : Array.isArray(defaultValue)
+ ? defaultValue
+ : [min, max],
+ [value, defaultValue, min, max]
+ )
+
+ return (
+
+
+
+
+ {Array.from({ length: _values.length }, (_, index) => (
+
+ ))}
+
+ )
+}
+
+export { Slider }