Visual and component guidelines for TSC plugins. All plugins inherit styles from TSC host.
Plugins render inside TSC's DOM, so all styles are inherited automatically:
TSC Host (index.css)
└─ :root CSS variables (colors, radius, etc.)
└─ Tailwind utilities loaded
└─ Dark mode (.dark class)
└─ Montserrat font
└─ Plugin Container (PluginView.tsx)
└─ Your Plugin (bundle.js)
└─ Uses inherited styles!
DO NOT bundle your own CSS. Just use Tailwind classes.
Use semantic color tokens (not hardcoded hex):
| Token | Usage |
|---|---|
bg-background |
Page/container background |
bg-card |
Card backgrounds |
text-foreground |
Primary text |
text-muted-foreground |
Secondary/subtle text |
bg-primary |
Primary buttons, accents |
text-primary |
Links, emphasis |
bg-destructive |
Error states, delete actions |
border |
Default borders |
// Success (green)
className="bg-green-500/10 text-green-600 dark:text-green-400"
// Error/Failed (red)
className="bg-red-500/10 text-red-600 dark:text-red-400"
// Warning/Running (yellow)
className="bg-yellow-500/10 text-yellow-600 dark:text-yellow-400"
// Inactive/Stopped (gray)
className="bg-muted text-muted-foreground"Tabs,TabsList,TabsTrigger,TabsContentCard,CardHeader,CardTitle,CardContentButtonBadgeDialog,DialogContent,DialogHeader,DialogTitleScrollArea
Table,TableHeader,TableRow,TableCellSelect,SelectTrigger,SelectContent,SelectItemPagination(or custom)
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
export function PluginApp() {
return (
<div className="p-6">
<Tabs defaultValue="services">
<TabsList>
<TabsTrigger value="services">Services</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>
<TabsContent value="services" className="mt-6">
{/* Content */}
</TabsContent>
<TabsContent value="settings" className="mt-6">
{/* Content */}
</TabsContent>
</Tabs>
</div>
)
}import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
interface ServiceCardProps {
name: string
status: "running" | "stopped" | "failed"
uptime?: string
onStart: () => void
onStop: () => void
onRestart: () => void
onLogs: () => void
}
export function ServiceCard({ name, status, uptime, ...actions }: ServiceCardProps) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-base font-medium">{name}</CardTitle>
<StatusBadge status={status} />
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{uptime ? `Up ${uptime}` : "Not running"}
</span>
<div className="flex gap-2">
{status === "running" ? (
<>
<Button size="sm" variant="outline" onClick={actions.onRestart}>
Restart
</Button>
<Button size="sm" variant="outline" onClick={actions.onStop}>
Stop
</Button>
</>
) : (
<Button size="sm" onClick={actions.onStart}>
Start
</Button>
)}
<Button size="sm" variant="ghost" onClick={actions.onLogs}>
Logs
</Button>
</div>
</div>
</CardContent>
</Card>
)
}import { Badge } from "@/components/ui/badge"
type Status = "running" | "stopped" | "failed" | "success" | "pending"
const statusConfig: Record<Status, { label: string; className: string }> = {
running: {
label: "Running",
className: "bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20",
},
stopped: {
label: "Stopped",
className: "bg-muted text-muted-foreground",
},
failed: {
label: "Failed",
className: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
},
success: {
label: "Success",
className: "bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20",
},
pending: {
label: "Pending",
className: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 border-yellow-500/20",
},
}
export function StatusBadge({ status }: { status: Status }) {
const config = statusConfig[status]
return (
<Badge variant="outline" className={config.className}>
{config.label}
</Badge>
)
}// Simple colored dot for inline status
export function StatusDot({ status }: { status: "running" | "stopped" | "failed" }) {
const colors = {
running: "bg-green-500",
stopped: "bg-muted-foreground",
failed: "bg-red-500",
}
return (
<span className={`inline-block h-2 w-2 rounded-full ${colors[status]}`} />
)
}
// Usage
<div className="flex items-center gap-2">
<StatusDot status="running" />
<span>nginx.service</span>
</div>import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { ScrollArea } from "@/components/ui/scroll-area"
interface LogDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
serviceName: string
logs: string[]
}
export function LogDialog({ open, onOpenChange, serviceName, logs }: LogDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[80vh]">
<DialogHeader>
<DialogTitle>Logs: {serviceName}</DialogTitle>
</DialogHeader>
<ScrollArea className="h-[60vh] rounded-md border bg-muted/50 p-4">
<pre className="font-mono text-sm whitespace-pre-wrap">
{logs.join("\n")}
</pre>
</ScrollArea>
</DialogContent>
</Dialog>
)
}import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
interface Execution {
id: string
startedAt: string
duration: string
status: "success" | "failed" | "running"
trigger: "timer" | "manual"
}
export function HistoryTable({
executions,
onRowClick
}: {
executions: Execution[]
onRowClick: (id: string) => void
}) {
return (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Time</TableHead>
<TableHead>Status</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Trigger</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{executions.map((exec) => (
<TableRow
key={exec.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => onRowClick(exec.id)}
>
<TableCell className="font-medium">{exec.startedAt}</TableCell>
<TableCell>
<StatusBadge status={exec.status} />
</TableCell>
<TableCell>{exec.duration}</TableCell>
<TableCell className="text-muted-foreground">{exec.trigger}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
export function TimerSelector({
timers,
value,
onChange,
}: {
timers: { name: string; label: string }[]
value: string
onChange: (value: string) => void
}) {
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger className="w-[280px]">
<SelectValue placeholder="Select a timer" />
</SelectTrigger>
<SelectContent>
{timers.map((timer) => (
<SelectItem key={timer.name} value={timer.name}>
{timer.label}
</SelectItem>
))}
</SelectContent>
</Select>
)
}import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button"
interface SettingsMultiselectProps {
items: { name: string; description?: string }[]
selected: string[]
onChange: (selected: string[]) => void
onSave: () => void
}
export function SettingsMultiselect({
items,
selected,
onChange,
onSave
}: SettingsMultiselectProps) {
const toggle = (name: string) => {
if (selected.includes(name)) {
onChange(selected.filter((s) => s !== name))
} else {
onChange([...selected, name])
}
}
return (
<div className="space-y-4">
<div className="space-y-3">
{items.map((item) => (
<div key={item.name} className="flex items-start space-x-3">
<Checkbox
id={item.name}
checked={selected.includes(item.name)}
onCheckedChange={() => toggle(item.name)}
/>
<div className="grid gap-1.5 leading-none">
<Label htmlFor={item.name} className="font-medium">
{item.name}
</Label>
{item.description && (
<p className="text-sm text-muted-foreground">
{item.description}
</p>
)}
</div>
</div>
))}
</div>
<Button onClick={onSave}>Save Settings</Button>
</div>
)
}- Page padding:
p-6 - Card gap in grid:
gap-4 - Section spacing:
space-y-6 - Button gaps:
gap-2
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{services.map((s) => <ServiceCard key={s.name} {...s} />)}
</div><div className="flex flex-col items-center justify-center py-12 text-center">
<p className="text-muted-foreground">No services configured</p>
<Button variant="link" onClick={goToSettings}>
Add services in Settings
</Button>
</div>Use lucide-react icons (already available in TSC):
import {
Play, // Start
Square, // Stop
RotateCw, // Restart
ScrollText, // Logs
Settings, // Settings tab
Clock, // Timer/Schedule
CheckCircle, // Success
XCircle, // Failed
Loader2, // Loading spinner
} from "lucide-react"
// Icon in button
<Button size="sm" variant="outline">
<RotateCw className="h-4 w-4 mr-1" />
Restart
</Button>
// Loading state
<Loader2 className="h-4 w-4 animate-spin" />frontend/
├── src/
│ ├── main.tsx # Entry point with mount/unmount
│ ├── App.tsx # Main app with tabs
│ ├── components/
│ │ ├── ui/ # shadcn components (copied)
│ │ │ ├── button.tsx
│ │ │ ├── card.tsx
│ │ │ ├── tabs.tsx
│ │ │ └── ...
│ │ ├── ServiceCard.tsx # Plugin-specific
│ │ ├── StatusBadge.tsx
│ │ ├── LogDialog.tsx
│ │ └── ...
│ ├── hooks/
│ │ └── useServices.ts # Data fetching
│ └── lib/
│ └── utils.ts # cn() helper
├── vite.config.ts # IIFE build config
└── package.json
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
build: {
lib: {
entry: path.resolve(__dirname, 'src/main.tsx'),
name: 'SystemdServicesPlugin', // or SystemdTimersPlugin
formats: ['iife'],
fileName: () => 'bundle.js',
},
rollupOptions: {
// Don't externalize anything - bundle everything
external: [],
},
},
})// src/main.tsx
import { createRoot, Root } from 'react-dom/client'
import App from './App'
const PLUGIN_ID = 'systemd-services'
let root: Root | null = null
// Register with TSC
;(window as any).ToruPlugins = (window as any).ToruPlugins || {}
;(window as any).ToruPlugins[PLUGIN_ID] = {
mount(container: HTMLElement, api: any) {
root = createRoot(container)
root.render(<App api={api} />)
},
unmount(container: HTMLElement) {
if (root) {
root.unmount()
root = null
}
},
}Before shipping:
- Uses semantic color tokens (not hardcoded colors)
- Works in dark mode
- Loading states show
Loader2spinner - Empty states have helpful message
- Buttons have appropriate variants
- Cards have consistent padding
- Tables are responsive
- Dialogs have proper max-width
- Icons are from lucide-react