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
5 changed files with 1602 additions and 790 deletions

View file

@ -8,10 +8,6 @@ const meta: Meta<typeof HistoryScreen> = {
component: HistoryScreen,
parameters: { layout: "fullscreen" },
args: {
searchText: "",
onSearchChange: fn(),
onMethodFilterChange: fn(),
onLoadMore: fn(),
onClearAll: fn(),
onExport: fn(),
},
@ -29,8 +25,7 @@ const entry1: HistoryEntryProps = {
parameters: { message: "Hello, world!" },
response: { result: "Message sent successfully" },
isPinned: false,
isExpanded: false,
onToggleExpand: fn(),
isListExpanded: false,
onReplay: fn(),
onTogglePin: fn(),
};
@ -44,8 +39,7 @@ const entry2: HistoryEntryProps = {
parameters: { uri: "file:///config.json" },
response: { contents: [{ uri: "file:///config.json", text: "{}" }] },
isPinned: false,
isExpanded: false,
onToggleExpand: fn(),
isListExpanded: false,
onReplay: fn(),
onTogglePin: fn(),
};
@ -59,8 +53,7 @@ const entry3: HistoryEntryProps = {
parameters: { ids: [1, 2, 3] },
response: { error: "Permission denied" },
isPinned: false,
isExpanded: true,
onToggleExpand: fn(),
isListExpanded: true,
onReplay: fn(),
onTogglePin: fn(),
};
@ -72,9 +65,7 @@ const pinnedEntry1: HistoryEntryProps = {
durationMs: 80,
response: { tools: ["send_message", "list_users"] },
isPinned: true,
pinLabel: "Tool discovery",
isExpanded: false,
onToggleExpand: fn(),
isListExpanded: false,
onReplay: fn(),
onTogglePin: fn(),
};
@ -90,9 +81,7 @@ const pinnedEntry2: HistoryEntryProps = {
messages: [{ role: "user", content: { type: "text", text: "Hello!" } }],
},
isPinned: true,
pinLabel: "Greeting prompt",
isExpanded: false,
onToggleExpand: fn(),
isListExpanded: false,
onReplay: fn(),
onTogglePin: fn(),
};
@ -101,8 +90,6 @@ export const WithEntries: Story = {
args: {
entries: [entry1, entry2, entry3],
pinnedEntries: [pinnedEntry1, pinnedEntry2],
totalCount: 25,
displayedCount: 3,
},
};
@ -110,17 +97,5 @@ export const Empty: Story = {
args: {
entries: [],
pinnedEntries: [],
totalCount: 0,
displayedCount: 0,
},
};
export const WithFilter: Story = {
args: {
entries: [entry1, entry3],
pinnedEntries: [],
methodFilter: "tools/call",
totalCount: 10,
displayedCount: 2,
},
};

View file

@ -1,155 +1,64 @@
import {
Button,
Container,
Divider,
Group,
Paper,
Select,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { HistoryEntry } from "../../groups/HistoryEntry/HistoryEntry";
import { useState } from "react";
import { Card, Flex, Stack } from "@mantine/core";
import { HistoryControls } from "../../groups/HistoryControls/HistoryControls";
import { HistoryListPanel } from "../../groups/HistoryListPanel/HistoryListPanel.js";
import type { HistoryEntryProps } from "../../groups/HistoryEntry/HistoryEntry";
export interface HistoryScreenProps {
entries: HistoryEntryProps[];
pinnedEntries: HistoryEntryProps[];
searchText: string;
methodFilter?: string;
totalCount: number;
displayedCount: number;
onSearchChange: (text: string) => void;
onMethodFilterChange: (method: string) => void;
onLoadMore: () => void;
onClearAll: () => void;
onExport: () => void;
}
const METHOD_OPTIONS = [
"tools/call",
"tools/list",
"resources/read",
"resources/list",
"prompts/get",
"prompts/list",
"sampling/createMessage",
"elicitation/create",
];
const PageContainer = Container.withProps({
size: "xl",
py: "xl",
const ScreenLayout = Flex.withProps({
variant: "screen",
h: "calc(100vh - var(--app-shell-header-height, 0px))",
gap: "xl",
p: "xl",
});
const ContentPanel = Paper.withProps({
const Sidebar = Stack.withProps({
w: 280,
flex: "0 0 auto",
});
const SidebarCard = Card.withProps({
withBorder: true,
p: "md",
padding: "lg",
});
const HeaderRow = Group.withProps({
justify: "space-between",
wrap: "wrap",
});
const ToolbarButton = Button.withProps({
variant: "light",
size: "sm",
});
const EmptyState = Text.withProps({
c: "dimmed",
ta: "center",
py: "xl",
});
const CountText = Text.withProps({
size: "sm",
c: "dimmed",
});
function entryKey(entry: HistoryEntryProps): string {
return `${entry.timestamp}-${entry.method}`;
}
function formatPinnedTitle(count: number): string {
return `Pinned Requests (${count})`;
}
function formatPagination(displayed: number, total: number): string {
return `Showing ${displayed} of ${total} entries`;
}
export function HistoryScreen({
entries,
pinnedEntries,
searchText,
methodFilter,
totalCount,
displayedCount,
onSearchChange,
onMethodFilterChange,
onLoadMore,
onClearAll,
onExport,
}: HistoryScreenProps) {
const [searchText, setSearchText] = useState("");
const [methodFilter, setMethodFilter] = useState<string | undefined>();
return (
<PageContainer>
<ContentPanel>
<Stack gap="md">
<HeaderRow>
<Title order={3}>Request History</Title>
<Group>
<TextInput
placeholder="Search..."
value={searchText}
onChange={(event) => onSearchChange(event.currentTarget.value)}
/>
<Select
placeholder="Filter by method"
data={METHOD_OPTIONS}
value={methodFilter}
onChange={(value) => onMethodFilterChange(value ?? "")}
clearable
/>
<ToolbarButton onClick={onExport}>Export JSON</ToolbarButton>
<ToolbarButton onClick={onClearAll}>Clear All</ToolbarButton>
</Group>
</HeaderRow>
{entries.length === 0 ? (
<EmptyState>No request history</EmptyState>
) : (
<Stack gap="md">
{entries.map((entry) => (
<HistoryEntry key={entryKey(entry)} {...entry} />
))}
</Stack>
)}
{pinnedEntries.length > 0 && (
<>
<Divider />
<Title order={4}>{formatPinnedTitle(pinnedEntries.length)}</Title>
<Stack gap="sm">
{pinnedEntries.map((entry) => (
<HistoryEntry key={entryKey(entry)} {...entry} />
))}
</Stack>
</>
)}
<Group justify="flex-end">
<CountText>
{formatPagination(displayedCount, totalCount)}
</CountText>
{displayedCount < totalCount && (
<ToolbarButton onClick={onLoadMore}>Load More</ToolbarButton>
)}
</Group>
</Stack>
</ContentPanel>
</PageContainer>
<ScreenLayout>
<Sidebar>
<SidebarCard>
<HistoryControls
searchText={searchText}
methodFilter={methodFilter}
onSearchChange={setSearchText}
onMethodFilterChange={(value) =>
setMethodFilter(value || undefined)
}
onClearAll={onClearAll}
onExport={onExport}
/>
</SidebarCard>
</Sidebar>
<HistoryListPanel
entries={entries}
pinnedEntries={pinnedEntries}
searchText={searchText}
methodFilter={methodFilter}
/>
</ScreenLayout>
);
}

View file

@ -1,39 +1,20 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { LoggingScreen } from "./LoggingScreen";
import type { LogControlsProps } from "../../groups/LogControls/LogControls";
import type { LogEntryProps, LogLevel } from "../../elements/LogEntry/LogEntry";
function makeControls(): LogControlsProps {
return {
currentLevel: "info",
filterText: "",
visibleLevels: {
debug: true,
info: true,
notice: true,
warning: true,
error: true,
critical: true,
alert: true,
emergency: true,
},
onSetLevel: fn(),
onFilterChange: fn(),
onToggleLevel: fn(),
onClear: fn(),
onExport: fn(),
};
}
const meta: Meta<typeof LoggingScreen> = {
title: "Screens/LoggingScreen",
component: LoggingScreen,
parameters: { layout: "fullscreen" },
args: {
currentLevel: "info",
onSetLevel: fn(),
onClear: fn(),
onExport: fn(),
autoScroll: true,
onToggleAutoScroll: fn(),
onCopyAll: fn(),
autoScroll: true,
},
};
@ -42,7 +23,6 @@ type Story = StoryObj<typeof LoggingScreen>;
export const Empty: Story = {
args: {
controls: makeControls(),
entries: [],
},
};
@ -137,7 +117,8 @@ const logMessages: { level: LogLevel; message: string; logger?: string }[] = [
},
{
level: "info",
message: "Session initialized with capabilities: tools, resources, prompts",
message:
"Session initialized with capabilities: tools, resources, prompts",
},
{
level: "debug",
@ -183,7 +164,6 @@ const mixedEntries: LogEntryProps[] = Array.from({ length: 50 }, (_, i) => {
export const WithEntries: Story = {
args: {
controls: makeControls(),
entries: mixedEntries,
},
};
@ -240,7 +220,6 @@ const allLevelEntries: LogEntryProps[] = [
export const MixedLevels: Story = {
args: {
controls: makeControls(),
entries: allLevelEntries,
},
};

View file

@ -1,31 +1,41 @@
import {
Button,
Card,
Checkbox,
Container,
Grid,
Group,
ScrollArea,
Stack,
Text,
Title,
} from "@mantine/core";
import { useState } from "react";
import { Card, Flex, Stack } from "@mantine/core";
import { LogControls } from "../../groups/LogControls/LogControls";
import { LogEntry } from "../../elements/LogEntry/LogEntry";
import type { LogControlsProps } from "../../groups/LogControls/LogControls";
import { LogStreamPanel } from "../../groups/LogStreamPanel/LogStreamPanel";
import type { LogEntryProps } from "../../elements/LogEntry/LogEntry";
export interface LoggingScreenProps {
controls: LogControlsProps;
entries: LogEntryProps[];
currentLevel: string;
onSetLevel: (level: string) => void;
onClear: () => void;
onExport: () => void;
autoScroll: boolean;
onToggleAutoScroll: () => void;
onCopyAll: () => void;
}
const PageContainer = Container.withProps({
size: "xl",
py: "xl",
const ALL_LEVELS_VISIBLE: Record<string, boolean> = {
debug: true,
info: true,
notice: true,
warning: true,
error: true,
critical: true,
alert: true,
emergency: true,
};
const ScreenLayout = Flex.withProps({
variant: "screen",
h: "calc(100vh - var(--app-shell-header-height, 0px))",
gap: "xl",
p: "xl",
});
const Sidebar = Stack.withProps({
w: 280,
flex: "0 0 auto",
});
const SidebarCard = Card.withProps({
@ -33,74 +43,58 @@ const SidebarCard = Card.withProps({
padding: "lg",
});
const FullHeightCard = Card.withProps({
withBorder: true,
padding: "lg",
h: "100%",
});
const FullHeightStack = Stack.withProps({
gap: "sm",
h: "100%",
});
const ToolbarButton = Button.withProps({
variant: "light",
size: "sm",
});
const EmptyCenter = Stack.withProps({
flex: 1,
align: "center",
justify: "center",
});
export function LoggingScreen({
controls,
entries,
currentLevel,
onSetLevel,
onClear,
onExport,
autoScroll,
onToggleAutoScroll,
onCopyAll,
}: LoggingScreenProps) {
const [filterText, setFilterText] = useState("");
const [visibleLevels, setVisibleLevels] =
useState<Record<string, boolean>>(ALL_LEVELS_VISIBLE);
function handleToggleLevel(level: string, visible: boolean) {
setVisibleLevels((prev) => ({ ...prev, [level]: visible }));
}
const NO_LEVELS_VISIBLE: Record<string, boolean> = Object.fromEntries(
Object.keys(ALL_LEVELS_VISIBLE).map((k) => [k, false]),
);
function handleToggleAllLevels() {
const allSelected = Object.values(visibleLevels).every(Boolean);
setVisibleLevels(allSelected ? NO_LEVELS_VISIBLE : ALL_LEVELS_VISIBLE);
}
return (
<PageContainer>
<Grid align="stretch">
<Grid.Col span={3}>
<SidebarCard>
<LogControls {...controls} />
</SidebarCard>
</Grid.Col>
<Grid.Col span={9}>
<FullHeightCard>
<FullHeightStack>
<Group justify="space-between">
<Title order={4}>Log Stream</Title>
<Group>
<Checkbox
label="Auto-scroll"
checked={autoScroll}
onChange={onToggleAutoScroll}
/>
<ToolbarButton onClick={onCopyAll}>Copy All</ToolbarButton>
</Group>
</Group>
{entries.length > 0 ? (
<ScrollArea flex={1}>
<Stack gap="xs">
{entries.map((entry, index) => (
<LogEntry key={index} {...entry} />
))}
</Stack>
</ScrollArea>
) : (
<EmptyCenter>
<Text c="dimmed">No log entries</Text>
</EmptyCenter>
)}
</FullHeightStack>
</FullHeightCard>
</Grid.Col>
</Grid>
</PageContainer>
<ScreenLayout>
<Sidebar>
<SidebarCard>
<LogControls
currentLevel={currentLevel}
filterText={filterText}
visibleLevels={visibleLevels}
onSetLevel={onSetLevel}
onFilterChange={setFilterText}
onToggleLevel={handleToggleLevel}
onToggleAllLevels={handleToggleAllLevels}
onClear={onClear}
onExport={onExport}
/>
</SidebarCard>
</Sidebar>
<LogStreamPanel
entries={entries}
filterText={filterText}
visibleLevels={visibleLevels}
autoScroll={autoScroll}
onToggleAutoScroll={onToggleAutoScroll}
onCopyAll={onCopyAll}
/>
</ScreenLayout>
);
}

View file

@ -1,6 +1,7 @@
import { Container, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { useState } from "react";
import { Container, SimpleGrid, Stack, Text } from "@mantine/core";
import { ServerCard } from "../../groups/ServerCard/ServerCard";
import { AddServerMenu } from "../../groups/AddServerMenu/AddServerMenu";
import { ServerListControls } from "../../groups/ServerListControls/ServerListControls";
import type { ServerCardProps } from "../../groups/ServerCard/ServerCard";
export interface ServerListScreenProps {
@ -27,16 +28,23 @@ export function ServerListScreen({
onImportConfig,
onImportServerJson,
}: ServerListScreenProps) {
const [compact, setCompact] = useState<boolean>(false);
function handleToggleList() {
setCompact((prev) => (prev === true ? false : true));
}
return (
<PageContainer>
<Stack gap="lg">
<Group justify="flex-end">
<AddServerMenu
onAddManually={onAddManually}
onImportConfig={onImportConfig}
onImportServerJson={onImportServerJson}
/>
</Group>
<ServerListControls
serverCount={servers.length}
compact={compact}
onToggleList={handleToggleList}
onAddManually={onAddManually}
onImportConfig={onImportConfig}
onImportServerJson={onImportServerJson}
/>
{servers.length === 0 ? (
<EmptyState>
@ -49,7 +57,7 @@ export function ServerListScreen({
className="grid-align-start"
>
{servers.map((server) => (
<ServerCard key={server.name} {...server} />
<ServerCard key={server.name} compact={compact} {...server} />
))}
</SimpleGrid>
)}