Add new components, typography, badge contrast, and screen layout refactors

New element components:
- ListToggle: expand/collapse button using react-icons (RiExpandVerticalLine/RiCollapseVerticalLine)
- LogLevelBadge: extracted from LogEntry, handles all 8 log levels with filled variant and dark/light text contrast

New group components:
- ViewHeader: shared header bar for ConnectedView and UnconnectedView with discriminated union on `connected` prop; MCP logo links to modelcontextprotocol.io
- ServerListControls: combines ListToggle and AddServerMenu, hides toggle when no servers
- LogStreamPanel: extracted from LoggingScreen with scroll containment, filtering by visible levels and search text
- HistoryControls: vertical sidebar with search, method filter, export, and clear
- HistoryListPanel: extracted from HistoryScreen with ListToggle compact mode, filtering by method and search text

Badge contrast improvements:
- AnnotationBadge: filled variant for both dark/light modes; black text in dark, white text in light
- LogLevelBadge: same contrast approach; bold weight for alert/emergency levels
- Badge theme default weight reduced from 700 to 600

Typography:
- Added Fredoka (300-700) as body and heading font
- Added Roboto Mono as monospace font
- Google Fonts loaded in index.html and Storybook previewHead
- Heading weight set to 600

Screen layout refactors:
- LoggingScreen: owns filterText and visibleLevels state; passes to LogControls and LogStreamPanel; removed controls prop in favor of flat props
- HistoryScreen: owns searchText and methodFilter state; passes to HistoryControls and HistoryListPanel for filtering
- Both screens use new ScreenLayout (Flex variant="screen") and Sidebar subcomponents
- Added Flex theme with "screen" variant (overflow: hidden)
- Added Paper theme "panel" variant (flex column, overflow hidden)

Component enhancements:
- ServerCard: added compact prop showing only header row
- HistoryEntry: renamed isExpanded to isListExpanded; internal useState syncs via useEffect; individual entries toggle independently
- LogControls: Select All / Deselect All toggle for level checkboxes; nowrap on level selector group
- ConnectionToggle: LoadingOverlay with rounded corners for connecting state
- ConnectedView: ThemeToggle ActionIcon matched to DisconnectButton height (36px) and border color (outline variant)
- Storybook preview: fullscreen layout wrapper with 100vh overflow hidden

New stories:
- UnconnectedView: Connecting and ManyServers (12 servers for scroll testing)
- ServerCard: CompactConnected and CompactDisconnected
- All new components have corresponding stories

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
cliffhall 2026-03-28 20:38:04 -04:00
commit 961dc283fc
6 changed files with 1602 additions and 790 deletions

View file

@ -13,16 +13,13 @@ export interface AnnotationBadgeProps {
variant?: AnnotationVariant;
}
const variantMap: Record<
AnnotationVariant,
{ color: string; variant: string }
> = {
audience: { color: "blue", variant: "outline" },
readOnly: { color: "green", variant: "filled" },
destructive: { color: "red", variant: "filled" },
longRun: { color: "yellow", variant: "filled" },
priority: { color: "orange", variant: "filled" },
default: { color: "gray", variant: "light" },
const colorMap: Record<AnnotationVariant, string> = {
audience: "blue",
readOnly: "green",
destructive: "red",
longRun: "yellow",
priority: "orange",
default: "gray",
};
export function AnnotationBadge({
@ -30,15 +27,10 @@ export function AnnotationBadge({
variant = "default",
}: AnnotationBadgeProps) {
const colorScheme = useComputedColorScheme();
const { color, variant: badgeVariant } = variantMap[variant];
const textColor =
(variant === "priority" || variant === "destructive") &&
colorScheme === "dark"
? "black"
: undefined;
const color = colorMap[variant];
const textColor = colorScheme === "dark" ? "black" : "white";
return (
<Badge color={color} variant={badgeVariant} c={textColor}>
<Badge color={color} variant="filled" fw={500} c={textColor}>
{label}
</Badge>
);

View file

@ -0,0 +1,25 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { ListToggle } from "./ListToggle";
const meta: Meta<typeof ListToggle> = {
title: "Elements/ListToggle",
component: ListToggle,
};
export default meta;
type Story = StoryObj<typeof ListToggle>;
export const Expanded: Story = {
args: {
compact: false,
onToggle: fn(),
},
};
export const Collapsed: Story = {
args: {
compact: true,
onToggle: fn(),
},
};

View file

@ -0,0 +1,17 @@
import { Button } from "@mantine/core";
import { RiExpandVerticalLine, RiCollapseVerticalLine } from "react-icons/ri";
export interface ListToggleProps {
compact: boolean;
onToggle: () => void;
}
export function ListToggle({ compact, onToggle }: ListToggleProps) {
const Icon = compact ? RiExpandVerticalLine : RiCollapseVerticalLine;
return (
<Button size="sm" onClick={onToggle}>
<Icon size={20} />
</Button>
);
}

View file

@ -1,4 +1,5 @@
import { Badge, Group, Text } from "@mantine/core";
import { Group, Text } from "@mantine/core";
import { LogLevelBadge } from "../LogLevelBadge/LogLevelBadge";
export type LogLevel =
| "debug"
@ -17,28 +18,6 @@ export interface LogEntryProps {
logger?: string;
}
const levelBadgeColor: Record<LogLevel, string> = {
debug: "gray",
info: "blue",
notice: "teal",
warning: "yellow",
error: "red",
critical: "red",
alert: "red",
emergency: "red",
};
const levelBadgeVariant: Record<LogLevel, string> = {
debug: "light",
info: "light",
notice: "light",
warning: "light",
error: "light",
critical: "filled",
alert: "filled",
emergency: "filled",
};
const levelMessageColor: Record<LogLevel, string | undefined> = {
debug: "dimmed",
info: "blue",
@ -50,14 +29,6 @@ const levelMessageColor: Record<LogLevel, string | undefined> = {
emergency: "red",
};
function isBoldLevel(level: LogLevel): boolean {
return level === "alert" || level === "emergency";
}
function getBoldWeight(level: LogLevel): number | undefined {
return isBoldLevel(level) ? 700 : undefined;
}
function formatLogger(logger: string): string {
return `[${logger}]`;
}
@ -70,6 +41,7 @@ const TimestampText = Text.withProps({
const LoggerText = Text.withProps({
size: "xs",
ff: "monospace",
c: "dimmed",
});
@ -77,15 +49,9 @@ export function LogEntry({ timestamp, level, message, logger }: LogEntryProps) {
return (
<Group gap="sm" wrap="nowrap">
<TimestampText>{timestamp}</TimestampText>
<Badge
color={levelBadgeColor[level]}
variant={levelBadgeVariant[level]}
fw={getBoldWeight(level)}
>
{level}
</Badge>
<LogLevelBadge level={level} />
{logger && <LoggerText>{formatLogger(logger)}</LoggerText>}
<Text size="sm" c={levelMessageColor[level]}>
<Text size="sm" ff="monospace" c={levelMessageColor[level]}>
{message}
</Text>
</Group>

View file

@ -0,0 +1,19 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { LogLevelBadge } from "./LogLevelBadge";
const meta: Meta<typeof LogLevelBadge> = {
title: "Elements/LogLevelBadge",
component: LogLevelBadge,
};
export default meta;
type Story = StoryObj<typeof LogLevelBadge>;
export const Debug: Story = { args: { level: "debug" } };
export const Info: Story = { args: { level: "info" } };
export const Notice: Story = { args: { level: "notice" } };
export const Warning: Story = { args: { level: "warning" } };
export const Error: Story = { args: { level: "error" } };
export const Critical: Story = { args: { level: "critical" } };
export const Alert: Story = { args: { level: "alert" } };
export const Emergency: Story = { args: { level: "emergency" } };

View file

@ -0,0 +1,31 @@
import { Badge, useComputedColorScheme } from "@mantine/core";
import type { LogLevel } from "../LogEntry/LogEntry";
export interface LogLevelBadgeProps {
level: LogLevel;
}
const levelColor: Record<LogLevel, string> = {
debug: "gray",
info: "blue",
notice: "teal",
warning: "yellow",
error: "red",
critical: "red",
alert: "red",
emergency: "red",
};
const boldLevels: Set<LogLevel> = new Set(["alert", "emergency"]);
export function LogLevelBadge({ level }: LogLevelBadgeProps) {
const colorScheme = useComputedColorScheme();
const textColor = colorScheme === "dark" ? "black" : "white";
const fw = boldLevels.has(level) ? 500 : undefined;
return (
<Badge color={levelColor[level]} variant="filled" fw={fw} c={textColor}>
{level}
</Badge>
);
}