"use client";

import { useEffect, useState, useCallback, useMemo, useRef } from "react";
import type { BeadsApiResponse, GraphNode, GraphLink, ColorMode, AssigneeAvatarInfo } from "@/lib/types";
import { getCatppuccinPrefixColor, getPrefixLabel } from "@/lib/types";
import { diffBeadsData, linkKey } from "@/lib/diff-beads";
import type { BeadsDiff } from "@/lib/diff-beads";
import BeadsGraph from "@/components/BeadsGraph";
import type { BeadsGraphHandle } from "@/components/BeadsGraph";
import NodeDetail from "@/components/NodeDetail";

import { AuthButton } from "@/components/AuthButton";
import { BeadsLogo } from "@/components/BeadsLogo";
import { CommentTooltip } from "@/components/CommentTooltip";
import { ContextMenu } from "@/components/ContextMenu";
import { DescriptionModal } from "@/components/DescriptionModal";
import { BeadTooltip } from "@/components/BeadTooltip";
import AllCommentsPanel from "@/components/AllCommentsPanel";
import { ActivityOverlay } from "@/components/ActivityOverlay";
import { ActivityPanel } from "@/components/ActivityPanel";
import { TasksPanel } from "@/components/TasksPanel";
import { HelpPanel } from "@/components/HelpPanel";
import { LeaderboardOverlay } from "@/components/LeaderboardOverlay";
import { LeaderboardPanel } from "@/components/LeaderboardPanel";
import type { LeaderboardEntry } from "@/components/LeaderboardOverlay";
import { SettingsModal } from "@/components/SettingsModal";
import { TutorialOverlay, TUTORIAL_STEPS } from "@/components/TutorialOverlay";
import { MobileActionSheet } from "@/components/MobileActionSheet";
import { ProfilePanel, UserProfile } from "@/components/ProfilePanel";
import { useBeadsComments } from "@/hooks/useBeadsComments";
import type { BeadsComment } from "@/hooks/useBeadsComments";
import { useIsMobile } from "@/hooks/useIsMobile";
import { useUrlState } from "@/hooks/useUrlState";
import { useTheme } from "@/hooks/useTheme";
import { useAuth } from "@/lib/auth";
import {
  buildHistoricalFeed,
  diffToActivityEvents,
  mergeFeedEvents,
} from "@/lib/activity";
import type { ActivityEvent } from "@/lib/activity";
import { buildTimelineEvents, filterDataAtTime } from "@/lib/timeline";
import type { TimelineRange } from "@/lib/timeline";
import TimelineBar from "@/components/TimelineBar";
import { formatRelativeTime } from "@/lib/utils";

type SearchResult =
  | { type: "node"; node: GraphNode }
  | { type: "profile"; handle: string; avatar?: string };

// Check if a node has been claimed (has a comment that is just "@handle")
function isNodeClaimed(comments?: BeadsComment[]): boolean {
  if (!comments) return false;
  return comments.some(
    (c) => c.text.startsWith("@") && c.text.trim().indexOf(" ") === -1
  );
}

// Find position of a neighbor node (for placing new nodes near connections)
function findNeighborPosition(
  nodeId: string,
  links: GraphLink[],
  nodeMap: Map<string, GraphNode>
): { x: number; y: number } | null {
  for (const link of links) {
    const src =
      typeof link.source === "object"
        ? (link.source as { id: string }).id
        : link.source;
    const tgt =
      typeof link.target === "object"
        ? (link.target as { id: string }).id
        : link.target;
    if (src === nodeId && nodeMap.has(tgt)) {
      const n = nodeMap.get(tgt)!;
      if (n.x != null && n.y != null)
        return { x: n.x as number, y: n.y as number };
    }
    if (tgt === nodeId && nodeMap.has(src)) {
      const n = nodeMap.get(src)!;
      if (n.x != null && n.y != null)
        return { x: n.x as number, y: n.y as number };
    }
  }
  return null;
}

// Merge old (with simulation positions) and new (from server) beads data,
// stamping animation metadata for spawn/exit/change transitions.
function mergeBeadsData(
  oldData: BeadsApiResponse,
  newData: BeadsApiResponse,
  diff: BeadsDiff
): BeadsApiResponse {
  const now = Date.now();

  // Build position map from old nodes (preserves x/y/fx/fy from simulation)
  const oldNodeMap = new Map(oldData.graphData.nodes.map((n) => [n.id, n]));
  const oldLinkKeySet = new Set(oldData.graphData.links.map(linkKey));

  // Merge nodes: carry over positions, stamp animation metadata
  const mergedNodes: GraphNode[] = newData.graphData.nodes.map((node) => {
    const oldNode = oldNodeMap.get(node.id);

    if (!oldNode) {
      // NEW NODE — stamp spawn time, place near a connected neighbor
      const neighbor = findNeighborPosition(
        node.id,
        newData.graphData.links,
        oldNodeMap
      );
      return {
        ...node,
        _spawnTime: now,
        x: neighbor ? neighbor.x + (Math.random() - 0.5) * 40 : undefined,
        y: neighbor ? neighbor.y + (Math.random() - 0.5) * 40 : undefined,
      } as GraphNode;
    }

    // EXISTING NODE — preserve position, check for changes
    const merged: GraphNode = {
      ...node,
      x: oldNode.x,
      y: oldNode.y,
      fx: oldNode.fx,
      fy: oldNode.fy,
    };

    // Stamp change metadata if status changed
    if (diff.changedNodes.has(node.id)) {
      const changes = diff.changedNodes.get(node.id)!;
      const statusChange = changes.find((c) => c.field === "status");
      if (statusChange) {
        merged._changedAt = now;
        merged._prevStatus = statusChange.from;
      }
    }

    return merged;
  });

  // Handle removed nodes: keep them briefly for exit animation
  for (const removedId of diff.removedNodeIds) {
    const oldNode = oldNodeMap.get(removedId);
    if (oldNode) {
      mergedNodes.push({
        ...oldNode,
        _removeTime: now,
      } as GraphNode);
    }
  }

  // Merge links: stamp spawn time on new links
  const mergedLinks = newData.graphData.links.map((link) => {
    const key = linkKey(link);
    if (!oldLinkKeySet.has(key)) {
      return { ...link, _spawnTime: now };
    }
    return link;
  });

  // Handle removed links: keep briefly for exit animation
  for (const removedKey of diff.removedLinkKeys) {
    const oldLink = oldData.graphData.links.find(
      (l) => linkKey(l) === removedKey
    );
    if (oldLink) {
      mergedLinks.push({
        source:
          typeof oldLink.source === "object"
            ? (oldLink.source as { id: string }).id
            : oldLink.source,
        target:
          typeof oldLink.target === "object"
            ? (oldLink.target as { id: string }).id
            : oldLink.target,
        type: oldLink.type,
        _removeTime: now,
      });
    }
  }

  return {
    ...newData,
    graphData: {
      nodes: mergedNodes as GraphNode[],
      links: mergedLinks as GraphLink[],
    },
  };
}

// Status badge colors for search results
const STATUS_DOT_COLORS: Record<string, string> = {
  open: "bg-emerald-500",
  in_progress: "bg-amber-500",
  blocked: "bg-red-500",
  deferred: "bg-violet-500",
  closed: "bg-zinc-400",
};

export default function Home() {
  const [data, setData] = useState<BeadsApiResponse | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
  const [selectedNodeIds, setSelectedNodeIds] = useState<Set<string>>(new Set());
  const [collapsedEpicIds, setCollapsedEpicIds] = useState<Set<string>>(new Set());
  const [focusedEpicId, setFocusedEpicId] = useState<string | null>(null);
  const [colorMode, setColorMode] = useState<ColorMode>("status");
  const [activeLegendFilters, setActiveLegendFilters] = useState<Set<string>>(new Set());
  const [projectName, setProjectName] = useState("Beads");
  const [repoCount, setRepoCount] = useState(0);
  const [repoUrls, setRepoUrls] = useState<Record<string, string>>({});

  // Auth state
  const { isAuthenticated, session } = useAuth();

  // Theme state
  const { theme, toggleTheme } = useTheme();

  // Comments from ATProto indexer
  const { commentsByNode, commentedNodeIds, allComments, refetch: refetchComments } =
    useBeadsComments();

  // URL state for shareable views
  const url = useUrlState();

  // Optimistic claims — immediately show avatar after user claims, before indexer picks it up
  // rkey is undefined for optimistic (not yet indexed), set once comment is fetched
  const [optimisticClaims, setOptimisticClaims] = useState<
    Map<string, { avatar?: string; handle: string; claimedAt: string; did?: string; rkey?: string }>
  >(new Map());

  // Optimistic unclaims — suppress nodes where user just unclaimed (until refetch clears them)
  const [optimisticUnclaims, setOptimisticUnclaims] = useState<Set<string>>(new Set());

  // Timeline replay state
  const [timelineActive, setTimelineActive] = useState(false);
  const [timelineStep, setTimelineStep] = useState(0);
  const [timelinePlaying, setTimelinePlaying] = useState(false);
  const [timelineSpeed, setTimelineSpeed] = useState(1);
  const [timelineData, setTimelineData] = useState<BeadsApiResponse | null>(null);

  // Clear legend filters when color mode changes
  useEffect(() => {
    setActiveLegendFilters(new Set());
  }, [colorMode]);

  // Set of node IDs in the local graph — used to filter global comments/activity
  // to only events relevant to beads in this repo
  const localNodeIds = useMemo(() => {
    if (!data) return new Set<string>();
    return new Set(data.graphData.nodes.map((n) => n.id));
  }, [data]);

  // Set of known prefixes — used to allow general comments posted to prefixes
  const knownPrefixes = useMemo(() => {
    if (!data) return new Set<string>();
    const prefixes = data.stats?.prefixes || [];
    const set = new Set<string>();
    for (const p of prefixes) {
      set.add(p);              // backward compat: old bare-prefix comments
      set.add(`${p}-general`); // new convention: {prefix}-general
    }
    return set;
  }, [data]);

  // Compute claimed node avatars from comments + optimistic claims
  // A claim comment has text "@handle" (starts with @, no spaces)
  const claimedNodeAvatars = useMemo(() => {
    const map = new Map<string, { avatar?: string; handle: string; claimedAt: string; did?: string; rkey?: string }>();
    // First: add from comments (has rkey for deletion)
    if (allComments) {
      for (const comment of allComments) {
        if (!localNodeIds.has(comment.nodeId)) continue;
        if (map.has(comment.nodeId)) continue;
        if (optimisticUnclaims.has(comment.nodeId)) continue; // suppressed by unclaim
        const text = comment.text.trim();
        if (text.startsWith("@") && text.indexOf(" ") === -1) {
          map.set(comment.nodeId, {
            avatar: comment.avatar,
            handle: comment.handle,
            claimedAt: comment.createdAt,
            did: comment.did,
            rkey: comment.rkey,
          });
        }
      }
    }
    // Then: add optimistic claims (only if not already from comments and not unclaimed)
    for (const [nodeId, info] of optimisticClaims) {
      if (!map.has(nodeId) && !optimisticUnclaims.has(nodeId)) {
        map.set(nodeId, info);
      }
    }
    return map;
  }, [allComments, optimisticClaims, optimisticUnclaims, localNodeIds]);

  // Compute assignee node avatars from beads data
  // Maps node ID -> { handle, avatar? } for nodes with valid ATProto handles
  // At most ONE entry per unique assignee (most recent node wins)
  const assigneeNodeAvatars = useMemo(() => {
    const map = new Map<string, AssigneeAvatarInfo>();
    if (!data) return map;
    if (timelineActive && !timelineData) return map;

    // Group nodes by assignee handle
    const nodesByAssignee = new Map<string, GraphNode[]>();
    const sourceNodes = timelineActive && timelineData
      ? timelineData.graphData.nodes
      : data.graphData.nodes;
    for (const node of sourceNodes) {
      if (!node.assignee) continue;
      // Valid ATProto handle must contain at least one dot
      if (!node.assignee.includes(".")) continue;
      // Skip if already claimed (claim avatars take priority)
      if (claimedNodeAvatars.has(node.id)) continue;

      if (!nodesByAssignee.has(node.assignee)) {
        nodesByAssignee.set(node.assignee, []);
      }
      nodesByAssignee.get(node.assignee)!.push(node);
    }

    // For each unique assignee, pick the most recent node
    for (const [handle, nodes] of nodesByAssignee) {
      // Sort by updatedAt descending (most recent first)
      nodes.sort((a, b) => {
        const cmp = b.updatedAt.localeCompare(a.updatedAt);
        if (cmp !== 0) return cmp;
        // Tiebreak: prefer non-closed
        if (a.status !== "closed" && b.status === "closed") return -1;
        if (a.status === "closed" && b.status !== "closed") return 1;
        // Tiebreak: alphabetically-first node ID
        return a.id.localeCompare(b.id);
      });

      const selectedNode = nodes[0];
      map.set(selectedNode.id, {
        handle,
        avatar: undefined, // Will be resolved in task 2
        updatedAt: selectedNode.updatedAt,
      });
    }

    return map;
  }, [data, claimedNodeAvatars, timelineActive, timelineData]);

  // Resolved assignee avatars from ATProto API
  // Maps handle -> avatar URL
  const [resolvedAssigneeAvatars, setResolvedAssigneeAvatars] = useState<Map<string, string>>(new Map());

  // Resolve ATProto handles to avatar URLs
  useEffect(() => {
    if (assigneeNodeAvatars.size === 0) return;

    const controller = new AbortController();
    const handlesToResolve: string[] = [];

    // Collect unique handles that need resolution
    for (const [, info] of assigneeNodeAvatars) {
      if (!resolvedAssigneeAvatars.has(info.handle)) {
        handlesToResolve.push(info.handle);
      }
    }

    if (handlesToResolve.length === 0) return;

    // Resolve in batches of 5
    const batchSize = 5;
    const batches: string[][] = [];
    for (let i = 0; i < handlesToResolve.length; i += batchSize) {
      batches.push(handlesToResolve.slice(i, i + batchSize));
    }

    async function resolveBatch(handles: string[]) {
      const promises = handles.map(async (handle) => {
        try {
          const response = await fetch(
            `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`,
            { signal: controller.signal }
          );
          if (!response.ok) return null;
          const data = await response.json();
          return { handle, avatar: data.avatar };
        } catch (err) {
          // Skip on error (fallback letter circle will show)
          return null;
        }
      });

      const results = await Promise.allSettled(promises);
      const resolved = new Map<string, string>();
      for (const result of results) {
        if (result.status === "fulfilled" && result.value && result.value.avatar) {
          resolved.set(result.value.handle, result.value.avatar);
        }
      }
      return resolved;
    }

    // Process all batches sequentially
    (async () => {
      for (const batch of batches) {
        const resolved = await resolveBatch(batch);
        if (resolved.size > 0) {
          setResolvedAssigneeAvatars((prev) => new Map([...prev, ...resolved]));
        }
      }
    })();

    return () => controller.abort();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [assigneeNodeAvatars]);

  // Merge resolved avatars into assigneeNodeAvatars
  const assigneeNodeAvatarsResolved = useMemo(() => {
    const map = new Map(assigneeNodeAvatars);
    for (const [nodeId, info] of map) {
      const resolved = resolvedAssigneeAvatars.get(info.handle);
      if (resolved) {
        map.set(nodeId, { ...info, avatar: resolved });
      }
    }
    return map;
  }, [assigneeNodeAvatars, resolvedAssigneeAvatars]);

  // Leaderboard time filter state (local + URL sync)
  type LeaderboardTimeFilter = "all" | "month" | "week" | "yesterday";
  const [leaderboardTimeFilter, setLeaderboardTimeFilterState] = useState<LeaderboardTimeFilter>(url.initial.timeFilter || "all");
  const setLeaderboardTimeFilter = useCallback((filter: LeaderboardTimeFilter) => {
    setLeaderboardTimeFilterState(filter);
    url.setTimeFilter(filter);
  }, [url]);

  // Compute base leaderboard stats (without time filter) - expensive operation
  const baseLeaderboardStats = useMemo(() => {
    if (!data) return new Map<string, {
      handle: string;
      avatar?: string;
      did?: string;
      createdNodes: Array<{ createdAt: string }>;
      closedNodes: Array<{ closedAt: string }>;
      comments: Array<{ createdAt: string }>;
    }>();
    
    const nodes = data.graphData.nodes;
    const handleStats = new Map<string, {
      handle: string;
      avatar?: string;
      did?: string;
      createdNodes: Array<{ createdAt: string }>;
      closedNodes: Array<{ closedAt: string }>;
      comments: Array<{ createdAt: string }>;
    }>();

    const getOrCreate = (handle: string) => {
      if (!handle) return null;
      let entry = handleStats.get(handle);
      if (!entry) {
        entry = { handle, createdNodes: [], closedNodes: [], comments: [] };
        handleStats.set(handle, entry);
      }
      return entry;
    };

    // Collect per-person stats from nodes
    for (const node of nodes) {
      // Created: credit the creator
      const creator = node.createdBy || node.owner || node.assignee;
      if (creator) {
        const e = getOrCreate(creator);
        if (e) e.createdNodes.push({ createdAt: node.createdAt });
      }

      // Closed: credit the ASSIGNEE
      if (node.status === "closed" && node.assignee && node.closedAt) {
        const e = getOrCreate(node.assignee);
        if (e) e.closedNodes.push({ closedAt: node.closedAt });
      }
    }

    // Collect comments (excluding claims)
    if (allComments) {
      for (const comment of allComments) {
        if (!localNodeIds.has(comment.nodeId)) continue;
        const isClaim = comment.text.startsWith("@") && comment.text.trim().indexOf(" ") === -1;
        if (!isClaim) {
          const e = getOrCreate(comment.handle);
          if (e) {
            e.comments.push({ createdAt: comment.createdAt });
            if (comment.avatar && !e.avatar) e.avatar = comment.avatar;
            if (comment.did && !e.did) e.did = comment.did;
          }
        }
      }
    }

    // Pull avatars from resolved assignee avatars
    for (const [, info] of assigneeNodeAvatarsResolved) {
      const e = handleStats.get(info.handle);
      if (e && info.avatar && !e.avatar) e.avatar = info.avatar;
    }

    // Also check resolvedAssigneeAvatars cache
    for (const [handle, avatar] of resolvedAssigneeAvatars) {
      const e = handleStats.get(handle);
      if (e && !e.avatar) e.avatar = avatar;
    }

    return handleStats;
  }, [data, allComments, assigneeNodeAvatarsResolved, resolvedAssigneeAvatars, localNodeIds]);

  // Apply time filter to base stats - cheap operation
  const leaderboardEntries = useMemo((): LeaderboardEntry[] => {
    // Compute cutoff date for time filter
    let cutoff: Date | null = null;
    if (leaderboardTimeFilter !== "all") {
      const now = new Date();
      if (leaderboardTimeFilter === "month") {
        cutoff = new Date(now);
        cutoff.setMonth(cutoff.getMonth() - 1);
      } else if (leaderboardTimeFilter === "week") {
        cutoff = new Date(now);
        cutoff.setDate(cutoff.getDate() - 7);
      } else if (leaderboardTimeFilter === "yesterday") {
        cutoff = new Date(now);
        cutoff.setDate(cutoff.getDate() - 1);
      }
    }
    const cutoffISO = cutoff ? cutoff.toISOString() : null;

    // Apply time filter and compute scores
    const entries: LeaderboardEntry[] = [];
    for (const stats of baseLeaderboardStats.values()) {
      const createdCount = cutoffISO 
        ? stats.createdNodes.filter(n => n.createdAt >= cutoffISO).length
        : stats.createdNodes.length;
      const closedCount = cutoffISO
        ? stats.closedNodes.filter(n => n.closedAt >= cutoffISO).length
        : stats.closedNodes.length;
      const commentCount = cutoffISO
        ? stats.comments.filter(c => c.createdAt >= cutoffISO).length
        : stats.comments.length;

      const score = closedCount * 3 + createdCount + commentCount;
      if (score > 0) {
        entries.push({
          handle: stats.handle,
          avatar: stats.avatar,
          did: stats.did,
          createdCount,
          closedCount,
          commentCount,
          score,
        });
      }
    }
    entries.sort((a, b) => b.score - a.score);
    return entries;
  }, [baseLeaderboardStats, leaderboardTimeFilter]);

  const searchableProfiles = useMemo(() => {
    const profileMap = new Map<string, { handle: string; avatar?: string }>();
    
    // From leaderboard entries (best source — has avatars)
    for (const entry of leaderboardEntries) {
      profileMap.set(entry.handle, { handle: entry.handle, avatar: entry.avatar });
    }
    
    // From node data (catch handles not in leaderboard)
    if (data) {
      for (const node of data.graphData.nodes) {
        for (const handle of [node.createdBy, node.owner, node.assignee]) {
          if (handle && !profileMap.has(handle)) {
            profileMap.set(handle, { handle });
          }
        }
      }
    }
    
    // From resolved avatars cache (fill in missing avatars)
    for (const [handle, avatar] of resolvedAssigneeAvatars) {
      const profile = profileMap.get(handle);
      if (profile && !profile.avatar) profile.avatar = avatar;
    }
    
    // From comment avatars (catch avatars from comment authors)
    if (allComments) {
      for (const comment of allComments) {
        if (comment.avatar && comment.handle) {
          const profile = profileMap.get(comment.handle);
          if (profile && !profile.avatar) {
            profile.avatar = comment.avatar;
          }
        }
      }
    }
    
    // From claimed node avatars (catch avatars from claims)
    for (const [, claim] of claimedNodeAvatars) {
      if (claim.avatar && claim.handle) {
        const profile = profileMap.get(claim.handle);
        if (profile && !profile.avatar) {
          profile.avatar = claim.avatar;
        }
      }
    }
    
    return Array.from(profileMap.values());
  }, [leaderboardEntries, data, resolvedAssigneeAvatars, allComments, claimedNodeAvatars]);

  // All Comments panel state
  const [allCommentsPanelOpen, setAllCommentsPanelOpen] = useState(false);

  // Profile panel state
  const [profilePanelHandle, setProfilePanelHandle] = useState<string | null>(null);

  // Activity feed state
  const [activityFeed, setActivityFeed] = useState<ActivityEvent[]>([]);
  const [activityPanelOpen, setActivityPanelOpen] = useState(false);
  const [activityOverlayCollapsed, setActivityOverlayCollapsed] = useState(true);
  const [tasksPanelOpen, setTasksPanelOpen] = useState(false);
  const [helpPanelOpen, setHelpPanelOpen] = useState(false);
  const [tutorialStep, setTutorialStep] = useState<number | null>(null);

  // Leaderboard state
  const [leaderboardPanelOpen, setLeaderboardPanelOpen] = useState(false);
  const [leaderboardOverlayCollapsed, setLeaderboardOverlayCollapsed] = useState(true);

  // Mobile responsiveness
  const isMobile = useIsMobile();
  const isMobileRef = useRef(false);
  useEffect(() => { isMobileRef.current = isMobile; }, [isMobile]);
  const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
  const [mobileActionSheet, setMobileActionSheet] = useState<{ node: GraphNode } | null>(null);

  // On mobile, collapse the activity overlay by default (show just the pill button)
  useEffect(() => {
    setLeaderboardOverlayCollapsed(isMobile);
    setActivityOverlayCollapsed(isMobile);
  }, [isMobile]);

  // Rebuild historical feed when data or comments change
  // Filter comments to only those targeting nodes in our graph, since the
  // Hypergoat indexer returns comments globally across all repos using beads
  useEffect(() => {
    if (!data) return;
    const localComments = allComments
      ? allComments.filter((c) => localNodeIds.has(c.nodeId) || knownPrefixes.has(c.nodeId))
      : null;
    const historical = buildHistoricalFeed(
      data.graphData.nodes,
      data.graphData.links,
      localComments
    );
    setActivityFeed((prev) => mergeFeedEvents(prev, historical));
  }, [data, allComments, localNodeIds, knownPrefixes]);

  // Resolve missing actor avatars in activity feed
  useEffect(() => {
    if (activityFeed.length === 0) return;

    // Build a handle -> avatar lookup from comments (already resolved by useBeadsComments)
    const commentAvatars = new Map<string, string>();
    if (allComments) {
      for (const c of allComments) {
        if (c.avatar && c.handle) commentAvatars.set(c.handle, c.avatar);
        for (const like of c.likes) {
          if (like.avatar && like.handle) commentAvatars.set(like.handle, like.avatar);
        }
      }
    }

    // Merge comment avatars into the shared cache
    let cacheUpdated = false;
    const cacheAdditions = new Map<string, string>();
    for (const [handle, avatar] of commentAvatars) {
      if (!resolvedAssigneeAvatars.has(handle)) {
        cacheAdditions.set(handle, avatar);
        cacheUpdated = true;
      }
    }

    // Collect handles that still need API resolution
    const handlesToResolve = new Set<string>();
    for (const event of activityFeed) {
      if (event.actor && !event.actor.avatar && event.actor.handle) {
        const handle = event.actor.handle;
        if (resolvedAssigneeAvatars.has(handle) || cacheAdditions.has(handle)) continue;
        // Only try API for valid ATProto handles (has dot, no @)
        if (handle.includes(".") && !handle.includes("@")) {
          handlesToResolve.add(handle);
        }
      }
    }

    if (cacheUpdated && handlesToResolve.size === 0) {
      // We have new comment-sourced avatars but no API calls needed
      setResolvedAssigneeAvatars((prev) => new Map([...prev, ...cacheAdditions]));
      return;
    }

    if (handlesToResolve.size === 0) {
      // All handles resolved — patch events
      let patched = false;
      const mergedCache = new Map([...resolvedAssigneeAvatars, ...cacheAdditions]);
      const updated = activityFeed.map((event) => {
        if (event.actor && !event.actor.avatar && event.actor.handle) {
          const avatar = mergedCache.get(event.actor.handle);
          if (avatar) {
            patched = true;
            return { ...event, actor: { ...event.actor, avatar } };
          }
        }
        return event;
      });
      if (patched) {
        setActivityFeed(updated);
      }
      return;
    }

    // Resolve new handles via API
    const controller = new AbortController();
    const handles = [...handlesToResolve];
    const batchSize = 5;

    (async () => {
      const allResolved = new Map<string, string>(cacheAdditions);
      for (let i = 0; i < handles.length; i += batchSize) {
        const batch = handles.slice(i, i + batchSize);
        const promises = batch.map(async (handle) => {
          try {
            const response = await fetch(
              `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`,
              { signal: controller.signal }
            );
            if (!response.ok) return null;
            const data = await response.json();
            return data.avatar ? { handle, avatar: data.avatar as string } : null;
          } catch {
            return null;
          }
        });
        const results = await Promise.allSettled(promises);
        for (const result of results) {
          if (result.status === "fulfilled" && result.value) {
            allResolved.set(result.value.handle, result.value.avatar);
          }
        }
      }
      if (allResolved.size > 0) {
        setResolvedAssigneeAvatars((prev) => new Map([...prev, ...allResolved]));
      }
    })();

    return () => controller.abort();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [activityFeed, resolvedAssigneeAvatars, allComments]);

  // Context menu state for right-click (phase 1: shows ContextMenu)
  const [contextMenu, setContextMenu] = useState<{
    node: GraphNode;
    x: number;
    y: number;
  } | null>(null);

  // Comment tooltip state (phase 2a: opened from context menu "Add comment")
  const [commentTooltipState, setCommentTooltipState] = useState<{
    node: GraphNode;
    x: number;
    y: number;
  } | null>(null);

  // Description modal state (phase 2b: opened from context menu "Show description")
  const [descriptionModalNode, setDescriptionModalNode] =
    useState<GraphNode | null>(null);

  // Description modal navigation: topological sort of connected component
  const descriptionModalFamily = useMemo(() => {
    if (!descriptionModalNode || !data) return [];
    const nodeId = descriptionModalNode.id;
    const allNodes = data.graphData.nodes;
    const allLinks = data.graphData.links;
    
    // Helper: force-graph mutates link source/target to objects after simulation
    const getId = (x: any): string | undefined =>
      typeof x === "string" ? x : x?.id;

    // Build undirected adjacency (for finding connected component)
    // and directed dependency map (for topological sort)
    const neighbors = new Map<string, Set<string>>();
    const dependsOn = new Map<string, Set<string>>(); // node → nodes it depends on (upstream)
    
    for (const link of allLinks) {
      if ((link as any)._removeTime) continue; // Skip links being removed (fade-out animation)
      const src = getId(link.source);
      const tgt = getId(link.target);
      if (!src || !tgt) continue;
      
      // Undirected for BFS
      if (!neighbors.has(src)) neighbors.set(src, new Set());
      if (!neighbors.has(tgt)) neighbors.set(tgt, new Set());
      neighbors.get(src)!.add(tgt);
      neighbors.get(tgt)!.add(src);
      
      // Directed: target depends on source (source is upstream)
      if (!dependsOn.has(tgt)) dependsOn.set(tgt, new Set());
      dependsOn.get(tgt)!.add(src);
    }
    
    // BFS to find connected component containing current node
    const component = new Set<string>();
    const bfsQueue = [nodeId];
    component.add(nodeId);
    while (bfsQueue.length > 0) {
      const current = bfsQueue.shift()!;
      for (const neighbor of neighbors.get(current) || []) {
        if (!component.has(neighbor)) {
          component.add(neighbor);
          bfsQueue.push(neighbor);
        }
      }
    }
    
    // Single node — no navigation
    if (component.size <= 1) return [];
    
    // Tiebreaker: sort by ID with numeric suffix awareness
    // e.g., "xxx.2" < "xxx.10" (numeric, not lexicographic)
    const compareIds = (a: string, b: string): number => {
      const aParts = a.split(".");
      const bParts = b.split(".");
      // Compare base part first
      if (aParts[0] !== bParts[0]) return a.localeCompare(b);
      // Same base: compare numeric suffixes depth by depth
      for (let i = 1; i < Math.max(aParts.length, bParts.length); i++) {
        const aNum = i < aParts.length ? parseInt(aParts[i], 10) : -1;
        const bNum = i < bParts.length ? parseInt(bParts[i], 10) : -1;
        if (aNum !== bNum) return aNum - bNum;
      }
      return 0;
    };
    
    // Kahn's topological sort with sorted ready queue (stable, deterministic)
    const inDegree = new Map<string, number>();
    for (const id of component) {
      inDegree.set(id, 0);
    }
    for (const id of component) {
      for (const dep of dependsOn.get(id) || []) {
        if (component.has(dep)) {
          inDegree.set(id, (inDegree.get(id) || 0) + 1);
        }
      }
    }
    
    // Build reverse adjacency: node -> set of nodes that depend on it
    const dependents = new Map<string, Set<string>>();
    for (const id of component) {
      for (const dep of dependsOn.get(id) || []) {
        if (component.has(dep)) {
          if (!dependents.has(dep)) dependents.set(dep, new Set());
          dependents.get(dep)!.add(id);
        }
      }
    }
    
    const sorted: string[] = [];
    // Start with nodes that have no dependencies (in-degree 0)
    const ready = Array.from(component)
      .filter(id => (inDegree.get(id) || 0) === 0)
      .sort(compareIds);
    
    while (ready.length > 0) {
      const node = ready.shift()!;
      sorted.push(node);
      
      // Reduce in-degree for nodes that depend on this one
      for (const dependent of dependents.get(node) || []) {
        const newDeg = (inDegree.get(dependent) || 1) - 1;
        inDegree.set(dependent, newDeg);
        if (newDeg === 0) {
          // Insert in sorted position (maintain order in ready queue)
          const insertIdx = ready.findIndex(r => compareIds(r, dependent) > 0);
          if (insertIdx === -1) ready.push(dependent);
          else ready.splice(insertIdx, 0, dependent);
        }
      }
    }
    
    // Handle cycles: append remaining nodes in sorted ID order
    if (sorted.length < component.size) {
      const sortedSet = new Set(sorted);
      const remaining = Array.from(component)
        .filter(id => !sortedSet.has(id))
        .sort(compareIds);
      sorted.push(...remaining);
    }
    
    // Map to GraphNode objects, filter to nodes with descriptions only
    const nodeMap = new Map(allNodes.map(n => [n.id, n]));
    return sorted
      .map(id => nodeMap.get(id))
      .filter((n): n is GraphNode => n != null && !!n.description);
  }, [descriptionModalNode, data]);

  // Description modal navigation handler
  const handleDescriptionNavigate = useCallback((direction: "prev" | "next") => {
    if (!descriptionModalNode || descriptionModalFamily.length <= 1) return;
    const currentIndex = descriptionModalFamily.findIndex(n => n.id === descriptionModalNode.id);
    if (currentIndex === -1) {
      // Current node no longer in family — close modal gracefully
      setDescriptionModalNode(null);
      return;
    }
    
    let nextIndex: number;
    if (direction === "next") {
      nextIndex = (currentIndex + 1) % descriptionModalFamily.length;
    } else {
      nextIndex = (currentIndex - 1 + descriptionModalFamily.length) % descriptionModalFamily.length;
    }
    
    setDescriptionModalNode(descriptionModalFamily[nextIndex]);
  }, [descriptionModalNode, descriptionModalFamily]);

  // Description modal navigation label
  const descriptionNavLabel = useMemo(() => {
    if (!descriptionModalNode || descriptionModalFamily.length <= 1) return undefined;
    const idx = descriptionModalFamily.findIndex(n => n.id === descriptionModalNode.id);
    if (idx === -1) return undefined;
    return `${idx + 1}/${descriptionModalFamily.length}`;
  }, [descriptionModalNode, descriptionModalFamily]);

  // Settings modal state
  const [settingsModalOpen, setSettingsModalOpen] = useState(false);

  // Avatar hover tooltip state
  const [avatarTooltip, setAvatarTooltip] = useState<{
    handle: string;
    avatar?: string;
    claimedAt: string;
    did?: string;
    x: number;
    y: number;
    isAssignee?: boolean;
    nodeId?: string;
    nodeClosed?: boolean;
  } | null>(null);

  // Node hover tooltip state
  const [nodeTooltip, setNodeTooltip] = useState<{
    node: GraphNode;
    x: number;
    y: number;
  } | null>(null);

  // Search state
  const [searchOpen, setSearchOpen] = useState(false);
  const [searchQuery, setSearchQuery] = useState("");
  const [searchHighlightIndex, setSearchHighlightIndex] = useState(0);

  // Auto-fit: when true, graph auto-zooms to fit after data updates and layout changes
  const [autoFit, setAutoFit] = useState(true);
  // Pulse: highlight most recently active node with a ripple animation
  const [showPulse, setShowPulse] = useState(true);
  // Particles: toggle flow particles on dependency links
  const [showParticles, setShowParticles] = useState(true);

  const graphRef = useRef<BeadsGraphHandle>(null);
  const searchInputRef = useRef<HTMLInputElement>(null);
  const prevDataRef = useRef<BeadsApiResponse | null>(null);
  const urlRestoredRef = useRef(false);
  // Tracks whether the desc modal was opened from the initial URL (to avoid writing back immediately)
  const initialDescAppliedRef = useRef(false);

  // Live-streaming beads data via SSE
  useEffect(() => {
    let eventSource: EventSource | null = null;
    let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
    let fallbackTimer: ReturnType<typeof setTimeout> | null = null;
    let mounted = true;

    function connect() {
      eventSource = new EventSource("/api/beads/stream");

      eventSource.onmessage = (event) => {
        if (!mounted) return;
        try {
          const parsed = JSON.parse(event.data);
          if (parsed.error) {
            setError(parsed.error as string);
            setLoading(false);
            return;
          }

          const newData = parsed as BeadsApiResponse;
          const oldData = prevDataRef.current;
          const diff = diffBeadsData(oldData, newData);

          if (!oldData) {
            // Initial load — no animations, just set data
            prevDataRef.current = newData;
            setData(newData);
            setLoading(false);
            return;
          }

          if (!diff.hasChanges) return; // No-op if nothing changed

          // Append real-time activity events from the diff
          const diffEvents = diffToActivityEvents(diff, newData.graphData.nodes);
          if (diffEvents.length > 0) {
            setActivityFeed((prev) => mergeFeedEvents(prev, diffEvents));
          }

          // Merge: stamp animation metadata and preserve positions
          const mergedData = mergeBeadsData(oldData, newData, diff);
          prevDataRef.current = mergedData;
          setData(mergedData);
        } catch (err) {
          console.error("Failed to parse SSE message:", err);
        }
      };

      eventSource.onerror = () => {
        // EventSource auto-reconnects, but handle permanent failure
        if (eventSource?.readyState === EventSource.CLOSED) {
          reconnectTimer = setTimeout(connect, 5000);
        }
      };

      // If still loading after 5s, fall back to one-shot fetch
      fallbackTimer = setTimeout(() => {
        if (!mounted) return;
        if (!prevDataRef.current) {
          fetch("/api/beads")
            .then((res) => res.json())
            .then((fallbackData) => {
              if (mounted && !prevDataRef.current) {
                prevDataRef.current = fallbackData;
                setData(fallbackData);
                setLoading(false);
              }
            })
            .catch(() => {});
        }
      }, 5000);
    }

    connect();

    return () => {
      mounted = false;
      eventSource?.close();
      if (reconnectTimer) clearTimeout(reconnectTimer);
      if (fallbackTimer) clearTimeout(fallbackTimer);
    };
  }, []);

  // Fetch project config for dynamic name
  useEffect(() => {
    fetch("/api/config")
      .then((res) => res.json())
      .then((config) => {
        if (config.name) setProjectName(config.name);
        if (config.repoCount) setRepoCount(config.repoCount);
        if (config.repoUrls) setRepoUrls(config.repoUrls);
      })
      .catch(() => {
        // Fallback to defaults
      });
  }, []);

  // Restore state from URL on initial data load (one-time)
  useEffect(() => {
    if (!data || urlRestoredRef.current) return;
    urlRestoredRef.current = true;

    const initial = url.initial;

    // Restore selected node
    if (initial.node) {
      const node = data.graphData.nodes.find(n => n.id === initial.node);
      if (node) setSelectedNode(node);
    }

    // Restore profile panel
    if (initial.profile) {
      setProfilePanelHandle(initial.profile);
    }

    // Restore panel (comments, activity, tasks, help, leaderboard)
    if (initial.panel === "comments") setAllCommentsPanelOpen(true);
    if (initial.panel === "activity") setActivityPanelOpen(true);
    if (initial.panel === "tasks") setTasksPanelOpen(true);
    if (initial.panel === "help") setHelpPanelOpen(true);
    if (initial.panel === "leaderboard") setLeaderboardPanelOpen(true);

    // Restore focused epic
    if (initial.epic) {
      setFocusedEpicId(initial.epic);
    }

    // Restore color mode
    if (initial.color && initial.color !== "status") {
      setColorMode(initial.color as ColorMode);
    }

    // Restore collapsed epic IDs
    if (initial.collapsed.size > 0) {
      setCollapsedEpicIds(initial.collapsed);
    }

    // Restore legend filters
    if (initial.filters.size > 0) {
      setActiveLegendFilters(initial.filters);
    }

    // Restore multi-selected nodes
    if (initial.selected.size > 0) {
      setSelectedNodeIds(initial.selected);
    }

    // Restore timeline replay
    if (initial.replay) {
      setTimelineActive(true);
    }

    // Restore description modal
    if (initial.desc) {
      const descNode = data.graphData.nodes.find(n => n.id === initial.desc);
      if (descNode) {
        initialDescAppliedRef.current = true;
        setDescriptionModalNode(descNode);
      } else {
        // Node not found — clear stale desc param
        url.setDesc(null);
      }
    }
  }, [data]);

  // Clean up expired exit animations (nodes/links with _removeTime older than 600ms)
  useEffect(() => {
    if (!data) return;
    const timer = setTimeout(() => {
      const now = Date.now();
      const EXPIRE_MS = 600;
      const nodes = data.graphData.nodes.filter(
        (n) => !n._removeTime || now - n._removeTime < EXPIRE_MS
      );
      const links = data.graphData.links.filter(
        (l) => !l._removeTime || now - l._removeTime < EXPIRE_MS
      );
      if (
        nodes.length !== data.graphData.nodes.length ||
        links.length !== data.graphData.links.length
      ) {
        setData((prev) =>
          prev
            ? {
                ...prev,
                graphData: { nodes, links },
              }
            : prev
        );
      }
    }, 700); // slightly after animation duration
    return () => clearTimeout(timer);
  }, [data]);

  // Update URL when sidebar state changes (node, profile, or panel)
  useEffect(() => {
    if (!urlRestoredRef.current) return; // Don't write URL before restore completes

    // Determine which sidebar is active (mutually exclusive)
    if (selectedNode) {
      url.setNode(selectedNode.id);
    } else if (profilePanelHandle) {
      url.setProfile(profilePanelHandle);
    } else if (allCommentsPanelOpen) {
      url.setPanel("comments");
    } else if (activityPanelOpen) {
      url.setPanel("activity");
    } else if (tasksPanelOpen) {
      url.setPanel("tasks");
    } else if (helpPanelOpen) {
      url.setPanel("help");
    } else if (leaderboardPanelOpen) {
      url.setPanel("leaderboard");
    } else {
      // No sidebar open — clear sidebar params
      url.setNode(null);
    }
  }, [selectedNode, profilePanelHandle, allCommentsPanelOpen, activityPanelOpen, tasksPanelOpen, helpPanelOpen, leaderboardPanelOpen]);

  // Update URL when focused epic changes
  useEffect(() => {
    if (!urlRestoredRef.current) return;
    url.setEpic(focusedEpicId);
  }, [focusedEpicId]);

  // Update URL when color mode changes
  useEffect(() => {
    if (!urlRestoredRef.current) return;
    url.setColor(colorMode);
  }, [colorMode]);

  // Collapsed epics
  useEffect(() => {
    if (!urlRestoredRef.current) return;
    url.setCollapsed(collapsedEpicIds);
  }, [collapsedEpicIds]);

  // Legend filters
  useEffect(() => {
    if (!urlRestoredRef.current) return;
    url.setFilters(activeLegendFilters);
  }, [activeLegendFilters]);

  // Multi-selected nodes
  useEffect(() => {
    if (!urlRestoredRef.current) return;
    url.setSelected(selectedNodeIds);
  }, [selectedNodeIds]);

  // Timeline replay
  useEffect(() => {
    if (!urlRestoredRef.current) return;
    url.setReplay(timelineActive);
  }, [timelineActive]);

  // Sync description modal open/close to URL desc param
  useEffect(() => {
    if (!urlRestoredRef.current) return;
    // Skip the very first write if the modal was opened from the URL (avoid loop)
    if (initialDescAppliedRef.current) {
      initialDescAppliedRef.current = false;
      return;
    }
    url.setDesc(descriptionModalNode?.id ?? null);
  }, [descriptionModalNode]);

  // --- Timeline replay logic ---

  // Compute timeline event range from full data
  const timelineRange = useMemo<TimelineRange | null>(() => {
    if (!data) return null;
    return buildTimelineEvents(data.graphData.nodes, data.graphData.links);
  }, [data]);

  // Filter activity feed by timeline time during replay
  const timelineFilteredActivity = useMemo(() => {
    if (!timelineActive || !timelineRange) return activityFeed;
    if (timelineStep === -1) return [];
    if (timelineRange.events.length === 0) return activityFeed;
    
    const event = timelineRange.events[timelineStep];
    if (!event) return activityFeed;
    
    const currentTime = event.time;
    return activityFeed.filter((e) => e.time <= currentTime);
  }, [activityFeed, timelineActive, timelineRange, timelineStep]);

  // Filter leaderboard by timeline during replay
  const timelineFilteredLeaderboard = useMemo((): LeaderboardEntry[] => {
    if (!timelineActive || !timelineRange || !timelineData) return leaderboardEntries;
    if (timelineStep === -1) return [];
    if (timelineRange.events.length === 0) return leaderboardEntries;

    const event = timelineRange.events[timelineStep];
    if (!event) return leaderboardEntries;
    const currentTime = event.time;

    // Recompute leaderboard from timeline-filtered nodes only
    const nodes = timelineData.graphData.nodes;
    const handleStats = new Map<string, {
      handle: string;
      avatar?: string;
      did?: string;
      createdCount: number;
      closedCount: number;
      commentCount: number;
    }>();

    const getOrCreate = (handle: string) => {
      if (!handle) return null;
      let entry = handleStats.get(handle);
      if (!entry) {
        entry = { handle, createdCount: 0, closedCount: 0, commentCount: 0 };
        handleStats.set(handle, entry);
      }
      return entry;
    };

    // Count per-person stats from timeline-filtered nodes
    for (const node of nodes) {
      const creator = node.createdBy || node.owner || node.assignee;
      if (creator) {
        const e = getOrCreate(creator);
        if (e) e.createdCount++;
      }
      if (node.status === "closed" && node.assignee) {
        const e = getOrCreate(node.assignee);
        if (e) e.closedCount++;
      }
    }

    // Count comments that happened before the current timeline time
    if (allComments) {
      for (const comment of allComments) {
        if (!localNodeIds.has(comment.nodeId)) continue;
        const commentTime = new Date(comment.createdAt).getTime();
        if (commentTime > currentTime) continue; // skip future comments
        const isClaim = comment.text.startsWith("@") && comment.text.trim().indexOf(" ") === -1;
        if (!isClaim) {
          const e = getOrCreate(comment.handle);
          if (e) {
            e.commentCount++;
            if (comment.avatar && !e.avatar) e.avatar = comment.avatar;
            if (comment.did && !e.did) e.did = comment.did;
          }
        }
      }
    }

    // Pull avatars from resolved sources
    for (const [, info] of assigneeNodeAvatarsResolved) {
      const e = handleStats.get(info.handle);
      if (e && info.avatar && !e.avatar) e.avatar = info.avatar;
    }
    for (const [handle, avatar] of resolvedAssigneeAvatars) {
      const e = handleStats.get(handle);
      if (e && !e.avatar) e.avatar = avatar;
    }

    // Build entries with score and sort
    const entries: LeaderboardEntry[] = [];
    for (const stats of handleStats.values()) {
      const score = stats.closedCount * 3 + stats.createdCount + stats.commentCount;
      if (score > 0) {
        entries.push({ ...stats, score });
      }
    }
    entries.sort((a, b) => b.score - a.score);
    return entries;
  }, [leaderboardEntries, timelineActive, timelineRange, timelineStep, timelineData, allComments, assigneeNodeAvatarsResolved, resolvedAssigneeAvatars, localNodeIds]);

  // Compute UserProfile from existing data for ProfilePanel
  const profilePanelUser = useMemo((): UserProfile | null => {
    if (!profilePanelHandle || !data) return null;
    
    const handle = profilePanelHandle;
    const nodes = timelineActive && timelineData 
      ? timelineData.graphData.nodes 
      : data.graphData.nodes;
    
    const currentTimelineTime = timelineActive && timelineRange && timelineStep >= 0
      ? timelineRange.events[timelineStep]?.time
      : undefined;

    const localComments = allComments
      ? allComments.filter(c => localNodeIds.has(c.nodeId) || knownPrefixes.has(c.nodeId))
      : [];
    const comments = currentTimelineTime
      ? localComments.filter(c => new Date(c.createdAt).getTime() <= currentTimelineTime)
      : localComments;
    
    // Find avatar from various sources
    let avatar: string | undefined;
    let did: string | undefined;
    
    // Check claimedNodeAvatars
    if (claimedNodeAvatars) {
      for (const [, claim] of claimedNodeAvatars) {
        if (claim.handle === handle) {
          avatar = avatar || claim.avatar;
          did = did || claim.did;
          break;
        }
      }
    }
    
    // Check assigneeNodeAvatars  
    if (assigneeNodeAvatarsResolved) {
      for (const [, info] of assigneeNodeAvatarsResolved) {
        if (info.handle === handle) {
          avatar = avatar || info.avatar;
          break;
        }
      }
    }
    
    // Check comments for avatar
    for (const comment of comments) {
      if (comment.handle === handle) {
        avatar = avatar || comment.avatar;
        did = did || comment.did;
        break;
      }
    }
    
    // Assigned nodes: nodes where assignee matches handle
    const assignedNodes = nodes.filter(n => n.assignee === handle);
    const assignedCount = assignedNodes.length;
    
    // Closed nodes: assigned or created by this user AND status is closed
    const closedCount = nodes.filter(n => 
      n.status === "closed" && (n.assignee === handle || n.createdBy === handle)
    ).length;
    
    // Claimed nodes
    let claimedCount = 0;
    if (claimedNodeAvatars) {
      for (const [, claim] of claimedNodeAvatars) {
        if (claim.handle === handle) claimedCount++;
      }
    }
    
    // Comments by this user
    const userComments = comments.filter(c => c.handle === handle);
    const nonClaimComments = userComments.filter(c => !(c.text.startsWith("@") && c.text.trim().indexOf(" ") === -1));
    const commentCount = nonClaimComments.length;
    
    // Likes given by this user (scan all comments for likes by this handle)
    let likeCount = 0;
    for (const comment of comments) {
      for (const like of comment.likes) {
        if (like.handle === handle) likeCount++;
      }
    }
    
    // Projects: count nodes per prefix where user is assignee or createdBy
    const projectCounts = new Map<string, number>();
    for (const node of nodes) {
      if (node.assignee === handle || node.createdBy === handle) {
        projectCounts.set(node.prefix, (projectCounts.get(node.prefix) || 0) + 1);
      }
    }
    const projects = Array.from(projectCounts.entries())
      .sort((a, b) => b[1] - a[1])
      .map(([prefix, count]) => ({
        prefix,
        prefixLabel: getPrefixLabel(prefix),
        color: getCatppuccinPrefixColor(prefix),
        count,
      }));
    
    // Recent activity: build from nodes + comments + claims + likes
    const recentActivity: UserProfile["recentActivity"] = [];
    
    // Assigned nodes
    for (const node of assignedNodes) {
      recentActivity.push({
        type: "assigned",
        nodeId: node.id,
        nodeTitle: node.title,
        time: node.updatedAt,
      });
    }
    
    // Closed nodes
    for (const node of nodes) {
      if (node.status === "closed" && node.closedAt && (node.assignee === handle || node.createdBy === handle)) {
        recentActivity.push({
          type: "closed",
          nodeId: node.id,
          nodeTitle: node.title,
          time: node.closedAt,
        });
      }
    }
    
    // Comments
    for (const comment of userComments) {
      // Skip claim comments — they appear in the "Claims" section below
      if (comment.text.startsWith("@") && comment.text.trim().indexOf(" ") === -1) continue;
      recentActivity.push({
        type: "commented",
        nodeId: comment.nodeId,
        nodeTitle: nodes.find(n => n.id === comment.nodeId)?.title || comment.nodeId,
        time: comment.createdAt,
        detail: comment.text.slice(0, 60) + (comment.text.length > 60 ? "..." : ""),
      });
    }
    
    // Claims (claim comments start with @ and have no space)
    for (const comment of comments) {
      if (comment.handle === handle && comment.text.startsWith("@") && comment.text.trim().indexOf(" ") === -1) {
        recentActivity.push({
          type: "claimed",
          nodeId: comment.nodeId,
          nodeTitle: nodes.find(n => n.id === comment.nodeId)?.title || comment.nodeId,
          time: comment.createdAt,
        });
      }
    }
    
    // Likes given
    for (const comment of comments) {
      for (const like of comment.likes) {
        if (like.handle === handle) {
          recentActivity.push({
            type: "liked",
            nodeId: comment.nodeId,
            nodeTitle: nodes.find(n => n.id === comment.nodeId)?.title || comment.nodeId,
            time: like.createdAt,
          });
        }
      }
    }
    
    // Sort newest-first, cap at 20
    recentActivity.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime());
    const capped = recentActivity.slice(0, 20);
    
    // Last active: most recent timestamp from all activity
    const lastActiveAt = capped.length > 0 ? capped[0].time : undefined;
    
    return {
      handle,
      avatar,
      did,
      assignedCount,
      closedCount,
      claimedCount,
      commentCount,
      likeCount,
      projects,
      recentActivity: capped,
      lastActiveAt,
    };
  }, [profilePanelHandle, data, claimedNodeAvatars, assigneeNodeAvatarsResolved, allComments, timelineActive, timelineData, timelineRange, timelineStep, localNodeIds]);

  // Toggle timeline mode on/off
  const handleTimelineToggle = useCallback(() => {
    setTimelineActive((prev) => {
      const next = !prev;
      if (next) {
        setTimelineStep(-1);
        setTimelinePlaying(false);
        setTimelineData(null);
      } else {
        setTimelinePlaying(false);
        setTimelineData(null);
      }
      return next;
    });
  }, []);

  // Event-step playback: advance one step every 5s/speed
  useEffect(() => {
    if (!timelinePlaying || !timelineActive || !timelineRange) return;
    const intervalMs = 2000 / timelineSpeed;
    const interval = setInterval(() => {
      setTimelineStep((prev) => {
        const next = prev + 1;
        if (next >= timelineRange.events.length) {
          setTimelinePlaying(false);
          return prev;
        }
        return next;
      });
    }, intervalMs);
    return () => clearInterval(interval);
  }, [timelinePlaying, timelineActive, timelineSpeed, timelineRange]);

  // Compute timelineData via diff/merge pipeline when step changes
  useEffect(() => {
    if (!timelineActive || !data || !timelineRange) return;

    // Preamble step: empty canvas
    if (timelineStep === -1) {
      setTimelineData((prev) => {
        const empty: BeadsApiResponse = {
          ...data,
          graphData: { nodes: [], links: [] },
        };
        if (!prev) return empty;
        const diff = diffBeadsData(prev, empty);
        if (!diff.hasChanges) return prev;
        return mergeBeadsData(prev, empty, diff);
      });
      return;
    }

    if (timelineRange.events.length === 0) return;
    const event = timelineRange.events[timelineStep];
    if (!event) return;

    const filtered = filterDataAtTime(
      data.graphData.nodes,
      data.graphData.links,
      event.time
    );
    const newSnapshot: BeadsApiResponse = {
      ...data,
      graphData: { nodes: filtered.nodes, links: filtered.links },
    };

    setTimelineData((prev) => {
      if (!prev) return newSnapshot; // first frame — no merge needed
      const diff = diffBeadsData(prev, newSnapshot);
      if (!diff.hasChanges) return prev;
      return mergeBeadsData(prev, newSnapshot, diff);
    });
  }, [timelineActive, data, timelineRange, timelineStep]);

  // --- End timeline replay logic ---

  const handleNodeClick = useCallback((node: GraphNode, event?: MouseEvent) => {
    // Cmd+Click (Mac) or Ctrl+Click (Windows/Linux): open description modal
    if ((event?.metaKey || event?.ctrlKey) && node.description) {
      setDescriptionModalNode(node);
      setAllCommentsPanelOpen(false);
      setProfilePanelHandle(null);
      return;
    }

    if (event?.shiftKey) {
      // Shift+click: toggle node in multi-selection
      setSelectedNodeIds((prev) => {
        const next = new Set(prev);
        if (next.has(node.id)) {
          next.delete(node.id);
        } else {
          next.add(node.id);
        }
        return next;
      });
      // Clear single selection when shift-clicking
      setSelectedNode(null);
      return;
    }

    // Normal click: single-select (existing behavior)
    setSelectedNode((prev) => (prev?.id === node.id ? null : node));
    setSelectedNodeIds(new Set());
    setAllCommentsPanelOpen(false);
    setActivityPanelOpen(false);
    setTasksPanelOpen(false);
    setHelpPanelOpen(false);
    setProfilePanelHandle(null);
    setLeaderboardPanelOpen(false);
    setTutorialStep(null);
    setMobileMenuOpen(false);
    setMobileActionSheet(null);
  }, []);

  const handleNodeHover = useCallback((node: GraphNode | null, x: number, y: number) => {
    // hoveredNode is handled by BeadsGraph internally via refs — no state needed here.
    // Only set tooltip state (needed for the tooltip DOM element).
    if (!isMobileRef.current) {
      setNodeTooltip(node ? { node, x, y } : null);
    }
  }, []);

  const handleAvatarClick = useCallback((info: { handle: string; avatar?: string; did?: string; nodeId: string; isAssignee?: boolean }) => {
    setProfilePanelHandle(info.handle);
    // Close other sidebars (mutual exclusivity)
    setSelectedNode(null);
    setAllCommentsPanelOpen(false);
    setActivityPanelOpen(false);
    setTasksPanelOpen(false);
    setHelpPanelOpen(false);
    setLeaderboardPanelOpen(false);
    setTutorialStep(null);
    setAvatarTooltip(null);
  }, []);

  const handleToggleEpicCollapse = useCallback((epicId: string) => {
    setCollapsedEpicIds((prev) => {
      const next = new Set(prev);
      if (next.has(epicId)) next.delete(epicId);
      else next.add(epicId);
      return next;
    });
  }, []);

  // Compute all epic IDs that have children (for collapse-all)
  const allParentEpicIds = useMemo(() => {
    if (!data) return new Set<string>();
    const { nodes, links } = data.graphData;
    const parentIds = new Set<string>();
    // From parent-child links
    for (const link of links) {
      if (link.type === "parent-child") {
        const src = typeof link.source === "object" ? (link.source as any).id : link.source;
        parentIds.add(src);
      }
    }
    // From hierarchical IDs
    const nodeIds = new Set(nodes.map((n) => n.id));
    for (const node of nodes) {
      if (node.id.includes(".")) {
        const parentId = node.id.split(".")[0];
        if (nodeIds.has(parentId)) parentIds.add(parentId);
      }
    }
    return parentIds;
  }, [data]);

  const handleCollapseAll = useCallback(() => {
    setCollapsedEpicIds(new Set(allParentEpicIds));
  }, [allParentEpicIds]);

  const handleExpandAll = useCallback(() => {
    setCollapsedEpicIds(new Set());
  }, []);

  const handleFocusEpic = useCallback((epicId: string) => {
    setFocusedEpicId(epicId);
  }, []);

  const handleExitFocusedEpic = useCallback(() => {
    setFocusedEpicId(null);
  }, []);

  // --- Tutorial callbacks ---
  const handleStartTutorial = useCallback(() => {
    setHelpPanelOpen(true);
    setSelectedNode(null);
    setAllCommentsPanelOpen(false);
    setActivityPanelOpen(false);
    setTasksPanelOpen(false);
    setLeaderboardPanelOpen(false);
    setTutorialStep(0);
  }, []);

  const handleNextTutorialStep = useCallback(() => {
    setTutorialStep((prev) => {
      if (prev === null) return null;
      if (prev >= TUTORIAL_STEPS.length - 1) return prev;
      return prev + 1;
    });
  }, []);

  const handlePrevTutorialStep = useCallback(() => {
    setTutorialStep((prev) => {
      if (prev === null || prev <= 0) return prev;
      return prev - 1;
    });
  }, []);

  const handleEndTutorial = useCallback(() => {
    setTutorialStep(null);
  }, []);

  const handleBackgroundClick = useCallback(() => {
    setSelectedNode(null);
    setSelectedNodeIds(new Set()); // clear multi-selection
    setContextMenu(null);
    setCommentTooltipState(null);
    setMobileActionSheet(null);
    setProfilePanelHandle(null);
    setLeaderboardPanelOpen(false);
  }, []);

  const handleNodeRightClick = useCallback(
    (node: GraphNode, event: MouseEvent) => {
      if (isMobileRef.current) return; // No right-click menu on mobile
      // Dismiss any open comment tooltip and hover tooltip
      setCommentTooltipState(null);
      setNodeTooltip(null);
      if (!node.description && !isAuthenticated && node.issueType !== "epic") {
        // No description and not logged in → only action is comment → skip menu
        setCommentTooltipState({
          node,
          x: event.clientX,
          y: event.clientY,
        });
      } else {
        setContextMenu({ node, x: event.clientX, y: event.clientY });
      }
    },
    [isAuthenticated]
  );

  const handleNodeDoubleTap = useCallback(
    (node: GraphNode) => {
      // On mobile double-tap: show bottom action sheet
      setCommentTooltipState(null);
      setNodeTooltip(null);
      setMobileActionSheet({ node });
    },
    []
  );

  const handlePostComment = useCallback(
    async (nodeId: string, text: string) => {
      const response = await fetch("/api/records", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          collection: "org.impactindexer.review.comment",
          record: {
            $type: "org.impactindexer.review.comment",
            subject: {
              uri: `beads:${nodeId}`,
              type: "record",
            },
            text,
            createdAt: new Date().toISOString(),
          },
        }),
      });

      if (!response.ok) {
        const errData = await response.json();
        throw new Error(errData.error || "Failed to post comment");
      }

      // Refetch comments to update the UI
      await refetchComments();
    },
    [refetchComments]
  );

  const handleClaimTask = useCallback(
    async (nodeId: string) => {
      if (!session?.handle) return;
      // Resolve avatar: use session avatar, or fetch from Bluesky public API
      let avatar = session.avatar;
      if (!avatar && session.did) {
        try {
          const res = await fetch(
            `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(session.did)}`
          );
          if (res.ok) {
            const profile = await res.json();
            avatar = profile.avatar;
          }
        } catch {
          // ignore — fallback letter will show
        }
      }
      // Optimistically show avatar immediately
      setOptimisticClaims((prev) => {
        const next = new Map(prev);
        next.set(nodeId, {
          avatar,
          claimedAt: new Date().toISOString(),
          handle: session.handle,
        });
        return next;
      });
      await handlePostComment(nodeId, `@${session.handle}`);
      // Delayed refetch — indexer may need a few seconds to pick up the new comment
      setTimeout(() => refetchComments(), 3000);
    },
    [session?.handle, session?.did, session?.avatar, handlePostComment, refetchComments]
  );

  const handleDeleteComment = useCallback(
    async (comment: { rkey: string }) => {
      const response = await fetch(
        `/api/records?collection=${encodeURIComponent("org.impactindexer.review.comment")}&rkey=${encodeURIComponent(comment.rkey)}`,
        { method: "DELETE" }
      );
      if (!response.ok) {
        const errData = await response.json();
        throw new Error(errData.error || "Failed to delete comment");
      }
      await refetchComments();
    },
    [refetchComments]
  );

  const handleUnclaimTask = useCallback(
    async (nodeId: string) => {
      const claim = claimedNodeAvatars.get(nodeId);
      if (!claim) return;

      // Optimistically suppress the claim immediately
      setOptimisticUnclaims((prev) => new Set(prev).add(nodeId));
      setOptimisticClaims((prev) => {
        const next = new Map(prev);
        next.delete(nodeId);
        return next;
      });

      // Fallback: clear optimistic unclaim after 10 seconds regardless
      setTimeout(() => {
        setOptimisticUnclaims((prev) => {
          const next = new Set(prev);
          next.delete(nodeId);
          return next;
        });
      }, 10000);

      // Delete the comment if we have the rkey
      if (claim.rkey) {
        try {
          await handleDeleteComment({ rkey: claim.rkey });
          // Refetch clears the comment from allComments, so remove from unclaims set
          setOptimisticUnclaims((prev) => {
            const next = new Set(prev);
            next.delete(nodeId);
            return next;
          });
        } catch (err) {
          console.error('Failed to unclaim:', err);
          // Clear optimistic state on error
          setOptimisticUnclaims((prev) => {
            const next = new Set(prev);
            next.delete(nodeId);
            return next;
          });
        }
      } else {
        // Optimistic claim not yet indexed — refetch after a delay, then clear unclaim
        setTimeout(async () => {
          try {
            await refetchComments();
          } catch (err) {
            console.error('Failed to refetch comments:', err);
          }
          setOptimisticUnclaims((prev) => {
            const next = new Set(prev);
            next.delete(nodeId);
            return next;
          });
        }, 3000);
      }
    },
    [claimedNodeAvatars, handleDeleteComment, refetchComments]
  );

  const handleLikeComment = useCallback(
    async (comment: BeadsComment) => {
      // Check if already liked by current user
      const existingLike = comment.likes.find(
        (l) => l.did === session?.did
      );

      if (existingLike) {
        // Unlike: DELETE the like record
        const response = await fetch(
          `/api/records?collection=${encodeURIComponent("org.impactindexer.review.like")}&rkey=${encodeURIComponent(existingLike.rkey)}`,
          { method: "DELETE" }
        );
        if (!response.ok) {
          const errData = await response.json();
          throw new Error(errData.error || "Failed to unlike");
        }
      } else {
        // Like: POST a new like record
        const response = await fetch("/api/records", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            collection: "org.impactindexer.review.like",
            record: {
              subject: { uri: comment.uri, type: "record" },
              createdAt: new Date().toISOString(),
            },
          }),
        });
        if (!response.ok) {
          const errData = await response.json();
          throw new Error(errData.error || "Failed to like");
        }
      }

      await refetchComments();
    },
    [session?.did, refetchComments]
  );

  const handleReplyComment = useCallback(
    async (parentComment: BeadsComment, text: string) => {
      const response = await fetch("/api/records", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          collection: "org.impactindexer.review.comment",
          record: {
            subject: {
              uri: `beads:${parentComment.nodeId}`,
              type: "record",
            },
            text,
            replyTo: parentComment.uri,
            createdAt: new Date().toISOString(),
          },
        }),
      });

      if (!response.ok) {
        const errData = await response.json();
        throw new Error(errData.error || "Failed to post reply");
      }

      await refetchComments();
    },
    [refetchComments]
  );

  const handleNodeNavigate = useCallback(
    (nodeId: string) => {
      if (!data) return;
      const node = data.graphData.nodes.find((n) => n.id === nodeId);
      if (node) {
        setSelectedNode(node);
      }
    },
    [data]
  );

  // Build a map of nodeId -> commenter handles string for search
  const commenterHandlesByNode = useMemo(() => {
    const map = new Map<string, string>();
    if (!allComments) return map;
    const handlesMap = new Map<string, Set<string>>();
    for (const comment of allComments) {
      if (!handlesMap.has(comment.nodeId)) {
        handlesMap.set(comment.nodeId, new Set());
      }
      handlesMap.get(comment.nodeId)!.add(comment.handle);
      if (comment.displayName) {
        handlesMap.get(comment.nodeId)!.add(comment.displayName);
      }
    }
    for (const [nodeId, handles] of handlesMap) {
      map.set(nodeId, Array.from(handles).join(" "));
    }
    return map;
  }, [allComments]);

  // Search results - fuzzy match on id, title, people, and commenter handles
  const searchResults = useMemo((): SearchResult[] => {
    if (!data || !searchQuery.trim()) return [];
    const term = searchQuery.toLowerCase();
    
    // Profile results (max 3)
    const profileResults: SearchResult[] = searchableProfiles
      .filter(p => p.handle.toLowerCase().includes(term))
      .slice(0, 3)
      .map(p => ({ type: "profile" as const, handle: p.handle, avatar: p.avatar }));
    
    // Node results (max 5, or more if fewer profiles matched)
    const maxNodes = 8 - profileResults.length;
    const nodeResults: SearchResult[] = data.graphData.nodes
      .filter((n) => {
        const commenters = commenterHandlesByNode.get(n.id) || "";
        const searchable = `${n.id} ${n.title} ${n.prefix} ${n.owner || ""} ${n.assignee || ""} ${n.createdBy || ""} ${commenters}`.toLowerCase();
        return searchable.includes(term);
      })
      .slice(0, maxNodes)
      .map(n => ({ type: "node" as const, node: n }));
    
    return [...profileResults, ...nodeResults];
  }, [searchQuery, data, commenterHandlesByNode, searchableProfiles]);

  // Reset highlight index when query changes
  useEffect(() => {
    setSearchHighlightIndex(0);
  }, [searchQuery]);

  // Focus node via graph ref, then close search
  const focusNode = useCallback(
    (node: GraphNode) => {
      graphRef.current?.focusNode(node);
      setSearchOpen(false);
      setSearchQuery("");
      setSearchHighlightIndex(0);
    },
    []
  );

  // Helper function to find the node ID where a contributor's avatar is displayed
  const findAvatarNodeId = useCallback(
    (handle: string): string | null => {
      // 1. Check claimed nodes first (claims take visual priority)
      for (const [nodeId, claim] of claimedNodeAvatars) {
        if (claim.handle === handle) return nodeId;
      }
      // 2. Fall back to assignee avatar node
      for (const [nodeId, info] of assigneeNodeAvatarsResolved) {
        if (info.handle === handle) return nodeId;
      }
      // 3. Last resort: any assigned open node, most recently updated
      if (data) {
        const fallback = data.graphData.nodes
          .filter((n) => n.assignee === handle && n.status !== "closed")
          .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
        if (fallback.length > 0) return fallback[0].id;
      }
      return null;
    },
    [claimedNodeAvatars, assigneeNodeAvatarsResolved, data]
  );

  const focusProfile = useCallback(
    (handle: string) => {
      // Open profile panel
      setProfilePanelHandle(handle);
      // Clear other panels
      setSelectedNode(null);
      setAllCommentsPanelOpen(false);
      setActivityPanelOpen(false);
      setTasksPanelOpen(false);
      setHelpPanelOpen(false);
      setLeaderboardPanelOpen(false);
      // Zoom to avatar node
      const nodeId = findAvatarNodeId(handle);
      if (nodeId) graphRef.current?.zoomToNode(nodeId);
      // Close search
      setSearchOpen(false);
      setSearchQuery("");
      setSearchHighlightIndex(0);
    },
    [findAvatarNodeId]
  );

  // Keyboard shortcut: Ctrl/Cmd+F to open search, Escape to close, Shift+0 to focus most recent node
  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      // Guard against input fields
      const tag = (e.target as HTMLElement)?.tagName;
      if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) {
        return;
      }

      if ((e.ctrlKey || e.metaKey) && e.key === "f") {
        e.preventDefault();
        setSearchOpen(true);
        setTimeout(() => searchInputRef.current?.focus(), 50);
      }
      if (e.key === "Escape" && searchOpen) {
        setSearchOpen(false);
        setSearchQuery("");
        setSearchHighlightIndex(0);
      }

      // Shift+0 to focus most recently edited node
      if (e.shiftKey && e.code === 'Digit0') {
        e.preventDefault();
        const mostRecentNodeId = timelineFilteredActivity.length > 0 
          ? timelineFilteredActivity[0].nodeId 
          : null;
        if (mostRecentNodeId && data) {
          const node = data.graphData.nodes.find(n => n.id === mostRecentNodeId);
          if (node) {
            graphRef.current?.focusNode(node);
          }
        }
      }

      // Shift+1-9 to focus on leaderboard contributor's node
      const digitMatch = e.code.match(/^Digit([1-9])$/);
      if (e.shiftKey && digitMatch) {
        e.preventDefault();
        const rank = parseInt(digitMatch[1], 10);
        const entry = timelineFilteredLeaderboard[rank - 1];
        if (!entry || !data) return;
        
        const targetNodeId = findAvatarNodeId(entry.handle);
        if (targetNodeId) {
          const node = data.graphData.nodes.find((n) => n.id === targetNodeId);
          if (node) graphRef.current?.focusNode(node);
        }
      }
    };
    window.addEventListener("keydown", handleKeyDown);
    return () => window.removeEventListener("keydown", handleKeyDown);
  }, [searchOpen, timelineFilteredActivity, data, timelineFilteredLeaderboard, findAvatarNodeId]);

  // Handle search result selection via keyboard
  const handleSearchKeyDown = useCallback(
    (e: React.KeyboardEvent) => {
      if (e.key === "ArrowDown") {
        e.preventDefault();
        setSearchHighlightIndex((prev) =>
          Math.min(prev + 1, searchResults.length - 1)
        );
      } else if (e.key === "ArrowUp") {
        e.preventDefault();
        setSearchHighlightIndex((prev) => Math.max(prev - 1, 0));
      } else if (e.key === "Enter" && searchResults.length > 0) {
        e.preventDefault();
        const result = searchResults[searchHighlightIndex];
        if (result.type === "profile") {
          focusProfile(result.handle);
        } else {
          focusNode(result.node);
        }
      }
    },
    [searchResults, searchHighlightIndex, focusNode, focusProfile]
  );

  // Loading state
  if (loading) {
    return (
      <div className="h-screen flex flex-col items-center justify-center bg-white dark:bg-zinc-900 text-zinc-800 dark:text-zinc-200 px-6 select-none">
        {/* Animated ECG trace with heartbeat logo */}
        <div className="relative w-full max-w-sm mb-8">
          <svg
            viewBox="0 0 400 40"
            fill="none"
            className="w-full text-zinc-200 dark:text-zinc-700"
            aria-hidden="true"
          >
            <line
              x1="0"
              y1="20"
              x2="400"
              y2="20"
              stroke="currentColor"
              strokeWidth="1.5"
              strokeLinecap="round"
              strokeDasharray="4 6"
              opacity="0.6"
            />
          </svg>
          <div className="absolute inset-0 flex items-center justify-center">
            <div className="bg-white dark:bg-zinc-900 px-4">
              <BeadsLogo className="w-10 h-10 text-emerald-500" />
            </div>
          </div>
        </div>

        <p className="text-sm text-zinc-500 dark:text-zinc-400 font-light animate-fade-in">
          Warming up the heartbeat...
        </p>
        <p
          className="text-xs text-zinc-400 dark:text-zinc-500 mt-1.5 animate-fade-in"
          style={{ animationDelay: "0.1s" }}
        >
          Discovering issues and mapping dependencies
        </p>
      </div>
    );
  }

  // Error state
  if (error) {
    return (
      <div className="h-screen flex items-center justify-center bg-white">
        <div className="max-w-sm text-center">
          <div className="w-14 h-14 mx-auto mb-4 bg-red-50 rounded-full flex items-center justify-center">
            <svg
              className="w-7 h-7 text-red-400"
              fill="none"
              stroke="currentColor"
              viewBox="0 0 24 24"
            >
              <path
                strokeLinecap="round"
                strokeLinejoin="round"
                strokeWidth={1.5}
                d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
              />
            </svg>
          </div>
          <h2 className="text-lg font-semibold text-zinc-900 mb-1">
            Unable to load data
          </h2>
          <p className="text-sm text-zinc-500 mb-4">{error}</p>
          <button
            onClick={() => window.location.reload()}
            className="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white text-sm rounded-lg hover:bg-emerald-700 transition-colors"
          >
            <svg
              className="w-4 h-4"
              fill="none"
              stroke="currentColor"
              viewBox="0 0 24 24"
            >
              <path
                strokeLinecap="round"
                strokeLinejoin="round"
                strokeWidth={2}
                d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
              />
            </svg>
            Try Again
          </button>
        </div>
      </div>
    );
  }

  if (!data) return null;

  return (
    <div className="h-screen flex flex-col overflow-hidden bg-white dark:bg-black">
      {/* Header */}
      <header className="sticky top-0 z-50 shrink-0 bg-white/95 dark:bg-black/95 backdrop-blur-sm border-b border-zinc-200/80 dark:border-zinc-800/80">
        <div className="px-3 sm:px-6 h-14 flex items-center">
          {/* Left: Logo */}
          <div className="flex items-center gap-3 shrink-0 group">
            <BeadsLogo className="w-8 h-8 text-emerald-500 transition-transform group-hover:scale-105" />
            <div className="flex items-baseline gap-2">
              <h1 className="text-[15px] font-semibold text-zinc-900 dark:text-zinc-100 tracking-tight">
                {projectName}
              </h1>
              <span className="font-normal text-zinc-400 text-[15px] hidden sm:inline">
                Heartbeads
              </span>
              <span className="text-[10px] text-zinc-400 bg-zinc-100 dark:bg-zinc-800 rounded-full px-1.5 py-0.5 font-medium hidden sm:inline">
                v{process.env.NEXT_PUBLIC_APP_VERSION}
              </span>
            </div>
          </div>

          {/* Center: Search */}
          <div className="flex-1 flex justify-center px-2 sm:px-4">
            <div className="relative w-full max-w-md" data-tutorial="search">
              {searchOpen ? (
                <div className="flex flex-col">
                  <div className="flex items-center bg-white dark:bg-zinc-900 rounded-full border border-zinc-200 dark:border-zinc-700 shadow-sm overflow-hidden">
                    <div className="pl-3 pr-1 text-zinc-400 dark:text-zinc-500">
                      <svg
                        className="w-3.5 h-3.5"
                        fill="none"
                        stroke="currentColor"
                        viewBox="0 0 24 24"
                        strokeWidth={2}
                      >
                        <path
                          strokeLinecap="round"
                          strokeLinejoin="round"
                          d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
                        />
                      </svg>
                    </div>
                    <input
                      ref={searchInputRef}
                      type="text"
                      value={searchQuery}
                      onChange={(e) => setSearchQuery(e.target.value)}
                      onKeyDown={handleSearchKeyDown}
                      placeholder="Search issues or profiles..."
                      className="flex-1 px-2 py-1.5 text-xs text-zinc-800 dark:text-zinc-200 bg-transparent outline-none placeholder:text-zinc-400 dark:placeholder:text-zinc-500"
                      autoFocus
                    />
                    <button
                      onClick={() => {
                        setSearchOpen(false);
                        setSearchQuery("");
                        setSearchHighlightIndex(0);
                      }}
                      className="px-2 py-1.5 text-zinc-400 dark:text-zinc-500 hover:text-zinc-600 dark:hover:text-zinc-300"
                    >
                      <svg
                        className="w-3.5 h-3.5"
                        fill="none"
                        stroke="currentColor"
                        viewBox="0 0 24 24"
                        strokeWidth={2}
                      >
                        <path
                          strokeLinecap="round"
                          strokeLinejoin="round"
                          d="M6 18L18 6M6 6l12 12"
                        />
                      </svg>
                    </button>
                  </div>

                  {/* Search results dropdown */}
                  {searchQuery.trim() && (
                    <div className="absolute top-full left-0 right-0 mt-2 bg-white dark:bg-zinc-900 rounded-xl border border-zinc-100 dark:border-zinc-800 shadow-xl overflow-hidden max-h-72 overflow-y-auto z-50">
                      {searchResults.length === 0 ? (
                        <div className="px-3 py-3 text-xs text-zinc-400 dark:text-zinc-500 text-center">
                          No matching issues or profiles
                        </div>
                      ) : (
                        searchResults.map((result, i) => (
                          result.type === "profile" ? (
                            <button
                              key={`profile-${result.handle}`}
                              onClick={() => focusProfile(result.handle)}
                              onMouseEnter={() => setSearchHighlightIndex(i)}
                              className={`w-full text-left px-3 py-2 text-xs flex items-center gap-2.5 transition-colors ${
                                i === searchHighlightIndex
                                  ? "bg-emerald-50 dark:bg-emerald-900/30"
                                  : "hover:bg-zinc-50 dark:hover:bg-zinc-800/50"
                              }`}
                            >
                              {/* Avatar */}
                              {result.avatar ? (
                                <>
                                  {/* eslint-disable-next-line @next/next/no-img-element */}
                                  <img src={result.avatar} alt={result.handle} className="w-6 h-6 rounded-full shrink-0" />
                                </>
                              ) : (
                                <div className="w-6 h-6 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center text-[10px] font-medium text-zinc-500 dark:text-zinc-400 shrink-0">
                                  {result.handle.charAt(0).toUpperCase()}
                                </div>
                              )}
                              {/* Handle */}
                              <div className="flex-1 min-w-0">
                                <span className="font-medium text-zinc-700 dark:text-zinc-300 truncate">{result.handle}</span>
                              </div>
                              {/* "Profile" badge */}
                              <span className="text-[10px] text-zinc-400 bg-zinc-100 dark:bg-zinc-800 rounded px-1.5 py-0.5 shrink-0">
                                profile
                              </span>
                            </button>
                          ) : (
                            <button
                              key={result.node.id}
                              onClick={() => focusNode(result.node)}
                              onMouseEnter={() => setSearchHighlightIndex(i)}
                              className={`w-full text-left px-3 py-2 text-xs transition-colors flex items-start gap-2.5 ${
                                i === searchHighlightIndex
                                  ? "bg-emerald-50 dark:bg-emerald-950"
                                  : "hover:bg-zinc-50 dark:hover:bg-zinc-800"
                              }`}
                            >
                              <div className="shrink-0 mt-1">
                                <div
                                  className={`w-2 h-2 rounded-full ${
                                    STATUS_DOT_COLORS[result.node.status] || "bg-zinc-400"
                                  }`}
                                />
                              </div>
                              <div className="min-w-0 flex-1">
                                <div className="flex items-center gap-1.5">
                                  <span className="font-medium text-zinc-600 dark:text-zinc-400 shrink-0">
                                    {result.node.id}
                                  </span>
                                  {result.node.priority <= 1 && (
                                    <span className="text-[10px]">
                                      {result.node.priority === 0
                                        ? "\uD83D\uDD25\uD83D\uDD25"
                                        : "\uD83D\uDD25"}
                                    </span>
                                  )}
                                </div>
                                <div className="text-zinc-400 dark:text-zinc-500 truncate mt-0.5">
                                  {result.node.title}
                                </div>
                              </div>
                              <span className="shrink-0 text-[10px] text-zinc-400 dark:text-zinc-500 bg-zinc-100 dark:bg-zinc-800 rounded px-1 py-0.5 mt-0.5">
                                {result.node.prefix}
                              </span>
                            </button>
                          )
                        ))
                      )}
                      {searchResults.length > 0 && (
                        <div className="px-3 py-1.5 text-[10px] text-zinc-400 dark:text-zinc-500 border-t border-zinc-100 dark:border-zinc-800 bg-zinc-50/50 dark:bg-zinc-800/50 flex items-center justify-between">
                          <span>
                            {searchResults.length} result
                            {searchResults.length !== 1 ? "s" : ""}
                          </span>
                          <span>
                            <kbd className="px-1 py-0.5 bg-white dark:bg-zinc-800 rounded border border-zinc-200 dark:border-zinc-700 text-[9px] font-mono">
                              Enter
                            </kbd>{" "}
                            to focus
                          </span>
                        </div>
                      )}
                    </div>
                  )}
                </div>
              ) : (
                <button
                  onClick={() => {
                    setSearchOpen(true);
                    setTimeout(() => searchInputRef.current?.focus(), 50);
                  }}
                  className="w-full flex items-center gap-2 px-3.5 py-1.5 text-sm text-zinc-400 rounded-full bg-zinc-50/80 dark:bg-zinc-800/80 border border-zinc-200/60 dark:border-zinc-700/60 hover:text-zinc-500 dark:hover:text-zinc-300 hover:border-zinc-300 dark:hover:border-zinc-600 hover:bg-zinc-100/50 dark:hover:bg-zinc-700/50 transition-all"
                  title="Search issues or profiles (Ctrl+F)"
                >
                  <svg
                    className="w-3.5 h-3.5 shrink-0"
                    fill="none"
                    stroke="currentColor"
                    viewBox="0 0 24 24"
                    strokeWidth={2}
                  >
                    <path
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
                    />
                  </svg>
                  <span className="flex-1 text-left">Search issues or profiles...</span>
                  <kbd className="hidden sm:inline-block px-1 py-0.5 bg-zinc-100 dark:bg-zinc-700 rounded border border-zinc-200 dark:border-zinc-600 text-[9px] font-mono text-zinc-400 dark:text-zinc-500">
                    {typeof navigator !== "undefined" &&
                    navigator.platform?.includes("Mac")
                      ? "\u2318"
                      : "Ctrl"}
                    F
                  </kbd>
                </button>
              )}
            </div>
          </div>

          {/* Mobile hamburger button */}
          <button
            onClick={() => setMobileMenuOpen(true)}
            className="md:hidden p-2 text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50 dark:text-zinc-400 dark:hover:text-zinc-200 dark:hover:bg-zinc-800 rounded-full transition-colors shrink-0"
            aria-label="Open menu"
          >
            <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
              <path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
            </svg>
          </button>

          {/* Right: Nav items (desktop) */}
          <div className="hidden md:flex items-center gap-1 shrink-0" data-tutorial="nav-pills">
            {/* Replay pill */}
            <button
              onClick={handleTimelineToggle}
              data-tutorial="nav-replay"
              className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-full transition-colors ${
                timelineActive
                  ? "text-emerald-700 bg-emerald-50 dark:text-emerald-400 dark:bg-emerald-950"
                  : "text-zinc-500 hover:text-zinc-900 hover:bg-zinc-50 dark:text-zinc-400 dark:hover:text-zinc-100 dark:hover:bg-zinc-800"
              }`}
            >
              <svg
                className="w-3.5 h-3.5"
                viewBox="0 0 16 16"
                fill="none"
                stroke="currentColor"
                strokeWidth="1.5"
              >
                <circle cx="8" cy="8" r="6" />
                <polyline points="8,4 8,8 11,10" />
              </svg>
              <span className="hidden sm:inline">Replay</span>
            </button>
            {/* Comments pill */}
            <button
              onClick={() => {
                setAllCommentsPanelOpen((prev) => !prev);
                if (!allCommentsPanelOpen) {
                  setSelectedNode(null);
                  setActivityPanelOpen(false);
                  setTasksPanelOpen(false);
                  setHelpPanelOpen(false);
                  setProfilePanelHandle(null);
                  setLeaderboardPanelOpen(false);
                  setTutorialStep(null);
                }
              }}
              data-tutorial="nav-comments"
              className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-full transition-colors ${
                allCommentsPanelOpen
                  ? "text-emerald-700 bg-emerald-50 dark:text-emerald-400 dark:bg-emerald-950"
                  : "text-zinc-500 hover:text-zinc-900 hover:bg-zinc-50 dark:text-zinc-400 dark:hover:text-zinc-100 dark:hover:bg-zinc-800"
              }`}
            >
              <svg
                className="w-3.5 h-3.5"
                fill="none"
                viewBox="0 0 24 24"
                strokeWidth={1.5}
                stroke="currentColor"
              >
                <path
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 011.037-.443 48.282 48.282 0 005.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z"
                />
              </svg>
              <span className="hidden sm:inline">Comments</span>
            </button>
            {/* Tasks pill */}
            <button
              onClick={() => {
                setTasksPanelOpen((prev) => !prev);
                if (!tasksPanelOpen) {
                  setSelectedNode(null);
                  setAllCommentsPanelOpen(false);
                  setActivityPanelOpen(false);
                  setHelpPanelOpen(false);
                  setProfilePanelHandle(null);
                  setLeaderboardPanelOpen(false);
                  setTutorialStep(null);
                }
              }}
              data-tutorial="nav-tasks"
              className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-full transition-colors ${
                tasksPanelOpen
                  ? "text-emerald-700 bg-emerald-50 dark:text-emerald-400 dark:bg-emerald-950"
                  : "text-zinc-500 hover:text-zinc-900 hover:bg-zinc-50 dark:text-zinc-400 dark:hover:text-zinc-100 dark:hover:bg-zinc-800"
              }`}
            >
              <svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
                <rect x="2" y="2" width="12" height="12" rx="2" />
                <path d="M5 6h6M5 8.5h4M5 11h5" strokeLinecap="round" />
              </svg>
              <span className="hidden sm:inline">Tasks</span>
            </button>
            {/* Learn pill */}
            <button
              onClick={() => {
                setHelpPanelOpen((prev) => !prev);
                if (!helpPanelOpen) {
                  setSelectedNode(null);
                  setAllCommentsPanelOpen(false);
                  setActivityPanelOpen(false);
                  setTasksPanelOpen(false);
                  setProfilePanelHandle(null);
                  setLeaderboardPanelOpen(false);
                }
              }}
              data-tutorial="nav-learn"
              className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-full transition-colors ${
                helpPanelOpen
                  ? "text-emerald-700 bg-emerald-50 dark:text-emerald-400 dark:bg-emerald-950"
                  : "text-zinc-500 hover:text-zinc-900 hover:bg-zinc-50 dark:text-zinc-400 dark:hover:text-zinc-100 dark:hover:bg-zinc-800"
              }`}
            >
              <svg
                className="w-3.5 h-3.5"
                fill="none"
                viewBox="0 0 24 24"
                strokeWidth={1.5}
                stroke="currentColor"
              >
                <path
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18"
                />
              </svg>
              <span className="hidden sm:inline">Learn</span>
            </button>
            <div className="w-px h-5 bg-zinc-200 dark:bg-zinc-700 mx-2" />
            <span data-tutorial="auth-section" className="flex items-center">
              <button
                onClick={() => setSettingsModalOpen(true)}
                className="p-1.5 text-zinc-400 hover:text-zinc-600 dark:text-zinc-500 dark:hover:text-zinc-300 rounded-full hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
                title="Settings"
              >
                <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
                  <path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 010 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 010-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28z" />
                  <path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
                </svg>
              </button>
              <button
                onClick={toggleTheme}
                className="p-1.5 text-zinc-400 hover:text-zinc-600 dark:text-zinc-500 dark:hover:text-zinc-300 rounded-full hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
                title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
                data-tutorial="btn-theme"
              >
                {theme === "dark" ? (
                  <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
                    <path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
                  </svg>
                ) : (
                  <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
                    <path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
                  </svg>
                )}
              </button>
              <AuthButton onViewProfile={(handle) => {
                setProfilePanelHandle(handle);
                setSelectedNode(null);
                setAllCommentsPanelOpen(false);
                setActivityPanelOpen(false);
                setTasksPanelOpen(false);
                setHelpPanelOpen(false);
                setLeaderboardPanelOpen(false);
                setTutorialStep(null);
              }} />
            </span>
          </div>
        </div>
      </header>

      {/* Mobile nav drawer — slides in from right */}
      {mobileMenuOpen && (
        <div className="md:hidden fixed inset-0 z-[60]">
          {/* Backdrop */}
          <div
            className="absolute inset-0 bg-black/30 backdrop-blur-[2px]"
            onClick={() => setMobileMenuOpen(false)}
          />
          {/* Drawer */}
          <div className="absolute top-0 right-0 h-full w-72 bg-white dark:bg-zinc-900 shadow-2xl flex flex-col animate-slide-in-right">
            {/* Drawer header */}
            <div className="flex items-center justify-between px-5 py-4 border-b border-zinc-100 dark:border-zinc-800">
              <span className="text-sm font-semibold text-zinc-700 dark:text-zinc-100">Menu</span>
              <button
                onClick={() => setMobileMenuOpen(false)}
                className="p-1.5 text-zinc-400 dark:text-zinc-500 hover:text-zinc-600 dark:hover:text-zinc-300 rounded-full hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
              >
                <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
                  <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
                </svg>
              </button>
            </div>
            {/* Nav items */}
            <nav className="flex-1 overflow-y-auto py-2">
              {/* Replay */}
              <button
                onClick={() => {
                  handleTimelineToggle();
                  setMobileMenuOpen(false);
                }}
                className={`w-full flex items-center gap-3 px-5 py-3.5 text-sm font-medium transition-colors ${
                  timelineActive ? "text-emerald-700 bg-emerald-50 dark:text-emerald-400 dark:bg-emerald-950" : "text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
                }`}
              >
                <svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
                  <circle cx="8" cy="8" r="6" />
                  <polyline points="8,4 8,8 11,10" />
                </svg>
                Replay
              </button>
              {/* Comments */}
              <button
                onClick={() => {
                  setAllCommentsPanelOpen((prev) => !prev);
                  if (!allCommentsPanelOpen) {
                    setSelectedNode(null);
                    setActivityPanelOpen(false);
                    setTasksPanelOpen(false);
                    setHelpPanelOpen(false);
                    setLeaderboardPanelOpen(false);
                    setTutorialStep(null);
                  }
                  setMobileMenuOpen(false);
                }}
                className={`w-full flex items-center gap-3 px-5 py-3.5 text-sm font-medium transition-colors ${
                  allCommentsPanelOpen ? "text-emerald-700 bg-emerald-50 dark:text-emerald-400 dark:bg-emerald-950" : "text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
                }`}
              >
                <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
                  <path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 011.037-.443 48.282 48.282 0 005.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" />
                </svg>
                Comments
              </button>
              {/* Tasks */}
              <button
                onClick={() => {
                  setTasksPanelOpen((prev) => !prev);
                  if (!tasksPanelOpen) {
                    setSelectedNode(null);
                    setAllCommentsPanelOpen(false);
                    setActivityPanelOpen(false);
                    setHelpPanelOpen(false);
                    setLeaderboardPanelOpen(false);
                    setTutorialStep(null);
                  }
                  setMobileMenuOpen(false);
                }}
                className={`w-full flex items-center gap-3 px-5 py-3.5 text-sm font-medium transition-colors ${
                  tasksPanelOpen ? "text-emerald-700 bg-emerald-50 dark:text-emerald-400 dark:bg-emerald-950" : "text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
                }`}
              >
                <svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
                  <rect x="2" y="2" width="12" height="12" rx="2" />
                  <path d="M5 6h6M5 8.5h4M5 11h5" strokeLinecap="round" />
                </svg>
                Tasks
              </button>
              {/* Learn */}
              <button
                onClick={() => {
                  setHelpPanelOpen((prev) => !prev);
                  if (!helpPanelOpen) {
                    setSelectedNode(null);
                    setAllCommentsPanelOpen(false);
                    setActivityPanelOpen(false);
                    setTasksPanelOpen(false);
                    setLeaderboardPanelOpen(false);
                  }
                  setMobileMenuOpen(false);
                }}
                className={`w-full flex items-center gap-3 px-5 py-3.5 text-sm font-medium transition-colors ${
                  helpPanelOpen ? "text-emerald-700 bg-emerald-50 dark:text-emerald-400 dark:bg-emerald-950" : "text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
                }`}
              >
                <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
                  <path strokeLinecap="round" strokeLinejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18" />
                </svg>
                Learn
              </button>
              {/* Divider */}
              <div className="h-px bg-zinc-100 dark:bg-zinc-800 mx-4 my-2" />
              {/* Settings */}
              <button
                onClick={() => {
                  setSettingsModalOpen(true);
                  setMobileMenuOpen(false);
                }}
                className="w-full flex items-center gap-3 px-5 py-3.5 text-sm font-medium text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
              >
                <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
                  <path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 010 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 010-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28z" />
                  <path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
                </svg>
                Settings
              </button>
              {/* Auth */}
              <div className="px-5 py-3.5">
                <AuthButton onViewProfile={(handle) => {
                  setProfilePanelHandle(handle);
                  setSelectedNode(null);
                  setAllCommentsPanelOpen(false);
                  setActivityPanelOpen(false);
                  setHelpPanelOpen(false);
                  setLeaderboardPanelOpen(false);
                  setTutorialStep(null);
                  setMobileMenuOpen(false);
                }} />
              </div>
            </nav>
          </div>
        </div>
      )}

      {/* Main content */}
      <div className="flex-1 flex overflow-hidden relative">
        {/* Graph area — full width on mobile, flex-1 on desktop */}
        <div className="flex-1 relative bg-zinc-50/50 dark:bg-black">
          {/* Subtle grid pattern */}
          <div
            className="absolute inset-0 opacity-[0.03] dark:opacity-[0.06]"
            style={{
              backgroundImage:
                "radial-gradient(circle, currentColor 1px, transparent 1px)",
              backgroundSize: "24px 24px",
            }}
          />

          <BeadsGraph
            ref={graphRef}
            nodes={timelineActive && timelineData ? timelineData.graphData.nodes : data.graphData.nodes}
            links={timelineActive && timelineData ? timelineData.graphData.links : data.graphData.links}
            selectedNode={selectedNode}
            selectedNodeIds={selectedNodeIds}
            onSelectedNodeIdsChange={setSelectedNodeIds}
            onNodeClick={handleNodeClick}
            onNodeHover={handleNodeHover}
            onBackgroundClick={handleBackgroundClick}
            onNodeRightClick={handleNodeRightClick}
            commentedNodeIds={commentedNodeIds}
            claimedNodeAvatars={claimedNodeAvatars}
            assigneeNodeAvatars={assigneeNodeAvatarsResolved}
            onAvatarHover={setAvatarTooltip}
            onAvatarClick={handleAvatarClick}
            timelineActive={timelineActive}
            stats={data.stats}
            sidebarOpen={!!selectedNode || allCommentsPanelOpen || activityPanelOpen || tasksPanelOpen || helpPanelOpen || !!profilePanelHandle || leaderboardPanelOpen}
            collapsedEpicIds={collapsedEpicIds}
            onCollapseAll={handleCollapseAll}
            onExpandAll={handleExpandAll}
            focusedEpicId={focusedEpicId}
            onExitFocusedEpic={handleExitFocusedEpic}
            colorMode={colorMode}
            onColorModeChange={setColorMode}
            activeLegendFilters={activeLegendFilters}
            onToggleLegendFilter={(label: string) => setActiveLegendFilters(prev => { const next = new Set(prev); if (next.has(label)) next.delete(label); else next.add(label); return next; })}
            onClearLegendFilters={() => setActiveLegendFilters(new Set())}
            autoFit={autoFit}
            onAutoFitToggle={() => setAutoFit((v) => !v)}
            pulseNodeId={timelineFilteredActivity.length > 0 ? timelineFilteredActivity[0].nodeId : null}
            showPulse={showPulse}
            onShowPulseToggle={() => setShowPulse((v) => !v)}
            showParticles={showParticles}
            onShowParticlesToggle={() => setShowParticles((v) => !v)}
            isMobile={isMobile}
            onNodeDoubleTap={handleNodeDoubleTap}
            theme={theme}
          />

          {/* Timeline bar — replaces legend hint when active */}
          {timelineActive && timelineRange && timelineRange.events.length > 0 && (
            <div
              className="absolute bottom-4 z-10 transition-[right] duration-300 ease-out"
              style={{ right: selectedNode || allCommentsPanelOpen || activityPanelOpen || tasksPanelOpen ? "calc(360px + 1rem)" : "1rem" }}
            >
              <TimelineBar
                totalSteps={timelineRange.events.length}
                currentStep={Math.max(timelineStep, 0)}
                currentTime={timelineStep >= 0 ? (timelineRange.events[timelineStep]?.time ?? timelineRange.minTime) : timelineRange.minTime}
                isPlaying={timelinePlaying}
                speed={timelineSpeed}
                onStepChange={setTimelineStep}
                onPlayPause={() => setTimelinePlaying((prev) => !prev)}
                onSpeedChange={setTimelineSpeed}
              />
            </div>
          )}

          {/* Activity overlay + Leaderboard overlay — top-right of canvas */}
          {!selectedNode && !allCommentsPanelOpen && !activityPanelOpen && !tasksPanelOpen && !helpPanelOpen && !profilePanelHandle && !leaderboardPanelOpen && (
            <div className="absolute top-3 right-3 sm:top-4 sm:right-4 z-10 flex flex-col items-end gap-2" data-tutorial="overlays">
              <ActivityOverlay
                events={timelineFilteredActivity}
                collapsed={activityOverlayCollapsed}
                compact={isMobile}
                onToggleCollapse={() => setActivityOverlayCollapsed((prev) => !prev)}
                onExpandPanel={() => {
                  setActivityPanelOpen(true);
                  setSelectedNode(null);
                  setAllCommentsPanelOpen(false);
                  setHelpPanelOpen(false);
                  setLeaderboardPanelOpen(false);
                }}
                onNodeClick={(nodeId) => {
                  const node = data?.graphData.nodes.find((n) => n.id === nodeId);
                  if (node) focusNode(node);
                }}
                onHandleClick={(handle) => {
                  setProfilePanelHandle(handle);
                  setSelectedNode(null);
                  setAllCommentsPanelOpen(false);
                  setHelpPanelOpen(false);
                  setLeaderboardPanelOpen(false);
                }}
              />
              <LeaderboardOverlay
                entries={timelineFilteredLeaderboard}
                collapsed={leaderboardOverlayCollapsed}
                compact={isMobile}
                onToggleCollapse={() => setLeaderboardOverlayCollapsed((prev) => !prev)}
                onExpandPanel={() => {
                  setLeaderboardPanelOpen(true);
                  setSelectedNode(null);
                  setAllCommentsPanelOpen(false);
                  setActivityPanelOpen(false);
                  setTasksPanelOpen(false);
                  setHelpPanelOpen(false);
                  setProfilePanelHandle(null);
                }}
                onProfileClick={(handle) => {
                  setProfilePanelHandle(handle);
                  setSelectedNode(null);
                  setAllCommentsPanelOpen(false);
                  setActivityPanelOpen(false);
                  setTasksPanelOpen(false);
                  setHelpPanelOpen(false);
                  setLeaderboardPanelOpen(false);
                  // Zoom to the avatar node
                  const nodeId = findAvatarNodeId(handle);
                  if (nodeId) graphRef.current?.zoomToNode(nodeId);
                }}
              />
            </div>
          )}

          {/* Right-click context menu */}
          {contextMenu && (
            <ContextMenu
              node={contextMenu.node}
              x={contextMenu.x}
              y={contextMenu.y}
              onShowDescription={() => {
                setDescriptionModalNode(contextMenu.node);
                setContextMenu(null);
              }}
              onAddComment={() => {
                setCommentTooltipState({
                  node: contextMenu.node,
                  x: contextMenu.x,
                  y: contextMenu.y,
                });
                setContextMenu(null);
              }}
              onClaimTask={
                isAuthenticated &&
                !claimedNodeAvatars.has(contextMenu.node.id)
                  ? () => {
                      handleClaimTask(contextMenu.node.id);
                      setContextMenu(null);
                    }
                  : undefined
              }
              onUnclaimTask={(() => {
                if (!isAuthenticated) return undefined;
                const claim = claimedNodeAvatars.get(contextMenu.node.id);
                if (!claim) return undefined;
                // Show "Unclaim" if claim.did matches current user, or if no did (optimistic = mine)
                const isMyClaim = claim.did === session?.did || !claim.did;
                if (!isMyClaim) return undefined;
                return () => {
                  handleUnclaimTask(contextMenu.node.id);
                  setContextMenu(null);
                };
              })()}
              onCollapseEpic={
                contextMenu.node.issueType === "epic" &&
                !collapsedEpicIds.has(contextMenu.node.id)
                  ? () => {
                      handleToggleEpicCollapse(contextMenu.node.id);
                      setContextMenu(null);
                    }
                  : undefined
              }
              onUncollapseEpic={
                contextMenu.node.issueType === "epic" &&
                collapsedEpicIds.has(contextMenu.node.id)
                  ? () => {
                      handleToggleEpicCollapse(contextMenu.node.id);
                      setContextMenu(null);
                    }
                  : undefined
              }
              onFocusEpic={
                contextMenu.node.issueType === "epic" && !focusedEpicId
                  ? () => {
                      handleFocusEpic(contextMenu.node.id);
                      setContextMenu(null);
                    }
                  : undefined
              }
              onExitFocusEpic={
                contextMenu.node.issueType === "epic" &&
                focusedEpicId === contextMenu.node.id
                  ? () => {
                      handleExitFocusedEpic();
                      setContextMenu(null);
                    }
                  : undefined
              }
              onClose={() => setContextMenu(null)}
            />
          )}

          {/* Mobile action sheet (double-tap on mobile) */}
          {mobileActionSheet && (
            <MobileActionSheet
              node={mobileActionSheet.node}
              onShowDescription={
                mobileActionSheet.node.description
                  ? () => {
                      setDescriptionModalNode(mobileActionSheet.node);
                      setMobileActionSheet(null);
                    }
                  : undefined
              }
              onAddComment={() => {
                setCommentTooltipState({
                  node: mobileActionSheet.node,
                  x: window.innerWidth / 2,
                  y: window.innerHeight / 2,
                });
                setMobileActionSheet(null);
              }}
              onClaimTask={
                isAuthenticated &&
                !claimedNodeAvatars.has(mobileActionSheet.node.id)
                  ? () => {
                      handleClaimTask(mobileActionSheet.node.id);
                      setMobileActionSheet(null);
                    }
                  : undefined
              }
              onUnclaimTask={(() => {
                if (!isAuthenticated) return undefined;
                const claim = claimedNodeAvatars.get(mobileActionSheet.node.id);
                if (!claim) return undefined;
                const isMyClaim = claim.did === session?.did || !claim.did;
                if (!isMyClaim) return undefined;
                return () => {
                  handleUnclaimTask(mobileActionSheet.node.id);
                  setMobileActionSheet(null);
                };
              })()}
              onCollapseEpic={
                mobileActionSheet.node.issueType === "epic" &&
                !collapsedEpicIds.has(mobileActionSheet.node.id)
                  ? () => {
                      handleToggleEpicCollapse(mobileActionSheet.node.id);
                      setMobileActionSheet(null);
                    }
                  : undefined
              }
              onUncollapseEpic={
                mobileActionSheet.node.issueType === "epic" &&
                collapsedEpicIds.has(mobileActionSheet.node.id)
                  ? () => {
                      handleToggleEpicCollapse(mobileActionSheet.node.id);
                      setMobileActionSheet(null);
                    }
                  : undefined
              }
              onFocusEpic={
                mobileActionSheet.node.issueType === "epic" && !focusedEpicId
                  ? () => {
                      handleFocusEpic(mobileActionSheet.node.id);
                      setMobileActionSheet(null);
                    }
                  : undefined
              }
              onExitFocusEpic={
                mobileActionSheet.node.issueType === "epic" &&
                focusedEpicId === mobileActionSheet.node.id
                  ? () => {
                      handleExitFocusedEpic();
                      setMobileActionSheet(null);
                    }
                  : undefined
              }
              onClose={() => setMobileActionSheet(null)}
            />
          )}

          {/* Comment tooltip (opened from context menu "Add comment") */}
          {commentTooltipState && (
            <CommentTooltip
              node={commentTooltipState.node}
              x={commentTooltipState.x}
              y={commentTooltipState.y}
              onClose={() => setCommentTooltipState(null)}
              onSubmit={async (text) => {
                await handlePostComment(commentTooltipState.node.id, text);
                setCommentTooltipState(null);
              }}
              isAuthenticated={isAuthenticated}
              existingComments={commentsByNode.get(
                commentTooltipState.node.id
              )}
            />
          )}

          {/* Description modal (opened from context menu "Show description") */}
          {descriptionModalNode && (
            <DescriptionModal
              node={descriptionModalNode}
              onClose={() => setDescriptionModalNode(null)}
              repoUrl={repoUrls[descriptionModalNode.prefix]}
              onOpenSettings={() => setSettingsModalOpen(true)}
              onNavigate={descriptionModalFamily.length > 1 ? handleDescriptionNavigate : undefined}
              navigationLabel={descriptionNavLabel}
              isAuthenticated={isAuthenticated}
              session={session}
              comments={allComments?.filter(c => c.nodeId === descriptionModalNode.id)}
              onPostComment={handlePostComment}
              onReplyComment={handleReplyComment}
              onDeleteComment={handleDeleteComment}
              onLikeComment={handleLikeComment}
            />
          )}

          {/* Settings modal */}
          <SettingsModal
            isOpen={settingsModalOpen}
            onClose={() => setSettingsModalOpen(false)}
          />

          {/* Node hover tooltip */}
          {nodeTooltip && !avatarTooltip && !isMobile && (
            <BeadTooltip
              node={nodeTooltip.node}
              x={nodeTooltip.x}
              y={nodeTooltip.y}
              prefixColor={getCatppuccinPrefixColor(nodeTooltip.node.prefix)}
              allNodes={timelineActive && timelineData ? timelineData.graphData.nodes : data.graphData.nodes}
              theme={theme}
            />
          )}

          {/* Avatar hover tooltip */}
          {avatarTooltip && !isMobile && (
            <div
              style={{
                position: "fixed",
                left: avatarTooltip.x + 12,
                top: avatarTooltip.y - 8,
                zIndex: 90,
                pointerEvents: "none",
              }}
            >
              <div className="flex items-center gap-2 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-lg shadow-lg px-2.5 py-2">
                {avatarTooltip.avatar ? (
                  /* eslint-disable-next-line @next/next/no-img-element */
                  <img
                    src={avatarTooltip.avatar}
                    alt=""
                    className="w-6 h-6 rounded-full shrink-0"
                  />
                ) : (
                  <div className="w-6 h-6 rounded-full bg-zinc-200 dark:bg-zinc-700 flex items-center justify-center text-[10px] font-medium text-zinc-500 dark:text-zinc-400 shrink-0">
                    {avatarTooltip.handle.charAt(0).toUpperCase()}
                  </div>
                )}
                <div className="flex flex-col">
                  <span className="text-xs text-zinc-700 dark:text-zinc-300 whitespace-nowrap">
                    <span className="font-semibold text-zinc-800 dark:text-zinc-200">{avatarTooltip.handle}</span>
                    {" "}
                    {avatarTooltip.isAssignee
                      ? (avatarTooltip.nodeClosed ? "closed" : "is assigned to")
                      : (avatarTooltip.nodeClosed ? "closed" : "claimed")
                    }
                    {" "}
                    <span className="font-mono text-emerald-700 dark:text-emerald-400">{avatarTooltip.nodeId}</span>
                  </span>
                  <span className="text-[10px] text-zinc-400 dark:text-zinc-500">
                    {formatRelativeTime(avatarTooltip.claimedAt)}
                  </span>
                </div>
              </div>
            </div>
          )}

        </div>

        {/* Desktop sidebar — slides in from right as an overlay when a node is selected */}
        <aside
          className={`hidden md:flex absolute top-0 right-0 h-full w-[360px] bg-white dark:bg-zinc-900 border-l border-zinc-200 dark:border-zinc-700 flex-col shadow-xl z-40 transform transition-transform duration-300 ease-out ${
            selectedNode ? "translate-x-0" : "translate-x-full"
          }`}
        >
          {/* Sidebar header with close button */}
          <div className="shrink-0 px-5 py-3 border-b border-zinc-100 dark:border-zinc-800 flex items-center justify-between">
            <h2 className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">
              Node Detail
            </h2>
            <button
              onClick={() => {
                setSelectedNode(null);
              }}
              className="p-1 text-zinc-400 hover:text-zinc-600 dark:text-zinc-500 dark:hover:text-zinc-300 dark:hover:bg-zinc-800 transition-colors rounded-full hover:bg-zinc-100"
            >
              <svg
                className="w-4 h-4"
                fill="none"
                stroke="currentColor"
                viewBox="0 0 24 24"
                strokeWidth={1.5}
              >
                <path
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  d="M6 18L18 6M6 6l12 12"
                />
              </svg>
            </button>
          </div>

          {/* Sidebar content */}
          <div className="flex-1 overflow-y-auto custom-scrollbar px-5 py-4 space-y-5">
            {/* Selected node detail */}
            <NodeDetail
              node={selectedNode}
              allNodes={data.graphData.nodes}
              onNodeNavigate={handleNodeNavigate}
              comments={
                selectedNode
                  ? commentsByNode.get(selectedNode.id)
                  : undefined
              }
              onPostComment={
                selectedNode
                  ? (text: string) =>
                      handlePostComment(selectedNode.id, text)
                  : undefined
              }
              onDeleteComment={handleDeleteComment}
              onLikeComment={handleLikeComment}
              onReplyComment={handleReplyComment}
              isAuthenticated={isAuthenticated}
              currentDid={session?.did}
              repoUrls={repoUrls}
              onOpenSettings={() => setSettingsModalOpen(true)}
              onProfileClick={(handle) => {
                setProfilePanelHandle(handle);
                setSelectedNode(null);
              }}
              onShowDescription={() => {
                if (selectedNode) setDescriptionModalNode(selectedNode);
              }}
            />

          </div>
        </aside>

        {/* Mobile bottom drawer — slides up when a node is selected */}
        <div
          className={`md:hidden fixed inset-x-0 bottom-0 z-20 transform transition-transform duration-300 ease-out ${
            selectedNode ? "translate-y-0" : "translate-y-full"
          }`}
        >
          <div className="bg-white dark:bg-zinc-900 rounded-t-2xl border-t border-zinc-200 dark:border-zinc-700 shadow-lg max-h-[60vh] flex flex-col">
            {/* Drag handle + close */}
            <div className="shrink-0 flex items-center justify-between px-4 pt-3 pb-2">
              <div className="w-8 h-1 bg-zinc-300 dark:bg-zinc-600 rounded-full mx-auto" />
              <button
                onClick={() => setSelectedNode(null)}
                className="absolute right-3 top-3 p-1 text-zinc-400 hover:text-zinc-600 dark:text-zinc-500 dark:hover:text-zinc-300 dark:hover:bg-zinc-800"
              >
                <svg
                  className="w-5 h-5"
                  fill="none"
                  stroke="currentColor"
                  viewBox="0 0 24 24"
                  strokeWidth={2}
                >
                  <path
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    d="M6 18L18 6M6 6l12 12"
                  />
                </svg>
              </button>
            </div>

            {/* Drawer content */}
            <div className="flex-1 overflow-y-auto custom-scrollbar px-4 pb-6 space-y-4">
              <NodeDetail
                node={selectedNode}
                allNodes={data.graphData.nodes}
                onNodeNavigate={handleNodeNavigate}
                comments={
                  selectedNode
                    ? commentsByNode.get(selectedNode.id)
                    : undefined
                }
                onPostComment={
                  selectedNode
                    ? (text: string) =>
                        handlePostComment(selectedNode.id, text)
                    : undefined
                }
                onDeleteComment={handleDeleteComment}
                onLikeComment={handleLikeComment}
                onReplyComment={handleReplyComment}
                isAuthenticated={isAuthenticated}
                currentDid={session?.did}
                repoUrls={repoUrls}
                onOpenSettings={() => setSettingsModalOpen(true)}
                onProfileClick={(handle) => {
                  setProfilePanelHandle(handle);
                  setSelectedNode(null);
                }}
                onShowDescription={() => {
                  if (selectedNode) setDescriptionModalNode(selectedNode);
                }}
              />
            </div>
          </div>
        </div>

        {/* All Comments panel */}
        <AllCommentsPanel
          isOpen={allCommentsPanelOpen}
          onClose={() => setAllCommentsPanelOpen(false)}
          allComments={allComments.filter((c) => localNodeIds.has(c.nodeId) || knownPrefixes.has(c.nodeId))}
          onNodeNavigate={(nodeId) => {
            handleNodeNavigate(nodeId);
            setAllCommentsPanelOpen(false);
          }}
          isAuthenticated={isAuthenticated}
          currentDid={session?.did}
          onLikeComment={handleLikeComment}
          onDeleteComment={handleDeleteComment}
          onReplyComment={handleReplyComment}
          onPostComment={handlePostComment}
          prefixes={data?.stats?.prefixes || []}
        />

        {/* Activity panel */}
        <ActivityPanel
          events={timelineFilteredActivity}
          isOpen={activityPanelOpen}
          onClose={() => setActivityPanelOpen(false)}
          onNodeClick={(nodeId) => {
            const node = data?.graphData.nodes.find((n) => n.id === nodeId);
            if (node) {
              focusNode(node);
              setActivityPanelOpen(false);
            }
          }}
          onHandleClick={(handle) => {
            setProfilePanelHandle(handle);
            setActivityPanelOpen(false);
            setSelectedNode(null);
            setAllCommentsPanelOpen(false);
            setTasksPanelOpen(false);
            setHelpPanelOpen(false);
            setLeaderboardPanelOpen(false);
          }}
        />

        {/* Tasks panel */}
        <TasksPanel
          nodes={data?.graphData.nodes || []}
          isOpen={tasksPanelOpen}
          onClose={() => setTasksPanelOpen(false)}
          onNodeClick={(nodeId) => {
            const node = data?.graphData.nodes.find(n => n.id === nodeId);
            if (node) {
              setSelectedNode(node);
              setSelectedNodeIds(new Set());
              setTasksPanelOpen(false);
              setAllCommentsPanelOpen(false);
              setActivityPanelOpen(false);
              setHelpPanelOpen(false);
              setProfilePanelHandle(null);
              setLeaderboardPanelOpen(false);
              graphRef.current?.focusNode(node);
            }
          }}
        />

        {/* Leaderboard panel */}
        <LeaderboardPanel
          entries={timelineFilteredLeaderboard}
          isOpen={leaderboardPanelOpen}
          onClose={() => setLeaderboardPanelOpen(false)}
          onProfileClick={(handle) => {
            setProfilePanelHandle(handle);
            setLeaderboardPanelOpen(false);
            setSelectedNode(null);
            setAllCommentsPanelOpen(false);
            setActivityPanelOpen(false);
            setTasksPanelOpen(false);
            setHelpPanelOpen(false);
            // Zoom to the avatar node
            const nodeId = findAvatarNodeId(handle);
            if (nodeId) graphRef.current?.zoomToNode(nodeId);
          }}
          timeFilter={leaderboardTimeFilter}
          onTimeFilterChange={setLeaderboardTimeFilter}
        />

        {/* Help panel */}
        <HelpPanel
          isOpen={helpPanelOpen}
          onClose={() => {
            setHelpPanelOpen(false);
            setTutorialStep(null);
          }}
          tutorialStep={tutorialStep}
          onStartTutorial={handleStartTutorial}
          onNextStep={handleNextTutorialStep}
          onPrevStep={handlePrevTutorialStep}
          onEndTutorial={handleEndTutorial}
        />

        {/* Profile panel */}
        <ProfilePanel
          isOpen={!!profilePanelHandle}
          onClose={() => setProfilePanelHandle(null)}
          profile={profilePanelUser}
          onNodeNavigate={(nodeId) => {
            const node = data?.graphData.nodes.find(n => n.id === nodeId);
            if (node) {
              setProfilePanelHandle(null);
              handleNodeClick(node);
              graphRef.current?.focusNode(node);
            }
          }}
        />
      </div>

      {/* Tutorial spotlight overlay */}
      <TutorialOverlay
        step={tutorialStep}
        onNext={handleNextTutorialStep}
        onPrev={handlePrevTutorialStep}
        onEnd={handleEndTutorial}
      />
    </div>
  );
}
