feat: Add Mantine theme, 12 atom components, and 25 molecule components with Storybook stories

- Mantine theme with component extensions (Badge, Card, Paper, Button, etc.)
- CSS custom properties for design tokens (--inspector-* vars)
- Dark/light mode support with auto detection
- 12 atoms: StatusIndicator, AnnotationBadge, CapabilityItem, LogEntry,
  ProgressDisplay, ContentViewer, ListChangedIndicator, CopyButton,
  ConnectionToggle, TransportBadge, InlineError, MessageBubble
- 25 molecules: ServerCard, AddServerMenu, SchemaForm, ToolListItem,
  ToolDetailPanel, ResultPanel, ResourceListItem, ResourcePreviewPanel,
  ResourceTemplateInput, PromptArgumentsForm, PromptMessagesDisplay,
  LogControls, TaskCard, HistoryEntry, ServerInfoContent, ServerSettingsForm,
  RootsTable, SamplingRequestPanel, ElicitationFormPanel, ElicitationUrlPanel,
  InlineSamplingRequest, InlineElicitationRequest, PendingClientRequests,
  ImportServerJsonPanel, ExperimentalFeaturesPanel
- Exclude storybook-static from version control
- Exclude .stories.tsx from app tsconfig (Storybook-only modules)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
cliffhall 2026-03-17 10:23:56 -04:00
commit 39ef184b53
74 changed files with 5895 additions and 102158 deletions

View file

@ -0,0 +1,52 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { AnnotationBadge } from './AnnotationBadge'
const meta: Meta<typeof AnnotationBadge> = {
title: 'Atoms/AnnotationBadge',
component: AnnotationBadge,
}
export default meta
type Story = StoryObj<typeof AnnotationBadge>
export const Audience: Story = {
args: {
label: 'user',
variant: 'audience',
},
}
export const ReadOnly: Story = {
args: {
label: 'read-only',
variant: 'readOnly',
},
}
export const Destructive: Story = {
args: {
label: 'destructive',
variant: 'destructive',
},
}
export const LongRun: Story = {
args: {
label: 'long-run',
variant: 'longRun',
},
}
export const PriorityHigh: Story = {
args: {
label: 'priority: high',
variant: 'priority',
},
}
export const Default: Story = {
args: {
label: 'custom',
variant: 'default',
},
}

View file

@ -0,0 +1,27 @@
import { Badge } from '@mantine/core'
export type AnnotationVariant = 'audience' | 'readOnly' | 'destructive' | 'longRun' | 'priority' | 'default'
export interface AnnotationBadgeProps {
label: string
variant?: AnnotationVariant
}
const variantMap: Record<AnnotationVariant, { color: string; variant: string }> = {
audience: { color: 'gray', variant: 'outline' },
readOnly: { color: 'dark', variant: 'filled' },
destructive: { color: 'red', variant: 'outline' },
longRun: { color: 'yellow', variant: 'outline' },
priority: { color: 'orange', variant: 'light' },
default: { color: 'gray', variant: 'light' },
}
export function AnnotationBadge({ label, variant = 'default' }: AnnotationBadgeProps) {
const { color, variant: badgeVariant } = variantMap[variant]
return (
<Badge color={color} variant={badgeVariant}>
{label}
</Badge>
)
}

View file

@ -0,0 +1,32 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { CapabilityItem } from './CapabilityItem';
const meta: Meta<typeof CapabilityItem> = {
title: 'Atoms/CapabilityItem',
component: CapabilityItem,
};
export default meta;
type Story = StoryObj<typeof CapabilityItem>;
export const Supported: Story = {
args: {
name: 'Tools',
supported: true,
},
};
export const SupportedWithCount: Story = {
args: {
name: 'Tools',
supported: true,
count: 4,
},
};
export const NotSupported: Story = {
args: {
name: 'Completions',
supported: false,
},
};

View file

@ -0,0 +1,18 @@
import { Group, Text } from '@mantine/core';
export interface CapabilityItemProps {
name: string;
supported: boolean;
count?: number;
}
export function CapabilityItem({ name, supported, count }: CapabilityItemProps) {
const label = count != null ? `${name} (${count})` : name;
return (
<Group gap="xs">
<Text c={supported ? 'green' : 'red'}>{supported ? '\u2713' : '\u2717'}</Text>
<Text>{label}</Text>
</Group>
);
}

View file

@ -0,0 +1,46 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { fn } from 'storybook/test';
import { ConnectionToggle } from './ConnectionToggle';
const meta: Meta<typeof ConnectionToggle> = {
title: 'Atoms/ConnectionToggle',
component: ConnectionToggle,
args: {
onChange: fn(),
},
};
export default meta;
type Story = StoryObj<typeof ConnectionToggle>;
export const Connected: Story = {
args: {
checked: true,
loading: false,
disabled: false,
},
};
export const Disconnected: Story = {
args: {
checked: false,
loading: false,
disabled: false,
},
};
export const Loading: Story = {
args: {
checked: false,
loading: true,
disabled: false,
},
};
export const Disabled: Story = {
args: {
checked: false,
loading: false,
disabled: true,
},
};

View file

@ -0,0 +1,19 @@
import { Switch } from '@mantine/core';
export interface ConnectionToggleProps {
checked: boolean;
loading: boolean;
disabled: boolean;
onChange: (checked: boolean) => void;
}
export function ConnectionToggle({ checked, loading, disabled, onChange }: ConnectionToggleProps) {
return (
<Switch
size="lg"
checked={checked}
disabled={disabled || loading}
onChange={(event) => onChange(event.currentTarget.checked)}
/>
);
}

View file

@ -0,0 +1,78 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { fn } from 'storybook/test';
import { ContentViewer } from './ContentViewer';
const meta: Meta<typeof ContentViewer> = {
title: 'Atoms/ContentViewer',
component: ContentViewer,
};
export default meta;
type Story = StoryObj<typeof ContentViewer>;
export const PlainText: Story = {
args: {
type: 'text',
content: 'Hello, world!\nThis is plain text.',
},
};
export const JsonContent: Story = {
args: {
type: 'json',
content: '{"name":"my-app","version":"1.0.0","description":"Sample configuration file"}',
},
};
export const ImagePreview: Story = {
args: {
type: 'image',
content:
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
mimeType: 'image/png',
},
};
export const LongContent: Story = {
args: {
type: 'json',
content: JSON.stringify({
name: 'my-app',
version: '2.5.0',
description: 'A comprehensive application configuration',
author: 'Inspector Team',
license: 'MIT',
repository: 'https://github.com/example/my-app',
dependencies: {
react: '^18.0.0',
typescript: '^5.0.0',
mantine: '^7.0.0',
},
devDependencies: {
vitest: '^1.0.0',
storybook: '^8.0.0',
eslint: '^9.0.0',
},
scripts: {
dev: 'vite',
build: 'tsc && vite build',
test: 'vitest',
lint: 'eslint .',
},
settings: {
theme: 'dark',
language: 'en',
notifications: true,
maxRetries: 3,
},
}),
},
};
export const WithCopyButton: Story = {
args: {
type: 'text',
content: 'Hello, world!\nThis text can be copied.',
onCopy: fn(),
},
};

View file

@ -0,0 +1,43 @@
import { Button, Code, Image, Stack } from '@mantine/core';
export interface ContentViewerProps {
type: 'text' | 'json' | 'image' | 'audio';
content: string;
mimeType?: string;
onCopy?: () => void;
}
function formatJson(content: string): string {
try {
return JSON.stringify(JSON.parse(content), null, 2);
} catch {
return content;
}
}
export function ContentViewer({ type, content, mimeType, onCopy }: ContentViewerProps) {
return (
<Stack gap="xs">
{type === 'json' && <Code block>{formatJson(content)}</Code>}
{type === 'text' && <Code block>{content}</Code>}
{type === 'image' && (
<Image
src={`data:${mimeType || 'image/png'};base64,${content}`}
alt="Content preview"
maw={400}
radius="md"
/>
)}
{type === 'audio' && (
<audio controls>
<source src={`data:${mimeType || 'audio/wav'};base64,${content}`} />
</audio>
)}
{onCopy && (
<Button variant="light" size="xs" onClick={onCopy}>
Copy
</Button>
)}
</Stack>
);
}

View file

@ -0,0 +1,23 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { CopyButton } from './CopyButton';
const meta: Meta<typeof CopyButton> = {
title: 'Atoms/CopyButton',
component: CopyButton,
};
export default meta;
type Story = StoryObj<typeof CopyButton>;
export const Default: Story = {
args: {
value: 'Hello, world!',
},
};
export const LongValue: Story = {
args: {
value:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.',
},
};

View file

@ -0,0 +1,19 @@
import { ActionIcon, CopyButton as MantineCopyButton, Tooltip } from '@mantine/core';
export interface CopyButtonProps {
value: string;
}
export function CopyButton({ value }: CopyButtonProps) {
return (
<MantineCopyButton value={value}>
{({ copied, copy }) => (
<Tooltip label={copied ? 'Copied' : 'Copy'}>
<ActionIcon variant="subtle" color={copied ? 'green' : 'gray'} onClick={copy}>
{copied ? '\u2713' : '\u2398'}
</ActionIcon>
</Tooltip>
)}
</MantineCopyButton>
);
}

View file

@ -0,0 +1,50 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { InlineError } from './InlineError';
const meta: Meta<typeof InlineError> = {
title: 'Atoms/InlineError',
component: InlineError,
};
export default meta;
type Story = StoryObj<typeof InlineError>;
export const ShortError: Story = {
args: {
message: 'Connection timeout after 20s',
},
};
export const WithRetryCount: Story = {
args: {
message: 'Connection failed',
retryCount: 3,
maxRetries: 5,
},
};
export const WithDetails: Story = {
args: {
message: 'Connection refused',
details:
'Error: ECONNREFUSED 127.0.0.1:3000\n at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1494:16)',
},
};
export const WithDocLink: Story = {
args: {
message: 'Connection failed',
docLink: 'https://example.com/troubleshooting',
},
};
export const FullError: Story = {
args: {
message: 'Connection refused',
details:
'Error: ECONNREFUSED 127.0.0.1:3000\n at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1494:16)',
retryCount: 3,
maxRetries: 5,
docLink: 'https://example.com/troubleshooting',
},
};

View file

@ -0,0 +1,43 @@
import { Alert, Anchor, Group, Spoiler, Stack, Text } from '@mantine/core';
export interface InlineErrorProps {
message: string;
details?: string;
retryCount?: number;
maxRetries?: number;
onShowMore?: () => void;
docLink?: string;
}
export function InlineError({
message,
details,
retryCount,
maxRetries,
docLink,
}: InlineErrorProps) {
return (
<Alert color="red" variant="light" title={message}>
<Stack gap="xs">
{retryCount !== undefined && (
<Text size="sm">
Retry attempt {retryCount}
{maxRetries !== undefined && ` of ${maxRetries}`}
</Text>
)}
{details && (
<Spoiler maxHeight={0} showLabel="Show more" hideLabel="Show less">
<Text size="sm">{details}</Text>
</Spoiler>
)}
{docLink && (
<Group>
<Anchor href={docLink} target="_blank" size="sm">
View Troubleshooting Guide &rarr;
</Anchor>
</Group>
)}
</Stack>
</Alert>
);
}

View file

@ -0,0 +1,25 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { fn } from 'storybook/test';
import { ListChangedIndicator } from './ListChangedIndicator';
const meta: Meta<typeof ListChangedIndicator> = {
title: 'Atoms/ListChangedIndicator',
component: ListChangedIndicator,
};
export default meta;
type Story = StoryObj<typeof ListChangedIndicator>;
export const Visible: Story = {
args: {
visible: true,
onRefresh: fn(),
},
};
export const Hidden: Story = {
args: {
visible: false,
onRefresh: fn(),
},
};

View file

@ -0,0 +1,27 @@
import { Button, Flex, Group, Text } from '@mantine/core';
export interface ListChangedIndicatorProps {
visible: boolean;
onRefresh: () => void;
}
const Dot = Flex.withProps({
w: 8,
h: 8,
style: { borderRadius: '50%' },
bg: 'var(--inspector-status-connecting)',
});
export function ListChangedIndicator({ visible, onRefresh }: ListChangedIndicatorProps) {
if (!visible) return null;
return (
<Group gap="xs">
<Dot />
<Text size="sm" c="dimmed">List updated</Text>
<Button size="xs" variant="light" onClick={onRefresh}>
↻ Refresh
</Button>
</Group>
);
}

View file

@ -0,0 +1,83 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { LogEntry } from './LogEntry';
const meta: Meta<typeof LogEntry> = {
title: 'Atoms/LogEntry',
component: LogEntry,
};
export default meta;
type Story = StoryObj<typeof LogEntry>;
export const Debug: Story = {
args: {
timestamp: '2026-03-17T10:00:00Z',
level: 'debug',
message: 'Initializing connection pool',
},
};
export const Info: Story = {
args: {
timestamp: '2026-03-17T10:00:01Z',
level: 'info',
message: 'Server started on port 3000',
},
};
export const Notice: Story = {
args: {
timestamp: '2026-03-17T10:00:02Z',
level: 'notice',
message: 'Configuration reloaded successfully',
},
};
export const Warning: Story = {
args: {
timestamp: '2026-03-17T10:00:03Z',
level: 'warning',
message: 'Memory usage above 80%',
},
};
export const Error: Story = {
args: {
timestamp: '2026-03-17T10:00:04Z',
level: 'error',
message: 'Failed to connect to database',
},
};
export const Critical: Story = {
args: {
timestamp: '2026-03-17T10:00:05Z',
level: 'critical',
message: 'Disk space critically low',
},
};
export const Alert: Story = {
args: {
timestamp: '2026-03-17T10:00:06Z',
level: 'alert',
message: 'Service degradation detected',
},
};
export const Emergency: Story = {
args: {
timestamp: '2026-03-17T10:00:07Z',
level: 'emergency',
message: 'System is unusable',
},
};
export const WithLogger: Story = {
args: {
timestamp: '2026-03-17T10:00:08Z',
level: 'info',
message: 'Tool execution completed',
logger: 'mcp-server',
},
};

View file

@ -0,0 +1,72 @@
import { Badge, Group, Text } from '@mantine/core';
export type LogLevel = 'debug' | 'info' | 'notice' | 'warning' | 'error' | 'critical' | 'alert' | 'emergency';
export interface LogEntryProps {
timestamp: string;
level: LogLevel;
message: string;
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',
notice: undefined,
warning: 'yellow',
error: 'red',
critical: 'red',
alert: 'red',
emergency: 'red',
};
function isBoldLevel(level: LogLevel): boolean {
return level === 'alert' || level === 'emergency';
}
export function LogEntry({ timestamp, level, message, logger }: LogEntryProps) {
return (
<Group gap="sm" wrap="nowrap">
<Text size="sm" ff="monospace" c="dimmed">
{timestamp}
</Text>
<Badge
color={levelBadgeColor[level]}
variant={levelBadgeVariant[level]}
fw={isBoldLevel(level) ? 700 : undefined}
>
{level}
</Badge>
{logger && (
<Text size="xs" c="dimmed">
[{logger}]
</Text>
)}
<Text size="sm" c={levelMessageColor[level]}>
{message}
</Text>
</Group>
);
}

View file

@ -0,0 +1,48 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { MessageBubble } from './MessageBubble';
const meta: Meta<typeof MessageBubble> = {
title: 'Atoms/MessageBubble',
component: MessageBubble,
};
export default meta;
type Story = StoryObj<typeof MessageBubble>;
export const UserText: Story = {
args: {
index: 0,
role: 'user',
content: 'Hello, my name is John and I like cats',
},
};
export const AssistantText: Story = {
args: {
index: 1,
role: 'assistant',
content:
"Nice to meet you, John! It's wonderful that you enjoy cats. They're such fascinating and independent creatures. Do you have any cats of your own?",
},
};
export const UserWithImage: Story = {
args: {
index: 0,
role: 'user',
content: 'Check this out',
imageContent: {
data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
mimeType: 'image/png',
},
},
};
export const LongMessage: Story = {
args: {
index: 0,
role: 'user',
content:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo.',
},
};

View file

@ -0,0 +1,44 @@
import { Image, Paper, Stack, Text } from '@mantine/core';
export interface MessageBubbleProps {
index: number;
role: 'user' | 'assistant';
content: string;
imageContent?: { data: string; mimeType: string };
audioContent?: { data: string; mimeType: string };
}
export function MessageBubble({
index,
role,
content,
imageContent,
audioContent,
}: MessageBubbleProps) {
return (
<Paper p="md" radius="md" withBorder>
<Stack gap="xs">
<Text size="xs" c="dimmed">
[{index}] role: {role}
</Text>
<Text size="sm">&quot;{content}&quot;</Text>
{imageContent && (
<Image
src={`data:${imageContent.mimeType};base64,${imageContent.data}`}
maw={300}
radius="sm"
mt="xs"
/>
)}
{audioContent && (
<audio controls>
<source
src={`data:${audioContent.mimeType};base64,${audioContent.data}`}
type={audioContent.mimeType}
/>
</audio>
)}
</Stack>
</Paper>
);
}

View file

@ -0,0 +1,39 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { ProgressDisplay } from './ProgressDisplay';
const meta: Meta<typeof ProgressDisplay> = {
title: 'Atoms/ProgressDisplay',
component: ProgressDisplay,
};
export default meta;
type Story = StoryObj<typeof ProgressDisplay>;
export const ZeroPercent: Story = {
args: {
progress: 0,
},
};
export const HalfComplete: Story = {
args: {
progress: 50,
description: 'Processing...',
},
};
export const NearComplete: Story = {
args: {
progress: 95,
description: 'Almost done',
elapsed: '1m 30s',
},
};
export const Complete: Story = {
args: {
progress: 100,
description: 'Done',
elapsed: '2m 15s',
},
};

View file

@ -0,0 +1,30 @@
import { Group, Progress, Stack, Text } from '@mantine/core';
export interface ProgressDisplayProps {
progress: number;
description?: string;
elapsed?: string;
}
export function ProgressDisplay({ progress, description, elapsed }: ProgressDisplayProps) {
return (
<Stack gap="xs">
<Group justify="space-between">
{description && (
<Text size="sm" c="dimmed">
{description}
</Text>
)}
<Text size="sm" c="dimmed">
{progress}%
</Text>
</Group>
<Progress value={progress} size="sm" />
{elapsed && (
<Text size="xs" c="dimmed">
{elapsed}
</Text>
)}
</Stack>
);
}

Some files were not shown because too many files have changed in this diff Show more