← Git Explorer results

Git Explorer

Grok 4.5 · full

Initial view of Git Explorer

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

Imported historical artifact. Its exact original instructions and lineage are not known.

Generation prompt · exact text unavailable

See the current prompt for reference. It may differ from the instructions used to generate this artifact.

Inspect original source 76,743 bytes · SHA-256 ea3a94d78e97
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Git & Timeline Visualizer</title>
<script src="https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js"></script>
<style>
  :root {
    --bg: #0f1419;
    --bg2: #1a2332;
    --bg3: #243044;
    --border: #2d3a4f;
    --text: #e7ecf3;
    --muted: #8b9bb4;
    --accent: #3b82f6;
    --accent2: #60a5fa;
    --green: #34d399;
    --amber: #fbbf24;
    --rose: #f87171;
    --violet: #a78bfa;
    --cyan: #22d3ee;
    --panel-w: 340px;
    --header-h: 56px;
    --radius: 10px;
    --shadow: 0 8px 32px rgba(0,0,0,.45);
    --font: "Segoe UI", system-ui, -apple-system, sans-serif;
    --mono: "Cascadia Code", "Fira Code", "SF Mono", Consolas, monospace;
  }

  * { box-sizing: border-box; margin: 0; padding: 0; }

  html, body {
    height: 100%;
    background: var(--bg);
    color: var(--text);
    font-family: var(--font);
    overflow: hidden;
  }

  /* ——— Header ——— */
  header {
    height: var(--header-h);
    background: linear-gradient(180deg, #1c2838 0%, var(--bg2) 100%);
    border-bottom: 1px solid var(--border);
    display: flex;
    align-items: center;
    padding: 0 18px;
    gap: 16px;
    z-index: 50;
    position: relative;
  }

  .logo {
    display: flex;
    align-items: center;
    gap: 10px;
    font-weight: 700;
    font-size: 15px;
    letter-spacing: .02em;
    white-space: nowrap;
  }

  .logo svg { flex-shrink: 0; }

  .logo span.grad {
    background: linear-gradient(90deg, var(--accent2), var(--cyan));
    -webkit-background-clip: text;
    -webkit-text-fill-color: transparent;
    background-clip: text;
  }

  .header-actions {
    margin-left: auto;
    display: flex;
    gap: 8px;
    align-items: center;
    flex-wrap: wrap;
  }

  button, .btn {
    font-family: var(--font);
    font-size: 12.5px;
    font-weight: 600;
    border: 1px solid var(--border);
    background: var(--bg3);
    color: var(--text);
    padding: 7px 12px;
    border-radius: 7px;
    cursor: pointer;
    display: inline-flex;
    align-items: center;
    gap: 6px;
    transition: background .15s, border-color .15s, transform .1s;
    white-space: nowrap;
  }

  button:hover, .btn:hover {
    background: #2c3d55;
    border-color: #3d5270;
  }

  button:active { transform: scale(.97); }

  button.primary {
    background: linear-gradient(135deg, #2563eb, #3b82f6);
    border-color: #3b82f6;
    color: #fff;
  }

  button.primary:hover {
    background: linear-gradient(135deg, #1d4ed8, #2563eb);
  }

  button.danger {
    background: rgba(248,113,113,.12);
    border-color: rgba(248,113,113,.35);
    color: var(--rose);
  }

  button.danger:hover {
    background: rgba(248,113,113,.22);
  }

  button.ghost {
    background: transparent;
    border-color: transparent;
    color: var(--muted);
  }

  button.ghost:hover {
    color: var(--text);
    background: var(--bg3);
  }

  button:disabled {
    opacity: .4;
    cursor: not-allowed;
  }

  /* ——— Layout ——— */
  .app {
    display: grid;
    grid-template-columns: var(--panel-w) 1fr var(--panel-w);
    height: calc(100% - var(--header-h));
  }

  .panel {
    background: var(--bg2);
    border-right: 1px solid var(--border);
    display: flex;
    flex-direction: column;
    overflow: hidden;
  }

  .panel.right {
    border-right: none;
    border-left: 1px solid var(--border);
  }

  .panel-tabs {
    display: flex;
    border-bottom: 1px solid var(--border);
    background: rgba(0,0,0,.2);
    flex-shrink: 0;
  }

  .panel-tabs button {
    flex: 1;
    border: none;
    border-radius: 0;
    border-bottom: 2px solid transparent;
    background: transparent;
    padding: 11px 8px;
    font-size: 12px;
    color: var(--muted);
  }

  .panel-tabs button.active {
    color: var(--accent2);
    border-bottom-color: var(--accent);
    background: rgba(59,130,246,.08);
  }

  .panel-body {
    flex: 1;
    overflow-y: auto;
    padding: 14px;
  }

  .panel-body::-webkit-scrollbar { width: 6px; }
  .panel-body::-webkit-scrollbar-thumb {
    background: var(--border);
    border-radius: 3px;
  }

  .section-title {
    font-size: 11px;
    text-transform: uppercase;
    letter-spacing: .08em;
    color: var(--muted);
    margin: 14px 0 8px;
    font-weight: 700;
  }

  .section-title:first-child { margin-top: 0; }

  /* Forms */
  label {
    display: block;
    font-size: 11.5px;
    color: var(--muted);
    margin-bottom: 4px;
    font-weight: 600;
  }

  input, select, textarea {
    width: 100%;
    background: var(--bg);
    border: 1px solid var(--border);
    color: var(--text);
    border-radius: 7px;
    padding: 8px 10px;
    font-size: 13px;
    font-family: var(--font);
    margin-bottom: 10px;
    outline: none;
    transition: border-color .15s;
  }

  input:focus, select:focus, textarea:focus {
    border-color: var(--accent);
  }

  textarea { resize: vertical; min-height: 64px; }

  .row { display: flex; gap: 8px; }
  .row > * { flex: 1; }

  .form-actions {
    display: flex;
    gap: 8px;
    margin-top: 4px;
    flex-wrap: wrap;
  }

  /* Cards / lists */
  .card {
    background: var(--bg);
    border: 1px solid var(--border);
    border-radius: var(--radius);
    padding: 10px 12px;
    margin-bottom: 8px;
    cursor: pointer;
    transition: border-color .15s, background .15s;
    position: relative;
  }

  .card:hover { border-color: #3d5270; }
  .card.selected {
    border-color: var(--accent);
    background: rgba(59,130,246,.08);
    box-shadow: 0 0 0 1px var(--accent);
  }

  .card .card-title {
    font-size: 13px;
    font-weight: 600;
    margin-bottom: 3px;
    display: flex;
    align-items: center;
    gap: 6px;
  }

  .card .card-meta {
    font-size: 11px;
    color: var(--muted);
    font-family: var(--mono);
  }

  .card .card-actions {
    display: none;
    position: absolute;
    top: 8px;
    right: 8px;
    gap: 4px;
  }

  .card:hover .card-actions { display: flex; }

  .card .card-actions button {
    padding: 3px 6px;
    font-size: 11px;
  }

  .badge {
    display: inline-block;
    font-size: 10px;
    font-weight: 700;
    padding: 2px 6px;
    border-radius: 4px;
    letter-spacing: .03em;
  }

  .badge.branch { background: rgba(52,211,153,.15); color: var(--green); }
  .badge.tag { background: rgba(251,191,36,.15); color: var(--amber); }
  .badge.event { background: rgba(167,139,250,.15); color: var(--violet); }
  .badge.head { background: rgba(59,130,246,.2); color: var(--accent2); }
  .badge.merge { background: rgba(34,211,238,.15); color: var(--cyan); }

  .empty-hint {
    text-align: center;
    color: var(--muted);
    font-size: 12.5px;
    padding: 24px 12px;
    line-height: 1.5;
  }

  /* ——— Main canvas area ——— */
  .main {
    display: flex;
    flex-direction: column;
    position: relative;
    overflow: hidden;
    background:
      radial-gradient(ellipse at 30% 20%, rgba(59,130,246,.06) 0%, transparent 50%),
      radial-gradient(ellipse at 70% 80%, rgba(167,139,250,.05) 0%, transparent 50%),
      var(--bg);
  }

  .toolbar {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 8px 14px;
    border-bottom: 1px solid var(--border);
    background: rgba(26,35,50,.85);
    backdrop-filter: blur(8px);
    flex-shrink: 0;
    flex-wrap: wrap;
    z-index: 10;
  }

  .toolbar .sep {
    width: 1px;
    height: 22px;
    background: var(--border);
    margin: 0 4px;
  }

  .toolbar label.inline {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    font-size: 12px;
    color: var(--muted);
    margin: 0;
    cursor: pointer;
  }

  .toolbar input[type="checkbox"] {
    width: auto;
    margin: 0;
    accent-color: var(--accent);
  }

  .view-toggle {
    display: flex;
    background: var(--bg);
    border: 1px solid var(--border);
    border-radius: 7px;
    overflow: hidden;
  }

  .view-toggle button {
    border: none;
    border-radius: 0;
    background: transparent;
    padding: 6px 12px;
    font-size: 12px;
  }

  .view-toggle button.active {
    background: var(--accent);
    color: #fff;
  }

  #graph-container {
    flex: 1;
    position: relative;
    overflow: hidden;
  }

  #graph-svg {
    width: 100%;
    height: 100%;
    cursor: grab;
    display: block;
  }

  #graph-svg:active { cursor: grabbing; }

  /* Timeline strip at bottom of main */
  .timeline-strip {
    height: 130px;
    border-top: 1px solid var(--border);
    background: var(--bg2);
    flex-shrink: 0;
    position: relative;
    overflow: hidden;
  }

  .timeline-strip.collapsed { height: 36px; }

  .timeline-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 6px 14px;
    border-bottom: 1px solid var(--border);
    height: 36px;
  }

  .timeline-header h3 {
    font-size: 11px;
    text-transform: uppercase;
    letter-spacing: .08em;
    color: var(--muted);
    font-weight: 700;
  }

  #timeline-svg {
    width: 100%;
    height: calc(100% - 36px);
    display: block;
  }

  /* Graph styles via classes */
  .link {
    fill: none;
    stroke-width: 2.2;
    opacity: .75;
  }

  .link.merge { stroke-dasharray: 6 3; }

  .commit-node circle {
    stroke-width: 2.5;
    cursor: pointer;
    transition: r .15s, stroke-width .15s;
  }

  .commit-node:hover circle,
  .commit-node.selected circle {
    stroke-width: 3.5;
    filter: drop-shadow(0 0 6px rgba(59,130,246,.6));
  }

  .commit-node.selected circle {
    stroke: #fff !important;
  }

  .commit-node .msg {
    font-size: 11px;
    fill: var(--text);
    pointer-events: none;
    font-family: var(--font);
  }

  .commit-node .sha {
    font-size: 9.5px;
    fill: var(--muted);
    font-family: var(--mono);
    pointer-events: none;
  }

  .branch-label {
    font-size: 10.5px;
    font-weight: 700;
    pointer-events: none;
  }

  .branch-label-bg {
    rx: 4;
    ry: 4;
  }

  .event-anchor {
    cursor: pointer;
  }

  .event-anchor circle {
    fill: var(--violet);
    stroke: #fff;
    stroke-width: 1.5;
  }

  .event-anchor:hover circle,
  .event-anchor.highlight circle {
    filter: drop-shadow(0 0 8px rgba(167,139,250,.9));
  }

  .event-label {
    font-size: 10px;
    fill: var(--violet);
    font-weight: 600;
    pointer-events: none;
  }

  .grid-line {
    stroke: var(--border);
    stroke-width: 1;
    opacity: .35;
  }

  /* Tooltip */
  .tooltip {
    position: absolute;
    pointer-events: none;
    background: #1e2a3c;
    border: 1px solid var(--border);
    border-radius: 8px;
    padding: 10px 12px;
    font-size: 12px;
    max-width: 280px;
    box-shadow: var(--shadow);
    z-index: 100;
    opacity: 0;
    transition: opacity .12s;
    line-height: 1.45;
  }

  .tooltip.visible { opacity: 1; }

  .tooltip .tt-title {
    font-weight: 700;
    margin-bottom: 4px;
    color: var(--text);
  }

  .tooltip .tt-row {
    color: var(--muted);
    font-size: 11.5px;
  }

  .tooltip .tt-row strong {
    color: var(--accent2);
    font-family: var(--mono);
    font-weight: 600;
  }

  /* Modal */
  .modal-backdrop {
    position: fixed;
    inset: 0;
    background: rgba(0,0,0,.6);
    backdrop-filter: blur(4px);
    z-index: 200;
    display: flex;
    align-items: center;
    justify-content: center;
    opacity: 0;
    pointer-events: none;
    transition: opacity .2s;
  }

  .modal-backdrop.open {
    opacity: 1;
    pointer-events: auto;
  }

  .modal {
    background: var(--bg2);
    border: 1px solid var(--border);
    border-radius: 14px;
    width: min(560px, 94vw);
    max-height: 86vh;
    overflow: hidden;
    display: flex;
    flex-direction: column;
    box-shadow: var(--shadow);
    transform: translateY(12px);
    transition: transform .2s;
  }

  .modal-backdrop.open .modal { transform: translateY(0); }

  .modal-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 16px 18px;
    border-bottom: 1px solid var(--border);
  }

  .modal-header h2 {
    font-size: 16px;
    font-weight: 700;
  }

  .modal-body {
    padding: 18px;
    overflow-y: auto;
    flex: 1;
  }

  .modal-footer {
    padding: 12px 18px;
    border-top: 1px solid var(--border);
    display: flex;
    justify-content: flex-end;
    gap: 8px;
  }

  /* Toast */
  .toast-wrap {
    position: fixed;
    bottom: 24px;
    left: 50%;
    transform: translateX(-50%);
    z-index: 300;
    display: flex;
    flex-direction: column;
    gap: 8px;
    pointer-events: none;
  }

  .toast {
    background: var(--bg3);
    border: 1px solid var(--border);
    color: var(--text);
    padding: 10px 18px;
    border-radius: 8px;
    font-size: 13px;
    box-shadow: var(--shadow);
    animation: toastIn .25s ease;
  }

  .toast.ok { border-color: rgba(52,211,153,.4); }
  .toast.err { border-color: rgba(248,113,113,.4); }

  @keyframes toastIn {
    from { opacity: 0; transform: translateY(8px); }
    to { opacity: 1; transform: translateY(0); }
  }

  /* Detail panel chips */
  .detail-block {
    background: var(--bg);
    border: 1px solid var(--border);
    border-radius: var(--radius);
    padding: 12px;
    margin-bottom: 12px;
  }

  .detail-block h4 {
    font-size: 13px;
    margin-bottom: 8px;
    display: flex;
    align-items: center;
    gap: 6px;
  }

  .kv {
    display: grid;
    grid-template-columns: 90px 1fr;
    gap: 4px 8px;
    font-size: 12px;
    margin-bottom: 6px;
  }

  .kv .k { color: var(--muted); }
  .kv .v { font-family: var(--mono); word-break: break-all; }

  .parent-list, .event-list-mini {
    display: flex;
    flex-wrap: wrap;
    gap: 4px;
    margin-top: 4px;
  }

  .chip {
    font-size: 11px;
    font-family: var(--mono);
    background: var(--bg3);
    border: 1px solid var(--border);
    padding: 2px 7px;
    border-radius: 4px;
    cursor: pointer;
  }

  .chip:hover { border-color: var(--accent); color: var(--accent2); }

  .status-bar {
    font-size: 11px;
    color: var(--muted);
    padding: 4px 14px;
    border-top: 1px solid var(--border);
    background: rgba(0,0,0,.25);
    display: flex;
    gap: 16px;
    flex-shrink: 0;
  }

  /* Legend */
  .legend {
    display: flex;
    gap: 12px;
    align-items: center;
    font-size: 11px;
    color: var(--muted);
  }

  .legend-item {
    display: inline-flex;
    align-items: center;
    gap: 5px;
  }

  .legend-dot {
    width: 10px;
    height: 10px;
    border-radius: 50%;
    border: 2px solid;
  }

  /* Import textarea large */
  #import-json {
    font-family: var(--mono);
    font-size: 11.5px;
    min-height: 220px;
  }

  .help-text {
    font-size: 12px;
    color: var(--muted);
    line-height: 1.55;
    margin-bottom: 12px;
  }

  .branch-color-swatch {
    width: 12px;
    height: 12px;
    border-radius: 3px;
    display: inline-block;
    vertical-align: middle;
  }
</style>
</head>
<body>

<header>
  <div class="logo">
    <svg width="28" height="28" viewBox="0 0 32 32" fill="none">
      <circle cx="16" cy="8" r="4" fill="#3b82f6"/>
      <circle cx="8" cy="24" r="4" fill="#34d399"/>
      <circle cx="24" cy="24" r="4" fill="#a78bfa"/>
      <path d="M16 12v4M16 16L8 20M16 16l8 4" stroke="#60a5fa" stroke-width="2"/>
    </svg>
    <span><span class="grad">Git</span> & Timeline</span>
  </div>
  <div class="header-actions">
    <button class="ghost" id="btn-sample" title="Load sample project">Load Sample</button>
    <button id="btn-import">Import</button>
    <button id="btn-export">Export</button>
    <button class="danger ghost" id="btn-reset" title="Clear all data">Reset</button>
  </div>
</header>

<div class="app">
  <!-- LEFT PANEL -->
  <aside class="panel left">
    <div class="panel-tabs">
      <button class="active" data-tab="commits">Commits</button>
      <button data-tab="branches">Branches</button>
      <button data-tab="tags">Tags</button>
    </div>
    <div class="panel-body" id="left-panel-body">
      <!-- dynamically filled -->
    </div>
  </aside>

  <!-- MAIN -->
  <main class="main">
    <div class="toolbar">
      <div class="view-toggle" id="layout-toggle">
        <button class="active" data-layout="time">Time Layout</button>
        <button data-layout="topo">Topo Layout</button>
      </div>
      <div class="sep"></div>
      <button id="btn-zoom-fit">Fit</button>
      <button id="btn-zoom-in">+</button>
      <button id="btn-zoom-out">−</button>
      <div class="sep"></div>
      <label class="inline"><input type="checkbox" id="chk-events" checked /> Show events</label>
      <label class="inline"><input type="checkbox" id="chk-labels" checked /> Labels</label>
      <label class="inline"><input type="checkbox" id="chk-timeline" checked /> Timeline strip</label>
      <div style="flex:1"></div>
      <div class="legend">
        <span class="legend-item"><span class="legend-dot" style="border-color:#3b82f6;background:#1e3a5f"></span> Commit</span>
        <span class="legend-item"><span class="legend-dot" style="border-color:#a78bfa;background:#a78bfa"></span> Event</span>
        <span class="legend-item"><span class="legend-dot" style="border-color:#34d399;background:transparent"></span> HEAD</span>
      </div>
    </div>
    <div id="graph-container">
      <svg id="graph-svg"></svg>
      <div class="tooltip" id="tooltip"></div>
    </div>
    <div class="timeline-strip" id="timeline-strip">
      <div class="timeline-header">
        <h3>Chronological Timeline</h3>
        <button class="ghost" id="btn-toggle-timeline" style="padding:2px 8px;font-size:11px">Collapse</button>
      </div>
      <svg id="timeline-svg"></svg>
    </div>
    <div class="status-bar" id="status-bar">
      <span id="stat-commits">0 commits</span>
      <span id="stat-branches">0 branches</span>
      <span id="stat-events">0 events</span>
      <span id="stat-selection">No selection</span>
    </div>
  </main>

  <!-- RIGHT PANEL -->
  <aside class="panel right">
    <div class="panel-tabs">
      <button class="active" data-rtab="detail">Detail</button>
      <button data-rtab="events">Events</button>
      <button data-rtab="create">Create</button>
    </div>
    <div class="panel-body" id="right-panel-body">
      <!-- dynamically filled -->
    </div>
  </aside>
</div>

<!-- Import Modal -->
<div class="modal-backdrop" id="import-modal">
  <div class="modal">
    <div class="modal-header">
      <h2>Import Data</h2>
      <button class="ghost" id="import-close">✕</button>
    </div>
    <div class="modal-body">
      <p class="help-text">
        Paste JSON matching the app schema. This will <strong>replace</strong> the current state.
        You can also import partial data (commits only, events only, etc.).
      </p>
      <label for="import-json">JSON payload</label>
      <textarea id="import-json" spellcheck="false" placeholder='{"commits":[],"branches":[],"tags":[],"events":[],"head":null}'></textarea>
    </div>
    <div class="modal-footer">
      <button class="ghost" id="import-cancel">Cancel</button>
      <button class="primary" id="import-apply">Import & Replace</button>
    </div>
  </div>
</div>

<div class="toast-wrap" id="toast-wrap"></div>

<script>
/* ============================================================
   Git & Timeline Visualizer — Application
   ============================================================ */

const STORAGE_KEY = 'git-timeline-viz-v1';

const BRANCH_PALETTE = [
  '#3b82f6', '#34d399', '#fbbf24', '#f87171', '#a78bfa',
  '#22d3ee', '#fb923c', '#e879f9', '#4ade80', '#38bdf8',
  '#c084fc', '#f472b6'
];

// ——— State ———
const state = {
  commits: [],      // {id, parents[], message, timestamp, author}
  branches: [],     // {name, commitId, color}
  tags: [],         // {name, commitId}
  events: [],       // {id, title, description, timestamp, commitId}
  head: null,       // branch name
  selectedCommitId: null,
  selectedEventId: null,
  layout: 'time',   // 'time' | 'topo'
  showEvents: true,
  showLabels: true,
  showTimeline: true,
  leftTab: 'commits',
  rightTab: 'detail',
};

// ——— Utilities ———
function uid(prefix = 'c') {
  return prefix + '_' + Math.random().toString(36).slice(2, 9) + Date.now().toString(36).slice(-4);
}

function shortSha(id) {
  return (id || '').replace(/^(c_|e_|t_)/, '').slice(0, 7);
}

function fmtDate(ts) {
  if (!ts) return '—';
  const d = new Date(ts);
  if (isNaN(d)) return String(ts);
  return d.toLocaleString(undefined, {
    year: 'numeric', month: 'short', day: 'numeric',
    hour: '2-digit', minute: '2-digit'
  });
}

function fmtDateShort(ts) {
  const d = new Date(ts);
  if (isNaN(d)) return '';
  return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: '2-digit' });
}

function toLocalInputValue(ts) {
  if (!ts) {
    const n = new Date();
    n.setMinutes(n.getMinutes() - n.getTimezoneOffset());
    return n.toISOString().slice(0, 16);
  }
  const d = new Date(ts);
  d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
  return d.toISOString().slice(0, 16);
}

function fromLocalInputValue(v) {
  if (!v) return Date.now();
  return new Date(v).getTime();
}

function toast(msg, type = 'ok') {
  const wrap = document.getElementById('toast-wrap');
  const el = document.createElement('div');
  el.className = 'toast ' + type;
  el.textContent = msg;
  wrap.appendChild(el);
  setTimeout(() => {
    el.style.opacity = '0';
    el.style.transition = 'opacity .3s';
    setTimeout(() => el.remove(), 300);
  }, 2400);
}

function cloneState() {
  return {
    commits: state.commits,
    branches: state.branches,
    tags: state.tags,
    events: state.events,
    head: state.head,
  };
}

// ——— Persistence ———
function save() {
  try {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(cloneState()));
  } catch (e) {
    console.warn('localStorage save failed', e);
  }
}

function load() {
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    if (!raw) return false;
    const data = JSON.parse(raw);
    applyData(data, false);
    return true;
  } catch (e) {
    console.warn('load failed', e);
    return false;
  }
}

function applyData(data, doSave = true) {
  if (data.commits) state.commits = data.commits;
  if (data.branches) state.branches = data.branches;
  if (data.tags) state.tags = data.tags;
  if (data.events) state.events = data.events;
  if ('head' in data) state.head = data.head;
  state.selectedCommitId = null;
  state.selectedEventId = null;
  if (doSave) save();
  fullRender();
}

// ——— Sample dataset ———
function loadSample() {
  const base = Date.parse('2024-01-15T09:00:00');
  const day = 86400000;
  const hour = 3600000;

  const c0 = uid('c');
  const c1 = uid('c');
  const c2 = uid('c');
  const c3 = uid('c');
  const c4 = uid('c');
  const c5 = uid('c');
  const c6 = uid('c');
  const c7 = uid('c');
  const c8 = uid('c');
  const c9 = uid('c');
  const c10 = uid('c');
  const c11 = uid('c');

  const commits = [
    { id: c0, parents: [], message: 'Initial commit: project scaffold', timestamp: base, author: 'Alex Chen' },
    { id: c1, parents: [c0], message: 'Add auth module skeleton', timestamp: base + 2 * day, author: 'Alex Chen' },
    { id: c2, parents: [c1], message: 'Implement JWT login flow', timestamp: base + 5 * day, author: 'Sam Rivera' },
    { id: c3, parents: [c1], message: 'Spike: OAuth provider research', timestamp: base + 4 * day + 3 * hour, author: 'Jordan Lee' },
    { id: c4, parents: [c3], message: 'OAuth: Google provider', timestamp: base + 8 * day, author: 'Jordan Lee' },
    { id: c5, parents: [c2], message: 'Session store + refresh tokens', timestamp: base + 9 * day, author: 'Sam Rivera' },
    { id: c6, parents: [c5, c4], message: 'Merge feature/oauth into develop', timestamp: base + 12 * day, author: 'Alex Chen' },
    { id: c7, parents: [c6], message: 'API rate limiting middleware', timestamp: base + 14 * day, author: 'Sam Rivera' },
    { id: c8, parents: [c6], message: 'Hotfix: token expiry edge case', timestamp: base + 13 * day + 5 * hour, author: 'Alex Chen' },
    { id: c9, parents: [c7, c8], message: 'Release prep: changelog + version bump', timestamp: base + 18 * day, author: 'Alex Chen' },
    { id: c10, parents: [c9], message: 'v1.0.0 production release', timestamp: base + 20 * day, author: 'Alex Chen' },
    { id: c11, parents: [c10], message: 'Post-release: monitoring dashboards', timestamp: base + 22 * day, author: 'Jordan Lee' },
  ];

  const branches = [
    { name: 'main', commitId: c11, color: BRANCH_PALETTE[0] },
    { name: 'develop', commitId: c9, color: BRANCH_PALETTE[1] },
    { name: 'feature/oauth', commitId: c4, color: BRANCH_PALETTE[4] },
    { name: 'hotfix/token-expiry', commitId: c8, color: BRANCH_PALETTE[3] },
  ];

  const tags = [
    { name: 'v0.1.0', commitId: c2 },
    { name: 'v1.0.0-rc1', commitId: c9 },
    { name: 'v1.0.0', commitId: c10 },
  ];

  const events = [
    { id: uid('e'), title: 'Project Kickoff', description: 'Team charter signed; repo created; CI pipeline provisioned.', timestamp: base + hour, commitId: c0 },
    { id: uid('e'), title: 'Security Review Gate', description: 'Auth design reviewed by security team. JWT approach approved with MFA follow-up.', timestamp: base + 5 * day + 2 * hour, commitId: c2 },
    { id: uid('e'), title: 'OAuth Spike Complete', description: 'Research spike closed; Google chosen as first provider.', timestamp: base + 8 * day + hour, commitId: c4 },
    { id: uid('e'), title: 'Feature Freeze', description: 'No new features for the release train. Bug fixes only.', timestamp: base + 12 * day + 3 * hour, commitId: c6 },
    { id: uid('e'), title: 'Incident: Token Expiry', description: 'Production-adjacent staging incident with refresh tokens near midnight UTC.', timestamp: base + 13 * day + 6 * hour, commitId: c8 },
    { id: uid('e'), title: 'Code Freeze', description: 'RC tagged. Only show-stopper fixes allowed.', timestamp: base + 18 * day + 2 * hour, commitId: c9 },
    { id: uid('e'), title: 'GA Launch', description: 'Public v1.0.0 launch. Press release and customer emails sent.', timestamp: base + 20 * day + 4 * hour, commitId: c10 },
    { id: uid('e'), title: 'Postmortem Review', description: 'Launch retro + observability follow-ups assigned.', timestamp: base + 22 * day + hour, commitId: c11 },
  ];

  applyData({ commits, branches, tags, events, head: 'main' }, true);
  toast('Sample project loaded');
}

// ——— Lookups ———
function getCommit(id) {
  return state.commits.find(c => c.id === id);
}

function getBranch(name) {
  return state.branches.find(b => b.name === name);
}

function branchesAt(commitId) {
  return state.branches.filter(b => b.commitId === commitId);
}

function tagsAt(commitId) {
  return state.tags.filter(t => t.commitId === commitId);
}

function eventsAt(commitId) {
  return state.events.filter(e => e.commitId === commitId);
}

function childrenOf(commitId) {
  return state.commits.filter(c => c.parents && c.parents.includes(commitId));
}

function nextBranchColor() {
  return BRANCH_PALETTE[state.branches.length % BRANCH_PALETTE.length];
}

// ——— CRUD: Commits ———
function createCommit({ message, parents, timestamp, author }) {
  const id = uid('c');
  const commit = {
    id,
    message: message || 'Empty commit',
    parents: parents || [],
    timestamp: timestamp || Date.now(),
    author: author || 'Developer',
  };
  state.commits.push(commit);

  // Advance HEAD branch pointer if parents include current HEAD tip
  if (state.head) {
    const hb = getBranch(state.head);
    if (hb && commit.parents.includes(hb.commitId)) {
      hb.commitId = id;
    }
  }
  save();
  state.selectedCommitId = id;
  fullRender();
  toast('Commit created · ' + shortSha(id));
  return id;
}

function updateCommit(id, patch) {
  const c = getCommit(id);
  if (!c) return;
  Object.assign(c, patch);
  // Ensure parents array uniqueness & no self
  if (c.parents) {
    c.parents = [...new Set(c.parents.filter(p => p && p !== id))];
  }
  save();
  fullRender();
  toast('Commit updated');
}

function deleteCommit(id) {
  // Remove as parent from others
  state.commits.forEach(c => {
    if (c.parents) c.parents = c.parents.filter(p => p !== id);
  });
  state.commits = state.commits.filter(c => c.id !== id);
  // Fix branches/tags/events referencing it
  state.branches.forEach(b => {
    if (b.commitId === id) b.commitId = null;
  });
  state.branches = state.branches.filter(b => b.commitId);
  state.tags = state.tags.filter(t => t.commitId !== id);
  state.events = state.events.filter(e => e.commitId !== id);
  if (state.selectedCommitId === id) state.selectedCommitId = null;
  save();
  fullRender();
  toast('Commit deleted', 'err');
}

// ——— CRUD: Branches ———
function createBranch(name, commitId, color) {
  name = (name || '').trim();
  if (!name) { toast('Branch name required', 'err'); return; }
  if (getBranch(name)) { toast('Branch already exists', 'err'); return; }
  if (!getCommit(commitId)) { toast('Commit required', 'err'); return; }
  state.branches.push({
    name,
    commitId,
    color: color || nextBranchColor(),
  });
  if (!state.head) state.head = name;
  save();
  fullRender();
  toast('Branch ' + name + ' created');
}

function updateBranch(name, patch) {
  const b = getBranch(name);
  if (!b) return;
  const oldName = b.name;
  Object.assign(b, patch);
  if (patch.name && patch.name !== oldName) {
    if (state.head === oldName) state.head = patch.name;
  }
  save();
  fullRender();
  toast('Branch updated');
}

function deleteBranch(name) {
  state.branches = state.branches.filter(b => b.name !== name);
  if (state.head === name) state.head = state.branches[0]?.name || null;
  save();
  fullRender();
  toast('Branch deleted', 'err');
}

function checkoutBranch(name) {
  if (!getBranch(name)) return;
  state.head = name;
  save();
  fullRender();
  toast('HEAD → ' + name);
}

// ——— CRUD: Tags ———
function createTag(name, commitId) {
  name = (name || '').trim();
  if (!name) { toast('Tag name required', 'err'); return; }
  if (state.tags.some(t => t.name === name)) { toast('Tag exists', 'err'); return; }
  if (!getCommit(commitId)) { toast('Commit required', 'err'); return; }
  state.tags.push({ name, commitId });
  save();
  fullRender();
  toast('Tag ' + name + ' created');
}

function deleteTag(name) {
  state.tags = state.tags.filter(t => t.name !== name);
  save();
  fullRender();
  toast('Tag deleted', 'err');
}

// ——— CRUD: Events ———
function createEvent({ title, description, timestamp, commitId }) {
  if (!title) { toast('Event title required', 'err'); return; }
  if (!getCommit(commitId)) { toast('Must associate with a commit', 'err'); return; }
  const e = {
    id: uid('e'),
    title,
    description: description || '',
    timestamp: timestamp || getCommit(commitId).timestamp,
    commitId,
  };
  state.events.push(e);
  state.selectedEventId = e.id;
  save();
  fullRender();
  toast('Event tagged to ' + shortSha(commitId));
  return e.id;
}

function updateEvent(id, patch) {
  const e = state.events.find(x => x.id === id);
  if (!e) return;
  Object.assign(e, patch);
  save();
  fullRender();
  toast('Event updated');
}

function deleteEvent(id) {
  state.events = state.events.filter(e => e.id !== id);
  if (state.selectedEventId === id) state.selectedEventId = null;
  save();
  fullRender();
  toast('Event deleted', 'err');
}

// ——— Graph layout ———
function computeLayout() {
  const commits = state.commits.slice();
  if (!commits.length) return { nodes: [], links: [], lanes: 0 };

  const byId = new Map(commits.map(c => [c.id, c]));

  // Build adjacency
  const children = new Map();
  commits.forEach(c => {
    (c.parents || []).forEach(p => {
      if (!children.has(p)) children.set(p, []);
      children.get(p).push(c.id);
    });
  });

  // Topological generations (longest path from roots)
  const gen = new Map();
  const indeg = new Map(commits.map(c => [c.id, (c.parents || []).filter(p => byId.has(p)).length]));
  const queue = commits.filter(c => indeg.get(c.id) === 0).map(c => c.id);
  queue.forEach(id => gen.set(id, 0));
  const order = [];
  const q = queue.slice();
  while (q.length) {
    const id = q.shift();
    order.push(id);
    const g = gen.get(id) || 0;
    (children.get(id) || []).forEach(cid => {
      gen.set(cid, Math.max(gen.get(cid) || 0, g + 1));
      indeg.set(cid, indeg.get(cid) - 1);
      if (indeg.get(cid) === 0) q.push(cid);
    });
  }
  // orphans with cycles fallback
  commits.forEach(c => {
    if (!gen.has(c.id)) {
      gen.set(c.id, 0);
      order.push(c.id);
    }
  });

  // Branch-aware lane assignment (like git log --graph)
  // Prefer keeping branch tips on stable lanes
  const laneOf = new Map();
  let maxLane = 0;
  const activeLanes = []; // commitId occupying lane index or null

  // Sort processing order: by generation, then timestamp
  const sorted = commits.slice().sort((a, b) => {
    const dg = (gen.get(a.id) || 0) - (gen.get(b.id) || 0);
    if (dg !== 0) return dg;
    return (a.timestamp || 0) - (b.timestamp || 0);
  });

  function allocLane(prefer) {
    if (prefer != null && (activeLanes[prefer] == null)) {
      activeLanes[prefer] = true;
      return prefer;
    }
    for (let i = 0; i < activeLanes.length; i++) {
      if (activeLanes[i] == null) {
        activeLanes[i] = true;
        return i;
      }
    }
    activeLanes.push(true);
    return activeLanes.length - 1;
  }

  // First pass: children inherit primary parent lane
  const primaryChild = new Map(); // parent -> first child by time
  sorted.forEach(c => {
    const parents = (c.parents || []).filter(p => byId.has(p));
    if (parents.length) {
      const p0 = parents[0];
      if (!primaryChild.has(p0)) primaryChild.set(p0, c.id);
    }
  });

  sorted.forEach(c => {
    const parents = (c.parents || []).filter(p => byId.has(p));
    let lane;
    if (!parents.length) {
      lane = allocLane(null);
    } else {
      const p0 = parents[0];
      const pLane = laneOf.get(p0);
      // If this is the first child of primary parent, reuse lane
      if (primaryChild.get(p0) === c.id && pLane != null) {
        lane = pLane;
      } else {
        lane = allocLane(null);
      }
    }
    laneOf.set(c.id, lane);
    maxLane = Math.max(maxLane, lane);
  });

  // Assign coordinates
  const xGap = 90;
  const yGap = 72;
  const marginX = 80;
  const marginY = 60;

  // For time layout, x = time scale; for topo, x = generation
  let nodes;

  if (state.layout === 'time') {
    const times = commits.map(c => c.timestamp || 0);
    const tMin = Math.min(...times);
    const tMax = Math.max(...times);
    const span = Math.max(tMax - tMin, 1);
    const width = Math.max(commits.length * xGap, 600);

    nodes = commits.map(c => {
      const t = c.timestamp || tMin;
      const x = marginX + ((t - tMin) / span) * width;
      const y = marginY + (laneOf.get(c.id) || 0) * yGap;
      return {
        ...c,
        x, y,
        lane: laneOf.get(c.id) || 0,
        gen: gen.get(c.id) || 0,
      };
    });
  } else {
    nodes = commits.map(c => {
      const g = gen.get(c.id) || 0;
      const x = marginX + g * xGap;
      const y = marginY + (laneOf.get(c.id) || 0) * yGap;
      return {
        ...c,
        x, y,
        lane: laneOf.get(c.id) || 0,
        gen: g,
      };
    });
  }

  const nodeById = new Map(nodes.map(n => [n.id, n]));

  // Links with bend for merges
  const links = [];
  nodes.forEach(n => {
    (n.parents || []).forEach((pid, idx) => {
      const p = nodeById.get(pid);
      if (!p) return;
      links.push({
        source: p,
        target: n,
        isMerge: idx > 0 || (n.parents || []).length > 1,
        branchColor: colorForEdge(p, n),
      });
    });
  });

  return { nodes, links, lanes: maxLane + 1, nodeById };
}

function colorForEdge(parent, child) {
  // Prefer branch color of any branch pointing at child or parent
  const atChild = branchesAt(child.id);
  if (atChild.length) return atChild[0].color;
  const atParent = branchesAt(parent.id);
  if (atParent.length) return atParent[0].color;
  // walk toward HEAD branch
  if (state.head) {
    const hb = getBranch(state.head);
    if (hb) return hb.color;
  }
  return BRANCH_PALETTE[0];
}

function colorForCommit(commitId) {
  const bs = branchesAt(commitId);
  if (bs.length) return bs[0].color;
  // inherit from HEAD if on its ancestry-ish: just use first parent chain branch
  const kids = childrenOf(commitId);
  for (const k of kids) {
    const bs2 = branchesAt(k.id);
    if (bs2.length) return bs2[0].color;
  }
  if (state.head) {
    const hb = getBranch(state.head);
    if (hb) return hb.color;
  }
  return BRANCH_PALETTE[0];
}

// ——— D3 rendering ———
let zoomBehavior = null;
let currentTransform = d3.zoomIdentity;
let layoutCache = null;

function initSvg() {
  const svg = d3.select('#graph-svg');
  svg.selectAll('*').remove();

  const g = svg.append('g').attr('class', 'viewport');
  g.append('g').attr('class', 'links');
  g.append('g').attr('class', 'nodes');
  g.append('g').attr('class', 'labels');
  g.append('g').attr('class', 'event-anchors');

  zoomBehavior = d3.zoom()
    .scaleExtent([0.15, 4])
    .on('zoom', (event) => {
      currentTransform = event.transform;
      g.attr('transform', event.transform);
    });

  svg.call(zoomBehavior);

  // click empty to deselect
  svg.on('click', (event) => {
    if (event.target === svg.node()) {
      state.selectedCommitId = null;
      state.selectedEventId = null;
      highlightSelection();
      renderRightPanel();
      updateStatus();
    }
  });
}

function pathForLink(d) {
  const x1 = d.source.x, y1 = d.source.y;
  const x2 = d.target.x, y2 = d.target.y;
  if (Math.abs(y1 - y2) < 1) {
    return `M${x1},${y1} L${x2},${y2}`;
  }
  // Smooth step
  const mx = (x1 + x2) / 2;
  return `M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}`;
}

function renderGraph() {
  const svg = d3.select('#graph-svg');
  if (svg.select('.viewport').empty()) initSvg();

  const { nodes, links, nodeById } = computeLayout();
  layoutCache = { nodes, links, nodeById };

  const gLinks = svg.select('.links');
  const gNodes = svg.select('.nodes');
  const gLabels = svg.select('.labels');
  const gEvents = svg.select('.event-anchors');

  // Links
  const linkSel = gLinks.selectAll('path.link').data(links, d => d.source.id + '->' + d.target.id);
  linkSel.exit().remove();
  linkSel.enter()
    .append('path')
    .attr('class', 'link')
    .merge(linkSel)
    .attr('class', d => 'link' + (d.isMerge ? ' merge' : ''))
    .attr('stroke', d => d.branchColor)
    .attr('d', pathForLink);

  // Nodes
  const nodeSel = gNodes.selectAll('g.commit-node').data(nodes, d => d.id);
  nodeSel.exit().remove();

  const nodeEnter = nodeSel.enter().append('g')
    .attr('class', 'commit-node')
    .attr('data-id', d => d.id);

  nodeEnter.append('circle').attr('r', 11);
  nodeEnter.append('text').attr('class', 'msg').attr('x', 16).attr('y', -4);
  nodeEnter.append('text').attr('class', 'sha').attr('x', 16).attr('y', 10);

  const nodeMerge = nodeEnter.merge(nodeSel);

  nodeMerge
    .attr('transform', d => `translate(${d.x},${d.y})`)
    .classed('selected', d => d.id === state.selectedCommitId)
    .on('click', (event, d) => {
      event.stopPropagation();
      state.selectedCommitId = d.id;
      state.selectedEventId = null;
      // pick first event if any
      const evs = eventsAt(d.id);
      if (evs.length) state.selectedEventId = evs[0].id;
      highlightSelection();
      renderRightPanel();
      renderLeftPanel();
      renderTimeline();
      updateStatus();
    })
    .on('mouseenter', (event, d) => showCommitTooltip(event, d))
    .on('mousemove', (event) => moveTooltip(event))
    .on('mouseleave', hideTooltip);

  nodeMerge.select('circle')
    .attr('fill', d => {
      const col = colorForCommit(d.id);
      return d3.color(col).darker(1.5);
    })
    .attr('stroke', d => {
      const isHead = state.branches.some(b => b.name === state.head && b.commitId === d.id);
      if (isHead) return '#34d399';
      if ((d.parents || []).length > 1) return '#22d3ee';
      return colorForCommit(d.id);
    })
    .attr('r', d => (d.parents || []).length > 1 ? 13 : 11);

  nodeMerge.select('text.msg')
    .text(d => state.showLabels ? truncate(d.message, 36) : '')
    .attr('opacity', state.showLabels ? 1 : 0);

  nodeMerge.select('text.sha')
    .text(d => state.showLabels ? shortSha(d.id) : '')
    .attr('opacity', state.showLabels ? 1 : 0);

  // Branch + tag labels
  const labelData = [];
  state.branches.forEach(b => {
    const n = nodeById.get(b.commitId);
    if (!n) return;
    labelData.push({
      key: 'b:' + b.name,
      x: n.x,
      y: n.y,
      text: b.name + (state.head === b.name ? ' ★' : ''),
      color: b.color,
      kind: 'branch',
      offset: 0,
    });
  });
  state.tags.forEach(t => {
    const n = nodeById.get(t.commitId);
    if (!n) return;
    labelData.push({
      key: 't:' + t.name,
      x: n.x,
      y: n.y,
      text: t.name,
      color: '#fbbf24',
      kind: 'tag',
      offset: 0,
    });
  });

  // stack labels per commit
  const byCommit = d3.group(labelData, d => d.x + ',' + d.y);
  byCommit.forEach(arr => {
    arr.forEach((d, i) => { d.offset = i; });
  });

  const labSel = gLabels.selectAll('g.ref-label').data(labelData, d => d.key);
  labSel.exit().remove();
  const labEnter = labSel.enter().append('g').attr('class', 'ref-label');
  labEnter.append('rect').attr('class', 'branch-label-bg');
  labEnter.append('text').attr('class', 'branch-label');

  const labMerge = labEnter.merge(labSel);
  labMerge.attr('transform', d => {
    const ox = -10;
    const oy = -28 - d.offset * 20;
    return `translate(${d.x + ox},${d.y + oy})`;
  });

  labMerge.select('text')
    .text(d => d.text)
    .attr('fill', d => d.color)
    .attr('x', 6)
    .attr('y', 12);

  labMerge.select('rect')
    .attr('fill', d => d3.color(d.color).copy({ opacity: 0.15 }))
    .attr('stroke', d => d.color)
    .attr('stroke-width', 1)
    .attr('x', 0)
    .attr('y', 0)
    .each(function (d) {
      const textEl = this.parentNode.querySelector('text');
      const w = textEl.getComputedTextLength() + 12;
      d3.select(this).attr('width', w).attr('height', 18);
    });

  // Event anchors
  const evData = state.showEvents
    ? state.events.map(e => {
        const n = nodeById.get(e.commitId);
        if (!n) return null;
        return { ...e, x: n.x, y: n.y };
      }).filter(Boolean)
    : [];

  // Offset multiple events on same commit
  const evGroups = d3.group(evData, d => d.commitId);
  evGroups.forEach(arr => {
    arr.forEach((d, i) => { d.eOffset = i; });
  });

  const evSel = gEvents.selectAll('g.event-anchor').data(evData, d => d.id);
  evSel.exit().remove();
  const evEnter = evSel.enter().append('g').attr('class', 'event-anchor');
  evEnter.append('line').attr('class', 'ev-stem');
  evEnter.append('circle').attr('r', 6);
  evEnter.append('text').attr('class', 'event-label');

  const evMerge = evEnter.merge(evSel);
  evMerge
    .attr('transform', d => `translate(${d.x + 18 + d.eOffset * 14},${d.y - 18 - d.eOffset * 4})`)
    .classed('highlight', d => d.id === state.selectedEventId || d.commitId === state.selectedCommitId)
    .on('click', (event, d) => {
      event.stopPropagation();
      state.selectedEventId = d.id;
      state.selectedCommitId = d.commitId;
      highlightSelection();
      renderRightPanel();
      renderLeftPanel();
      renderTimeline();
      updateStatus();
    })
    .on('mouseenter', (event, d) => showEventTooltip(event, d))
    .on('mousemove', (event) => moveTooltip(event))
    .on('mouseleave', hideTooltip);

  evMerge.select('line.ev-stem')
    .attr('x1', 0).attr('y1', 0)
    .attr('x2', -14).attr('y2', 14)
    .attr('stroke', '#a78bfa')
    .attr('stroke-width', 1.5)
    .attr('stroke-dasharray', '3 2');

  evMerge.select('text.event-label')
    .text(d => state.showLabels ? truncate(d.title, 22) : '')
    .attr('x', 10)
    .attr('y', 4)
    .attr('opacity', state.showLabels ? 1 : 0);

  highlightSelection();
}

function truncate(s, n) {
  if (!s) return '';
  return s.length > n ? s.slice(0, n - 1) + '…' : s;
}

function highlightSelection() {
  d3.selectAll('.commit-node')
    .classed('selected', function () {
      return this.getAttribute('data-id') === state.selectedCommitId;
    });
  d3.selectAll('.event-anchor')
    .classed('highlight', d =>
      d.id === state.selectedEventId || d.commitId === state.selectedCommitId
    );

  // Dim unrelated
  const hasSel = !!state.selectedCommitId;
  d3.selectAll('.commit-node').attr('opacity', d => {
    if (!hasSel) return 1;
    if (d.id === state.selectedCommitId) return 1;
    if ((d.parents || []).includes(state.selectedCommitId)) return 0.95;
    // children
    const sel = getCommit(state.selectedCommitId);
    if (sel && (sel.parents || []).includes(d.id)) return 0.95;
    return 0.35;
  });
  d3.selectAll('path.link').attr('opacity', d => {
    if (!hasSel) return 0.75;
    if (d.source.id === state.selectedCommitId || d.target.id === state.selectedCommitId) return 1;
    return 0.15;
  });
}

function fitGraph() {
  if (!layoutCache || !layoutCache.nodes.length) return;
  const svg = document.getElementById('graph-svg');
  const bounds = svg.getBoundingClientRect();
  const nodes = layoutCache.nodes;
  const minX = d3.min(nodes, d => d.x) - 80;
  const maxX = d3.max(nodes, d => d.x) + 200;
  const minY = d3.min(nodes, d => d.y) - 60;
  const maxY = d3.max(nodes, d => d.y) + 80;
  const w = maxX - minX;
  const h = maxY - minY;
  const scale = Math.min(bounds.width / w, bounds.height / h, 1.5) * 0.9;
  const tx = (bounds.width - w * scale) / 2 - minX * scale;
  const ty = (bounds.height - h * scale) / 2 - minY * scale;
  const t = d3.zoomIdentity.translate(tx, ty).scale(scale);
  d3.select('#graph-svg').transition().duration(400).call(zoomBehavior.transform, t);
}

// ——— Timeline strip ———
function renderTimeline() {
  const svg = d3.select('#timeline-svg');
  svg.selectAll('*').remove();

  const strip = document.getElementById('timeline-strip');
  if (!state.showTimeline) {
    strip.classList.add('collapsed');
    return;
  }
  strip.classList.remove('collapsed');

  const el = document.getElementById('timeline-svg');
  const width = el.clientWidth || 800;
  const height = el.clientHeight || 90;
  if (height < 20) return;

  const items = [
    ...state.commits.map(c => ({
      kind: 'commit',
      id: c.id,
      title: c.message,
      timestamp: c.timestamp,
      color: colorForCommit(c.id),
    })),
    ...state.events.map(e => ({
      kind: 'event',
      id: e.id,
      title: e.title,
      timestamp: e.timestamp,
      commitId: e.commitId,
      color: '#a78bfa',
    })),
  ].filter(d => d.timestamp != null);

  if (!items.length) {
    svg.append('text')
      .attr('x', width / 2)
      .attr('y', height / 2)
      .attr('text-anchor', 'middle')
      .attr('fill', '#8b9bb4')
      .attr('font-size', 12)
      .text('No timeline data yet');
    return;
  }

  const tMin = d3.min(items, d => d.timestamp);
  const tMax = d3.max(items, d => d.timestamp);
  const pad = 40;
  const x = d3.scaleTime()
    .domain([tMin - (tMax - tMin) * 0.02, tMax + (tMax - tMin) * 0.02 || tMin + 1])
    .range([pad, width - pad]);

  // axis line
  svg.append('line')
    .attr('x1', pad).attr('x2', width - pad)
    .attr('y1', height * 0.55).attr('y2', height * 0.55)
    .attr('stroke', '#2d3a4f')
    .attr('stroke-width', 2);

  // ticks
  const axis = d3.axisBottom(x).ticks(Math.min(8, items.length)).tickFormat(d => fmtDateShort(+d));
  svg.append('g')
    .attr('transform', `translate(0,${height * 0.55})`)
    .call(axis)
    .call(g => g.selectAll('text').attr('fill', '#8b9bb4').attr('font-size', 10))
    .call(g => g.selectAll('line').attr('stroke', '#2d3a4f'))
    .call(g => g.select('.domain').attr('stroke', 'transparent'));

  // Commit ticks (bottom)
  const commits = items.filter(d => d.kind === 'commit');
  svg.selectAll('circle.tl-commit')
    .data(commits)
    .enter()
    .append('circle')
    .attr('class', 'tl-commit')
    .attr('cx', d => x(d.timestamp))
    .attr('cy', height * 0.55)
    .attr('r', d => d.id === state.selectedCommitId ? 6 : 4)
    .attr('fill', d => d.color)
    .attr('stroke', d => d.id === state.selectedCommitId ? '#fff' : 'transparent')
    .attr('stroke-width', 2)
    .style('cursor', 'pointer')
    .on('click', (event, d) => {
      state.selectedCommitId = d.id;
      state.selectedEventId = null;
      const evs = eventsAt(d.id);
      if (evs.length) state.selectedEventId = evs[0].id;
      fullRender(false);
    })
    .on('mouseenter', (event, d) => {
      const c = getCommit(d.id);
      if (c) showCommitTooltip(event, c);
    })
    .on('mousemove', moveTooltip)
    .on('mouseleave', hideTooltip);

  // Events (top diamonds)
  const events = items.filter(d => d.kind === 'event');
  const evG = svg.selectAll('g.tl-event')
    .data(events)
    .enter()
    .append('g')
    .attr('class', 'tl-event')
    .attr('transform', d => `translate(${x(d.timestamp)},${height * 0.28})`)
    .style('cursor', 'pointer')
    .on('click', (event, d) => {
      state.selectedEventId = d.id;
      state.selectedCommitId = d.commitId;
      fullRender(false);
    })
    .on('mouseenter', (event, d) => {
      const e = state.events.find(x => x.id === d.id);
      if (e) showEventTooltip(event, e);
    })
    .on('mousemove', moveTooltip)
    .on('mouseleave', hideTooltip);

  evG.append('line')
    .attr('x1', 0).attr('x2', 0)
    .attr('y1', 8).attr('y2', height * 0.55 - height * 0.28)
    .attr('stroke', '#a78bfa')
    .attr('stroke-width', 1)
    .attr('stroke-dasharray', '2 2')
    .attr('opacity', 0.6);

  evG.append('polygon')
    .attr('points', '0,-7 7,0 0,7 -7,0')
    .attr('fill', d => d.id === state.selectedEventId ? '#c4b5fd' : '#a78bfa')
    .attr('stroke', d =>
      d.id === state.selectedEventId || d.commitId === state.selectedCommitId ? '#fff' : 'transparent'
    )
    .attr('stroke-width', 1.5);

  evG.append('text')
    .text(d => truncate(d.title, 18))
    .attr('y', -12)
    .attr('text-anchor', 'middle')
    .attr('fill', '#c4b5fd')
    .attr('font-size', 9.5)
    .attr('font-weight', 600);
}

// ——— Tooltips ———
function showCommitTooltip(event, d) {
  const tip = document.getElementById('tooltip');
  const br = branchesAt(d.id).map(b => b.name).join(', ') || '—';
  const tg = tagsAt(d.id).map(t => t.name).join(', ') || '—';
  const ev = eventsAt(d.id);
  tip.innerHTML = `
    <div class="tt-title">${escapeHtml(d.message || '')}</div>
    <div class="tt-row">SHA: <strong>${shortSha(d.id)}</strong></div>
    <div class="tt-row">Date: ${fmtDate(d.timestamp)}</div>
    <div class="tt-row">Author: ${escapeHtml(d.author || '—')}</div>
    <div class="tt-row">Branches: ${escapeHtml(br)}</div>
    <div class="tt-row">Tags: ${escapeHtml(tg)}</div>
    ${ev.length ? `<div class="tt-row">Events: ${ev.map(e => escapeHtml(e.title)).join('; ')}</div>` : ''}
  `;
  tip.classList.add('visible');
  moveTooltip(event);
}

function showEventTooltip(event, d) {
  const tip = document.getElementById('tooltip');
  const c = getCommit(d.commitId);
  tip.innerHTML = `
    <div class="tt-title">◈ ${escapeHtml(d.title)}</div>
    <div class="tt-row">${escapeHtml(d.description || 'No description')}</div>
    <div class="tt-row">When: ${fmtDate(d.timestamp)}</div>
    <div class="tt-row">Anchored commit: <strong>${c ? shortSha(c.id) : '—'}</strong></div>
    <div class="tt-row">${c ? escapeHtml(c.message) : ''}</div>
  `;
  tip.classList.add('visible');
  moveTooltip(event);
}

function moveTooltip(event) {
  const tip = document.getElementById('tooltip');
  const container = document.getElementById('graph-container');
  const rect = container.getBoundingClientRect();
  let x = event.clientX - rect.left + 14;
  let y = event.clientY - rect.top + 14;
  if (x + 290 > rect.width) x = event.clientX - rect.left - 290;
  if (y + 140 > rect.height) y = event.clientY - rect.top - 120;
  tip.style.left = Math.max(8, x) + 'px';
  tip.style.top = Math.max(8, y) + 'px';
}

function hideTooltip() {
  document.getElementById('tooltip').classList.remove('visible');
}

function escapeHtml(s) {
  const d = document.createElement('div');
  d.textContent = String(s);
  return d.innerHTML.replace(/"/g, '&' + 'quot;');
}

// ——— Panels ———
function renderLeftPanel() {
  const body = document.getElementById('left-panel-body');
  const tab = state.leftTab;

  if (tab === 'commits') {
    const sorted = state.commits.slice().sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0));
    if (!sorted.length) {
      body.innerHTML = `<div class="empty-hint">No commits yet.<br/>Use the Create tab or Load Sample.</div>`;
      return;
    }
    body.innerHTML = `<div class="section-title">All Commits (${sorted.length})</div>` +
      sorted.map(c => {
        const ev = eventsAt(c.id).length;
        const isHead = state.branches.some(b => b.name === state.head && b.commitId === c.id);
        return `
          <div class="card ${c.id === state.selectedCommitId ? 'selected' : ''}" data-cid="${c.id}">
            <div class="card-title">
              ${isHead ? '<span class="badge head">HEAD</span>' : ''}
              ${(c.parents || []).length > 1 ? '<span class="badge merge">MERGE</span>' : ''}
              ${ev ? `<span class="badge event">${ev} evt</span>` : ''}
              ${escapeHtml(truncate(c.message, 40))}
            </div>
            <div class="card-meta">${shortSha(c.id)} · ${fmtDateShort(c.timestamp)} · ${escapeHtml(c.author || '')}</div>
            <div class="card-actions">
              <button data-act="edit-commit" data-id="${c.id}">Edit</button>
              <button class="danger" data-act="del-commit" data-id="${c.id}">Del</button>
            </div>
          </div>`;
      }).join('');

    body.querySelectorAll('.card').forEach(card => {
      card.addEventListener('click', (e) => {
        if (e.target.closest('button')) return;
        state.selectedCommitId = card.dataset.cid;
        state.selectedEventId = eventsAt(card.dataset.cid)[0]?.id || null;
        state.rightTab = 'detail';
        fullRender(false);
      });
    });
    body.querySelectorAll('[data-act="del-commit"]').forEach(btn => {
      btn.addEventListener('click', (e) => {
        e.stopPropagation();
        if (confirm('Delete this commit?')) deleteCommit(btn.dataset.id);
      });
    });
    body.querySelectorAll('[data-act="edit-commit"]').forEach(btn => {
      btn.addEventListener('click', (e) => {
        e.stopPropagation();
        state.selectedCommitId = btn.dataset.id;
        state.rightTab = 'detail';
        renderRightPanel();
        // focus edit form
        document.querySelectorAll('.panel.right .panel-tabs button').forEach(b => {
          b.classList.toggle('active', b.dataset.rtab === 'detail');
        });
      });
    });
  }

  if (tab === 'branches') {
    body.innerHTML = `<div class="section-title">Branches (${state.branches.length})</div>` +
      (state.branches.length ? state.branches.map(b => {
        const c = getCommit(b.commitId);
        const isHead = state.head === b.name;
        return `
          <div class="card ${isHead ? 'selected' : ''}">
            <div class="card-title">
              <span class="branch-color-swatch" style="background:${b.color}"></span>
              ${escapeHtml(b.name)}
              ${isHead ? '<span class="badge head">HEAD</span>' : ''}
            </div>
            <div class="card-meta">→ ${c ? shortSha(c.id) + ' · ' + escapeHtml(truncate(c.message, 28)) : 'orphaned'}</div>
            <div class="card-actions">
              ${!isHead ? `<button data-act="checkout" data-name="${escapeHtml(b.name)}">Checkout</button>` : ''}
              <button class="danger" data-act="del-branch" data-name="${escapeHtml(b.name)}">Del</button>
            </div>
          </div>`;
      }).join('') : `<div class="empty-hint">No branches. Create one from the Create tab.</div>`);

    body.querySelectorAll('[data-act="checkout"]').forEach(btn => {
      btn.addEventListener('click', () => checkoutBranch(btn.dataset.name));
    });
    body.querySelectorAll('[data-act="del-branch"]').forEach(btn => {
      btn.addEventListener('click', () => {
        if (confirm('Delete branch ' + btn.dataset.name + '?')) deleteBranch(btn.dataset.name);
      });
    });
  }

  if (tab === 'tags') {
    body.innerHTML = `<div class="section-title">Tags (${state.tags.length})</div>` +
      (state.tags.length ? state.tags.map(t => {
        const c = getCommit(t.commitId);
        return `
          <div class="card">
            <div class="card-title"><span class="badge tag">TAG</span> ${escapeHtml(t.name)}</div>
            <div class="card-meta">→ ${c ? shortSha(c.id) + ' · ' + escapeHtml(truncate(c.message, 28)) : '—'}</div>
            <div class="card-actions">
              <button data-act="goto-tag" data-cid="${t.commitId}">Go</button>
              <button class="danger" data-act="del-tag" data-name="${escapeHtml(t.name)}">Del</button>
            </div>
          </div>`;
      }).join('') : `<div class="empty-hint">No tags yet.</div>`);

    body.querySelectorAll('[data-act="del-tag"]').forEach(btn => {
      btn.addEventListener('click', () => {
        if (confirm('Delete tag?')) deleteTag(btn.dataset.name);
      });
    });
    body.querySelectorAll('[data-act="goto-tag"]').forEach(btn => {
      btn.addEventListener('click', () => {
        state.selectedCommitId = btn.dataset.cid;
        state.rightTab = 'detail';
        fullRender(false);
      });
    });
  }
}

function renderRightPanel() {
  const body = document.getElementById('right-panel-body');
  const tab = state.rightTab;

  // sync tab buttons
  document.querySelectorAll('.panel.right .panel-tabs button').forEach(b => {
    b.classList.toggle('active', b.dataset.rtab === tab);
  });

  if (tab === 'detail') {
    const c = getCommit(state.selectedCommitId);
    if (!c) {
      body.innerHTML = `<div class="empty-hint">Select a commit on the graph or from the list to inspect and edit it. Associated timeline events appear here too.</div>`;
      return;
    }
    const parents = (c.parents || []).map(pid => {
      const p = getCommit(pid);
      return `<span class="chip" data-goto="${pid}">${shortSha(pid)}${p ? ' · ' + escapeHtml(truncate(p.message, 20)) : ''}</span>`;
    }).join('') || '<span style="color:var(--muted);font-size:12px">Root commit</span>';

    const kids = childrenOf(c.id).map(k =>
      `<span class="chip" data-goto="${k.id}">${shortSha(k.id)} · ${escapeHtml(truncate(k.message, 20))}</span>`
    ).join('') || '<span style="color:var(--muted);font-size:12px">None</span>';

    const br = branchesAt(c.id).map(b =>
      `<span class="badge branch">${escapeHtml(b.name)}</span>`
    ).join(' ') || '—';
    const tg = tagsAt(c.id).map(t =>
      `<span class="badge tag">${escapeHtml(t.name)}</span>`
    ).join(' ') || '—';

    const evs = eventsAt(c.id);

    body.innerHTML = `
      <div class="detail-block">
        <h4>Commit <span class="badge head">${shortSha(c.id)}</span></h4>
        <div class="kv"><span class="k">Message</span><span class="v">${escapeHtml(c.message)}</span></div>
        <div class="kv"><span class="k">Author</span><span class="v">${escapeHtml(c.author || '—')}</span></div>
        <div class="kv"><span class="k">Date</span><span class="v">${fmtDate(c.timestamp)}</span></div>
        <div class="kv"><span class="k">Branches</span><span class="v">${br}</span></div>
        <div class="kv"><span class="k">Tags</span><span class="v">${tg}</span></div>
        <div style="margin-top:8px"><label>Parents</label><div class="parent-list">${parents}</div></div>
        <div style="margin-top:8px"><label>Children</label><div class="parent-list">${kids}</div></div>
      </div>

      <div class="section-title">Edit Commit</div>
      <label>Message</label>
      <input id="edit-msg" value="${escapeHtml(c.message)}" />
      <label>Author</label>
      <input id="edit-author" value="${escapeHtml(c.author || '')}" />
      <label>Timestamp</label>
      <input id="edit-ts" type="datetime-local" value="${toLocalInputValue(c.timestamp)}" />
      <label>Parents (comma-separated IDs)</label>
      <input id="edit-parents" value="${(c.parents || []).join(', ')}" />
      <div class="form-actions">
        <button class="primary" id="btn-save-commit">Save</button>
        <button class="danger" id="btn-delete-commit">Delete</button>
      </div>

      <div class="section-title">Anchored Events (${evs.length})</div>
      ${evs.length ? evs.map(e => `
        <div class="card ${e.id === state.selectedEventId ? 'selected' : ''}" data-eid="${e.id}">
          <div class="card-title"><span class="badge event">EVENT</span> ${escapeHtml(e.title)}</div>
          <div class="card-meta">${fmtDate(e.timestamp)}</div>
        </div>
      `).join('') : `<div class="empty-hint" style="padding:12px">No events on this commit. Create one in the Events or Create tab.</div>`}
    `;

    body.querySelectorAll('[data-goto]').forEach(el => {
      el.addEventListener('click', () => {
        state.selectedCommitId = el.dataset.goto;
        fullRender(false);
      });
    });
    body.querySelectorAll('[data-eid]').forEach(el => {
      el.addEventListener('click', () => {
        state.selectedEventId = el.dataset.eid;
        state.rightTab = 'events';
        fullRender(false);
      });
    });
    body.querySelector('#btn-save-commit')?.addEventListener('click', () => {
      const parentsRaw = body.querySelector('#edit-parents').value;
      const parents = parentsRaw.split(/[,\s]+/).map(s => s.trim()).filter(Boolean);
      updateCommit(c.id, {
        message: body.querySelector('#edit-msg').value,
        author: body.querySelector('#edit-author').value,
        timestamp: fromLocalInputValue(body.querySelector('#edit-ts').value),
        parents,
      });
    });
    body.querySelector('#btn-delete-commit')?.addEventListener('click', () => {
      if (confirm('Delete commit ' + shortSha(c.id) + '?')) deleteCommit(c.id);
    });
  }

  if (tab === 'events') {
    const sorted = state.events.slice().sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
    const selected = state.events.find(e => e.id === state.selectedEventId);

    let html = `<div class="section-title">Timeline Events (${sorted.length})</div>`;
    if (!sorted.length) {
      html += `<div class="empty-hint">No timeline events. Associate real-world milestones with commits.</div>`;
    } else {
      html += sorted.map(e => {
        const c = getCommit(e.commitId);
        return `
          <div class="card ${e.id === state.selectedEventId ? 'selected' : ''}" data-eid="${e.id}">
            <div class="card-title"><span class="badge event">EVENT</span> ${escapeHtml(e.title)}</div>
            <div class="card-meta">${fmtDateShort(e.timestamp)} · → ${c ? shortSha(c.id) : '?'}</div>
          </div>`;
      }).join('');
    }

    if (selected) {
      const c = getCommit(selected.commitId);
      html += `
        <div class="section-title">Edit Event</div>
        <div class="detail-block">
          <div class="kv"><span class="k">Linked</span><span class="v">${c ? shortSha(c.id) + ' — ' + escapeHtml(c.message) : 'missing'}</span></div>
        </div>
        <label>Title</label>
        <input id="ev-title" value="${escapeHtml(selected.title)}" />
        <label>Description</label>
        <textarea id="ev-desc">${escapeHtml(selected.description || '')}</textarea>
        <label>Timestamp</label>
        <input id="ev-ts" type="datetime-local" value="${toLocalInputValue(selected.timestamp)}" />
        <label>Associated Commit</label>
        <select id="ev-commit">
          ${state.commits.slice().sort((a,b)=>(a.timestamp||0)-(b.timestamp||0)).map(c =>
            `<option value="${c.id}" ${c.id === selected.commitId ? 'selected' : ''}>${shortSha(c.id)} — ${escapeHtml(truncate(c.message, 40))}</option>`
          ).join('')}
        </select>
        <div class="form-actions">
          <button class="primary" id="btn-save-event">Save Event</button>
          <button class="danger" id="btn-del-event">Delete</button>
          <button id="btn-focus-commit">Focus Commit</button>
        </div>
      `;
    }

    body.innerHTML = html;

    body.querySelectorAll('[data-eid]').forEach(el => {
      el.addEventListener('click', () => {
        const e = state.events.find(x => x.id === el.dataset.eid);
        if (!e) return;
        state.selectedEventId = e.id;
        state.selectedCommitId = e.commitId;
        fullRender(false);
      });
    });

    body.querySelector('#btn-save-event')?.addEventListener('click', () => {
      updateEvent(selected.id, {
        title: body.querySelector('#ev-title').value,
        description: body.querySelector('#ev-desc').value,
        timestamp: fromLocalInputValue(body.querySelector('#ev-ts').value),
        commitId: body.querySelector('#ev-commit').value,
      });
      state.selectedCommitId = body.querySelector('#ev-commit').value;
    });
    body.querySelector('#btn-del-event')?.addEventListener('click', () => {
      if (confirm('Delete this event?')) deleteEvent(selected.id);
    });
    body.querySelector('#btn-focus-commit')?.addEventListener('click', () => {
      state.selectedCommitId = selected.commitId;
      state.rightTab = 'detail';
      fullRender(false);
    });
  }

  if (tab === 'create') {
    const commitOpts = state.commits
      .slice()
      .sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0))
      .map(c => `<option value="${c.id}">${shortSha(c.id)} — ${escapeHtml(truncate(c.message, 42))}</option>`)
      .join('');

    const headCommit = state.head && getBranch(state.head)
      ? getBranch(state.head).commitId
      : (state.commits[state.commits.length - 1]?.id || '');

    body.innerHTML = `
      <div class="section-title">New Commit</div>
      <label>Message</label>
      <input id="new-msg" placeholder="What changed?" />
      <label>Author</label>
      <input id="new-author" placeholder="Developer" value="Developer" />
      <label>Timestamp</label>
      <input id="new-ts" type="datetime-local" value="${toLocalInputValue()}" />
      <label>Parent commit(s)</label>
      <select id="new-parent" multiple size="4" style="margin-bottom:10px">
        ${commitOpts}
      </select>
      <p class="help-text" style="margin-top:-6px">Hold Ctrl/Cmd to select multiple parents for a merge commit. Defaults to HEAD tip.</p>
      <div class="form-actions">
        <button class="primary" id="btn-add-commit">Create Commit</button>
      </div>

      <div class="section-title">New Branch</div>
      <label>Name</label>
      <input id="new-branch-name" placeholder="feature/…
" />
      <label>Points to commit</label>
      <select id="new-branch-commit">${commitOpts}</select>
      <div class="form-actions">
        <button class="primary" id="btn-add-branch">Create Branch</button>
      </div>

      <div class="section-title">New Tag</div>
      <label>Name</label>
      <input id="new-tag-name" placeholder="v1.2.0" />
      <label>Commit</label>
      <select id="new-tag-commit">${commitOpts}</select>
      <div class="form-actions">
        <button class="primary" id="btn-add-tag">Create Tag</button>
      </div>

      <div class="section-title">New Timeline Event</div>
      <label>Title</label>
      <input id="new-ev-title" placeholder="Code Freeze" />
      <label>Description</label>
      <textarea id="new-ev-desc" placeholder="What happened in the real world?"></textarea>
      <label>Timestamp</label>
      <input id="new-ev-ts" type="datetime-local" value="${toLocalInputValue()}" />
      <label>Associate with commit</label>
      <select id="new-ev-commit">${commitOpts}</select>
      <div class="form-actions">
        <button class="primary" id="btn-add-event">Create Event</button>
      </div>
    `;

    // preselect head tip
    const parentSel = body.querySelector('#new-parent');
    if (headCommit && parentSel) {
      [...parentSel.options].forEach(o => { o.selected = o.value === headCommit; });
    }
    ['new-branch-commit', 'new-tag-commit', 'new-ev-commit'].forEach(id => {
      const sel = body.querySelector('#' + id);
      if (sel && (state.selectedCommitId || headCommit)) {
        sel.value = state.selectedCommitId || headCommit;
      }
    });

    body.querySelector('#btn-add-commit').addEventListener('click', () => {
      const sel = body.querySelector('#new-parent');
      let parents = [...sel.selectedOptions].map(o => o.value);
      if (!parents.length && headCommit) parents = [headCommit];
      if (!parents.length && state.commits.length) {
        toast('Select at least one parent (or start with Load Sample)', 'err');
        // allow root if empty repo
        if (state.commits.length) return;
      }
      createCommit({
        message: body.querySelector('#new-msg').value || 'Update',
        author: body.querySelector('#new-author').value || 'Developer',
        timestamp: fromLocalInputValue(body.querySelector('#new-ts').value),
        parents,
      });
      body.querySelector('#new-msg').value = '';
    });

    body.querySelector('#btn-add-branch').addEventListener('click', () => {
      createBranch(
        body.querySelector('#new-branch-name').value,
        body.querySelector('#new-branch-commit').value
      );
      body.querySelector('#new-branch-name').value = '';
    });

    body.querySelector('#btn-add-tag').addEventListener('click', () => {
      createTag(
        body.querySelector('#new-tag-name').value,
        body.querySelector('#new-tag-commit').value
      );
      body.querySelector('#new-tag-name').value = '';
    });

    body.querySelector('#btn-add-event').addEventListener('click', () => {
      createEvent({
        title: body.querySelector('#new-ev-title').value,
        description: body.querySelector('#new-ev-desc').value,
        timestamp: fromLocalInputValue(body.querySelector('#new-ev-ts').value),
        commitId: body.querySelector('#new-ev-commit').value,
      });
      body.querySelector('#new-ev-title').value = '';
      body.querySelector('#new-ev-desc').value = '';
    });
  }
}

function updateStatus() {
  document.getElementById('stat-commits').textContent = state.commits.length + ' commits';
  document.getElementById('stat-branches').textContent = state.branches.length + ' branches';
  document.getElementById('stat-events').textContent = state.events.length + ' events';
  const sel = state.selectedCommitId
    ? 'Selected ' + shortSha(state.selectedCommitId) + (state.selectedEventId ? ' + event' : '')
    : 'No selection';
  document.getElementById('stat-selection').textContent = sel;
}

function fullRender(refit = false) {
  renderGraph();
  renderTimeline();
  renderLeftPanel();
  renderRightPanel();
  updateStatus();
  if (refit) requestAnimationFrame(() => fitGraph());
}

// ——— Import / Export ———
function exportData() {
  const data = cloneState();
  const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = 'git-timeline-data.json';
  a.click();
  URL.revokeObjectURL(a.href);
  toast('Exported JSON');
}

function openImport() {
  document.getElementById('import-modal').classList.add('open');
  document.getElementById('import-json').value = '';
}

function closeImport() {
  document.getElementById('import-modal').classList.remove('open');
}

function applyImport() {
  const raw = document.getElementById('import-json').value.trim();
  if (!raw) { toast('Paste JSON first', 'err'); return; }
  try {
    const data = JSON.parse(raw);
    // basic validation
    if (data.commits && !Array.isArray(data.commits)) throw new Error('commits must be array');
    if (data.events && !Array.isArray(data.events)) throw new Error('events must be array');
    applyData({
      commits: data.commits || [],
      branches: data.branches || [],
      tags: data.tags || [],
      events: data.events || [],
      head: data.head ?? null,
    }, true);
    closeImport();
    toast('Import successful');
    requestAnimationFrame(() => fitGraph());
  } catch (e) {
    toast('Import failed: ' + e.message, 'err');
  }
}

// ——— Wiring ———
function bindUI() {
  // Left tabs
  document.querySelectorAll('.panel.left .panel-tabs button').forEach(btn => {
    btn.addEventListener('click', () => {
      state.leftTab = btn.dataset.tab;
      document.querySelectorAll('.panel.left .panel-tabs button').forEach(b => {
        b.classList.toggle('active', b === btn);
      });
      renderLeftPanel();
    });
  });

  // Right tabs
  document.querySelectorAll('.panel.right .panel-tabs button').forEach(btn => {
    btn.addEventListener('click', () => {
      state.rightTab = btn.dataset.rtab;
      renderRightPanel();
    });
  });

  // Layout toggle
  document.querySelectorAll('#layout-toggle button').forEach(btn => {
    btn.addEventListener('click', () => {
      state.layout = btn.dataset.layout;
      document.querySelectorAll('#layout-toggle button').forEach(b => {
        b.classList.toggle('active', b === btn);
      });
      renderGraph();
      requestAnimationFrame(() => fitGraph());
    });
  });

  document.getElementById('btn-zoom-fit').addEventListener('click', fitGraph);
  document.getElementById('btn-zoom-in').addEventListener('click', () => {
    d3.select('#graph-svg').transition().duration(200).call(zoomBehavior.scaleBy, 1.3);
  });
  document.getElementById('btn-zoom-out').addEventListener('click', () => {
    d3.select('#graph-svg').transition().duration(200).call(zoomBehavior.scaleBy, 0.7);
  });

  document.getElementById('chk-events').addEventListener('change', (e) => {
    state.showEvents = e.target.checked;
    renderGraph();
  });
  document.getElementById('chk-labels').addEventListener('change', (e) => {
    state.showLabels = e.target.checked;
    renderGraph();
  });
  document.getElementById('chk-timeline').addEventListener('change', (e) => {
    state.showTimeline = e.target.checked;
    renderTimeline();
    // reflow graph
    setTimeout(() => { renderGraph(); }, 50);
  });
  document.getElementById('btn-toggle-timeline').addEventListener('click', () => {
    state.showTimeline = !state.showTimeline;
    document.getElementById('chk-timeline').checked = state.showTimeline;
    document.getElementById('btn-toggle-timeline').textContent = state.showTimeline ? 'Collapse' : 'Expand';
    renderTimeline();
  });

  document.getElementById('btn-sample').addEventListener('click', () => {
    if (state.commits.length && !confirm('Replace current data with the sample project?')) return;
    loadSample();
    requestAnimationFrame(() => fitGraph());
  });
  document.getElementById('btn-export').addEventListener('click', exportData);
  document.getElementById('btn-import').addEventListener('click', openImport);
  document.getElementById('import-close').addEventListener('click', closeImport);
  document.getElementById('import-cancel').addEventListener('click', closeImport);
  document.getElementById('import-apply').addEventListener('click', applyImport);
  document.getElementById('btn-reset').addEventListener('click', () => {
    if (!confirm('Clear all data? This cannot be undone.')) return;
    applyData({ commits: [], branches: [], tags: [], events: [], head: null }, true);
    toast('Workspace cleared', 'err');
  });

  window.addEventListener('resize', () => {
    renderTimeline();
  });

  // Keyboard
  window.addEventListener('keydown', (e) => {
    if (e.key === 'Escape') {
      closeImport();
      state.selectedCommitId = null;
      state.selectedEventId = null;
      fullRender(false);
    }
    if ((e.metaKey || e.ctrlKey) && e.key === 's') {
      e.preventDefault();
      save();
      toast('Saved to localStorage');
    }
  });
}

// ——— Boot ———
function boot() {
  bindUI();
  initSvg();
  const ok = load();
  if (!ok || !state.commits.length) {
    loadSample();
  } else {
    fullRender();
  }
  requestAnimationFrame(() => {
    requestAnimationFrame(() => fitGraph());
  });
}

boot();
</script>
</body>
</html>