diff --git a/src/components/layout/workflow-card-meta.ts b/src/components/layout/workflow-card-meta.ts
new file mode 100644
index 0000000..4d5dabb
--- /dev/null
+++ b/src/components/layout/workflow-card-meta.ts
@@ -0,0 +1,39 @@
+import type { RadarSourceType, JanitorSourceType } from "@/lib/graph-api"
+import type { LucideIcon } from "lucide-react"
+import { AtSign, Video, Rss, Hash, Sparkles, Layers, ShieldCheck } from "lucide-react"
+
+export interface WorkflowTypeMeta {
+ icon: LucideIcon
+ label: string
+ tone: string // Tailwind color group, e.g. "sky", "red", "violet"
+}
+
+export const WORKFLOW_TYPE_META: Record = {
+ twitter_handle: { icon: AtSign, label: "Twitter Handle", tone: "sky" },
+ youtube_channel: { icon: Video, label: "YouTube Channel", tone: "red" },
+ rss: { icon: Rss, label: "RSS Feed", tone: "orange" },
+ topic: { icon: Hash, label: "Topic", tone: "amber" },
+ deduplication: { icon: Layers, label: "Deduplication", tone: "violet" },
+ content_review: { icon: ShieldCheck, label: "Content Review", tone: "emerald" },
+ topic_review: { icon: Sparkles, label: "Topic Review", tone: "fuchsia" },
+}
+
+// Static tone → Tailwind class mapping to avoid Tailwind purge issues
+export const TONE_CLASSES: Record = {
+ sky: "bg-sky-500/10 text-sky-400 border-sky-500/20",
+ red: "bg-red-500/10 text-red-400 border-red-500/20",
+ orange: "bg-orange-500/10 text-orange-400 border-orange-500/20",
+ amber: "bg-amber-500/10 text-amber-400 border-amber-500/20",
+ violet: "bg-violet-500/10 text-violet-400 border-violet-500/20",
+ emerald: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
+ fuchsia: "bg-fuchsia-500/10 text-fuchsia-400 border-fuchsia-500/20",
+ slate: "bg-slate-500/10 text-slate-400 border-slate-500/20",
+}
+
+// Returns item.label if set, otherwise the human-friendly label from meta
+export function getWorkflowDisplayName(item: {
+ label?: string
+ source_type: RadarSourceType | JanitorSourceType
+}): string {
+ return item.label ?? WORKFLOW_TYPE_META[item.source_type]?.label ?? item.source_type
+}
diff --git a/src/components/layout/workflows-panel.tsx b/src/components/layout/workflows-panel.tsx
index d596639..1895bd0 100644
--- a/src/components/layout/workflows-panel.tsx
+++ b/src/components/layout/workflows-panel.tsx
@@ -6,6 +6,11 @@ import { ScrollArea } from "@/components/ui/scroll-area"
import { getWorkflowMarketplace, type WorkflowMarketplaceItem, type CronKind } from "@/lib/graph-api"
import { isMocksEnabled, MOCK_WORKFLOW_MARKETPLACE } from "@/lib/mock-data"
import { cn } from "@/lib/utils"
+import {
+ WORKFLOW_TYPE_META,
+ TONE_CLASSES,
+ getWorkflowDisplayName,
+} from "./workflow-card-meta"
type FilterKind = "all" | CronKind
@@ -26,33 +31,49 @@ function kindBadgeClass(kind: CronKind): string {
}
function WorkflowCard({ item }: { item: WorkflowMarketplaceItem }) {
- const label = item.label || item.source_type
+ const meta = WORKFLOW_TYPE_META[item.source_type]
+ const Icon = meta?.icon ?? Cpu
+ const tone = meta?.tone ?? "slate"
+ const displayName = getWorkflowDisplayName(item)
+
return (
-
-
+ {/* Tinted icon tile */}
+
+ >
+
+
+
+ {/* Name — single source of truth, no duplicate sub-text */}
-
{label}
-
- {item.source_type}
-
+
{displayName}
+
+
+ {/* Kind chip + enabled dot */}
+
+
+ {kindBadgeLabel(item.kind)}
+
+
-
- {kindBadgeLabel(item.kind)}
-
)
}
@@ -144,7 +165,7 @@ export function WorkflowsPanel({ onClose }: { onClose: () => void }) {
) : (
-
+
{filtered.map((item) => (
))}
diff --git a/src/lib/__tests__/workflows-panel.test.tsx b/src/lib/__tests__/workflows-panel.test.tsx
index f63a7b1..3fe52be 100644
--- a/src/lib/__tests__/workflows-panel.test.tsx
+++ b/src/lib/__tests__/workflows-panel.test.tsx
@@ -71,12 +71,18 @@ describe("WorkflowsPanel", () => {
render( {}} />)
await waitFor(() => {
- // twitter_handle appears twice (label fallback + sub-text); ensure at least one exists
- expect(screen.getAllByText("twitter_handle").length).toBeGreaterThan(0)
+ // twitter_handle has no label — humanized name "Twitter Handle" appears once
+ expect(screen.getByText("Twitter Handle")).toBeInTheDocument()
expect(screen.getByText("YouTube Channel")).toBeInTheDocument()
expect(screen.getByText("Deduplication")).toBeInTheDocument()
expect(screen.getByText("Content Review")).toBeInTheDocument()
})
+
+ // Icon tiles render with data-testid per source_type
+ expect(document.querySelector("[data-testid='workflow-icon-twitter_handle']")).toBeTruthy()
+ expect(document.querySelector("[data-testid='workflow-icon-youtube_channel']")).toBeTruthy()
+ expect(document.querySelector("[data-testid='workflow-icon-deduplication']")).toBeTruthy()
+ expect(document.querySelector("[data-testid='workflow-icon-content_review']")).toBeTruthy()
})
it("falls back to source_type when label is absent", async () => {
@@ -86,8 +92,9 @@ describe("WorkflowsPanel", () => {
render( {}} />)
await waitFor(() => {
- // rc-twitter has no label — source_type is rendered as both primary text and sub-text
- expect(screen.getAllByText("twitter_handle").length).toBeGreaterThan(0)
+ // rc-twitter has no label — humanized display name shown once, raw source_type not visible
+ expect(screen.getByText("Twitter Handle")).toBeInTheDocument()
+ expect(screen.queryByText("twitter_handle")).not.toBeInTheDocument()
})
})
@@ -123,8 +130,8 @@ describe("WorkflowsPanel", () => {
await userEvent.click(screen.getByRole("button", { name: "Ingestion" }))
- // Source items should be visible (twitter_handle may appear twice as label+sub-text)
- expect(screen.getAllByText("twitter_handle").length).toBeGreaterThan(0)
+ // Source items should be visible
+ expect(screen.getByText("Twitter Handle")).toBeInTheDocument()
expect(screen.getByText("YouTube Channel")).toBeInTheDocument()
// Janitor items should not be visible
expect(screen.queryByText("Deduplication")).not.toBeInTheDocument()
@@ -144,7 +151,7 @@ describe("WorkflowsPanel", () => {
expect(screen.getByText("Deduplication")).toBeInTheDocument()
expect(screen.getByText("Content Review")).toBeInTheDocument()
// Source items should not be visible
- expect(screen.queryByText("twitter_handle")).not.toBeInTheDocument()
+ expect(screen.queryByText("Twitter Handle")).not.toBeInTheDocument()
expect(screen.queryByText("YouTube Channel")).not.toBeInTheDocument()
})
@@ -159,7 +166,7 @@ describe("WorkflowsPanel", () => {
await userEvent.click(screen.getByRole("button", { name: "Janitor" }))
await userEvent.click(screen.getByRole("button", { name: "All" }))
- expect(screen.getAllByText("twitter_handle").length).toBeGreaterThan(0)
+ expect(screen.getByText("Twitter Handle")).toBeInTheDocument()
expect(screen.getByText("Deduplication")).toBeInTheDocument()
})
@@ -184,8 +191,9 @@ describe("WorkflowsPanel", () => {
render( {}} />)
await waitFor(() => {
- // MOCK_ITEMS has twitter_handle (no label) — appears as both primary text and sub-text
- expect(screen.getAllByText("twitter_handle").length).toBeGreaterThan(0)
+ // twitter_handle item has no label — humanized name shown once
+ expect(screen.getByText("Twitter Handle")).toBeInTheDocument()
+ expect(screen.queryByText("twitter_handle")).not.toBeInTheDocument()
})
})
})