style(playground): polish UI design for better consistency and alignment

- ChatInput: Redesign with rounded container, better button alignment, helper text
- ChatHeader: Consistent bg-surface, refined typography and spacing
- ChatMessages: Improved empty state with icon badge and suggestion box
- MessageBubble: Cleaner message cards, collapsible tool input/output, status badges
- ToolsSidebar: Header/content separation, keyboard accessibility, wider width
- SettingsSidebar: Matching header style, dashed empty state, footer info section

All components now use consistent theme tokens (bg-surface, bg-background, etc.)
and follow the same visual patterns for headers, cards, and spacing.
This commit is contained in:
Ajax Davis 2025-12-12 12:36:36 +10:00
parent a793322710
commit 3ce5ea9bb7
6 changed files with 275 additions and 219 deletions

View file

@ -23,26 +23,26 @@ export function ChatHeader({ onClear }: ChatHeaderProps): React.ReactElement {
};
return (
<header className="border-b border-border bg-background px-4 py-3">
<header className="border-b border-border bg-surface px-6 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<h1 className="text-xl font-bold">TPMJS Playground</h1>
<div className="flex items-center gap-6">
<h1 className="text-lg font-bold tracking-tight">TPMJS Playground</h1>
<Link
href="https://tpmjs.com"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-foreground-secondary hover:text-foreground"
className="text-sm text-foreground-tertiary transition-colors hover:text-foreground"
>
View Registry
</Link>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
{mounted && (
<Button variant="ghost" onClick={toggleTheme} size="md">
<Button variant="ghost" onClick={toggleTheme} size="sm">
{theme === 'dark' ? '☀️' : '🌙'}
</Button>
)}
<Button variant="ghost" onClick={onClear} size="md">
<Button variant="ghost" onClick={onClear} size="sm">
Clear Chat
</Button>
</div>

View file

@ -32,20 +32,31 @@ export function ChatInput({
};
return (
<form onSubmit={onSubmit} className="border-t border-border bg-background p-4">
<div className="mx-auto flex max-w-4xl gap-2">
<Textarea
value={input}
onChange={onInputChange}
onKeyDown={handleKeyDown}
placeholder="Ask me to tell you a fish joke..."
className="min-h-[60px] flex-1 resize-none"
disabled={isLoading}
rows={3}
/>
<Button type="submit" disabled={!input.trim() || isLoading} loading={isLoading} size="lg">
Send
</Button>
<form onSubmit={onSubmit} className="border-t border-border bg-surface p-4">
<div className="mx-auto max-w-3xl">
<div className="flex items-end gap-3 rounded-xl border border-border bg-background p-3 shadow-sm">
<Textarea
value={input}
onChange={onInputChange}
onKeyDown={handleKeyDown}
placeholder="Ask me to tell you a fish joke..."
className="min-h-[44px] max-h-[200px] flex-1 resize-none border-0 bg-transparent p-0 focus:ring-0"
disabled={isLoading}
rows={1}
/>
<Button
type="submit"
disabled={!input.trim() || isLoading}
loading={isLoading}
size="md"
className="shrink-0"
>
Send
</Button>
</div>
<p className="mt-2 text-center text-xs text-foreground-tertiary">
Press Enter to send, Shift+Enter for new line
</p>
</div>
</form>
);

View file

@ -22,11 +22,20 @@ export function ChatMessages({
if (messages.length === 0) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center text-foreground-secondary">
<div className="mb-2 text-4xl">🐟</div>
<p className="text-lg">Start chatting to test TPMJS tools</p>
<p className="mt-2 text-sm">Try asking: &ldquo;Tell me a fish joke&rdquo;</p>
<div className="flex h-full items-center justify-center p-8">
<div className="max-w-md text-center">
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-surface">
<span className="text-3xl">🐟</span>
</div>
<h2 className="mb-2 text-lg font-semibold text-foreground">Test TPMJS Tools</h2>
<p className="mb-4 text-sm text-foreground-secondary">
Select tools from the sidebar and start chatting to test them.
</p>
<div className="rounded-lg border border-dashed border-border bg-surface/50 p-4">
<p className="text-xs text-foreground-tertiary">
Try: &ldquo;Tell me a fish joke&rdquo; or &ldquo;What tools are available?&rdquo;
</p>
</div>
</div>
</div>
);

View file

@ -1,7 +1,6 @@
'use client';
import { Badge } from '@tpmjs/ui/Badge/Badge';
import { Card, CardContent } from '@tpmjs/ui/Card/Card';
import type { UIMessage } from 'ai';
import { useEffect, useRef, useState } from 'react';
import { Streamdown } from 'streamdown';
@ -93,119 +92,134 @@ export function MessageBubble({
return (
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
<Card variant={isUser ? 'elevated' : 'outline'} className="w-full max-w-2xl">
<CardContent className="p-4">
<div className="mb-2 flex items-center gap-2">
<Badge variant={isUser ? 'default' : 'secondary'} size="sm">
{isUser ? 'You' : 'AI'}
</Badge>
<span className="text-xs text-foreground-tertiary">
{new Date().toLocaleTimeString()}
</span>
<div
className={`w-full max-w-3xl rounded-xl border p-4 ${
isUser ? 'border-foreground/10 bg-foreground/5' : 'border-border bg-background shadow-sm'
}`}
>
<div className="mb-3 flex items-center gap-2">
<div
className={`flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium ${
isUser ? 'bg-foreground text-background' : 'bg-surface text-foreground-secondary'
}`}
>
{isUser ? 'Y' : 'AI'}
</div>
<span className="text-xs font-medium text-foreground-secondary">
{isUser ? 'You' : 'Assistant'}
</span>
<span className="text-xs text-foreground-tertiary">
{new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
{/* Render message parts */}
{message.parts && message.parts.length > 0 ? (
<div className="space-y-3">
{message.parts.map((part, idx) => {
// Skip step-start markers
if (part.type === 'step-start') {
return null;
}
// Render text parts with markdown support
if (part.type === 'text') {
const timing = getPartTiming(part, idx);
return (
<div key={`text-${message.id}-${idx}`} className="text-sm">
<Streamdown isAnimating={isStreaming && !isUser}>
{part.text || ''}
</Streamdown>
{timing?.duration && (
<div className="mt-1 text-xs text-foreground-tertiary">
{formatDuration(timing.duration)}
</div>
)}
</div>
);
}
// Render tool calls (type starts with 'tool-')
if (part.type.startsWith('tool-')) {
const toolName = part.type.replace('tool-', '');
// Type assertion for tool parts
const toolPart = part as any;
const timing = getPartTiming(part, idx);
return (
<div
key={toolPart.toolCallId || idx}
className="rounded border border-amber-500/20 bg-amber-500/5 p-3"
>
<div className="mb-3 flex items-center gap-2 border-b border-amber-500/20 pb-2">
<span className="text-base">🔧</span>
<strong className="font-mono text-sm text-foreground">{toolName}</strong>
{toolPart.state && (
<Badge variant="secondary" size="sm">
{toolPart.state}
</Badge>
)}
{timing?.duration && (
<span className="ml-auto text-xs text-foreground-tertiary">
{formatDuration(timing.duration)}
</span>
)}
</div>
{/* Tool Input */}
{toolPart.input && (
<div className="mb-3">
<div className="mb-1 text-xs font-semibold text-foreground-secondary">
Input:
</div>
<pre className="overflow-x-auto rounded bg-surface p-2 text-xs text-foreground-secondary">
{JSON.stringify(toolPart.input, null, 2)}
</pre>
</div>
)}
{/* Tool Output */}
{toolPart.output && (
<div>
<div className="mb-1 text-xs font-semibold text-foreground-secondary">
Output:
</div>
<pre className="overflow-x-auto rounded bg-surface p-2 text-xs text-foreground-secondary">
{JSON.stringify(toolPart.output, null, 2)}
</pre>
</div>
)}
{/* Tool Error */}
{(toolPart.errorText || toolPart.error) && (
<div>
<div className="mb-1 text-xs font-semibold text-red-500">Error:</div>
<pre className="overflow-x-auto rounded bg-red-500/10 p-2 text-xs text-red-400">
{typeof (toolPart.errorText || toolPart.error) === 'string'
? toolPart.errorText || toolPart.error
: JSON.stringify(toolPart.errorText || toolPart.error, null, 2)}
</pre>
</div>
)}
</div>
);
}
{/* Render message parts */}
{message.parts && message.parts.length > 0 ? (
<div className="space-y-4">
{message.parts.map((part, idx) => {
// Skip step-start markers
if (part.type === 'step-start') {
return null;
})}
</div>
) : (
// Fallback if no parts
<div className="text-sm">
<Streamdown isAnimating={isStreaming && !isUser}>...</Streamdown>
</div>
)}
</CardContent>
</Card>
}
// Render text parts with markdown support
if (part.type === 'text') {
const timing = getPartTiming(part, idx);
return (
<div key={`text-${message.id}-${idx}`} className="text-sm leading-relaxed">
<Streamdown isAnimating={isStreaming && !isUser}>{part.text || ''}</Streamdown>
{timing?.duration && (
<div className="mt-2 text-xs text-foreground-tertiary">
{formatDuration(timing.duration)}
</div>
)}
</div>
);
}
// Render tool calls (type starts with 'tool-')
if (part.type.startsWith('tool-')) {
const toolName = part.type.replace('tool-', '');
// Type assertion for tool parts
const toolPart = part as any;
const timing = getPartTiming(part, idx);
const isResult = toolPart.state === 'result';
const hasError = toolPart.errorText || toolPart.error;
return (
<div
key={toolPart.toolCallId || idx}
className={`rounded-lg border p-3 ${
hasError
? 'border-red-500/30 bg-red-500/5'
: 'border-amber-500/30 bg-amber-500/5'
}`}
>
<div className="mb-2 flex items-center gap-2">
<span className="text-sm">🔧</span>
<code className="text-sm font-medium">{toolName}</code>
<Badge
variant={hasError ? 'error' : isResult ? 'success' : 'secondary'}
size="sm"
>
{hasError ? 'error' : toolPart.state || 'running'}
</Badge>
{timing?.duration && (
<span className="ml-auto text-xs text-foreground-tertiary">
{formatDuration(timing.duration)}
</span>
)}
</div>
{/* Tool Input */}
{toolPart.input && (
<details className="group">
<summary className="cursor-pointer text-xs font-medium text-foreground-secondary hover:text-foreground">
Input
</summary>
<pre className="mt-2 overflow-x-auto rounded-md bg-surface p-2 text-xs text-foreground-secondary">
{JSON.stringify(toolPart.input, null, 2)}
</pre>
</details>
)}
{/* Tool Output */}
{toolPart.output && (
<details className="group mt-2" open>
<summary className="cursor-pointer text-xs font-medium text-foreground-secondary hover:text-foreground">
Output
</summary>
<pre className="mt-2 max-h-48 overflow-auto rounded-md bg-surface p-2 text-xs text-foreground-secondary">
{JSON.stringify(toolPart.output, null, 2)}
</pre>
</details>
)}
{/* Tool Error */}
{hasError && (
<div className="mt-2">
<div className="mb-1 text-xs font-medium text-red-500">Error</div>
<pre className="overflow-x-auto rounded-md bg-red-500/10 p-2 text-xs text-red-400">
{typeof (toolPart.errorText || toolPart.error) === 'string'
? toolPart.errorText || toolPart.error
: JSON.stringify(toolPart.errorText || toolPart.error, null, 2)}
</pre>
</div>
)}
</div>
);
}
return null;
})}
</div>
) : (
// Fallback if no parts
<div className="text-sm">
<Streamdown isAnimating={isStreaming && !isUser}>...</Streamdown>
</div>
)}
</div>
</div>
);
}

View file

@ -2,7 +2,6 @@
import { Badge } from '@tpmjs/ui/Badge/Badge';
import { Button } from '@tpmjs/ui/Button/Button';
import { Card, CardContent, CardHeader, CardTitle } from '@tpmjs/ui/Card/Card';
import { Input } from '@tpmjs/ui/Input/Input';
import { useEffect, useState } from 'react';
@ -65,24 +64,32 @@ export function SettingsSidebar(): React.ReactElement {
};
return (
<aside className="hidden w-80 border-l border-border bg-surface md:block">
<div className="flex h-full flex-col p-4">
<h2 className="mb-4 text-lg font-bold">Settings</h2>
<aside className="hidden w-72 border-l border-border bg-surface md:block">
<div className="flex h-full flex-col">
{/* Header */}
<div className="border-b border-border p-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold uppercase tracking-wide text-foreground-secondary">
Settings
</h2>
</div>
</div>
{/* Environment Variables Section */}
<Card variant="outline" className="mb-4">
<CardHeader>
<CardTitle className="text-sm">
Environment Variables{' '}
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{/* Environment Variables Section */}
<div className="mb-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-xs font-semibold uppercase tracking-wide text-foreground-tertiary">
Environment Variables
</h3>
<Badge variant="secondary" size="sm">
{envVars.length}
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<p className="mb-4 text-xs text-foreground-secondary">
Add API keys and other environment variables. They will be forwarded to tool
executions.
</div>
<p className="mb-4 text-xs text-foreground-tertiary">
Add API keys for tools that require authentication.
</p>
{/* Add new env var form */}
@ -109,24 +116,26 @@ export function SettingsSidebar(): React.ReactElement {
{/* List of env vars */}
<div className="space-y-2">
{envVars.length === 0 ? (
<p className="text-xs text-foreground-tertiary">No environment variables set</p>
<div className="rounded-lg border border-dashed border-border p-4 text-center">
<p className="text-xs text-foreground-tertiary">No variables configured</p>
</div>
) : (
envVars.map((env) => (
<div
key={env.key}
className="flex items-center justify-between rounded border border-border bg-background p-2"
className="flex items-center justify-between rounded-lg border border-border bg-background p-3"
>
<div className="flex-1 overflow-hidden">
<p className="truncate font-mono text-xs font-semibold">{env.key}</p>
<p className="truncate font-mono text-xs font-medium">{env.key}</p>
<p className="truncate font-mono text-xs text-foreground-tertiary">
{env.value ? '•'.repeat(Math.min(env.value.length, 20)) : '(empty)'}
{env.value ? '•'.repeat(Math.min(env.value.length, 16)) : '(empty)'}
</p>
</div>
<Button
onClick={() => handleRemoveEnvVar(env.key)}
size="sm"
variant="ghost"
className="ml-2"
className="ml-2 h-6 w-6 p-0 text-foreground-tertiary hover:text-foreground"
>
×
</Button>
@ -134,14 +143,13 @@ export function SettingsSidebar(): React.ReactElement {
))
)}
</div>
</CardContent>
</Card>
</div>
</div>
{/* Info Section */}
<div className="mt-auto rounded border border-border bg-background p-3">
<p className="text-xs text-foreground-secondary">
<strong>Note:</strong> Environment variables are stored locally in your browser and sent
with each tool execution request.
{/* Footer Info */}
<div className="border-t border-border p-4">
<p className="text-xs text-foreground-tertiary">
Variables are stored in your browser and sent with each tool execution.
</p>
</div>
</div>

View file

@ -1,7 +1,6 @@
'use client';
import { Badge } from '@tpmjs/ui/Badge/Badge';
import { Card, CardContent, CardHeader, CardTitle } from '@tpmjs/ui/Card/Card';
import { Checkbox } from '@tpmjs/ui/Checkbox/Checkbox';
import { Input } from '@tpmjs/ui/Input/Input';
import { ToolHealthBadge } from '@tpmjs/ui/ToolHealthBadge/ToolHealthBadge';
@ -76,69 +75,84 @@ export function ToolsSidebar(): React.ReactElement {
return (
<>
<aside className="hidden w-64 border-r border-border bg-surface md:block">
<div className="flex h-full flex-col p-4">
<h2 className="mb-4 text-lg font-bold">
Available Tools <Badge variant="secondary">{filteredTools.length}</Badge>
</h2>
<aside className="hidden w-72 border-r border-border bg-surface md:block">
<div className="flex h-full flex-col">
{/* Header */}
<div className="border-b border-border p-4">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-semibold uppercase tracking-wide text-foreground-secondary">
Tools
</h2>
<Badge variant="secondary" size="sm">
{filteredTools.length}
</Badge>
</div>
<Input
type="text"
placeholder="Filter tools..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="mb-3"
/>
<div className="mb-4 flex items-center gap-2 text-sm text-foreground-secondary">
<Checkbox
id="hide-broken"
checked={hideBroken}
onChange={(e) => setHideBroken(e.target.checked)}
size="sm"
<Input
type="text"
placeholder="Search tools..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="mb-3"
/>
{/* biome-ignore lint/a11y/noLabelWithoutControl: Checkbox is associated via htmlFor */}
<label htmlFor="hide-broken" className="cursor-pointer">
Hide broken tools
</label>
<div className="flex items-center gap-2 text-xs text-foreground-secondary">
<Checkbox
id="hide-broken"
checked={hideBroken}
onChange={(e) => setHideBroken(e.target.checked)}
size="sm"
/>
<label htmlFor="hide-broken" className="cursor-pointer select-none">
Hide broken tools
</label>
</div>
</div>
<div className="flex-1 space-y-2 overflow-y-auto">
{loading ? (
<p className="text-sm text-foreground-secondary">Loading tools...</p>
) : filteredTools.length === 0 ? (
<p className="text-sm text-foreground-secondary">No tools found</p>
) : (
filteredTools.map((tool) => (
<Card
key={`${tool.packageName}-${tool.exportName}`}
variant="outline"
className="cursor-pointer transition-all hover:bg-background hover:shadow-md"
onClick={() => setSelectedTool(tool)}
>
<CardHeader>
<CardTitle className="text-sm">{tool.exportName}</CardTitle>
<p className="text-xs text-foreground-tertiary mt-0.5">{tool.packageName}</p>
</CardHeader>
<CardContent>
<p className="text-xs text-foreground-secondary line-clamp-2">
{tool.description}
</p>
<div className="mt-2 flex items-center gap-2 flex-wrap">
<Badge variant="secondary" size="sm">
{tool.category}
</Badge>
<span className="text-xs text-foreground-tertiary">v{tool.version}</span>
{/* Tools List */}
<div className="flex-1 overflow-y-auto p-3">
<div className="space-y-2">
{loading ? (
<div className="flex items-center justify-center py-8">
<p className="text-sm text-foreground-tertiary">Loading tools...</p>
</div>
) : filteredTools.length === 0 ? (
<div className="flex items-center justify-center py-8">
<p className="text-sm text-foreground-tertiary">No tools found</p>
</div>
) : (
filteredTools.map((tool) => (
// biome-ignore lint/a11y/useSemanticElements: Custom styled card with complex layout
<div
key={`${tool.packageName}-${tool.exportName}`}
className="cursor-pointer rounded-lg border border-border bg-background p-3 transition-all hover:border-foreground-tertiary hover:shadow-sm"
onClick={() => setSelectedTool(tool)}
onKeyDown={(e) => e.key === 'Enter' && setSelectedTool(tool)}
role="button"
tabIndex={0}
>
<div className="mb-1 flex items-start justify-between gap-2">
<h3 className="text-sm font-medium leading-tight">{tool.exportName}</h3>
<ToolHealthBadge
importHealth={tool.importHealth}
executionHealth={tool.executionHealth}
size="sm"
/>
</div>
</CardContent>
</Card>
))
)}
<p className="mb-2 text-xs text-foreground-tertiary">{tool.packageName}</p>
<p className="text-xs text-foreground-secondary line-clamp-2">
{tool.description}
</p>
<div className="mt-2 flex items-center gap-2">
<Badge variant="secondary" size="sm">
{tool.category}
</Badge>
<span className="text-xs text-foreground-tertiary">v{tool.version}</span>
</div>
</div>
))
)}
</div>
</div>
</div>
</aside>
@ -159,7 +173,7 @@ export function ToolsSidebar(): React.ReactElement {
tabIndex={0}
aria-label="Close modal"
>
<div className="relative mx-4 max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-lg border border-border bg-white dark:bg-gray-900 p-6 shadow-xl">
<div className="relative mx-4 max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-xl border border-border bg-background p-6 shadow-xl">
{/* Close button */}
<button
type="button"