From b7f2d84c775982775c5dc2dbc6f6a0aa06d2158a Mon Sep 17 00:00:00 2001 From: Renzo F <45401804+rennf93@users.noreply.github.com> Date: Wed, 15 Jul 2026 04:34:17 +0200 Subject: [PATCH] =?UTF-8?q?W9-5:=20Tooltip=20sweep=20=E2=80=94=20HelpTip?= =?UTF-8?q?=20helper=20+=20per-view=20decode=20(#533)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [W9-5a] Tooltip sweep foundation: HelpTip helper + shared cryptic badges HelpTip: DRY wrapper over the verbose 3-element Radix Tooltip pattern so a broad sweep stays a one-line wrap per site (falsy label short-circuits to the bare child). Unit-tested (3 cases). TaskStatusBadge + AgentStateBadge: the panel's most cryptic, most-frequent elements (15 task lifecycle states, 11 agent states) had no explanation anywhere. Add a per-state tooltip via HelpTip, with the canonical text in one description map and exported as taskStatusDescription / agentStateDescription so the per-view inline renderers (kanban, task header) reuse it instead of re-declaring. This is part 1 of the W9-5 tooltip sweep; the per-view inline surfaces follow in subsequent PRs. * [W9-5b] Per-view tooltip sweep: decode cryptic badges, icon-only buttons, status dots 35 HelpTip additions across 22 panel components, reusing the W9-5a helper plus taskStatusDescription/agentStateDescription. Tipped: task-id/commit-hash/branch/PR badges, severity/origin/status badges, priority (P0-P3), migration/shared flags, MegaTask umbrella badge, Review Gate / For Resumption / Confidential note badges, icon-only view/delete/edit/clear/show-hide buttons, semver bump + gate-state badges, ahead-not-pushed badge. Skipped self-explanatory labeled buttons and elements already carrying title=. --------- Co-authored-by: Renn F --- panel/src/components/a2a/a2a-transcript.tsx | 9 ++- .../components/agents/agent-state-badge.tsx | 31 +++++++-- panel/src/components/auditor/flagged-item.tsx | 9 ++- .../src/components/auditor/reports-panel.tsx | 9 ++- .../src/components/business/secretary-tab.tsx | 21 +++--- .../conventions/conventions-tab.tsx | 17 +++-- .../dashboard/active-blockers-panel.tsx | 9 ++- .../dashboard/ceo-approval-queue.tsx | 27 +++++--- .../dashboard/release-proposal-card.tsx | 13 ++-- .../dashboard/roadmap-review-queue.tsx | 9 ++- .../src/components/git/git-actions-panel.tsx | 11 +-- panel/src/components/git/git-branch-panel.tsx | 9 ++- panel/src/components/git/git-log-panel.tsx | 15 ++-- .../knowledge-base/kb-search-bar.tsx | 21 +++--- .../knowledge-base/knowledge-base-browser.tsx | 47 +++++++------ .../components/prompter/batch-review-card.tsx | 21 +++--- .../settings/self-hosted-section.tsx | 31 +++++---- .../tasks/task-detail/acceptance-criteria.tsx | 37 +++++----- .../tasks/task-detail/subtasks-list.tsx | 16 +++-- .../tasks/task-detail/tab-commits.tsx | 69 +++++++++++-------- .../tasks/task-detail/tab-dependencies.tsx | 41 ++++++----- .../tasks/task-detail/tab-notes.tsx | 31 +++++---- .../components/tasks/task-status-badge.tsx | 39 +++++++++-- panel/src/components/tasks/task-table.tsx | 29 ++++---- .../components/ui/__tests__/help-tip.test.tsx | 40 +++++++++++ panel/src/components/ui/help-tip.tsx | 34 +++++++++ 26 files changed, 438 insertions(+), 207 deletions(-) create mode 100644 panel/src/components/ui/__tests__/help-tip.test.tsx create mode 100644 panel/src/components/ui/help-tip.tsx diff --git a/panel/src/components/a2a/a2a-transcript.tsx b/panel/src/components/a2a/a2a-transcript.tsx index a3713156..05de109e 100644 --- a/panel/src/components/a2a/a2a-transcript.tsx +++ b/panel/src/components/a2a/a2a-transcript.tsx @@ -15,6 +15,7 @@ import { cn } from "@/lib/utils"; import type { A2AChatMessage } from "@/lib/api/a2a"; import { formatDistanceToNow } from "date-fns"; import { AlertTriangle, MessagesSquare } from "lucide-react"; +import { HelpTip } from "@/components/ui/help-tip"; interface A2ATranscriptProps { messages: A2AChatMessage[]; @@ -227,9 +228,11 @@ export function A2ATranscript({ {getAgentDisplayName(message.from_agent)} {message.message_kind && ( - - {message.message_kind} - + + + {message.message_kind} + + )} {formatDistanceToNow(new Date(message.created_at))} ago diff --git a/panel/src/components/agents/agent-state-badge.tsx b/panel/src/components/agents/agent-state-badge.tsx index 39d182af..1c6ac27a 100644 --- a/panel/src/components/agents/agent-state-badge.tsx +++ b/panel/src/components/agents/agent-state-badge.tsx @@ -1,4 +1,5 @@ import { Badge } from "@/components/ui/badge"; +import { HelpTip } from "@/components/ui/help-tip"; import { Clock, RefreshCw, @@ -53,12 +54,32 @@ const stateIcons: Record = { terminated: , }; +const stateDescriptions: Record = { + active: "Agent is actively working a task.", + running: "Agent is running — currently executing.", + ready: "Agent is ready and waiting for work.", + starting: "Agent container is starting up.", + idle: "Agent is idle — no task currently claimed.", + waiting_long: "Agent has been waiting a long time; may need attention.", + paused: "Agent is paused; can resume.", + stopped: "Agent container is stopped; can be restarted.", + terminated: "Agent container has been terminated.", + offline: "Agent is offline.", + error: "Agent hit an error state; needs attention.", +}; + interface AgentStateBadgeProps { state: AgentStateString | string; showIcon?: boolean; size?: "sm" | "md" | "lg"; } +/** Plain-language explanation for an agent state. Reused by the shared badge + * and by inline agent-state renderers. Empty for an unknown state. */ +export function agentStateDescription(state: string): string { + return stateDescriptions[state] ?? ""; +} + export function AgentStateBadge({ state, showIcon = true, @@ -74,10 +95,12 @@ export function AgentStateBadge({ const icon = stateIcons[state] || ; return ( - - {showIcon && {icon}} - {state.replace(/_/g, " ")} - + + + {showIcon && {icon}} + {state.replace(/_/g, " ")} + + ); } diff --git a/panel/src/components/auditor/flagged-item.tsx b/panel/src/components/auditor/flagged-item.tsx index 4e627b7f..a4250b28 100644 --- a/panel/src/components/auditor/flagged-item.tsx +++ b/panel/src/components/auditor/flagged-item.tsx @@ -3,6 +3,7 @@ import { AuditorFlag, FlagSeverity } from "@/types"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { HelpTip } from "@/components/ui/help-tip"; import { Eye, CheckCircle, Send, Clock } from "lucide-react"; import Link from "next/link"; @@ -89,9 +90,11 @@ export function FlaggedItem({
{flag.related_task_id && ( - + + + )}
- + + + {isDraft && ( + + + ) : (
- - {finding.rule} - + + {finding.rule} + + ))} diff --git a/panel/src/components/dashboard/active-blockers-panel.tsx b/panel/src/components/dashboard/active-blockers-panel.tsx index 5fc25a68..4fb9e4bf 100644 --- a/panel/src/components/dashboard/active-blockers-panel.tsx +++ b/panel/src/components/dashboard/active-blockers-panel.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { AlertTriangle, Clock, ArrowRight } from "lucide-react"; import Link from "next/link"; +import { HelpTip } from "@/components/ui/help-tip"; interface ActiveBlockersPanelProps { tasks: Task[] | undefined; @@ -71,9 +72,11 @@ export function ActiveBlockersPanel({ \uD83D\uDD34
- - Task #{task.id.slice(0, 8)} - + + + Task #{task.id.slice(0, 8)} + + {task.team.replace(/_/g, " ")} diff --git a/panel/src/components/dashboard/ceo-approval-queue.tsx b/panel/src/components/dashboard/ceo-approval-queue.tsx index 86e3b755..20179f4a 100644 --- a/panel/src/components/dashboard/ceo-approval-queue.tsx +++ b/panel/src/components/dashboard/ceo-approval-queue.tsx @@ -34,6 +34,7 @@ import { import Link from "next/link"; import { TaskStatus, Team, type Task } from "@/types"; import { toast } from "sonner"; +import { HelpTip } from "@/components/ui/help-tip"; interface CeoApprovalQueueProps { className?: string; @@ -171,18 +172,24 @@ export function CeoApprovalQueue({ className }: CeoApprovalQueueProps) { { label: string; variant: "default" | "secondary" | "destructive" | "outline"; + desc: string; } > = { - 0: { label: "P0", variant: "destructive" }, - 1: { label: "P1", variant: "destructive" }, - 2: { label: "P2", variant: "secondary" }, - 3: { label: "P3", variant: "outline" }, + 0: { label: "P0", variant: "destructive", desc: "Critical priority — blocks all other work" }, + 1: { label: "P1", variant: "destructive", desc: "High priority — should be done soon" }, + 2: { label: "P2", variant: "secondary", desc: "Medium priority — normal scheduling" }, + 3: { label: "P3", variant: "outline", desc: "Low priority — can wait" }, }; - const { label, variant } = variants[priority] || { + const { label, variant, desc } = variants[priority] || { label: `P${priority}`, variant: "outline" as const, + desc: `Priority level ${priority}`, }; - return {label}; + return ( + + {label} + + ); }; if (isLoading) { @@ -237,9 +244,11 @@ export function CeoApprovalQueue({ className }: CeoApprovalQueueProps) { clipping against it (mirrors the dialog footer's flex-col sm:flex-row). */}
- + + + diff --git a/panel/src/components/git/git-branch-panel.tsx b/panel/src/components/git/git-branch-panel.tsx index 45e4cdd6..3fec4afa 100644 --- a/panel/src/components/git/git-branch-panel.tsx +++ b/panel/src/components/git/git-branch-panel.tsx @@ -23,6 +23,7 @@ import { DialogTrigger, } from "@/components/ui/dialog"; import { GitBranch, Check, Cloud, Plus, RefreshCw } from "lucide-react"; +import { HelpTip } from "@/components/ui/help-tip"; interface GitBranchPanelProps { branches: GitBranchListResponse | undefined; @@ -183,9 +184,11 @@ export function GitBranchPanel({
{branch.last_commit && ( - - {branch.last_commit.slice(0, 7)} - + + + {branch.last_commit.slice(0, 7)} + + )} ))} diff --git a/panel/src/components/git/git-log-panel.tsx b/panel/src/components/git/git-log-panel.tsx index 03763ffe..a71b570f 100644 --- a/panel/src/components/git/git-log-panel.tsx +++ b/panel/src/components/git/git-log-panel.tsx @@ -9,6 +9,7 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { cn } from "@/lib/utils"; import { GitCommit, User, Calendar } from "lucide-react"; import { formatDistanceToNow } from "date-fns"; +import { HelpTip } from "@/components/ui/help-tip"; interface GitLogPanelProps { log: GitLogResponse | undefined; @@ -105,12 +106,14 @@ export function GitLogPanel({

{commit.message}

- - {commit.short_hash} - + + + {commit.short_hash} + +
diff --git a/panel/src/components/knowledge-base/kb-search-bar.tsx b/panel/src/components/knowledge-base/kb-search-bar.tsx index 923e159e..eacc378a 100644 --- a/panel/src/components/knowledge-base/kb-search-bar.tsx +++ b/panel/src/components/knowledge-base/kb-search-bar.tsx @@ -4,6 +4,7 @@ import { useState, useEffect } from "react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Search, X, Loader2 } from "lucide-react"; +import { HelpTip } from "@/components/ui/help-tip"; interface KBSearchBarProps { value: string; @@ -64,15 +65,17 @@ export function KBSearchBar({ className="pl-9 pr-9" /> {localValue && ( - + + + )}
{onSearch && ( diff --git a/panel/src/components/knowledge-base/knowledge-base-browser.tsx b/panel/src/components/knowledge-base/knowledge-base-browser.tsx index c204d015..b197c584 100644 --- a/panel/src/components/knowledge-base/knowledge-base-browser.tsx +++ b/panel/src/components/knowledge-base/knowledge-base-browser.tsx @@ -46,6 +46,7 @@ import { } from "lucide-react"; import { OfflineState } from "@/components/ui/offline-state"; import { Skeleton } from "@/components/ui/skeleton"; +import { HelpTip } from "@/components/ui/help-tip"; import { toast } from "sonner"; import { formatDistanceToNow } from "date-fns"; import { getErrorMessage } from "@/lib/api/client"; @@ -593,29 +594,33 @@ function KnowledgeBaseBrowserContent() {
- + + + - + + + diff --git a/panel/src/components/prompter/batch-review-card.tsx b/panel/src/components/prompter/batch-review-card.tsx index 570e08a7..06b4242e 100644 --- a/panel/src/components/prompter/batch-review-card.tsx +++ b/panel/src/components/prompter/batch-review-card.tsx @@ -17,6 +17,7 @@ import type { BatchProposal, StartRoute } from "@/hooks/use-prompter"; import type { ProjectSummary } from "@/types"; import type { CellWork, DraftProposal } from "@/lib/api/prompter"; import { Team } from "@/types"; +import { HelpTip } from "@/components/ui/help-tip"; /** The delivery cells a multi-cell draft fans out to (one the_work entry each). */ const CELL_TEAMS: Team[] = [Team.BACKEND, Team.FRONTEND, Team.UX_UI]; @@ -158,16 +159,20 @@ export function BatchReviewCard({
{draft.adds_migration && ( - - - migration - + + + + migration + + )} {draft.touches_shared && ( - - - shared - + + + + shared + + )}
diff --git a/panel/src/components/settings/self-hosted-section.tsx b/panel/src/components/settings/self-hosted-section.tsx index eb76ce91..b95b10d5 100644 --- a/panel/src/components/settings/self-hosted-section.tsx +++ b/panel/src/components/settings/self-hosted-section.tsx @@ -12,6 +12,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Badge } from "@/components/ui/badge"; +import { HelpTip } from "@/components/ui/help-tip"; import { AlertTriangle, CheckCircle2, @@ -194,20 +195,22 @@ export function SelfHostedSection({ } className="pr-10" /> - + + + - + + + + + + )} diff --git a/panel/src/components/tasks/task-detail/subtasks-list.tsx b/panel/src/components/tasks/task-detail/subtasks-list.tsx index e9af807d..c9787b51 100644 --- a/panel/src/components/tasks/task-detail/subtasks-list.tsx +++ b/panel/src/components/tasks/task-detail/subtasks-list.tsx @@ -9,6 +9,8 @@ import { ListTree, ExternalLink, Plus } from "lucide-react"; import Link from "next/link"; import { Button } from "@/components/ui/button"; import { getAgentDisplayName } from "@/lib/agent-utils"; +import { HelpTip } from "@/components/ui/help-tip"; +import { taskStatusDescription } from "@/components/tasks/task-status-badge"; interface SubtasksListProps { task: Task; @@ -128,12 +130,14 @@ export function SubtasksList({ task }: SubtasksListProps) { >
- - {subtask.status.replace(/_/g, " ")} - + + + {subtask.status.replace(/_/g, " ")} + + {subtask.title}
diff --git a/panel/src/components/tasks/task-detail/tab-commits.tsx b/panel/src/components/tasks/task-detail/tab-commits.tsx index b29db557..0d438bc6 100644 --- a/panel/src/components/tasks/task-detail/tab-commits.tsx +++ b/panel/src/components/tasks/task-detail/tab-commits.tsx @@ -20,6 +20,7 @@ import { } from "lucide-react"; import { toast } from "sonner"; import { getAgentDisplayName } from "@/lib/agent-utils"; +import { HelpTip } from "@/components/ui/help-tip"; interface TabCommitsProps { task: Task; @@ -122,24 +123,28 @@ export function TabCommits({ task }: TabCommitsProps) {
{task.branch_name && ( - - - {task.branch_name} - + + + + {task.branch_name} + + )} {task.pr_url && ( - - - PR #{task.pr_number} - - - + + + + PR #{task.pr_number} + + + + )}
@@ -243,12 +248,14 @@ export function TabCommits({ task }: TabCommitsProps) { {/* Meta info */}
{/* Hash */} - - {commit.hash.slice(0, 7)} - + + + {commit.hash.slice(0, 7)} + + {/* Author */} {commit.author_agent_id && ( @@ -266,14 +273,16 @@ export function TabCommits({ task }: TabCommitsProps) {
- + + +
diff --git a/panel/src/components/tasks/task-detail/tab-dependencies.tsx b/panel/src/components/tasks/task-detail/tab-dependencies.tsx index 78599fd3..27d834d8 100644 --- a/panel/src/components/tasks/task-detail/tab-dependencies.tsx +++ b/panel/src/components/tasks/task-detail/tab-dependencies.tsx @@ -20,6 +20,7 @@ import { } from "lucide-react"; import Link from "next/link"; import { toast } from "sonner"; +import { HelpTip } from "@/components/ui/help-tip"; interface TabDependenciesProps { task: Task; @@ -155,27 +156,31 @@ function DependencyList({ className={`flex items-center gap-2 p-3 rounded-lg ${itemBorderClass} ${itemBgClass} transition-colors`} > - - - {depId.slice(0, 8)}... - - + + + + {depId.slice(0, 8)}... + + + {badgeLabel} - + + + ))} diff --git a/panel/src/components/tasks/task-detail/tab-notes.tsx b/panel/src/components/tasks/task-detail/tab-notes.tsx index c44f9277..d6b6a736 100644 --- a/panel/src/components/tasks/task-detail/tab-notes.tsx +++ b/panel/src/components/tasks/task-detail/tab-notes.tsx @@ -24,6 +24,7 @@ import { Plus, } from "lucide-react"; import { toast } from "sonner"; +import { HelpTip } from "@/components/ui/help-tip"; interface TabNotesProps { task: Task; @@ -47,9 +48,11 @@ function prReviewBadge(task: Task): React.ReactNode { )?.pr_review?.verdict; if (!verdict) { return ( - - Review Gate - + + + Review Gate + + ); } const map: Record = { @@ -345,9 +348,11 @@ export function TabNotes({ task }: TabNotesProps) { title="Quick Context" icon={} badge={ - - For Resumption - + + + For Resumption + + } bgClass="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800" /> @@ -420,12 +425,14 @@ export function TabNotes({ task }: TabNotesProps) { title="Auditor Notes" icon={} badge={ - - Confidential - + + + Confidential + + } bgClass="bg-purple-50 dark:bg-purple-950 border border-purple-200 dark:border-purple-800" /> diff --git a/panel/src/components/tasks/task-status-badge.tsx b/panel/src/components/tasks/task-status-badge.tsx index efad1763..754b2da9 100644 --- a/panel/src/components/tasks/task-status-badge.tsx +++ b/panel/src/components/tasks/task-status-badge.tsx @@ -1,5 +1,6 @@ import { TaskStatus } from "@/types"; import { Badge } from "@/components/ui/badge"; +import { HelpTip } from "@/components/ui/help-tip"; const statusColors: Record = { [TaskStatus.BACKLOG]: "bg-slate-500", @@ -19,14 +20,44 @@ const statusColors: Record = { [TaskStatus.CANCELLED]: "bg-gray-400", }; +const statusDescriptions: Record = { + [TaskStatus.BACKLOG]: "PM setup phase — dependencies or session not ready yet.", + [TaskStatus.PENDING]: "Ready for work — the orchestrator can spawn an agent.", + [TaskStatus.CLAIMED]: "An agent has locked this task.", + [TaskStatus.IN_PROGRESS]: "Active development in progress.", + [TaskStatus.BLOCKED]: "An external dependency is blocking progress.", + [TaskStatus.PAUSED]: "Temporarily stopped; can resume.", + [TaskStatus.VERIFYING]: "The developer is self-verifying their work.", + [TaskStatus.NEEDS_REVISION]: + "QA / PR-review / PM / CEO requested changes — back with the dev.", + [TaskStatus.AWAITING_QA]: "Submitted for QA review (PR already open).", + [TaskStatus.AWAITING_DOCUMENTATION]: + "Documentation phase — the documenter is writing docs.", + [TaskStatus.AWAITING_PR_REVIEW]: + "PR-review gate: a reviewer checks the assembled PR before the PM merges.", + [TaskStatus.AWAITING_PM_REVIEW]: "Docs complete; the PM is reviewing and merging.", + [TaskStatus.AWAITING_CEO_APPROVAL]: "Escalated for the CEO's final approval.", + [TaskStatus.COMPLETED]: "Terminal — work done and merged.", + [TaskStatus.CANCELLED]: "Terminal — work cancelled.", +}; + interface TaskStatusBadgeProps { status: TaskStatus; } +/** Plain-language explanation for a task lifecycle state. Reused by the + * shared badge and by inline status renderers (kanban, task header) so the + * canonical text lives in one place. Empty for an unknown status. */ +export function taskStatusDescription(status: TaskStatus): string { + return statusDescriptions[status] ?? ""; +} + export function TaskStatusBadge({ status }: TaskStatusBadgeProps) { return ( - - {status.replace(/_/g, " ")} - + + + {status.replace(/_/g, " ")} + + ); -} +} \ No newline at end of file diff --git a/panel/src/components/tasks/task-table.tsx b/panel/src/components/tasks/task-table.tsx index 9e122841..9530cdb9 100644 --- a/panel/src/components/tasks/task-table.tsx +++ b/panel/src/components/tasks/task-table.tsx @@ -30,6 +30,7 @@ import { ResponsiveTableCardEmpty, } from "@/components/ui/responsive-table"; import { TaskStatusBadge } from "./task-status-badge"; +import { HelpTip } from "@/components/ui/help-tip"; import { TaskActions } from "./task-actions"; import { GitStatusBadge } from "./git-status-badge"; import Link from "next/link"; @@ -614,12 +615,14 @@ export function TaskTable({ {task.title}
{task.batch_id && !task.parent_task_id && ( - - MegaTask - + + + MegaTask + + )} {childCount > 0 && ( 0 ? (
{task.batch_id && !task.parent_task_id && ( - - MegaTask - + + + MegaTask + + )} {childCount > 0 && ( diff --git a/panel/src/components/ui/__tests__/help-tip.test.tsx b/panel/src/components/ui/__tests__/help-tip.test.tsx new file mode 100644 index 00000000..d0d53f32 --- /dev/null +++ b/panel/src/components/ui/__tests__/help-tip.test.tsx @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import React from "react"; + +// Radix Tooltip renders content into a portal only when open; for a static +// render we assert on the trigger passthrough and the short-circuit, not the +// (hidden) portal content. + +import { HelpTip } from "../help-tip"; + +describe("HelpTip", () => { + it("wraps the child so the child still renders", () => { + render( + + + , + ); + expect(screen.getByText("Refresh")).toBeInTheDocument(); + }); + + it("renders the child bare when label is falsy", () => { + const { container } = render( + + + , + ); + // No Tooltip wrapper: just the button. + expect(container.querySelector("button")).not.toBeNull(); + expect(container.firstChild).toBe(container.querySelector("button")); + }); + + it("renders the child bare when label is an empty string", () => { + const { container } = render( + + x + , + ); + expect(container.firstChild).toBe(container.querySelector("span")); + }); +}); \ No newline at end of file diff --git a/panel/src/components/ui/help-tip.tsx b/panel/src/components/ui/help-tip.tsx new file mode 100644 index 00000000..b4230821 --- /dev/null +++ b/panel/src/components/ui/help-tip.tsx @@ -0,0 +1,34 @@ +"use client"; + +import type { ReactNode } from "react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +interface HelpTipProps { + /** The explanation shown on hover/focus. Falsy → renders the child bare. */ + label: ReactNode; + /** Tooltip side relative to the trigger. */ + side?: "top" | "bottom" | "left" | "right"; + /** The element to hover; must forward its ref (Button, Badge, span, etc.). */ + children: ReactNode; +} + +/** + * DRY wrapper for the verbose three-element Tooltip pattern. Use for any + * element that benefits from a hover/focus explanation — icon-only buttons, + * status/severity/origin badges, health dots, metric numbers, abbreviations. + * A falsy `label` short-circuits to the bare child, so callers can gate a tip + * on whether an explanation is available without conditional markup. + */ +export function HelpTip({ label, side = "top", children }: HelpTipProps) { + if (!label) return <>{children}; + return ( + + {children} + {label} + + ); +} \ No newline at end of file