$sloprun.dev

Commit Graph Playground

demo sandbox: allow-scripts · csp: default-src 'none'

sandboxed and isolated in your browser · never enter a real password in a demo

▶ 9 ran · ✓ 0 worked
i ran it — no login needed:
share: preview embed ↗
post it anywhere:
email the card:

0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously

Git is usually taught as a list of commands to memorise, which is why "merge" feels risky — you cannot picture what it is about to do. This draws the picture: a live commit graph driven by commit, branch, checkout and merge buttons, with every action narrated in plain English beside the command that caused it. It opens on a small website's history — gallery already merged back into main, dark-mode still off on its own while main has moved on — so there is something to read the second it loads. Press Play the feature-branch story for the whole branch → work → merge loop in nine steps, then merge dark-mode yourself and watch the two coloured lanes tie together into a filled dot with two lines running in.

Embed this demo — put a runnable demo in your blog post

Paste this where you write. It renders a live, runnable demo inline anywhere raw HTML / iframes are allowed — Ghost, WordPress, Notion, Discourse, your own site.

<iframe src="https://sloprun.dev/embed/git-graph" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Commit Graph Playground — a runnable demo on sloprun.dev"></iframe>
preview ↗

On Medium and dev.to the plain link becomes a rich preview card that links back here — they don't run third-party iframes, so paste the URL there and the reader clicks through to run it. https://sloprun.dev/p/git-graph

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 1182 lines 54.6 KB index.html
demos/git-graph/index.html
<!doctype html>
<html lang="en" data-theme="">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Commit Graph Playground</title>
<style>
/* ── sloprun design tokens (verbatim from demos/tokens.css) ───────────────── */
:root {
  --bg: #F6F6F3;          /* neutral paper, not cream */
  --surface: #FFFFFF;
  --ink: #1A1D21;
  --muted: #5A6068;
  --line: #E3E2DC;
  --accent: #C05600;      /* phosphor amber, darkened for light bg */
  --accent-soft: #FFF3E6;
  --run: #1A7F37;         /* earned green */
  --run-soft: #E7F4EA;
  --danger: #C93C37;
  --radius: 10px;
  --font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
  --font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
    --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
    --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
  }
}
:root[data-theme="dark"] {
  --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
  --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
  --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }
/* ── end token block ──────────────────────────────────────────────────────── */

/* lane colours + form control scheme (theme-aware, additive to the tokens) */
:root {
  color-scheme: light;
  --l0: #C05600; --l1: #2563EB; --l2: #7C3AED;
  --l3: #C026D3; --l4: #0E7490; --l5: #9A3412;
  --danger-soft: #FBEBEA;
  --surface-t: rgba(255,255,255,0);   /* --surface at zero alpha, for the edge fade */
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    color-scheme: dark;
    --l0: #FFB454; --l1: #7AA7FF; --l2: #C4A2FF;
    --l3: #FF8CC8; --l4: #5AD1E6; --l5: #E0A458;
    --danger-soft: #2C1A19;
    --surface-t: rgba(28,31,38,0);
  }
}
:root[data-theme="dark"] {
  color-scheme: dark;
  --l0: #FFB454; --l1: #7AA7FF; --l2: #C4A2FF;
  --l3: #FF8CC8; --l4: #5AD1E6; --l5: #E0A458;
  --danger-soft: #2C1A19;
  --surface-t: rgba(28,31,38,0);
}
:root[data-theme="light"] { color-scheme: light; --surface-t: rgba(255,255,255,0); }

*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body { margin: 0; min-height: 100vh; overflow-x: hidden; }
.sr { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden;
      clip:rect(0 0 0 0); white-space:nowrap; border:0; }

.wrap { max-width: 1180px; margin: 0 auto; padding: 20px 16px 26px; }
header h1 { margin: 0 0 5px; font-size: 19px; font-weight: 650; letter-spacing: -.01em; }
header p  { margin: 0; font-size: 13.5px; line-height: 1.55; color: var(--muted); max-width: 72ch; }

/* ── status strip ─────────────────────────────────────────────────────────── */
.strip { display:flex; flex-wrap:wrap; align-items:center; gap:7px 14px; margin-top: 14px;
  font: 500 11.5px var(--font-mono); color: var(--muted);
  border: 1px solid var(--line); background: var(--surface);
  border-radius: var(--radius); padding: 9px 12px; }
.strip b { color: var(--ink); font-weight: 650; }
.strip .hd { color: var(--accent); }
.strip .det { color: var(--danger); }
.strip .sep { width:1px; height:13px; background: var(--line); }

/* ── controls ─────────────────────────────────────────────────────────────── */
.bar { display:flex; flex-wrap:wrap; align-items:center; gap:8px; margin:12px 0 0; }
.grp { display:flex; align-items:center; gap:6px; flex: 0 1 auto; min-width: 0; }
.grp.wide { flex: 1 1 300px; }
.btn { font: 600 13px/1 var(--font-sans); color: var(--ink); background: var(--surface);
  border: 1px solid var(--line); border-radius: 8px; padding: 9px 12px;
  display: inline-flex; align-items: center; gap: 6px; cursor: pointer; white-space: nowrap;
  transition: border-color .12s, color .12s, background .12s; }
.btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.btn:disabled { opacity: .45; cursor: not-allowed; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--bg); }
.btn.primary:hover:not(:disabled) { filter: brightness(1.08); color: var(--bg); }
.btn.stop { background: var(--danger); border-color: var(--danger); color: var(--surface); }
.btn.stop:hover:not(:disabled) { color: var(--surface); filter: brightness(1.08); }
.btn.sm { padding: 6px 8px; font-size: 12px; }
.btn .gl { font: 600 11px var(--font-mono); line-height: 1; }
input[type=text], select { font: 500 13px var(--font-mono); color: var(--ink);
  background: var(--surface); border: 1px solid var(--line); border-radius: 8px;
  padding: 9px 10px; min-width: 0; max-width: 100%; }
input[type=text] { flex: 1 1 120px; }
select { flex: 0 1 auto; cursor: pointer; }
input[type=text]::placeholder { color: var(--muted); opacity: .85; }
input:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent); }
input.bad { border-color: var(--danger); }
@keyframes shake { 0%,100%{transform:translateX(0)} 25%{transform:translateX(-4px)} 75%{transform:translateX(4px)} }
input.shake { animation: shake .22s linear 2; }

/* ── note banner ──────────────────────────────────────────────────────────── */
.note { margin-top: 10px; border: 1px solid var(--line); border-left-width: 3px;
  border-left-color: var(--accent); background: var(--surface); border-radius: 8px;
  padding: 9px 12px; font-size: 13.5px; line-height: 1.5; display: flex; gap: 9px; align-items: baseline; }
.note[data-k="ok"]  { border-left-color: var(--run);    background: var(--run-soft); }
.note[data-k="err"] { border-left-color: var(--danger); background: var(--danger-soft); }
.note .tag { font: 650 10.5px var(--font-mono); letter-spacing: .09em; text-transform: uppercase;
  color: var(--accent); border: 1px solid var(--accent); background: var(--bg);
  border-radius: 999px; padding: 2px 7px; white-space: nowrap; flex: none; }
.note[data-k="ok"] .tag  { color: var(--run); border-color: var(--run); }
.note[data-k="err"] .tag { color: var(--danger); border-color: var(--danger); }
.note p { margin: 0; }
code { font: 500 12px var(--font-mono); background: var(--bg); border: 1px solid var(--line);
  border-radius: 5px; padding: 1px 5px; white-space: nowrap; }

/* ── stage ────────────────────────────────────────────────────────────────── */
.stage { display: grid; grid-template-columns: minmax(0,1fr) 306px; gap: 12px; margin-top: 12px; align-items: start; }
@media (max-width: 940px) { .stage { grid-template-columns: minmax(0,1fr); } }
.panel { border: 1px solid var(--line); background: var(--surface); border-radius: var(--radius); overflow: hidden; }
.phd { display:flex; flex-wrap:wrap; align-items:baseline; gap: 4px 10px; padding: 9px 12px;
  border-bottom: 1px solid var(--line); }
.phd h2 { margin:0; font: 650 11px var(--font-mono); letter-spacing:.1em; text-transform: uppercase; color: var(--muted); }
.phd .hint { font-size: 12px; color: var(--muted); }
.side { display: grid; gap: 12px; }

.cwrap { position: relative; }
.canvas { overflow: auto; overscroll-behavior: contain; background: var(--surface);
  min-height: 190px; max-height: clamp(320px, 62vh, 470px); }
/* soft edge, so a message running off the right reads as "there is more that way" */
.fade { position: absolute; top: 0; right: 0; bottom: 0; width: 46px; pointer-events: none;
  opacity: 0; transition: opacity .18s ease;
  background: linear-gradient(to right, var(--surface-t), var(--surface) 82%); }
.fade.on { opacity: 1; }
svg.board { display: block; min-width: 100%; }
.canvas:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }

/* ── graph pieces ─────────────────────────────────────────────────────────── */
.c0 { --lc: var(--l0); } .c1 { --lc: var(--l1); } .c2 { --lc: var(--l2); }
.c3 { --lc: var(--l3); } .c4 { --lc: var(--l4); } .c5 { --lc: var(--l5); }
.edge { fill: none; stroke: var(--lc); stroke-width: 2; stroke-linecap: round; }
.row { cursor: pointer; }
.rbg { fill: transparent; }
.row:hover .rbg { fill: var(--bg); }
.frect { fill: none; stroke: var(--accent); stroke-width: 2; rx: 7; opacity: 0; }
.row:focus { outline: none; }
.row:focus-visible .frect { opacity: 1; }
.dot { fill: var(--surface); stroke: var(--lc); stroke-width: 2.4;
  transform-box: fill-box; transform-origin: center; }
.dot.merge { fill: var(--lc); }
.core { fill: var(--lc); transform-box: fill-box; transform-origin: center; }
.core.hide { display: none; }
.headring { fill: none; stroke: var(--accent); stroke-width: 1.6; stroke-dasharray: 3 3.6; opacity: .95;
  transform-box: fill-box; transform-origin: center; }
.sha { font: 500 11.5px var(--font-mono); fill: var(--muted); }
.row.is-head .sha { fill: var(--ink); }
.msg { font: 400 13px var(--font-sans); fill: var(--ink); }
.row.is-head .msg { font-weight: 600; }
.chip rect { fill: var(--surface); stroke: var(--lc); stroke-width: 1.2; }
.chip text { font: 600 10.5px var(--font-mono); fill: var(--lc); }
.chip.head rect { fill: var(--accent-soft); stroke: var(--accent); }
.chip.head text { fill: var(--accent); }
.chip.det rect { fill: var(--accent); stroke: var(--accent); }
.chip.det text { fill: var(--bg); }
.chip.click { cursor: pointer; }
.chip.click:hover rect { stroke-width: 2.2; }
.node, .row { transition: transform .34s cubic-bezier(.3,.9,.3,1); }
@keyframes pop { from { opacity:0; transform: scale(.2); } to { opacity:1; transform: scale(1); } }
@keyframes fade { from { opacity:0 } to { opacity:1 } }
.row.enter .dot, .row.enter .core { animation: pop .36s cubic-bezier(.2,.9,.3,1.35) both; }
.row.enter text, .row.enter .chip { animation: fade .34s .06s both; }
@keyframes pulse { 0%,100% { opacity:.95; transform: scale(1) } 50% { opacity:.4; transform: scale(1.14) } }
.headring.live { animation: pulse 2.4s ease-in-out infinite; }

/* ── branch list ──────────────────────────────────────────────────────────── */
ul.brs { list-style: none; margin: 0; padding: 6px; display: grid; grid-template-columns: minmax(0,1fr); gap: 4px; }
ul.brs li { display: flex; align-items: center; gap: 7px; padding: 5px 6px; border-radius: 7px; }
ul.brs li.cur { background: var(--accent-soft); }
ul.brs .nm { font: 600 12px var(--font-mono); color: var(--lc); overflow: hidden;
  text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
ul.brs li.cur .nm { color: var(--accent); }
ul.brs .tip { font: 500 11px var(--font-mono); color: var(--muted); margin-left: auto; flex: none; }
ul.brs .mk { font: 650 9.5px var(--font-mono); letter-spacing:.08em; color: var(--accent);
  border: 1px solid var(--accent); border-radius: 999px; padding: 1px 5px; flex: none; }
ul.brs .acts { display: flex; gap: 3px; flex: none; }
ul.brs .ic { width: 30px; height: 30px; display:grid; place-items:center; padding: 0;
  font: 600 12px var(--font-mono); }

/* ── log ──────────────────────────────────────────────────────────────────── */
.logwrap { max-height: 300px; overflow: auto; overscroll-behavior: contain; }
@media (max-width: 940px) { .logwrap { max-height: 260px; } }
ol.log { list-style: none; margin: 0; padding: 8px; display: grid; gap: 7px; }
ol.log li { border-left: 2px solid var(--line); padding: 1px 0 1px 9px; }
ol.log li .cmd { display: block; font: 600 11.5px var(--font-mono); color: var(--muted);
  background: none; border: 0; padding: 0; white-space: normal; word-break: break-word; }
ol.log li .txt { display: block; margin-top: 2px; font-size: 12.5px; line-height: 1.45; color: var(--ink); }
ol.log li .txt code { font-size: 11.5px; }
ol.log li .n { font: 500 10.5px var(--font-mono); color: var(--muted); opacity: .8; }
ol.log li[data-k="info"] { border-left-color: var(--accent); }
ol.log li[data-k="info"] .cmd { color: var(--accent); }
ol.log li[data-k="ok"]   { border-left-color: var(--run); }
ol.log li[data-k="ok"] .cmd { color: var(--run); }
ol.log li[data-k="err"]  { border-left-color: var(--danger); }
ol.log li[data-k="err"] .cmd { color: var(--danger); }

.legend { display:flex; flex-wrap:wrap; gap: 4px 12px; padding: 8px 12px; border-top: 1px solid var(--line);
  font: 500 11px var(--font-mono); color: var(--muted); }
.legend span { display: inline-flex; align-items: center; gap: 5px; }
.legend i { width: 11px; height: 11px; border-radius: 50%; border: 2.2px solid var(--accent); display:block; }
.legend i.m { background: var(--accent); border-color: var(--accent); }
.legend i.h { border-style: dashed; border-width: 1.6px; }
.legend .swipe { color: var(--accent); }
.legend .swipe[hidden] { display: none; }

footer { margin-top: 16px; font: 500 11.5px var(--font-mono); color: var(--muted); opacity: .85; }

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation-duration: .001ms !important; animation-iteration-count: 1 !important;
    transition-duration: .001ms !important; scroll-behavior: auto !important; }
}
</style>
</head>
<body>
<div class="wrap">
  <header>
    <h1>Commit Graph Playground</h1>
    <p>A commit is a saved snapshot that remembers the one before it, so a project's history is a
      picture, not a line. Press the buttons and watch the picture change — nothing here touches a
      real repository.</p>
  </header>

  <div class="strip">
    <span>HEAD <b id="sHead" class="hd">main</b></span>
    <i class="sep" aria-hidden="true"></i>
    <span>at <b id="sSha">0000000</b></span>
    <i class="sep" aria-hidden="true"></i>
    <span>commits <b id="sN">0</b></span>
    <i class="sep" aria-hidden="true"></i>
    <span>branches <b id="sB">0</b></span>
    <i class="sep" aria-hidden="true"></i>
    <span>lines of work <b id="sL">0</b></span>
  </div>

  <div class="bar">
    <div class="grp wide">
      <label class="sr" for="msg">Commit message</label>
      <input id="msg" type="text" maxlength="48" autocomplete="off" spellcheck="false" placeholder="fix the footer spacing">
      <button id="bCommit" class="btn primary"><span class="gl" aria-hidden="true">●</span>Commit</button>
    </div>
    <div class="grp wide">
      <label class="sr" for="bname">New branch name</label>
      <input id="bname" type="text" maxlength="28" autocomplete="off" spellcheck="false" placeholder="branch name">
      <button id="bBranch" class="btn"><span class="gl" aria-hidden="true">⑂</span>Branch</button>
    </div>
  </div>
  <div class="bar">
    <div class="grp">
      <label class="sr" for="coSel">Branch to check out</label>
      <select id="coSel"></select>
      <button id="bCheckout" class="btn"><span class="gl" aria-hidden="true">⇢</span>Checkout</button>
    </div>
    <div class="grp">
      <label class="sr" for="mgSel">Branch to merge in</label>
      <select id="mgSel"></select>
      <button id="bMerge" class="btn"><span class="gl" aria-hidden="true">⑃</span><span id="mgLabel">Merge into main</span></button>
    </div>
  </div>
  <div class="bar">
    <button id="bTour" class="btn primary"><span class="gl" aria-hidden="true">▶</span><span id="tourLabel">Play the feature-branch story</span></button>
    <button id="bUndo" class="btn sm">Undo</button>
    <button id="bReset" class="btn sm">Reset</button>
  </div>

  <div class="note" id="note" data-k="info" role="status" aria-live="polite">
    <span class="tag" id="noteTag">ready</span>
    <p id="noteTxt">Loaded an example history.</p>
  </div>

  <div class="stage">
    <section class="panel">
      <div class="phd">
        <h2>History</h2>
        <span class="hint">oldest at the top · newer work grows downward · click a dot to check it out</span>
      </div>
      <div class="cwrap">
      <div class="canvas" id="canvas" tabindex="0" role="region"
           aria-label="Commit graph. Scroll with the arrow keys, or Tab into it to step through the commits.">
        <svg class="board" id="board" role="group" aria-label="Commit graph" preserveAspectRatio="xMinYMin meet">
          <g id="elayer"></g>
          <g id="nlayer"></g>
        </svg>
      </div>
      <div class="fade" id="fade" aria-hidden="true"></div>
      </div>
      <div class="legend">
        <span><i aria-hidden="true"></i> commit</span>
        <span><i class="m" aria-hidden="true"></i> merge commit</span>
        <span><i class="h" aria-hidden="true"></i> where HEAD is</span>
        <span>each colour = one line of work</span>
        <span class="swipe" id="swipe" hidden>scroll the history sideways for more →</span>
      </div>
    </section>

    <aside class="side">
      <section class="panel">
        <div class="phd"><h2>Branches</h2><span class="hint">a branch is just a sticky note on a commit</span></div>
        <ul class="brs" id="brs"></ul>
      </section>
      <section class="panel">
        <div class="phd"><h2>What happened</h2><span class="hint">newest first</span></div>
        <div class="logwrap"><ol class="log" id="log"></ol></div>
      </section>
    </aside>
  </div>

  <footer>demo · sloprun.dev</footer>
</div>
<script>
(function () {
"use strict";

/* ── theme bridge ───────────────────────────────────────────────────────── */
addEventListener("message", function (e) {
  var d = e && e.data;
  if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
    document.documentElement.setAttribute("data-theme", d.theme);
  }
});

var SVGNS = "http://www.w3.org/2000/svg";
var $ = function (id) { return document.getElementById(id); };
var mqReduce = matchMedia("(prefers-reduced-motion: reduce)");
function reduced() { return mqReduce.matches; }

/* ── geometry ───────────────────────────────────────────────────────────── */
var PADL = 16, LANEW = 26, ROWH = 44, PADT = 26, R = 6.7, GAPX = 12;
function laneX(l) { return PADL + l * LANEW + LANEW / 2; }
function rowY(r) { return PADT + r * ROWH; }

/* ── everyday content pools (no jargon, nothing official) ───────────────── */
var MSGS = [
  "fix the footer spacing", "add a photo to the about page", "make the buttons rounder",
  "tidy up the stylesheet", "fix a typo in the menu", "shrink the header image",
  "add a contact form", "speed up the home page", "pick a warmer orange",
  "write the readme", "fix the layout on phones", "add a hover state to links",
  "delete a file nobody used", "rename a confusing variable", "add a loading spinner"
];
var BRNAMES = [
  "dark-mode", "new-logo", "faster-search", "fix-signup", "tidy-css",
  "mobile-menu", "photo-captions", "contact-form", "spell-check", "rounder-buttons"
];

/* ── state ──────────────────────────────────────────────────────────────── */
var state = null, undoStack = [], els = {}, edges = {}, lanes = {}, laneCount = 1;
var board = $("board"), elayer = $("elayer"), nlayer = $("nlayer"), canvas = $("canvas");
var busy = false, focusRow = 0;

function fresh() {
  return {
    commits: [], branches: [], head: { type: "branch", ref: "main", line: null },
    seq: 0, seed: 20250813, msgI: 0, brI: 0, step: 0
  };
}
function rnd() { state.seed = (state.seed * 1664525 + 1013904223) >>> 0; return state.seed / 4294967296; }
function newSha() {
  var h = "0123456789abcdef", s = "";
  for (var i = 0; i < 7; i++) s += h.charAt(Math.floor(rnd() * 16));
  return s;
}
function newLine() { return state.seq++; }
function byId(id) {
  for (var i = 0; i < state.commits.length; i++) if (state.commits[i].id === id) return state.commits[i];
  return null;
}
function brOf(name) {
  for (var i = 0; i < state.branches.length; i++) if (state.branches[i].name === name) return state.branches[i];
  return null;
}
function headId() { return state.head.type === "branch" ? (brOf(state.head.ref) || {}).tip : state.head.ref; }
function isAncestor(a, b) {                    /* is commit a an ancestor of (or equal to) b? */
  if (!a || !b) return false;
  var seen = {}, stack = [b];
  while (stack.length) {
    var id = stack.pop();
    if (id === a) return true;
    if (seen[id]) continue;
    seen[id] = 1;
    var c = byId(id);
    if (c) for (var i = 0; i < c.parents.length; i++) stack.push(c.parents[i]);
  }
  return false;
}
function branchesAt(id) {
  var out = [];
  for (var i = 0; i < state.branches.length; i++) if (state.branches[i].tip === id) out.push(state.branches[i]);
  return out;
}

/* ── mutations (pure model) ─────────────────────────────────────────────── */
function mkCommit(msg, second) {
  var p = headId(), line;
  if (state.head.type === "branch") line = brOf(state.head.ref).line;
  else { if (state.head.line == null) state.head.line = newLine(); line = state.head.line; }
  var par = [];
  if (p) par.push(p);
  if (second) par.push(second);
  var c = { id: newSha(), msg: msg, parents: par, line: line };
  state.commits.push(c);
  if (state.head.type === "branch") brOf(state.head.ref).tip = c.id;
  else state.head.ref = c.id;
  return c;
}

/* ── layout: pack lines of work into lanes ──────────────────────────────── */
function relayout() {
  var i, c;
  for (i = 0; i < state.commits.length; i++) state.commits[i].row = i;
  var spans = {}, order = [];
  for (i = 0; i < state.commits.length; i++) {
    c = state.commits[i];
    if (!spans[c.line]) { spans[c.line] = { min: c.row, max: c.row }; order.push(c.line); }
    else spans[c.line].max = c.row;
  }
  var alive = {};
  for (i = 0; i < state.branches.length; i++) alive[state.branches[i].line] = 1;
  if (state.head.type === "commit" && state.head.line != null) alive[state.head.line] = 1;
  order.sort(function (a, b) { return a - b; });
  var ends = [];
  lanes = {};
  for (i = 0; i < order.length; i++) {
    var L = order[i], s = spans[L], k = 0;
    while (k < ends.length && ends[k] >= s.min) k++;
    lanes[L] = k;
    ends[k] = alive[L] ? Infinity : s.max;
  }
  laneCount = Math.max(ends.length, 1);
}

/* ── small DOM helpers ──────────────────────────────────────────────────── */
function svg(tag, attrs) {
  var e = document.createElementNS(SVGNS, tag);
  if (attrs) for (var k in attrs) if (attrs.hasOwnProperty(k)) e.setAttribute(k, attrs[k]);
  return e;
}
function el(tag, cls, txt) {
  var e = document.createElement(tag);
  if (cls) e.className = cls;
  if (txt != null) e.textContent = txt;
  return e;
}
/* renders "plain words with `mono bits`" safely, no innerHTML */
function rich(parent, str) {
  var parts = String(str).split("`");
  for (var i = 0; i < parts.length; i++) {
    if (!parts[i]) continue;
    if (i % 2) parent.appendChild(el("code", null, parts[i]));
    else parent.appendChild(document.createTextNode(parts[i]));
  }
  return parent;
}

/* ── the graph ──────────────────────────────────────────────────────────── */
function makeRow(c) {
  var g = svg("g", { class: "row", tabindex: "-1", role: "button" });
  var rbg = svg("rect", { class: "rbg", x: 2, y: -(ROWH / 2 - 3), height: ROWH - 6, rx: 7, width: 10 });
  var frect = svg("rect", { class: "frect", x: 2, y: -(ROWH / 2 - 3), height: ROWH - 6, rx: 7, width: 10 });
  var node = svg("g", { class: "node" });
  var ring = svg("circle", { class: "headring", r: 11.5, cx: 0, cy: 0 });
  var dot = svg("circle", { class: "dot", r: R, cx: 0, cy: 0 });
  var core = svg("circle", { class: "core", r: 2.4, cx: 0, cy: 0 });
  node.appendChild(ring); node.appendChild(dot); node.appendChild(core);
  var sha = svg("text", { class: "sha", y: 0, dy: ".35em" });
  var chips = svg("g", { class: "chips" });
  var msg = svg("text", { class: "msg", y: 0, dy: ".35em" });
  g.appendChild(rbg); g.appendChild(frect); g.appendChild(node);
  g.appendChild(sha); g.appendChild(chips); g.appendChild(msg);
  var o = { g: g, rbg: rbg, frect: frect, node: node, ring: ring, dot: dot, core: core, sha: sha, chips: chips, msg: msg, w: 0 };
  g.addEventListener("click", function () { if (!busy) clickCommit(c.id); });
  g.addEventListener("keydown", function (ev) {
    if (ev.key === "Enter" || ev.key === " " || ev.key === "Spacebar") { ev.preventDefault(); if (!busy) clickCommit(c.id); }
    else if (ev.key === "ArrowDown" || ev.key === "ArrowUp" || ev.key === "Home" || ev.key === "End") {
      ev.preventDefault();
      var n = state.commits.length, i = indexOfId(c.id), t;
      t = ev.key === "ArrowDown" ? Math.min(n - 1, i + 1) : ev.key === "ArrowUp" ? Math.max(0, i - 1) : ev.key === "Home" ? 0 : n - 1;
      focusRowAt(t);
    }
  });
  g.addEventListener("focus", function () { focusRow = indexOfId(c.id); });
  return o;
}
function indexOfId(id) {
  for (var i = 0; i < state.commits.length; i++) if (state.commits[i].id === id) return i;
  return 0;
}
function focusRowAt(i) {
  var c = state.commits[i];
  if (!c || !els[c.id]) return;
  focusRow = i;
  syncTabIndex();
  els[c.id].g.focus();
}
function syncTabIndex() {
  if (focusRow >= state.commits.length) focusRow = state.commits.length - 1;
  if (focusRow < 0) focusRow = 0;
  for (var i = 0; i < state.commits.length; i++) {
    var e = els[state.commits[i].id];
    if (e) e.g.setAttribute("tabindex", i === focusRow ? "0" : "-1");
  }
}

function chip(parent, text, cls, colorIdx, onClick) {
  var g = svg("g", { class: "chip " + cls + (colorIdx != null ? " c" + (colorIdx % 6) : "") });
  var r = svg("rect", { x: 0, y: -9, height: 18, rx: 5, width: 10 });
  var t = svg("text", { x: 7, y: 0, dy: ".35em" });
  t.textContent = text;
  g.appendChild(r); g.appendChild(t);
  parent.appendChild(g);
  var w = 14;
  try { w = t.getComputedTextLength() + 14; } catch (e) { w = 0; }
  if (w < 16) w = text.length * 6.6 + 14;
  r.setAttribute("width", Math.round(w));
  if (onClick) {
    g.setAttribute("class", g.getAttribute("class") + " click");
    g.addEventListener("click", function (ev) { ev.stopPropagation(); if (!busy) onClick(); });
  }
  return Math.round(w);
}

function updateRow(e, c) {
  var hid = headId();
  var isHead = c.id === hid;
  var x = laneX(lanes[c.line] || 0), y = rowY(c.row);
  e.g.style.transform = "translate(0px," + y + "px)";
  e.node.style.transform = "translate(" + x + "px,0px)";
  e.node.setAttribute("class", "node c" + (c.line % 6));
  e.dot.setAttribute("class", "dot" + (c.parents.length > 1 ? " merge" : ""));
  e.core.setAttribute("class", "core" + (c.parents.length > 1 ? " hide" : ""));
  e.ring.style.display = isHead ? "" : "none";
  e.ring.setAttribute("class", "headring" + (isHead && !reduced() ? " live" : ""));
  e.g.setAttribute("class", "row" + (isHead ? " is-head" : "") + (e.g.classList.contains("enter") ? " enter" : ""));

  var tx = PADL + laneCount * LANEW + GAPX;
  e.sha.setAttribute("x", tx);
  e.sha.textContent = c.id;
  var shaW = 0;
  try { shaW = e.sha.getComputedTextLength(); } catch (er) { shaW = 0; }
  if (!shaW) shaW = 48;

  while (e.chips.firstChild) e.chips.removeChild(e.chips.firstChild);
  var cx = tx + shaW + 10, used = 0;
  var here = branchesAt(c.id), i, b;
  var headBranch = state.head.type === "branch" ? state.head.ref : null;
  for (i = 0; i < here.length; i++) {
    b = here[i];
    var isCur = b.name === headBranch;
    var label = isCur ? "HEAD → " + b.name : b.name;
    var w = chip(e.chips, label, isCur ? "head" : "", b.line, (function (nm) {
      return function () { actCheckout(nm); };
    })(b.name));
    e.chips.lastChild.setAttribute("transform", "translate(" + (cx + used) + ",0)");
    used += w + 5;
  }
  if (state.head.type === "commit" && isHead) {
    var w2 = chip(e.chips, "HEAD", "det", null, null);
    e.chips.lastChild.setAttribute("transform", "translate(" + (cx + used) + ",0)");
    used += w2 + 5;
  }
  var mx = cx + used;
  e.msg.setAttribute("x", mx);
  e.msg.textContent = c.msg;
  var mw = 0;
  try { mw = e.msg.getComputedTextLength(); } catch (er2) { mw = 0; }
  if (!mw) mw = c.msg.length * 7;
  e.w = mx + mw + 16;

  var names = here.map(function (b2) { return b2.name; }).join(", ");
  e.g.setAttribute("aria-label",
    "commit " + c.id + ", " + c.msg +
    (names ? ", branch " + names : "") +
    (isHead ? ", HEAD is here" : "") +
    (c.parents.length > 1 ? ", merge commit" : "") +
    (names ? ". Press Enter to check out " + here[0].name + "."
           : ". Press Enter to move HEAD here, with no branch attached."));
}

function edgePath(x1, y1, x2, y2, fork) {
  if (x1 === x2) return "M" + x1 + "," + y1 + " L" + x2 + "," + y2;
  var span = y2 - y1;
  var b = Math.min(ROWH * 0.8, span * 0.85);
  if (fork) {                                  /* a split: bend away right below the parent */
    var t1 = y1 + b;
    return "M" + x1 + "," + y1 +
      " C" + x1 + "," + (y1 + b * 0.5).toFixed(1) +
      " " + x2 + "," + (t1 - b * 0.55).toFixed(1) +
      " " + x2 + "," + t1.toFixed(1) +
      " L" + x2 + "," + y2;
  }
  var t = y2 - b;                              /* a merge: bend in just above the child */
  return "M" + x1 + "," + y1 + " L" + x1 + "," + t.toFixed(1) +
    " C" + x1 + "," + (t + b * 0.55).toFixed(1) +
    " " + x2 + "," + (y2 - b * 0.5).toFixed(1) +
    " " + x2 + "," + y2;
}

function render(opts) {
  opts = opts || {};
  if (opts.rebuild) {
    while (nlayer.firstChild) nlayer.removeChild(nlayer.firstChild);
    while (elayer.firstChild) elayer.removeChild(elayer.firstChild);
    els = {}; edges = {};
  }
  relayout();

  var seen = {}, i, c, e;
  for (i = 0; i < state.commits.length; i++) {
    c = state.commits[i];
    seen[c.id] = 1;
    e = els[c.id];
    if (!e) {
      e = makeRow(c);
      els[c.id] = e;
      nlayer.appendChild(e.g);
      e.g.style.transition = "none"; e.node.style.transition = "none";
      (function (o) {
        requestAnimationFrame(function () { o.g.style.transition = ""; o.node.style.transition = ""; });
      })(e);
      if (!opts.rebuild && !reduced()) {
        e.g.classList.add("enter");
        (function (g) { setTimeout(function () { g.classList.remove("enter"); }, 460); })(e.g);
      }
    }
    updateRow(e, c);
  }
  for (var id in els) if (els.hasOwnProperty(id) && !seen[id]) { els[id].g.remove(); delete els[id]; }

  var W = PADL + laneCount * LANEW + 120;
  for (i = 0; i < state.commits.length; i++) { e = els[state.commits[i].id]; if (e.w > W) W = e.w; }
  for (i = 0; i < state.commits.length; i++) {
    e = els[state.commits[i].id];
    e.rbg.setAttribute("width", W - 4);
    e.frect.setAttribute("width", W - 4);
  }

  /* edges */
  var want = {};
  for (i = 0; i < state.commits.length; i++) {
    c = state.commits[i];
    for (var p = 0; p < c.parents.length; p++) {
      var par = byId(c.parents[p]);
      if (!par) continue;
      var key = par.id + ">" + c.id;
      want[key] = 1;
      var colorLine = (p === 0) ? c.line : par.line;
      var d = edgePath(laneX(lanes[par.line] || 0), rowY(par.row), laneX(lanes[c.line] || 0), rowY(c.row), p === 0);
      var pe = edges[key];
      if (!pe) {
        pe = svg("path", { class: "edge c" + (colorLine % 6), d: d });
        elayer.appendChild(pe);
        edges[key] = pe;
        if (!opts.rebuild && !reduced()) drawIn(pe);
      } else {
        pe.setAttribute("d", d);
        pe.setAttribute("class", "edge c" + (colorLine % 6));
      }
    }
  }
  for (var k in edges) if (edges.hasOwnProperty(k) && !want[k]) { edges[k].remove(); delete edges[k]; }

  var H = PADT * 2 + Math.max(0, state.commits.length - 1) * ROWH;
  board.setAttribute("width", Math.round(W));
  board.setAttribute("height", Math.round(H));
  board.setAttribute("viewBox", "0 0 " + Math.round(W) + " " + Math.round(H));

  paintSwipe();
  /* park the graph's single tab stop on HEAD, unless the user is already in there */
  if (!nlayer.contains(document.activeElement)) {
    var hc = byId(headId());
    if (hc) focusRow = indexOfId(hc.id);
  }
  syncTabIndex();
  paintStrip();
  paintBranches();
  paintSelects();
}

/* the sideways-scroll hint depends on the viewport, so it is recomputed on resize too */
function paintSwipe() {
  var over = canvas.scrollWidth > canvas.clientWidth + 4;
  $("swipe").hidden = !over;
  var more = over && canvas.scrollLeft + canvas.clientWidth < canvas.scrollWidth - 4;
  var f = $("fade");
  /* keep the fade off any scrollbars the platform draws inside the canvas */
  f.style.right = (canvas.offsetWidth - canvas.clientWidth) + "px";
  f.style.bottom = (canvas.offsetHeight - canvas.clientHeight) + "px";
  f.className = more ? "fade on" : "fade";
}

function drawIn(p) {
  var L;
  try { L = p.getTotalLength(); } catch (e) { return; }
  if (!L) return;
  p.style.strokeDasharray = L;
  p.style.strokeDashoffset = L;
  requestAnimationFrame(function () {
    p.style.transition = "stroke-dashoffset .36s ease-out";
    p.style.strokeDashoffset = "0";
  });
  setTimeout(function () { p.style.strokeDasharray = ""; p.style.strokeDashoffset = ""; p.style.transition = ""; }, 480);
}

/* keep the HEAD commit in view, but never yank a view that already shows it */
function scrollToHead(smooth) {
  var c = byId(headId());
  if (!c) return;
  var ch = canvas.clientHeight, cw = canvas.clientWidth;
  if (!ch || !cw) { requestAnimationFrame(function () { scrollToHead(false); }); return; }
  var y = rowY(c.row), x = laneX(lanes[c.line] || 0);
  var top = canvas.scrollTop, left = canvas.scrollLeft;
  if (y - ROWH < top) top = Math.max(0, y - ROWH);
  else if (y + ROWH > top + ch) top = y + ROWH - ch;
  if (x - 26 < left) left = Math.max(0, x - 26);
  else if (x + 30 > left + cw) left = x + 30 - cw;
  var beh = (smooth && !reduced()) ? "smooth" : "auto";
  if (canvas.scrollTo) canvas.scrollTo({ top: top, left: left, behavior: beh });
  else { canvas.scrollTop = top; canvas.scrollLeft = left; }
}

/* ── panels ─────────────────────────────────────────────────────────────── */
function paintStrip() {
  var hd = $("sHead");
  hd.textContent = state.head.type === "branch" ? state.head.ref : "detached";
  hd.className = state.head.type === "branch" ? "hd" : "det";
  $("sSha").textContent = headId() || "-------";
  $("sN").textContent = state.commits.length;
  $("sB").textContent = state.branches.length;
  $("sL").textContent = laneCount;
}

function paintBranches() {
  var ul = $("brs");
  while (ul.firstChild) ul.removeChild(ul.firstChild);
  for (var i = 0; i < state.branches.length; i++) {
    (function (b) {
      var cur = state.head.type === "branch" && state.head.ref === b.name;
      var li = el("li", cur ? "cur c" + (b.line % 6) : "c" + (b.line % 6));
      var nmEl = el("span", "nm", b.name);
      nmEl.title = b.name;            /* long names ellipsize — hover shows the whole thing */
      li.appendChild(nmEl);
      if (cur) li.appendChild(el("span", "mk", "HEAD"));
      li.appendChild(el("span", "tip", b.tip));
      var acts = el("div", "acts");
      var co = el("button", "btn sm ic", "⇢");
      co.type = "button";
      co.title = "Check out " + b.name;
      co.setAttribute("aria-label", "Check out branch " + b.name);
      co.disabled = cur || busy;
      co.addEventListener("click", function () { actCheckout(b.name); });
      var dl = el("button", "btn sm ic", "×");
      dl.type = "button";
      dl.title = "Delete " + b.name;
      dl.setAttribute("aria-label", "Delete branch " + b.name);
      dl.disabled = cur || busy;
      dl.addEventListener("click", function () { actDelete(b.name); });
      acts.appendChild(co); acts.appendChild(dl);
      li.appendChild(acts);
      ul.appendChild(li);
    })(state.branches[i]);
  }
}

function fillSelect(sel, names, prefer) {
  var prev = sel.value;
  while (sel.firstChild) sel.removeChild(sel.firstChild);
  for (var i = 0; i < names.length; i++) {
    var o = document.createElement("option");
    o.value = names[i]; o.textContent = names[i];
    sel.appendChild(o);
  }
  if (names.indexOf(prev) >= 0) sel.value = prev;
  else if (prefer && names.indexOf(prefer) >= 0) sel.value = prefer;
  sel.disabled = names.length === 0 || busy;
}

function paintSelects() {
  var all = state.branches.map(function (b) { return b.name; });
  var cur = state.head.type === "branch" ? state.head.ref : null;
  var co = $("coSel");
  var next = cur && all.length > 1 ? all[(all.indexOf(cur) + 1) % all.length] : all[0];
  fillSelect(co, all, next);
  /* never leave Checkout pointing at the branch you are already on — it would
     have nothing to do but say so */
  if (cur && all.length > 1 && co.value === cur) co.value = next;
  var others = all.filter(function (n) { return n !== cur; });
  var prefer = null, curTip = headId();
  for (var i = 0; i < others.length; i++) {
    var t = brOf(others[i]).tip;
    if (!isAncestor(t, curTip)) { prefer = others[i]; break; }
  }
  fillSelect($("mgSel"), others, prefer);
  var lab = cur ? "Merge into " + (cur.length > 12 ? cur.slice(0, 11) + "…" : cur) : "Merge (no branch)";
  $("mgLabel").textContent = lab;
  $("bMerge").disabled = busy || !cur || others.length === 0;
  $("bCheckout").disabled = busy || all.length === 0;
  $("bUndo").disabled = busy || undoStack.length === 0;
}

/* ── note + log ─────────────────────────────────────────────────────────── */
function note(kind, tag, text) {
  var n = $("note");
  n.setAttribute("data-k", kind);
  $("noteTag").textContent = tag;
  var p = $("noteTxt");
  while (p.firstChild) p.removeChild(p.firstChild);
  rich(p, text);
}
function logIt(kind, cmd, text) {
  state.step++;
  var ol = $("log");
  var li = el("li");
  li.setAttribute("data-k", kind);
  var c = el("span", "cmd");
  c.appendChild(el("span", "n", "#" + state.step + "  "));
  c.appendChild(document.createTextNode(cmd));
  li.appendChild(c);
  var t = el("span", "txt");
  rich(t, text);
  li.appendChild(t);
  ol.insertBefore(li, ol.firstChild);
  while (ol.childNodes.length > 60) ol.removeChild(ol.lastChild);
}
function say(kind, tag, cmd, text) { note(kind, tag, text); logIt(kind, cmd, text); }

/* ── undo ───────────────────────────────────────────────────────────────── */
function snapshot() {
  undoStack.push(JSON.stringify({
    c: state.commits, b: state.branches, h: state.head,
    q: state.seq, s: state.seed, m: state.msgI, r: state.brI
  }));
  if (undoStack.length > 60) undoStack.shift();
}
function undo() {
  if (!undoStack.length) return;
  var s = JSON.parse(undoStack.pop());
  state.commits = s.c; state.branches = s.b; state.head = s.h;
  state.seq = s.q; state.seed = s.s; state.msgI = s.m; state.brI = s.r;
  render({ rebuild: true });
  say("info", "undo", "(step back)", "Rewound one step. Real git has ways to undo too — this button is just the friendly version.");
  scrollToHead(false);
}

/* ── actions ────────────────────────────────────────────────────────────── */
function nextMsg() { var m = MSGS[state.msgI % MSGS.length]; state.msgI++; return m; }
function nextBrName() {
  for (var i = 0; i < BRNAMES.length * 2; i++) {
    var n = BRNAMES[(state.brI + i) % BRNAMES.length];
    var suffix = Math.floor((state.brI + i) / BRNAMES.length);
    var name = suffix ? n + "-" + (suffix + 1) : n;
    if (!brOf(name)) { state.brI = state.brI + i + 1; return name; }
  }
  return "branch-" + state.seq;
}
function refreshPlaceholders() {
  $("msg").placeholder = MSGS[state.msgI % MSGS.length];
  if (!$("bname").value) $("bname").placeholder = nextBrPreview();
}
function nextBrPreview() {
  for (var i = 0; i < BRNAMES.length * 2; i++) {
    var n = BRNAMES[(state.brI + i) % BRNAMES.length];
    var suffix = Math.floor((state.brI + i) / BRNAMES.length);
    var name = suffix ? n + "-" + (suffix + 1) : n;
    if (!brOf(name)) return name;
  }
  return "my-branch";
}

function actCommit(msgIn) {
  var input = $("msg");
  var msg = (msgIn != null ? msgIn : input.value).trim();
  if (msgIn == null) input.value = "";   /* clear even when it held only spaces */
  if (!msg) msg = nextMsg();
  snapshot();
  var where = state.head.type === "branch" ? state.head.ref : "no branch";
  var c = mkCommit(msg);
  render();
  scrollToHead(true);
  if (state.head.type === "branch") {
    say("info", "commit", 'git commit -m "' + msg + '"',
      "Saved a new snapshot `" + c.id + "` on `" + where + "`. It points back at the commit before it, and the branch label slid down to sit on the new one.");
  } else {
    say("info", "commit", 'git commit -m "' + msg + '"',
      "Saved `" + c.id + "`, but no branch label came along — HEAD is loose. Make a branch here if you want to find this work again.");
  }
  refreshPlaceholders();
}

function actBranch(nameIn) {
  var input = $("bname");
  var raw = (nameIn != null ? nameIn : input.value).trim();
  if (!raw) raw = nextBrName();
  if (!/^[A-Za-z0-9][A-Za-z0-9._\/-]{0,27}$/.test(raw)) {
    badInput(input);
    say("err", "refused", "git switch -c " + raw,
      "That name has characters git would not accept. Letters, numbers, and `- _ . /` please.");
    return;
  }
  if (brOf(raw)) {
    badInput(input);
    say("err", "refused", "git switch -c " + raw,
      "A branch called `" + raw + "` already exists. Two sticky notes cannot share a name.");
    return;
  }
  snapshot();
  var at = headId();
  /* if the commit we are labelling sits on a line nothing owns any more — work done
     with a detached HEAD, or left behind by a deleted branch — the new label adopts
     that line so its lane carries on instead of orphaning and jumping sideways */
  var tipC = byId(at), claimed = false;
  if (tipC) for (var q = 0; q < state.branches.length; q++) if (state.branches[q].line === tipC.line) claimed = true;
  state.branches.push({ name: raw, tip: at, line: (tipC && !claimed) ? tipC.line : newLine() });
  state.head = { type: "branch", ref: raw, line: null };
  if (nameIn == null) input.value = "";
  render();
  scrollToHead(true);
  say("info", "branch", "git switch -c " + raw,
    "Made a new label `" + raw + "` on commit `" + at + "` and moved HEAD onto it. Nothing was copied — a branch is just a name pointing at a commit. New commits land here now, and the branch you came from stays exactly where it was.");
  refreshPlaceholders();
}

function actCheckout(name) {
  if (state.head.type === "branch" && state.head.ref === name) {
    say("info", "no change", "git switch " + name, "You are already on `" + name + "`.");
    return;
  }
  var b = brOf(name);
  if (!b) return;
  snapshot();
  state.head = { type: "branch", ref: name, line: null };
  render();
  scrollToHead(true);
  say("info", "checkout", "git switch " + name,
    "HEAD now points at the branch `" + name + "`, whose newest commit is `" + b.tip + "`. Your files would now look like that snapshot.");
}

function clickCommit(id) {
  var here = branchesAt(id);
  if (here.length) {
    /* the panel promises "click a dot to check it out", so any labelled commit
       checks out a label — prefer one you are not already standing on */
    var hb = state.head.type === "branch" ? state.head.ref : null, pick = null;
    for (var i = 0; i < here.length; i++) if (here[i].name !== hb) { pick = here[i].name; break; }
    actCheckout(pick || here[0].name);
    return;
  }
  if (state.head.type === "commit" && state.head.ref === id) {
    say("info", "no change", "git switch --detach " + id, "HEAD is already sitting on `" + id + "` with no branch.");
    return;
  }
  snapshot();
  var c = byId(id);
  state.head = { type: "commit", ref: id, line: null };
  render();
  scrollToHead(true);
  say("info", "detached", "git switch --detach " + id,
    'Looking straight at commit `' + id + '` ("' + c.msg + '") with no branch attached — git calls this a detached HEAD. It is safe to look around; commit here and the work has no label to find it by, so make a branch first.');
}

function actDelete(name) {
  var b = brOf(name);
  if (!b) return;
  if (state.head.type === "branch" && state.head.ref === name) {
    say("err", "refused", "git branch -d " + name, "You cannot remove the label you are standing on. Check out another branch first.");
    return;
  }
  snapshot();
  var merged = isAncestor(b.tip, headId());
  state.branches = state.branches.filter(function (x) { return x.name !== name; });
  render();
  say("info", "delete", "git branch -" + (merged ? "d" : "D") + " " + name,
    merged
      ? "Removed the label `" + name + "`. Its commits live on inside this branch, so nothing was lost — and the lane it was holding can be reused by later work."
      : "Removed the label `" + name + "`. Its commits are still there but nothing points at them any more, which is how work gets genuinely lost in real life.");
}

function actMerge(name) {
  if (state.head.type !== "branch") {
    say("err", "refused", "git merge " + name, "You are not on a branch right now, so there is nothing to merge into. Check out a branch first.");
    return;
  }
  var cur = state.head.ref;
  if (name === cur) { say("err", "refused", "git merge " + name, "A branch cannot merge itself."); return; }
  var src = brOf(name);
  if (!src) return;
  var curTip = headId();
  if (isAncestor(src.tip, curTip)) {
    say("info", "nothing to do", "git merge " + name,
      "Already up to date: everything on `" + name + "` is already part of `" + cur + "`. Git changes nothing rather than making a pointless commit.");
    return;
  }
  snapshot();
  if (isAncestor(curTip, src.tip)) {
    /* Fast-forward: the commits being caught up on are now plain ancestors of the
       current branch, so they belong in ITS lane — otherwise the current branch's
       line visibly zig-zags out to the side branch's column and back again. */
    var chain = [], w = src.tip, reached = false, guard = 0;
    while (w && guard++ < 5000) {
      if (w === curTip) { reached = true; break; }
      var wc = byId(w);
      if (!wc) break;
      chain.push(wc);
      w = wc.parents[0];
    }
    if (reached) {
      var curLine = brOf(cur).line;
      for (var q = 0; q < chain.length; q++) chain[q].line = curLine;
    }
    brOf(cur).tip = src.tip;
    render();
    scrollToHead(true);
    say("ok", "merged", "git merge " + name,
      "Fast-forward. `" + cur + "` had nothing of its own since the split, so git just slid the label along to `" + src.tip + "` — no merge commit needed.");
    return;
  }
  var c = mkCommit("merge branch '" + name + "'", src.tip);
  render();
  scrollToHead(true);
  say("ok", "merged", "git merge " + name,
    "Both lines had new work, so git tied them together with commit `" + c.id + "` — the filled dot with two lines running into it. A commit with two parents — that is all a merge really is.");
}

function badInput(input) {
  input.classList.add("bad", "shake");
  setTimeout(function () { input.classList.remove("bad", "shake"); }, 700);
}

/* ── preloaded example history ──────────────────────────────────────────── */
function preload() {
  state = fresh();
  state.branches.push({ name: "main", tip: null, line: newLine() });
  state.head = { type: "branch", ref: "main", line: null };
  var C = function (m) { return mkCommit(m); };
  C("start the project");
  C("add the home page");
  state.branches.push({ name: "gallery", tip: headId(), line: newLine() });
  state.head = { type: "branch", ref: "gallery", line: null };
  C("add a photo gallery");
  C("make the gallery grid nicer");
  state.head = { type: "branch", ref: "main", line: null };
  C("add a favicon");
  mkCommit("merge branch 'gallery'", brOf("gallery").tip);
  state.branches.push({ name: "dark-mode", tip: headId(), line: newLine() });
  state.head = { type: "branch", ref: "dark-mode", line: null };
  C("try a dark colour scheme");
  C("remember the choice");
  state.head = { type: "branch", ref: "main", line: null };
  /* main moves on after the split, so dark-mode has genuinely diverged and
     merging it needs a real merge commit rather than a fast-forward. */
  C("swap in a new hero photo");
  state.msgI = 0; state.brI = 0; state.step = 0;
  undoStack = [];
  focusRow = state.commits.length - 1;
}

function resetAll(quiet) {
  var ol = $("log");
  while (ol.firstChild) ol.removeChild(ol.firstChild);
  preload();
  render({ rebuild: true });
  scrollToHead(false);
  refreshPlaceholders();
  if (!quiet) {
    say("info", "loaded", "git log --graph --oneline",
      "An example history of a small website: `gallery` was built on the side and merged back in — that is the filled dot with two lines running into it. `dark-mode` is still off on its own, and `main` has moved on since, so merging it will need a real merge commit. You are on `main`.");
  }
}

/* ── scripted tour ──────────────────────────────────────────────────────── */
var tour = { on: false, i: 0, timer: null, steps: [] };

function buildTour() {
  return [
    { say: "Starting from a clean copy of the example history. HEAD is on `main` — the branch everyone shares.",
      run: function () { resetAll(true); } },
    { say: "Something needs fixing. Instead of touching `main`, open a side branch for it.",
      run: function () { actBranch("fix-signup"); } },
    { say: "Now do the work. Commits land on the side branch, and `main` has not moved at all.",
      run: function () { actCommit("fix the signup button"); } },
    { say: "A second small commit. Notice the new lane growing on its own, away from `main`.",
      run: function () { actCommit("add a test for signup"); } },
    { say: "Meanwhile someone else has work to land on `main`, so hop back over to it. The side branch keeps its lane and its commits — nothing was lost.",
      run: function () { actCheckout("main"); } },
    { say: "And there it goes — `main` grew too. The two lines of work have genuinely diverged now.",
      run: function () { actCommit("update the site footer"); } },
    { say: "The fix is finished, so bring it home. Both sides changed, so this needs a real merge commit.",
      run: function () { actMerge("fix-signup"); } },
    { say: "Tidy up: the label has done its job. Deleting it removes the name, never the work — those commits are part of `main` now.",
      run: function () { actDelete("fix-signup"); } },
    { say: "That is the whole loop — branch, work, merge, tidy up. Everything else in git is a variation on this picture.",
      run: null, ok: true }
  ];
}

function setBusy(v) {
  busy = v;
  var ids = ["bCommit", "bBranch", "bCheckout", "bMerge", "bUndo", "bReset", "msg", "bname", "coSel", "mgSel"];
  for (var i = 0; i < ids.length; i++) $(ids[i]).disabled = v;
  $("tourLabel").textContent = v ? "Stop the tour" : "Play the feature-branch story";
  $("bTour").className = v ? "btn stop" : "btn primary";
  $("bTour").querySelector(".gl").textContent = v ? "\u25a0" : "\u25b6";
  if (!v) paintSelects();
  paintBranches();
}

function tourStep() {
  if (!tour.on) return;
  if (tour.i >= tour.steps.length) { stopTour(true); return; }
  var s = tour.steps[tour.i];
  tour.i++;
  if (s.run) s.run();
  note(s.ok ? "ok" : "info", "story " + tour.i + "/" + tour.steps.length, s.say);
  if (tour.i >= tour.steps.length) {
    tour.timer = setTimeout(function () { stopTour(true); }, 1200);
  } else {
    tour.timer = setTimeout(tourStep, reduced() ? 1100 : 1650);
  }
}
function startTour() {
  tour.steps = buildTour();
  tour.i = 0; tour.on = true;
  setBusy(true);
  tourStep();
}
function stopTour(finished) {
  tour.on = false;
  if (tour.timer) { clearTimeout(tour.timer); tour.timer = null; }
  setBusy(false);
  if (finished) {
    logIt("ok", "story complete", "Branch, work, merge, tidy up — the whole loop, in nine steps. Keep going with the buttons above.");
    note("ok", "done", "That is the whole loop. Now try it yourself: commit a few times, branch off, and merge back.");
  } else {
    note("info", "stopped", "Tour stopped. The graph is left exactly where the story got to — carry on with the buttons.");
  }
}

/* ── wiring ─────────────────────────────────────────────────────────────── */
$("bCommit").addEventListener("click", function () { actCommit(); });
$("bBranch").addEventListener("click", function () { actBranch(); });
$("bCheckout").addEventListener("click", function () { if ($("coSel").value) actCheckout($("coSel").value); });
$("bMerge").addEventListener("click", function () { if ($("mgSel").value) actMerge($("mgSel").value); });
$("bUndo").addEventListener("click", function () { undo(); });
$("bReset").addEventListener("click", function () { resetAll(false); });
$("bTour").addEventListener("click", function () { if (tour.on) stopTour(false); else startTour(); });
$("msg").addEventListener("keydown", function (e) { if (e.key === "Enter") { e.preventDefault(); actCommit(); } });
$("bname").addEventListener("keydown", function (e) { if (e.key === "Enter") { e.preventDefault(); actBranch(); } });
mqReduce.addEventListener ? mqReduce.addEventListener("change", function () { render(); }) : null;
canvas.addEventListener("scroll", function () {
  if (canvas._raf) return;
  canvas._raf = requestAnimationFrame(function () { canvas._raf = 0; paintSwipe(); });
}, { passive: true });
var rzT = null;
addEventListener("resize", function () {
  if (rzT) clearTimeout(rzT);
  rzT = setTimeout(function () { rzT = null; paintSwipe(); scrollToHead(false); }, 140);
});

/* ── go ─────────────────────────────────────────────────────────────────── */
resetAll(false);
})();
</script>
</body>
</html>

source-visible by construction · nothing is published here without its code

Comments

0 total

No comments yet. If you ran it, say what happened.

Report this post

Goes straight to the moderation queue. Enough independent reports and the post is suspended automatically until a human looks.

what is wrong