<script lang="ts">
  import { onMount } from 'svelte';

  // ── Types ──────────────────────────────────────────────────────────────────

  type NodeId = 'device' | 'browser-cache' | 'os-cache' | 'resolver' | 'google-server';

  interface Step {
    label: string;
    desc: string;
    from: NodeId;
    to: NodeId;
    isReturn: boolean;
    hitNode?: NodeId;
    hitText?: string;
    hitColor?: 'blue' | 'green' | 'red';
    packetLabel?: string;
    cacheAt?: NodeId;
    lineId: string;
    showHostsCheck?: boolean;
  }

  interface NodeLayout {
    x: number;
    y: number;
    w: number;
    h: number;
    title: string;
    sub: string;
  }

  // ── Constants ──────────────────────────────────────────────────────────────

  const DOMAIN = 'google.com';
  const IP = '142.250.80.46';

  // Horizontal layout (desktop)
  const NODES_H: Record<NodeId, NodeLayout> = {
    'device':        { x: 20,  y: 60, w: 140, h: 60, title: 'Your device',   sub: 'Browser'       },
    'browser-cache': { x: 200, y: 60, w: 140, h: 60, title: 'Browser cache', sub: 'TTL-based'     },
    'os-cache':      { x: 380, y: 60, w: 140, h: 60, title: 'OS layer',      sub: 'hosts + cache' },
    'resolver':      { x: 560, y: 60, w: 140, h: 60, title: 'Resolver',      sub: '1.1.1.1'       },
    'google-server': { x: 20,  y: 200, w: 140, h: 60, title: 'Google server', sub: IP              },
  };

  // Two-column mobile layout: device + google on left, DNS chain on right
  const NODES_V: Record<NodeId, NodeLayout> = {
    // Left column
    'device':        { x: 8,   y: 8,   w: 130, h: 50, title: 'Your device',   sub: 'Browser'       },
    'google-server': { x: 8,   y: 148, w: 130, h: 50, title: 'Google',        sub: IP              },
    // Right column (DNS chain)
    'browser-cache': { x: 158, y: 8,   w: 130, h: 50, title: 'Browser cache', sub: 'TTL-based'     },
    'os-cache':      { x: 158, y: 78,  w: 130, h: 50, title: 'OS layer',      sub: 'hosts + cache' },
    'resolver':      { x: 158, y: 148, w: 130, h: 50, title: 'Resolver',      sub: '1.1.1.1'       },
  };

  const DNS_ORDER: NodeId[] = ['device', 'browser-cache', 'os-cache', 'resolver'];

  // ── Reactive layout ────────────────────────────────────────────────────────

  let isVertical = $state(false);
  let NODES = $derived(isVertical ? NODES_V : NODES_H);
  let viewBoxWidth = $derived(isVertical ? 296 : 720);
  let viewBoxHeight = $derived(isVertical ? 206 : 300);

  function checkLayout() {
    if (typeof window !== 'undefined') {
      isVertical = window.innerWidth < 640;
    }
  }

  onMount(() => {
    checkLayout();
    window.addEventListener('resize', checkLayout);
    return () => window.removeEventListener('resize', checkLayout);
  });

  // ── Helper functions ───────────────────────────────────────────────────────

  function ncx(id: NodeId) { return NODES[id].x + NODES[id].w / 2; }
  function ncy(id: NodeId) { return NODES[id].y + NODES[id].h / 2; }
  function nright(id: NodeId) { return NODES[id].x + NODES[id].w; }
  function nleft(id: NodeId) { return NODES[id].x; }
  function ntop(id: NodeId) { return NODES[id].y; }
  function nbottom(id: NodeId) { return NODES[id].y + NODES[id].h; }

  // ── Line definitions ───────────────────────────────────────────────────────

  interface LineConfig {
    id: string;
    from: NodeId;
    to: NodeId;
  }

  const LINES: LineConfig[] = [
    { id: 'dns-0', from: 'device', to: 'browser-cache' },
    { id: 'dns-1', from: 'browser-cache', to: 'os-cache' },
    { id: 'dns-2', from: 'os-cache', to: 'resolver' },
    { id: 'http', from: 'device', to: 'google-server' },
  ];

  function getLineEndpoints(lineId: string, reverse: boolean = false) {
    const line = LINES.find(l => l.id === lineId);
    if (!line) return { x1: 0, y1: 0, x2: 0, y2: 0 };

    const { from, to } = line;
    let pts: { x1: number; y1: number; x2: number; y2: number };

    if (isVertical) {
      // Two-column mobile layout
      if (lineId === 'dns-0') {
        // device → browser-cache: horizontal (right of device to left of browser-cache)
        pts = {
          x1: nright(from), y1: ncy(from),
          x2: nleft(to), y2: ncy(to)
        };
      } else if (lineId === 'http') {
        // device → google-server: vertical down left column
        pts = {
          x1: ncx(from), y1: nbottom(from),
          x2: ncx(to), y2: ntop(to)
        };
      } else {
        // dns-1, dns-2: vertical down right column
        pts = {
          x1: ncx(from), y1: nbottom(from),
          x2: ncx(to), y2: ntop(to)
        };
      }
    } else {
      // Desktop horizontal layout
      if (lineId === 'http') {
        pts = {
          x1: ncx(from), y1: nbottom(from),
          x2: ncx(to), y2: ntop(to)
        };
      } else {
        pts = {
          x1: nright(from), y1: ncy(from),
          x2: nleft(to), y2: ncy(to)
        };
      }
    }

    return reverse ? { x1: pts.x2, y1: pts.y2, x2: pts.x1, y2: pts.y1 } : pts;
  }

  function getLineLength(lineId: string) {
    const { x1, y1, x2, y2 } = getLineEndpoints(lineId);
    return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
  }

  // ── Steps ──────────────────────────────────────────────────────────────────

  const STEPS: Step[] = [
    {
      label: 'Step 1 / 10 — browser cache',
      desc: `The browser checks its own DNS cache for "${DOMAIN}". This is the first time visiting, so nothing is cached yet.`,
      from: 'device', to: 'browser-cache',
      isReturn: false, lineId: 'dns-0',
      hitNode: 'browser-cache', hitText: 'Cache miss', hitColor: 'red',
      packetLabel: DOMAIN,
    },
    {
      label: 'Step 2 / 10 — /etc/hosts & OS cache',
      desc: `The OS first checks /etc/hosts — a static file where you can manually map domains (this is how ad-blocking works: blocked domains point to 0.0.0.0). Then it checks its DNS cache. Nothing found.`,
      from: 'browser-cache', to: 'os-cache',
      isReturn: false, lineId: 'dns-1',
      hitNode: 'os-cache', hitText: 'Not found locally', hitColor: 'red',
      packetLabel: DOMAIN,
      showHostsCheck: true,
    },
    {
      label: 'Step 3 / 10 — query resolver',
      desc: `Nothing locally. The query goes to the recursive resolver at 1.1.1.1, which contacts authoritative DNS servers on your behalf.`,
      from: 'os-cache', to: 'resolver',
      isReturn: false, lineId: 'dns-2',
      hitNode: 'resolver', hitText: `Resolved: ${IP}`, hitColor: 'blue',
      packetLabel: DOMAIN,
    },
    {
      label: 'Step 4 / 10 — response to OS',
      desc: `The resolver returns ${IP}. The OS stores this in its DNS cache (not /etc/hosts — that file is only for manual entries). The cache respects the TTL.`,
      from: 'resolver', to: 'os-cache',
      isReturn: true, lineId: 'dns-2',
      hitNode: 'os-cache', hitText: 'Stored in OS cache', hitColor: 'green',
      packetLabel: IP, cacheAt: 'os-cache',
    },
    {
      label: 'Step 5 / 10 — response to browser',
      desc: `The OS passes the IP to the browser, which stores it in its own cache for even faster subsequent lookups.`,
      from: 'os-cache', to: 'browser-cache',
      isReturn: true, lineId: 'dns-1',
      hitNode: 'browser-cache', hitText: 'Stored in browser', hitColor: 'green',
      packetLabel: IP, cacheAt: 'browser-cache',
    },
    {
      label: 'Step 6 / 10 — DNS complete!',
      desc: `The browser receives ${IP}. DNS resolution complete! The browser can now open a TCP connection to Google's server.`,
      from: 'browser-cache', to: 'device',
      isReturn: true, lineId: 'dns-0',
      hitNode: 'device', hitText: 'Ready to connect!', hitColor: 'green',
      packetLabel: IP,
    },
    {
      label: 'Step 7 / 10 — connect to server',
      desc: `Using the resolved IP, the browser connects to ${IP} and requests the webpage. The server responds with HTML, CSS, and JavaScript.`,
      from: 'device', to: 'google-server',
      isReturn: false, lineId: 'http',
      hitNode: 'google-server', hitText: 'Connected!', hitColor: 'green',
      packetLabel: 'GET /',
    },
    {
      label: 'Step 8 / 10 — second visit',
      desc: `You visit "${DOMAIN}" again. The browser checks its cache...`,
      from: 'device', to: 'browser-cache',
      isReturn: false, lineId: 'dns-0',
      hitNode: 'browser-cache', hitText: 'Cache hit!', hitColor: 'green',
      packetLabel: DOMAIN,
    },
    {
      label: 'Step 9 / 10 — instant response',
      desc: `Cache hit! The IP is returned immediately — no network lookup, no /etc/hosts check, nothing leaves your machine.`,
      from: 'browser-cache', to: 'device',
      isReturn: true, lineId: 'dns-0',
      hitNode: 'device', hitText: 'Got IP instantly!', hitColor: 'green',
      packetLabel: IP,
    },
    {
      label: 'Step 10 / 10 — connect to server',
      desc: `With the cached IP, the browser connects immediately. DNS caching makes repeat visits much faster!`,
      from: 'device', to: 'google-server',
      isReturn: false, lineId: 'http',
      hitNode: 'google-server', hitText: 'Connected!', hitColor: 'green',
      packetLabel: 'GET /',
    },
  ];

  // ── Reactive state ─────────────────────────────────────────────────────────

  let litNodes = $state<Record<NodeId, string>>({
    'device': '', 'browser-cache': '', 'os-cache': '', 'resolver': '', 'google-server': ''
  });
  let cachedNodes = $state<Record<NodeId, boolean>>({
    'device': false, 'browser-cache': false, 'os-cache': false, 'resolver': false, 'google-server': false
  });
  let hostsFileChecking = $state(false);

  let lineStates = $state<Record<string, { visible: boolean; progress: number; reverse: boolean; color: string }>>({
    'dns-0': { visible: false, progress: 0, reverse: false, color: '' },
    'dns-1': { visible: false, progress: 0, reverse: false, color: '' },
    'dns-2': { visible: false, progress: 0, reverse: false, color: '' },
    'http': { visible: false, progress: 0, reverse: false, color: '' },
  });

  let badge = $state<{ node: NodeId; text: string; color: 'blue' | 'green' | 'red' } | null>(null);
  let dot = $state<{ x: number; y: number; color: string; visible: boolean }>({ x: 0, y: 0, color: '', visible: false });
  let packet = $state<{ label: string; visible: boolean }>({ label: '', visible: false });
  let current = $state(-1);
  let busy = $state(false);
  let done = $state(false);
  let stepLabel = $state('');
  let desc = $state(`Press "Start" to walk through a DNS lookup for ${DOMAIN}.`);

  const COLOR_PRIMARY = 'oklch(0.65 0.19 240)';
  const COLOR_SUCCESS = 'oklch(0.65 0.19 145)';

  // ── Animation ──────────────────────────────────────────────────────────────

  function ease(t: number) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }

  function animateStep(
    lineId: string,
    reverse: boolean,
    color: string,
    packetLbl: string | undefined,
    ms: number,
    cb: () => void
  ) {
    const { x1, y1, x2, y2 } = getLineEndpoints(lineId, reverse);

    dot = { x: x1, y: y1, color, visible: true };
    packet = { label: packetLbl ?? '', visible: !!packetLbl };

    lineStates = {
      ...lineStates,
      [lineId]: { visible: true, progress: 0, reverse, color }
    };

    let t0: number | null = null;
    function frame(ts: number) {
      if (!t0) t0 = ts;
      const raw = Math.min((ts - t0) / ms, 1);
      const t = ease(raw);

      const px = x1 + (x2 - x1) * t;
      const py = y1 + (y2 - y1) * t;
      dot = { x: px, y: py, color, visible: true };

      lineStates = {
        ...lineStates,
        [lineId]: { visible: true, progress: t, reverse, color }
      };

      if (raw < 1) {
        requestAnimationFrame(frame);
      } else {
        dot = { ...dot, visible: false };
        packet = { ...packet, visible: false };
        cb();
      }
    }
    requestAnimationFrame(frame);
  }

  // ── Step logic ─────────────────────────────────────────────────────────────

  function getStateAtStep(stepIndex: number) {
    // Calculate what the state should be at a given step
    const nodes: Record<NodeId, string> = {
      'device': '', 'browser-cache': '', 'os-cache': '', 'resolver': '', 'google-server': ''
    };
    const cached: Record<NodeId, boolean> = {
      'device': false, 'browser-cache': false, 'os-cache': false, 'resolver': false, 'google-server': false
    };
    const lines: Record<string, { visible: boolean; progress: number; reverse: boolean; color: string }> = {
      'dns-0': { visible: false, progress: 0, reverse: false, color: '' },
      'dns-1': { visible: false, progress: 0, reverse: false, color: '' },
      'dns-2': { visible: false, progress: 0, reverse: false, color: '' },
      'http': { visible: false, progress: 0, reverse: false, color: '' },
    };

    for (let i = 0; i <= stepIndex; i++) {
      // Reset before step 8 (second lookup)
      if (i === 7) {
        Object.keys(nodes).forEach(k => nodes[k as NodeId] = '');
        Object.keys(lines).forEach(k => {
          lines[k] = { visible: false, progress: 0, reverse: false, color: '' };
        });
      }

      const step = STEPS[i];
      const color = step.isReturn ? COLOR_SUCCESS : COLOR_PRIMARY;

      nodes[step.from] = color;
      nodes[step.to] = color;

      if (step.cacheAt) {
        cached[step.cacheAt] = true;
      }

      lines[step.lineId] = { visible: true, progress: 1, reverse: step.isReturn, color };
    }

    return { nodes, cached, lines };
  }

  function applyStateAtStep(stepIndex: number) {
    const state = getStateAtStep(stepIndex);
    litNodes = state.nodes;
    cachedNodes = state.cached;
    lineStates = state.lines;

    const step = STEPS[stepIndex];
    stepLabel = step.label;
    desc = step.desc;

    if (step.hitNode && step.hitText && step.hitColor) {
      badge = { node: step.hitNode, text: step.hitText, color: step.hitColor };
    } else {
      badge = null;
    }
  }

  function runStep(step: Step, animate: boolean = true) {
    busy = true;
    badge = null;
    stepLabel = step.label;
    desc = step.desc;
    hostsFileChecking = false;

    const color = step.isReturn ? COLOR_SUCCESS : COLOR_PRIMARY;

    litNodes = { ...litNodes, [step.from]: color };

    if (step.showHostsCheck) {
      hostsFileChecking = true;
    }

    const duration = animate ? 600 : 0;

    if (!animate) {
      // Instant state update
      applyStateAtStep(current);
      busy = false;
      if (current === STEPS.length - 1) done = true;
      return;
    }

    animateStep(step.lineId, step.isReturn, color, step.packetLabel, duration, () => {
      litNodes = { ...litNodes, [step.to]: color };
      hostsFileChecking = false;

      if (step.cacheAt) {
        cachedNodes = { ...cachedNodes, [step.cacheAt]: true };
      }

      if (step.hitNode && step.hitText && step.hitColor) {
        badge = { node: step.hitNode, text: step.hitText, color: step.hitColor };
      }

      setTimeout(() => {
        busy = false;
        if (current === STEPS.length - 1) done = true;
      }, 200);
    });
  }

  function advance() {
    if (busy || done) return;
    current++;

    // Reset before step 8 (second lookup)
    if (current === 7) {
      litNodes = { 'device': '', 'browser-cache': '', 'os-cache': '', 'resolver': '', 'google-server': '' };
      lineStates = {
        'dns-0': { visible: false, progress: 0, reverse: false, color: '' },
        'dns-1': { visible: false, progress: 0, reverse: false, color: '' },
        'dns-2': { visible: false, progress: 0, reverse: false, color: '' },
        'http': { visible: false, progress: 0, reverse: false, color: '' },
      };
      badge = null;
    }

    runStep(STEPS[current]);
  }

  function goBack() {
    if (busy || current < 0) return;
    current--;
    done = false;

    if (current < 0) {
      // Go back to initial state
      reset();
    } else {
      // Apply the state at the previous step instantly
      applyStateAtStep(current);
    }
  }

  function reset() {
    current = -1;
    busy = false;
    done = false;
    stepLabel = '';
    badge = null;
    hostsFileChecking = false;
    cachedNodes = { 'device': false, 'browser-cache': false, 'os-cache': false, 'resolver': false, 'google-server': false };
    dot = { x: 0, y: 0, color: '', visible: false };
    packet = { label: '', visible: false };
    litNodes = { 'device': '', 'browser-cache': '', 'os-cache': '', 'resolver': '', 'google-server': '' };
    lineStates = {
      'dns-0': { visible: false, progress: 0, reverse: false, color: '' },
      'dns-1': { visible: false, progress: 0, reverse: false, color: '' },
      'dns-2': { visible: false, progress: 0, reverse: false, color: '' },
      'http': { visible: false, progress: 0, reverse: false, color: '' },
    };
    desc = `Press "Start" to walk through a DNS lookup for ${DOMAIN}.`;
  }

  // ── Badge geometry ─────────────────────────────────────────────────────────

  function badgePos(id: NodeId) {
    const n = NODES[id];
    const w = 160;
    if (isVertical) {
      return {
        x: n.x + n.w + 8, y: n.y + (n.h - 28) / 2,
        cx: n.x + n.w + 8 + w / 2, cy: n.y + n.h / 2 + 4, w
      };
    } else {
      return {
        x: n.x + (n.w - w) / 2, y: n.y + n.h + 8,
        cx: ncx(id), cy: n.y + n.h + 8 + 16, w
      };
    }
  }

  // ── Render helpers ─────────────────────────────────────────────────────────

  function renderLine(lineId: string) {
    const state = lineStates[lineId];
    if (!state.visible) return null;

    const length = getLineLength(lineId);
    const { x1, y1, x2, y2 } = getLineEndpoints(lineId, state.reverse);
    const drawn = length * state.progress;
    const dashArray = `${drawn} ${length - drawn}`;

    return { x1, y1, x2, y2, dashArray, color: state.color };
  }
</script>

<!-- ── Markup ──────────────────────────────────────────────────────────────── -->

<div class="mx-auto my-8 w-full max-w-3xl font-sans">

  <svg
    width="100%"
    viewBox="0 0 {viewBoxWidth} {viewBoxHeight}"
    aria-label="Interactive DNS lookup flow diagram"
    class="overflow-visible"
  >
    <defs>
      <marker id="arr-blue" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto">
        <path d="M2 1L8 5L2 9" fill="none" stroke={COLOR_PRIMARY} stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
      </marker>
      <marker id="arr-green" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto">
        <path d="M2 1L8 5L2 9" fill="none" stroke={COLOR_SUCCESS} stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
      </marker>
    </defs>

    <!-- Animated lines -->
    {#each LINES as line}
      {@const rendered = renderLine(line.id)}
      {@const state = lineStates[line.id]}
      {#if rendered}
        <line
          x1={rendered.x1} y1={rendered.y1}
          x2={rendered.x2} y2={rendered.y2}
          stroke={rendered.color}
          stroke-width="2"
          stroke-dasharray={rendered.dashArray}
          marker-end={state.progress === 1 ? (rendered.color === COLOR_SUCCESS ? 'url(#arr-green)' : 'url(#arr-blue)') : undefined}
        />
      {/if}
    {/each}

    <!-- Nodes -->
    {#each [...DNS_ORDER, 'google-server'] as id}
      {@const n = NODES[id as NodeId]}
      {@const lit = litNodes[id as NodeId]}
      {@const cached = cachedNodes[id as NodeId]}
      <g>
        <rect
          x={n.x} y={n.y} width={n.w} height={n.h} rx="8"
          fill="var(--card)"
          stroke={lit || 'var(--border)'}
          stroke-width={lit ? 2 : 0.5}
        />

        <!-- Centered content -->
        {#if id === 'device'}
          <g transform="translate({n.x + n.w/2}, {n.y + n.h/2 - 4})">
            <rect x="-10" y="-10" width="20" height="14" rx="2" fill="none" stroke="var(--muted-foreground)" stroke-width="1.2"/>
            <line x1="0" y1="4" x2="0" y2="8" stroke="var(--muted-foreground)" stroke-width="1.2"/>
            <line x1="-5" y1="8" x2="5" y2="8" stroke="var(--muted-foreground)" stroke-width="1.2"/>
          </g>
          <text x={n.x + n.w/2} y={n.y + n.h - 8} class="node-title" text-anchor="middle">{n.title}</text>
        {:else if id === 'browser-cache'}
          <g transform="translate({n.x + n.w/2}, {n.y + n.h/2 - 6})">
            {#each [-6, 0, 6] as dy}
              <rect x="-9" y={dy - 2} width="18" height="4" rx="1.5"
                fill="none"
                stroke={cached && dy === 0 ? COLOR_SUCCESS : 'var(--muted-foreground)'}
                stroke-width="1"/>
            {/each}
            {#if cached}
              <circle cx="0" cy="0" r="3" fill={COLOR_SUCCESS}/>
            {/if}
          </g>
          <text x={n.x + n.w/2} y={n.y + n.h - 8} class="node-title" text-anchor="middle">{n.title}</text>
        {:else if id === 'os-cache'}
          <g transform="translate({n.x + n.w/2}, {n.y + n.h/2 - 6})">
            <!-- Left: /etc/hosts file icon -->
            <g transform="translate(-14, 0)" class={hostsFileChecking ? 'hosts-checking' : ''}>
              <rect x="-6" y="-8" width="12" height="15" rx="1.5"
                fill="none"
                stroke={hostsFileChecking ? COLOR_PRIMARY : 'var(--muted-foreground)'}
                stroke-width={hostsFileChecking ? 1.5 : 1}
              />
              <line x1="-3" y1="-3" x2="3" y2="-3" stroke={hostsFileChecking ? COLOR_PRIMARY : 'var(--muted-foreground)'} stroke-width="0.7"/>
              <line x1="-3" y1="1" x2="3" y2="1" stroke={hostsFileChecking ? COLOR_PRIMARY : 'var(--muted-foreground)'} stroke-width="0.7"/>
              <line x1="-3" y1="5" x2="1" y2="5" stroke={hostsFileChecking ? COLOR_PRIMARY : 'var(--muted-foreground)'} stroke-width="0.7"/>
            </g>
            <!-- Right: OS cache (cylinder/database icon) -->
            <g transform="translate(14, 0)">
              <ellipse cx="0" cy="-6" rx="7" ry="3"
                fill="none"
                stroke={cached ? COLOR_SUCCESS : 'var(--muted-foreground)'}
                stroke-width="1"/>
              <path d="M-7,-6 L-7,4 A7,3 0 0,0 7,4 L7,-6"
                fill="none"
                stroke={cached ? COLOR_SUCCESS : 'var(--muted-foreground)'}
                stroke-width="1"/>
              {#if cached}
                <circle cx="0" cy="0" r="3" fill={COLOR_SUCCESS}/>
              {/if}
            </g>
          </g>
          <!-- Label -->
          <text
            x={n.x + n.w/2}
            y={n.y + n.h - 8}
            class="node-title"
            class:hosts-label-checking={hostsFileChecking}
            text-anchor="middle"
          >{hostsFileChecking ? 'checking /etc/hosts...' : n.title}</text>
        {:else if id === 'resolver'}
          <g transform="translate({n.x + n.w/2}, {n.y + n.h/2 - 4})">
            <circle cx="0" cy="0" r="11" fill="none" stroke="var(--muted-foreground)" stroke-width="1"/>
            <ellipse cx="0" cy="0" rx="4.5" ry="11" fill="none" stroke="var(--muted-foreground)" stroke-width="0.8"/>
            <line x1="-11" y1="0" x2="11" y2="0" stroke="var(--muted-foreground)" stroke-width="0.8"/>
          </g>
          <text x={n.x + n.w/2} y={n.y + n.h - 8} class="node-title" text-anchor="middle">{n.title}</text>
        {:else if id === 'google-server'}
          <g transform="translate({n.x + n.w/2}, {n.y + n.h/2 - 4})">
            <rect x="-12" y="-10" width="24" height="8" rx="2" fill="none" stroke="var(--muted-foreground)" stroke-width="1"/>
            <rect x="-12" y="2" width="24" height="8" rx="2" fill="none" stroke="var(--muted-foreground)" stroke-width="1"/>
            <circle cx="-7" cy="-6" r="1.5" fill="var(--muted-foreground)"/>
            <circle cx="-7" cy="6" r="1.5" fill="var(--muted-foreground)"/>
          </g>
          <text x={n.x + n.w/2} y={n.y + n.h - 8} class="node-title" text-anchor="middle">{n.title}</text>
        {/if}
      </g>
    {/each}

    <!-- Travelling dot + packet label -->
    {#if dot.visible}
      <circle cx={dot.x} cy={dot.y} r="5" fill={dot.color}/>
      {#if packet.visible && packet.label}
        {@const pw = packet.label.length * 6.4 + 12}
        <rect x={dot.x - pw/2} y={dot.y - 22} width={pw} height="16" rx="4" fill={dot.color} opacity="0.92"/>
        <text x={dot.x} y={dot.y - 11} class="packet-label">{packet.label}</text>
      {/if}
    {/if}

    <!-- Badge (desktop) -->
    {#if badge && !isVertical}
      {@const bp = badgePos(badge.node)}
      <g class="badge">
        <rect
          x={bp.x} y={bp.y} width={bp.w} height="28" rx="6"
          fill={badge.color === 'green' ? 'oklch(0.95 0.05 145)'
              : badge.color === 'red'   ? 'oklch(0.95 0.05 25)'
              :                           'oklch(0.95 0.05 240)'}
          stroke={badge.color === 'green' ? 'oklch(0.75 0.15 145)'
                : badge.color === 'red'   ? 'oklch(0.75 0.15 25)'
                :                           'oklch(0.75 0.15 240)'}
          stroke-width="0.5"
        />
        <text
          x={bp.cx} y={bp.cy}
          text-anchor="middle"
          font-size="11"
          font-weight="500"
          fill={badge.color === 'green' ? 'oklch(0.40 0.15 145)'
              : badge.color === 'red'   ? 'oklch(0.40 0.15 25)'
              :                           'oklch(0.40 0.15 240)'}
        >{badge.text}</text>
      </g>
    {/if}

    <!-- Step label (desktop) -->
    {#if stepLabel && !isVertical}
      <text x={viewBoxWidth / 2} y={viewBoxHeight - 8} class="step-label">{stepLabel}</text>
    {/if}
  </svg>

  <!-- Mobile info section - fixed height to prevent layout shift -->
  {#if isVertical}
    <div class="mt-2 flex h-12 flex-col justify-center">
      {#if badge}
        {@const badgeClasses = badge.color === 'green'
          ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
          : badge.color === 'red'
          ? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300'
          : 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'}
        <div class="rounded-md px-2 py-1 text-center text-xs font-medium {badgeClasses}">
          {badge.text}
        </div>
      {/if}
      {#if stepLabel}
        <p class="mt-1 text-center text-xs text-muted-foreground">{stepLabel}</p>
      {/if}
    </div>
  {/if}

  <!-- Description - fixed height on mobile to prevent layout shift -->
  <p class="mb-2 mt-2 h-20 overflow-y-auto rounded-lg bg-muted px-3 py-2 text-sm leading-relaxed text-muted-foreground sm:mb-3 sm:mt-4 sm:h-auto sm:min-h-16 sm:px-3.5 sm:py-2.5">
    {desc}
  </p>

  <!-- Controls -->
  <div class="flex items-center gap-2 pb-2 sm:pb-0">
    <!-- Back button -->
    <button
      onclick={goBack}
      disabled={busy || current < 0}
      class="inline-flex h-9 w-20 items-center justify-center gap-1 whitespace-nowrap rounded-full border bg-background text-sm font-medium shadow-xs transition-all hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 dark:border-input dark:bg-input/30 dark:hover:bg-input/50"
    >
      ← Back
    </button>

    <!-- Next/Start button -->
    {#if !done}
      <button
        onclick={advance}
        disabled={busy}
        class="inline-flex h-9 w-20 items-center justify-center gap-1 whitespace-nowrap rounded-full border bg-background text-sm font-medium shadow-xs transition-all hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 dark:border-input dark:bg-input/30 dark:hover:bg-input/50"
      >
        {current < 0 ? 'Start' : 'Next →'}
      </button>
    {:else}
      <button
        onclick={reset}
        class="inline-flex h-9 w-20 items-center justify-center gap-1 whitespace-nowrap rounded-full border bg-background text-sm font-medium shadow-xs transition-all hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50"
      >
        Restart
      </button>
    {/if}

    <!-- Reset button -->
    <button
      onclick={reset}
      disabled={current < 0}
      class="inline-flex h-9 w-9 items-center justify-center whitespace-nowrap rounded-full border bg-background text-sm font-medium shadow-xs transition-all hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 dark:border-input dark:bg-input/30 dark:hover:bg-input/50"
      title="Reset"
    >
      ↺
    </button>

    <!-- Step counter -->
    <span class="ml-auto text-xs text-muted-foreground">
      {Math.max(current + 1, 0)} / {STEPS.length}
    </span>
  </div>

</div>

<!-- ── Styles ─────────────────────────────────────────────────────────────── -->

<style>
  .node-title {
    font-size: 11px;
    font-weight: 500;
    fill: var(--foreground);
    font-family: var(--font-sans);
  }

  .packet-label {
    font-size: 10px;
    font-family: var(--font-mono);
    fill: white;
    text-anchor: middle;
  }

  .step-label {
    font-size: 11px;
    fill: var(--muted-foreground);
    text-anchor: middle;
    font-family: var(--font-sans);
  }

  .badge {
    animation: badge-pop 0.28s ease forwards;
  }

  @keyframes badge-pop {
    0%   { opacity: 0; transform: translateY(5px); }
    65%  { opacity: 1; transform: translateY(-1px); }
    100% { opacity: 1; transform: translateY(0); }
  }

  .hosts-checking {
    animation: hosts-pulse 0.5s ease-in-out infinite alternate;
  }

  .hosts-label-checking {
    fill: oklch(0.65 0.19 240);
    font-weight: 600;
    animation: hosts-text-pulse 0.5s ease-in-out infinite alternate;
  }

  @keyframes hosts-pulse {
    from { stroke-width: 1.5; }
    to { stroke-width: 2.5; }
  }

  @keyframes hosts-text-pulse {
    from { opacity: 0.7; }
    to { opacity: 1; }
  }
</style>
