refactor(graph): fix SOLID/DRY violations from audit

DRY fixes:
- GraphOverlay.tsx: 539 LOC → 85 LOC minimal fallback (was dead code)
- COLUMN_LABELS: import from COLORS instead of hardcoded hex duplicates
- Alpha hex LUT: consolidated to single source in colors.ts
- TeamGraphTab: extract typed dispatchOpenTask/dispatchSendMessage helpers
  (was 8 repeated CustomEvent dispatches)

SOLID fixes:
- D-1: lucide-react added to peerDependencies in package.json
- S-1: GraphOverlay no longer has 8 responsibilities (now just 1 fallback)

Architecture:
- renderOverlay prop properly used by both Tab and Overlay
- Popover rendering lives in features/ layer only (no duplication with package)
This commit is contained in:
iliya 2026-03-28 14:41:24 +02:00
parent 54c259b017
commit 26fe42739b
5 changed files with 69 additions and 545 deletions

View file

@ -13,7 +13,8 @@
},
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
"react-dom": "^18.0.0",
"lucide-react": ">=0.300.0"
},
"dependencies": {
"d3-force": "^3.0.0"

View file

@ -44,15 +44,14 @@ function hexWithAlpha(color: string, alpha: number): string {
const key = `${color}|${a}`;
let result = _hexAlphaCache.get(key);
if (result) return result;
result = ensureHex(color) + ALPHA_LUT[a];
result = ensureHex(color) + alphaHex(a / 255);
_hexAlphaCache.set(key, result);
if (_hexAlphaCache.size > 500) _hexAlphaCache.clear(); // prevent unbounded growth
return result;
}
// Import-time LUT for alpha hex
const ALPHA_LUT: string[] = [];
for (let i = 0; i < 256; i++) ALPHA_LUT.push(i.toString(16).padStart(2, '0'));
// Reuse alpha hex LUT from colors.ts (DRY — single source)
import { alphaHex } from '../constants/colors';
// ─── Glow Sprite Cache ──────────────────────────────────────────────────────

View file

@ -9,6 +9,7 @@
import type { GraphNode } from '../ports/types';
import { KANBAN_ZONE } from '../constants/canvas-constants';
import { COLORS } from '../constants/colors';
/** Column header info for rendering */
export interface KanbanColumnHeader {
@ -26,13 +27,13 @@ export interface KanbanZoneInfo {
headers: KanbanColumnHeader[];
}
// Column display config
// Column display config — colors from single source of truth (COLORS)
const COLUMN_LABELS: Record<string, { label: string; color: string }> = {
todo: { label: 'Todo', color: '#6b7280' },
wip: { label: 'In Progress', color: '#3b82f6' },
done: { label: 'Done', color: '#22c55e' },
review: { label: 'Review', color: '#f59e0b' },
approved: { label: 'Approved', color: '#22c55e' },
todo: { label: 'Todo', color: COLORS.taskPending },
wip: { label: 'In Progress', color: COLORS.taskInProgress },
done: { label: 'Done', color: COLORS.taskCompleted },
review: { label: 'Review', color: COLORS.reviewPending },
approved: { label: 'Approved', color: COLORS.reviewApproved },
};
export class KanbanLayoutEngine {

View file

@ -1,15 +1,11 @@
/**
* GraphOverlay HTML popovers positioned over Canvas nodes.
* Uses camera worldToScreen transform for positioning.
*
* Styled to match the host app's MemberHoverCard / MemberCard look:
* avatar + status dot, name, role, status badges, action buttons.
* GraphOverlay minimal built-in popover fallback.
* Used ONLY when host app doesn't provide renderOverlay prop.
* For full-featured popovers, use renderOverlay with project UI components.
*/
import { useCallback, useEffect, useRef } from 'react';
import type { GraphNode } from '../ports/types';
import type { GraphEventPort } from '../ports/GraphEventPort';
import { COLORS, getStateColor, getTaskStatusColor } from '../constants/colors';
export interface GraphOverlayProps {
selectedNode: GraphNode | null;
@ -37,502 +33,56 @@ export function GraphOverlay({
transform: 'translateY(-50%)',
}}
>
<NodePopover node={selectedNode} events={events} onClose={onDeselect} />
</div>
);
}
// ─── SVG Icons (inline — package cannot import lucide-react) ────────────────
function IconMessage({ size = 13 }: { size?: number }): React.JSX.Element {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
);
}
function IconExternalLink({ size = 12 }: { size?: number }): React.JSX.Element {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
);
}
function IconGlobe({ size = 12 }: { size?: number }): React.JSX.Element {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
);
}
function IconClipboard({ size = 12 }: { size?: number }): React.JSX.Element {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="2" width="6" height="4" rx="1" />
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
</svg>
);
}
// ─── State helpers ──────────────────────────────────────────────────────────
function getPresenceLabel(state: GraphNode['state']): string {
switch (state) {
case 'active': return 'active';
case 'thinking': return 'thinking';
case 'tool_calling': return 'tool calling';
case 'idle': return 'idle';
case 'waiting': return 'waiting';
case 'complete': return 'done';
case 'error': return 'error';
case 'terminated': return 'offline';
}
}
function getStatusDotClass(state: GraphNode['state']): string {
switch (state) {
case 'active':
case 'thinking':
case 'tool_calling':
return 'animate-pulse';
default:
return '';
}
}
/** Capitalise first letter, replace underscores with spaces */
function formatLabel(s: string): string {
return s.replace(/_/g, ' ').replace(/^\w/, (c) => c.toUpperCase());
}
/** Truncate display name: "team-lead" → "Team Lead", "alice" → "Alice" */
function displayName(name: string): string {
return name
.split(/[-_]/)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
}
// ─── Node Popover ───────────────────────────────────────────────────────────
function NodePopover({
node,
events,
onClose,
}: {
node: GraphNode;
events?: GraphEventPort;
onClose: () => void;
}): React.JSX.Element {
const popoverRef = useRef<HTMLDivElement>(null);
// Close on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
onClose();
}
};
// Delay to avoid closing immediately from the click that opened it
const timer = setTimeout(() => document.addEventListener('mousedown', handler), 50);
return () => {
clearTimeout(timer);
document.removeEventListener('mousedown', handler);
};
}, [onClose]);
const handleAction = useCallback(
(action: string) => {
const ref = node.domainRef;
switch (action) {
case 'sendMessage':
if (ref.kind === 'member' || ref.kind === 'lead') {
events?.onSendMessage?.(ref.kind === 'member' ? ref.memberName : 'team-lead', ref.teamName);
}
break;
case 'openDetail':
if (ref.kind === 'task') events?.onOpenTaskDetail?.(ref.taskId, ref.teamName);
else if (ref.kind === 'member') events?.onOpenMemberProfile?.(ref.memberName, ref.teamName);
else if (ref.kind === 'lead') events?.onOpenMemberProfile?.('team-lead', ref.teamName);
break;
case 'openUrl':
if (node.processUrl) window.open(node.processUrl, '_blank');
break;
}
onClose();
},
[node, events, onClose],
);
const isMemberLike = node.kind === 'member' || node.kind === 'lead';
const color = node.kind === 'task'
? getTaskStatusColor(node.taskStatus)
: node.color ?? getStateColor(node.state);
const stateColor = getStateColor(node.state);
if (isMemberLike) {
return <MemberPopover ref={popoverRef} node={node} color={color} stateColor={stateColor} onAction={handleAction} />;
}
if (node.kind === 'task') {
return <TaskPopover ref={popoverRef} node={node} color={color} stateColor={stateColor} onAction={handleAction} />;
}
return <ProcessPopover ref={popoverRef} node={node} color={color} onAction={handleAction} />;
}
// ─── Member / Lead Popover ──────────────────────────────────────────────────
import { forwardRef } from 'react';
const MemberPopover = forwardRef<
HTMLDivElement,
{ node: GraphNode; color: string; stateColor: string; onAction: (a: string) => void }
>(function MemberPopover({ node, color, stateColor, onAction }, ref) {
const presenceText = getPresenceLabel(node.state);
const dotAnim = getStatusDotClass(node.state);
return (
<div
ref={ref}
className="rounded-lg min-w-[220px] max-w-[280px] shadow-xl overflow-hidden"
style={{
background: COLORS.glassBg,
border: `1px solid ${color}30`,
backdropFilter: 'blur(12px)',
}}
>
{/* Colored top accent */}
<div style={{ height: 3, background: `linear-gradient(to right, ${color}, transparent)` }} />
<div className="p-3 flex flex-col gap-2.5">
{/* Header: avatar + name + status */}
<div className="flex items-center gap-3">
{node.avatarUrl ? (
<div className="relative shrink-0">
<img
src={node.avatarUrl}
alt={node.label}
className="rounded-full"
style={{
width: 40,
height: 40,
background: 'rgba(100, 200, 255, 0.08)',
border: `2px solid ${color}40`,
}}
loading="lazy"
/>
<span
className={`absolute rounded-full ${dotAnim}`}
style={{
bottom: -1,
right: -1,
width: 12,
height: 12,
background: stateColor,
border: '2px solid rgba(10, 15, 30, 0.9)',
}}
/>
</div>
) : (
<div className="relative shrink-0">
<div
className="rounded-full flex items-center justify-center"
style={{
width: 40,
height: 40,
background: `${color}20`,
border: `2px solid ${color}40`,
color: COLORS.holoBright,
fontSize: 14,
fontWeight: 700,
}}
>
{node.label.charAt(0).toUpperCase()}
</div>
<span
className={`absolute rounded-full ${dotAnim}`}
style={{
bottom: -1,
right: -1,
width: 12,
height: 12,
background: stateColor,
border: '2px solid rgba(10, 15, 30, 0.9)',
}}
/>
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span
className="truncate font-bold"
style={{ color, fontSize: 13 }}
>
{displayName(node.label)}
</span>
</div>
{node.role && (
<div
className="truncate"
style={{ color: COLORS.textDim, fontSize: 11, marginTop: 1 }}
>
{node.role}
</div>
)}
</div>
<div
className="rounded-lg p-3 min-w-[160px] max-w-[220px] shadow-xl"
style={{
background: 'rgba(10, 15, 30, 0.9)',
border: '1px solid rgba(100, 200, 255, 0.15)',
backdropFilter: 'blur(8px)',
}}
>
<div className="text-xs font-mono font-bold" style={{ color: selectedNode.color ?? '#aaeeff' }}>
{selectedNode.label}
</div>
{/* Status badge */}
<div className="flex gap-1.5 flex-wrap">
<StatusBadge label={presenceText} color={stateColor} />
{node.kind === 'lead' && (
<StatusBadge label="lead" color={COLORS.dispatch} />
)}
{node.spawnStatus && node.spawnStatus !== 'online' && (
<StatusBadge label={node.spawnStatus} color={
node.spawnStatus === 'error' ? COLORS.error :
node.spawnStatus === 'spawning' ? COLORS.waiting :
COLORS.terminated
} />
)}
</div>
{/* Context usage bar (lead only) */}
{node.contextUsage != null && node.contextUsage > 0 && (
<div>
<div className="flex justify-between" style={{ fontSize: 10, color: COLORS.textDim, marginBottom: 2 }}>
<span>Context</span>
<span>{Math.round(node.contextUsage * 100)}%</span>
</div>
<div
className="rounded-full overflow-hidden"
style={{ height: 3, background: 'rgba(100, 200, 255, 0.1)' }}
>
<div
className="rounded-full h-full transition-all"
style={{
width: `${Math.round(node.contextUsage * 100)}%`,
background: node.contextUsage > 0.8 ? COLORS.error : color,
}}
/>
</div>
{selectedNode.sublabel && (
<div className="mt-0.5 text-[10px] truncate" style={{ color: '#66ccff90' }}>
{selectedNode.sublabel}
</div>
)}
{/* Sublabel (current activity) */}
{node.sublabel && (
<div
className="rounded px-2 py-1.5 truncate"
style={{
fontSize: 10,
color: COLORS.textDim,
background: 'rgba(100, 200, 255, 0.05)',
border: `1px solid ${COLORS.glassBorder}`,
}}
>
{node.sublabel}
{selectedNode.role && (
<div className="mt-0.5 text-[10px]" style={{ color: '#66ccff70' }}>
{selectedNode.role}
</div>
)}
{/* Action buttons */}
<div className="flex gap-1.5">
<ActionButton icon={<IconMessage />} label="Message" onClick={() => onAction('sendMessage')} color={color} />
<ActionButton icon={<IconExternalLink />} label="Profile" onClick={() => onAction('openDetail')} color={color} />
</div>
</div>
</div>
);
});
// ─── Task Popover ───────────────────────────────────────────────────────────
const TaskPopover = forwardRef<
HTMLDivElement,
{ node: GraphNode; color: string; stateColor: string; onAction: (a: string) => void }
>(function TaskPopover({ node, color, stateColor, onAction }, ref) {
const taskStatusLabel = node.taskStatus ? formatLabel(node.taskStatus) : 'pending';
return (
<div
ref={ref}
className="rounded-lg min-w-[200px] max-w-[280px] shadow-xl overflow-hidden"
style={{
background: COLORS.glassBg,
border: `1px solid ${color}30`,
backdropFilter: 'blur(12px)',
}}
>
{/* Colored top accent */}
<div style={{ height: 3, background: `linear-gradient(to right, ${color}, transparent)` }} />
<div className="p-3 flex flex-col gap-2">
{/* Header: display ID + label */}
<div className="flex items-center gap-2">
{node.displayId && (
<span
className="shrink-0 font-mono font-bold"
style={{ color, fontSize: 12 }}
>
{node.displayId}
</span>
)}
<span
className="truncate font-bold"
style={{ color: COLORS.holoBright, fontSize: 12 }}
>
{node.label}
</span>
</div>
{/* Subject / description */}
{node.sublabel && (
<div
className="truncate"
style={{ color: COLORS.textDim, fontSize: 11 }}
>
{node.sublabel}
</div>
)}
{/* Status badges */}
<div className="flex gap-1.5 flex-wrap">
<StatusBadge label={taskStatusLabel} color={color} />
{node.state !== 'idle' && (
<StatusBadge label={getPresenceLabel(node.state)} color={stateColor} />
)}
{node.reviewState && node.reviewState !== 'none' && (
<StatusBadge
label={node.reviewState === 'needsFix' ? 'needs fix' : node.reviewState}
color={
node.reviewState === 'approved' ? COLORS.complete :
node.reviewState === 'needsFix' ? COLORS.error :
COLORS.waiting
}
<div className="mt-2 flex gap-1">
{(selectedNode.kind === 'member' || selectedNode.kind === 'lead') && (
<FallbackButton
label="Message"
onClick={() => {
const ref = selectedNode.domainRef;
if (ref.kind === 'member') events?.onSendMessage?.(ref.memberName, ref.teamName);
onDeselect();
}}
/>
)}
{node.needsClarification && (
<StatusBadge label={`needs ${node.needsClarification}`} color={COLORS.waiting} />
)}
</div>
{/* Action */}
<div className="flex gap-1.5 mt-0.5">
<ActionButton icon={<IconClipboard />} label="Open task" onClick={() => onAction('openDetail')} color={color} />
<FallbackButton label="Close" onClick={onDeselect} />
</div>
</div>
</div>
);
});
// ─── Process Popover ────────────────────────────────────────────────────────
const ProcessPopover = forwardRef<
HTMLDivElement,
{ node: GraphNode; color: string; onAction: (a: string) => void }
>(function ProcessPopover({ node, color, onAction }, ref) {
return (
<div
ref={ref}
className="rounded-lg min-w-[180px] max-w-[260px] shadow-xl overflow-hidden"
style={{
background: COLORS.glassBg,
border: `1px solid ${color}30`,
backdropFilter: 'blur(12px)',
}}
>
<div style={{ height: 3, background: `linear-gradient(to right, ${color}, transparent)` }} />
<div className="p-3 flex flex-col gap-2">
<div className="flex items-center gap-2">
<div
className="rounded-full"
style={{ width: 8, height: 8, background: color }}
/>
<span
className="truncate font-bold"
style={{ color: COLORS.holoBright, fontSize: 12 }}
>
{node.label}
</span>
</div>
{node.sublabel && (
<div className="truncate" style={{ color: COLORS.textDim, fontSize: 11 }}>
{node.sublabel}
</div>
)}
<div className="flex gap-1.5 flex-wrap">
<StatusBadge label={getPresenceLabel(node.state)} color={getStateColor(node.state)} />
</div>
{node.processUrl && (
<div className="flex gap-1.5 mt-0.5">
<ActionButton icon={<IconGlobe />} label="Open URL" onClick={() => onAction('openUrl')} color={color} />
</div>
)}
</div>
</div>
);
});
// ─── UI Primitives ──────────────────────────────────────────────────────────
function StatusBadge({ label, color }: { label: string; color: string }): React.JSX.Element {
return (
<span
className="px-2 py-0.5 rounded-full font-medium"
style={{
fontSize: 10,
background: `${color}18`,
color,
border: `1px solid ${color}25`,
letterSpacing: '0.01em',
}}
>
{label}
</span>
);
}
function ActionButton({
icon,
label,
onClick,
color,
}: {
icon: React.ReactNode;
label: string;
onClick: () => void;
color: string;
}): React.JSX.Element {
function FallbackButton({ label, onClick }: { label: string; onClick: () => void }): React.JSX.Element {
return (
<button
onClick={onClick}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md cursor-pointer transition-all"
className="text-[10px] px-2 py-1 rounded font-mono cursor-pointer"
style={{
fontSize: 11,
background: `${color}10`,
border: `1px solid ${color}20`,
color: COLORS.holoBright,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = `${color}25`;
e.currentTarget.style.borderColor = `${color}40`;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = `${color}10`;
e.currentTarget.style.borderColor = `${color}20`;
background: 'rgba(100, 200, 255, 0.08)',
border: '1px solid rgba(100, 200, 255, 0.15)',
color: '#aaeeff',
}}
>
{icon}
{label}
</button>
);

View file

@ -24,46 +24,31 @@ export const TeamGraphTab = ({ teamName }: TeamGraphTabProps): React.JSX.Element
const graphData = useTeamGraphAdapter(teamName);
const [fullscreen, setFullscreen] = useState(false);
// Typed event dispatchers (DRY — used in both events + renderOverlay)
const dispatchOpenTask = useCallback(
(taskId: string) =>
window.dispatchEvent(new CustomEvent('graph:open-task', { detail: { teamName, taskId } })),
[teamName]
);
const dispatchSendMessage = useCallback(
(memberName: string) =>
window.dispatchEvent(
new CustomEvent('graph:send-message', { detail: { teamName, memberName } })
),
[teamName]
);
const events: GraphEventPort = {
onNodeDoubleClick: useCallback(
(ref: GraphDomainRef) => {
// Dispatch to TeamDetailView's dialog system via CustomEvent
if (ref.kind === 'task') {
window.dispatchEvent(
new CustomEvent('graph:open-task', { detail: { teamName, taskId: ref.taskId } })
);
} else if (ref.kind === 'member') {
window.dispatchEvent(
new CustomEvent('graph:send-message', {
detail: { teamName, memberName: ref.memberName },
})
);
}
if (ref.kind === 'task') dispatchOpenTask(ref.taskId);
else if (ref.kind === 'member') dispatchSendMessage(ref.memberName);
},
[teamName]
),
onSendMessage: useCallback(
(memberName: string) => {
window.dispatchEvent(
new CustomEvent('graph:send-message', { detail: { teamName, memberName } })
);
},
[teamName]
),
onOpenTaskDetail: useCallback(
(taskId: string) => {
window.dispatchEvent(new CustomEvent('graph:open-task', { detail: { teamName, taskId } }));
},
[teamName]
),
onOpenMemberProfile: useCallback(
(memberName: string) => {
window.dispatchEvent(
new CustomEvent('graph:send-message', { detail: { teamName, memberName } })
);
},
[teamName]
[dispatchOpenTask, dispatchSendMessage]
),
onSendMessage: dispatchSendMessage,
onOpenTaskDetail: dispatchOpenTask,
onOpenMemberProfile: dispatchSendMessage,
};
return (
@ -77,21 +62,9 @@ export const TeamGraphTab = ({ teamName }: TeamGraphTabProps): React.JSX.Element
<GraphNodePopover
node={node}
onClose={onClose}
onSendMessage={(name) =>
window.dispatchEvent(
new CustomEvent('graph:send-message', { detail: { teamName, memberName: name } })
)
}
onOpenTaskDetail={(id) =>
window.dispatchEvent(
new CustomEvent('graph:open-task', { detail: { teamName, taskId: id } })
)
}
onOpenMemberProfile={(name) =>
window.dispatchEvent(
new CustomEvent('graph:send-message', { detail: { teamName, memberName: name } })
)
}
onSendMessage={dispatchSendMessage}
onOpenTaskDetail={dispatchOpenTask}
onOpenMemberProfile={dispatchSendMessage}
/>
)}
/>