<?php
// ── NAME / FILE RESOLUTION ──────────────────────────────────────────────────
// "name" can be passed as ?name=xxxx (or default 'flowchart').
// If it has a .xml or .svg extension, that extension is stripped before
// building the actual input (.xml) and output (.svg) filenames.
//
// If "name" is omitted entirely, or explicitly passed as "new", the client
// offers to create a brand-new flowchart under a name the operator supplies
// (see IS_NEW_REQUEST below) rather than silently defaulting to a
// pre-existing 'flowchart.xml'.
$requestedName = $_GET['name'] ?? ($_POST['name'] ?? null);
$isNewRequest = ($requestedName === null) || (strtolower(trim($requestedName)) === 'new');

$rawName = $requestedName ?? 'flowchart';
$rawName = basename($rawName);                 // strip any path components
$rawName = preg_replace('/\.(xml|svg)$/i', '', $rawName); // strip known ext
$rawName = preg_replace('/[^A-Za-z0-9_\-]/', '', $rawName); // sanitize
if ($rawName === '' || strtolower($rawName) === 'new') {
    $rawName = 'flowchart';
}
$baseName = $rawName;
$xmlFile  = $baseName . '.xml';
$svgFile  = $baseName . '.svg';
$mapFile  = $baseName . '.map.html';

// ── DETECT PREVIOUSLY-EXPORTED WIDTH ────────────────────────────────────────
// The image map file (written by the "Export SVG + Image Map" action) embeds
// an <img ... width="NNN" ...> tag carrying the width the diagram was last
// exported at. If that file exists, use its width as the default selection
// for the "Export width" dropdown instead of the hardcoded 600px default.
// If the map file doesn't exist yet (diagram never exported), fall back to 600.
$detectedWidth = 600;
$mapFilePath = __DIR__ . '/' . $mapFile;
if (is_file($mapFilePath)) {
    $mapContents = @file_get_contents($mapFilePath);
    if ($mapContents !== false && preg_match('/<img\b[^>]*\bwidth="(\d+)"/i', $mapContents, $m)) {
        $detectedWidth = (int) $m[1];
    }
}

// ── LOGO DISCOVERY ───────────────────────────────────────────────────────────
// Any image file placed in a "logos" subfolder next to this script becomes
// selectable from the "Logo" dropdown and gets drawn at the top-right corner
// of the live preview and the exported SVG. Drop .png/.jpg/.jpeg/.gif/.svg/
// .webp files into ./logos/ to make them available (no code changes needed).
$logoDir = __DIR__ . '/logos';
$logoFiles = [];
if (is_dir($logoDir)) {
    $exts = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'];
    foreach ($exts as $ext) {
        foreach ((glob($logoDir . '/*.' . $ext) ?: []) as $f) {
            $logoFiles[] = basename($f);
        }
        foreach ((glob($logoDir . '/*.' . strtoupper($ext)) ?: []) as $f) {
            $logoFiles[] = basename($f);
        }
    }
    $logoFiles = array_values(array_unique($logoFiles));
    sort($logoFiles, SORT_NATURAL | SORT_FLAG_CASE);
}
// Preferred default logo, matched case-insensitively (file systems/uploads
// don't always preserve exact case). If that specific file isn't found but
// exactly one logo exists in the folder, default to that one instead.
$defaultLogo = '';
foreach ($logoFiles as $lf) {
    if (strcasecmp($lf, 'ALA_logo_gray.png') === 0) { $defaultLogo = $lf; break; }
}
if ($defaultLogo === '' && count($logoFiles) === 1) {
    $defaultLogo = $logoFiles[0];
}

// ── SAVE HANDLER (XML) ──────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'save') {
    header('Content-Type: application/json');
    $xml = $_POST['xml'] ?? '';
    if ($xml === '') {
        echo json_encode(['ok' => false, 'error' => 'No XML content received.']);
        exit;
    }
    $targetFile = __DIR__ . '/' . $xmlFile;
    $result = @file_put_contents($targetFile, $xml);
    if ($result === false) {
        echo json_encode(['ok' => false, 'error' => "Failed to write {$xmlFile}. Check permissions."]);
    } else {
        echo json_encode(['ok' => true, 'bytes' => $result, 'file' => $xmlFile]);
    }
    exit;
}

// ── SAVE HANDLER (SVG) ───────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'save_svg') {
    header('Content-Type: application/json');
    $svg = $_POST['svg'] ?? '';
    if ($svg === '') {
        echo json_encode(['ok' => false, 'error' => 'No SVG content received.']);
        exit;
    }
    $targetFile = __DIR__ . '/' . $svgFile;
    $result = @file_put_contents($targetFile, $svg);
    if ($result === false) {
        echo json_encode(['ok' => false, 'error' => "Failed to write {$svgFile}. Check permissions."]);
    } else {
        echo json_encode(['ok' => true, 'bytes' => $result, 'file' => $svgFile]);
    }
    exit;
}

// ── SAVE HANDLER (HTML IMAGE MAP) ────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'save_map') {
    header('Content-Type: application/json');
    $map = $_POST['map'] ?? '';
    if ($map === '') {
        echo json_encode(['ok' => false, 'error' => 'No map content received.']);
        exit;
    }
    $mapFile = $baseName . '.map.html';
    $targetFile = __DIR__ . '/' . $mapFile;
    $result = @file_put_contents($targetFile, $map);
    if ($result === false) {
        echo json_encode(['ok' => false, 'error' => "Failed to write {$mapFile}. Check permissions."]);
    } else {
        echo json_encode(['ok' => true, 'bytes' => $result, 'file' => $mapFile]);
    }
    exit;
}

// ── SAVE HANDLER (SVG + HTML IMAGE MAP TOGETHER) ─────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'save_both') {
    header('Content-Type: application/json');
    $svg = $_POST['svg'] ?? '';
    $map = $_POST['map'] ?? '';
    if ($svg === '' || $map === '') {
        echo json_encode(['ok' => false, 'error' => 'Missing SVG or map content.']);
        exit;
    }

    $svgResult = @file_put_contents(__DIR__ . '/' . $svgFile, $svg);
    $mapFile = $baseName . '.map.html';
    $mapResult = @file_put_contents(__DIR__ . '/' . $mapFile, $map);

    if ($svgResult === false || $mapResult === false) {
        $err = [];
        if ($svgResult === false) $err[] = "Failed to write {$svgFile}.";
        if ($mapResult === false) $err[] = "Failed to write {$mapFile}.";
        echo json_encode(['ok' => false, 'error' => implode(' ', $err) . ' Check permissions.']);
    } else {
        echo json_encode([
            'ok' => true,
            'svg_file' => $svgFile,
            'svg_bytes' => $svgResult,
            'map_file' => $mapFile,
            'map_bytes' => $mapResult,
        ]);
    }
    exit;
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>MAGEC Flow Diagram</title>
<style>
  /* ── THEME VARIABLES ──────────────────────────────────────────────────
     Defaults here are the "Bold" theme (this app's original look). The
     theme <select> in the topbar overwrites these at runtime via JS
     (see THEMES / applyTheme() below) — this block just ensures the page
     looks correct before JS finishes initializing, and acts as the
     canonical fallback values. */
  :root {
    --bg-app: #0f1117;
    --bg-panel: #1a1d27;
    --bg-input: #20232f;
    --border: #2d3148;
    --border-strong: #4a5280;
    --text-primary: #e2e8f0;
    --text-muted: #94a3b8;
    --text-dim: #64748b;
    --accent: #a5b4fc;
    --accent-strong: #6366f1;
    --accent-hover-glow: rgba(99,102,241,.18);
    --success: #4ade80;
    --warn: #fbbf24;
    --warn-bg: #fbbf2422;
    --warn-border: #fbbf2455;
    --danger-bg: #7f1d1d;
    --danger-text: #fecaca;
    --danger-soft-bg: #7f1d1d33;
    --edge-color: #8b95c4;
    --mono-text: #c7d2fe;
    --node-label-color: #ffffff;
  }

  * { box-sizing: border-box; margin: 0; padding: 0; }
  body {
    font-family: 'Segoe UI', system-ui, sans-serif;
    background: var(--bg-app);
    color: var(--text-primary);
    height: 100vh;
    display: flex;
    flex-direction: column;
    overflow: hidden;
  }
  #topbar {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 12px;
    padding: 10px 16px;
    background: var(--bg-panel);
    border-bottom: 1px solid var(--border);
    flex-shrink: 0;
  }
  #topbar h1 {
    font-size: 15px;
    font-weight: 600;
    color: var(--accent);
    letter-spacing: .04em;
  }
  #theme-select {
    background: var(--bg-input);
    color: var(--text-primary);
    border: 1px solid var(--border);
    border-radius: 6px;
    padding: 4px 8px;
    font-size: 13px;
  }
  .btn {
    padding: 6px 14px;
    border-radius: 6px;
    border: none;
    font-size: 13px;
    font-weight: 500;
    cursor: pointer;
    transition: opacity .15s;
  }
  .btn:hover { opacity: .85; }
  .btn-primary { background: var(--accent-strong); color: #fff; }

  #emoji-panel {
    display: none;
    background: var(--bg-panel);
    border-bottom: 1px solid var(--border);
    padding: 10px 16px 14px;
    flex-shrink: 0;
  }
  #emoji-panel.open { display: block; }
  #emoji-panel-header {
    font-size: 12px;
    color: var(--text-dim);
    margin-bottom: 8px;
  }
  #emoji-copied-msg {
    color: var(--success);
    font-weight: 600;
    margin-left: 8px;
  }
  #emoji-grid {
    display: flex;
    flex-wrap: wrap;
    gap: 4px;
    max-height: 140px;
    overflow-y: auto;
  }
  .emoji-cell {
    width: 34px;
    height: 34px;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 18px;
    border-radius: 6px;
    border: 1px solid transparent;
    background: var(--bg-input);
    cursor: pointer;
    transition: background .12s, border-color .12s;
  }
  .emoji-cell:hover { background: var(--border); border-color: var(--border-strong); }

  #main { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-height: 0; }
  #chart-panel { display: flex; flex-direction: column; flex: 1; min-height: 0; }
  #chart-panel .panel-header {
    padding: 8px 12px;
    font-size: 11px;
    font-weight: 600;
    letter-spacing: .08em;
    text-transform: uppercase;
    color: var(--text-dim);
    border-bottom: 1px solid var(--border);
    background: var(--bg-panel);
    display: flex;
    align-items: center;
    gap: 8px;
  }
  #svg-container {
    flex: 1;
    min-height: 0;
    overflow: auto;
    padding: 40px;
    background: var(--bg-app);
    display: flex;
    align-items: flex-start;
    justify-content: center;
  }
  #flowchart-svg { display: block; }

  .node-shape { cursor: pointer; transition: filter .15s; }
  .node-shape:hover { filter: brightness(1.25); }

  /* Flags a node that covers >=75% of another node's area: alternates
     fully visible / fully invisible every 2s (4s full cycle) so whatever
     it's hiding becomes visible during the "off" half, making the overlap
     obvious without having to move anything to check. Hard cut via
     steps(1, end), not a fade, so it reads as "on/off" rather than a
     pulsing glow. */
  @keyframes node-blink-obscures {
    0%, 49.9% { opacity: 1; }
    50%, 100% { opacity: 0; }
  }
  .node-blink-obscures { animation: node-blink-obscures 4s steps(1, end) infinite; }

  .copyright-notice {
    font-family: 'Segoe UI', system-ui, sans-serif;
    font-size: 10px;
    fill: var(--text-muted);
    opacity: .85;
    pointer-events: none;
    user-select: none;
  }

  /* Multi-select / bulk move ------------------------------------------- */
  body.select-mode #flowchart-svg { cursor: crosshair; }

  .node-shape.node-selected {
    filter: drop-shadow(0 0 3px var(--accent-strong)) drop-shadow(0 0 3px var(--accent-strong));
  }

  #marquee-rect {
    fill: var(--accent-hover-glow);
    stroke: var(--accent-strong);
    stroke-width: 1.5;
    stroke-dasharray: 5,3;
    pointer-events: none;
  }

  #bulk-move-panel {
    display: none;
    position: fixed;
    right: 24px;
    bottom: 24px;
    width: 280px;
    background: var(--bg-panel);
    border: 1px solid var(--border-strong);
    border-radius: 10px;
    padding: 14px 16px;
    box-shadow: 0 12px 32px rgba(0,0,0,.45);
    z-index: 500;
    font-size: 13px;
    color: var(--text-primary);
  }
  #bulk-move-panel.open { display: block; }
  #bulk-move-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-bottom: 10px;
    font-weight: 600;
  }
  .bulk-move-axis {
    margin-bottom: 10px;
    padding-bottom: 10px;
    border-bottom: 1px solid var(--border);
  }
  .bulk-move-axis:last-of-type { border-bottom: none; margin-bottom: 4px; padding-bottom: 0; }
  .bulk-move-axis-label {
    display: block;
    font-size: 11px;
    text-transform: uppercase;
    letter-spacing: .06em;
    color: var(--text-dim);
    margin-bottom: 6px;
  }
  #compass-rose {
    display: grid;
    grid-template-columns: repeat(3, 32px);
    grid-template-rows: repeat(3, 32px);
    gap: 4px;
    justify-content: center;
    margin: 2px 0 4px;
  }
  .compass-btn {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 32px;
    height: 32px;
    border-radius: 50%;
    border: 1px solid var(--border-strong);
    background: var(--bg-input);
    color: var(--text-primary);
    font-size: 14px;
    line-height: 1;
    padding: 0;
    cursor: pointer;
    transition: background .12s, border-color .12s, color .12s;
  }
  .compass-btn:hover { background: var(--accent-strong); border-color: var(--accent-strong); color: #fff; }
  .compass-btn:active { filter: brightness(1.25); }
  #compass-step {
    width: 32px;
    height: 32px;
    border-radius: 50%;
    border: 1px solid var(--border-strong);
    background: var(--bg-panel);
    color: var(--text-primary);
    text-align: center;
    font-size: 11px;
    padding: 0;
  }
  .bulk-move-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
  .bulk-move-row:last-child { margin-bottom: 0; }
  .bulk-move-row label {
    display: flex;
    align-items: center;
    gap: 6px;
    font-size: 12.5px;
    color: var(--text-muted);
    flex: 1;
    cursor: pointer;
  }
  .bulk-move-row input[type=number] {
    width: 80px;
    background: var(--bg-input);
    border: 1px solid var(--border);
    border-radius: 6px;
    padding: 4px 6px;
    color: var(--text-primary);
    font-size: 13px;
  }
  .bulk-move-row input[type=number]:disabled { opacity: .4; cursor: not-allowed; }
  .bulk-move-row label.bulk-move-option-disabled { opacity: .4; cursor: not-allowed; }
  .bulk-move-note { font-size: 11px; color: var(--text-dim); }
  #bulk-move-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; }

  #undo-toast {
    display: none;
    align-items: center;
    gap: 12px;
    position: fixed;
    left: 24px;
    bottom: 24px;
    background: var(--bg-panel);
    border: 1px solid var(--border-strong);
    border-radius: 8px;
    padding: 10px 14px;
    box-shadow: 0 8px 24px rgba(0,0,0,.4);
    font-size: 13px;
    color: var(--text-primary);
    z-index: 500;
  }
  #undo-toast.open { display: flex; }
  #undo-toast button {
    background: none;
    border: none;
    color: var(--accent);
    font-weight: 600;
    cursor: pointer;
    font-size: 13px;
    padding: 0;
  }
  #undo-toast button:hover { text-decoration: underline; }

  .node-label {
    font-family: 'Segoe UI', system-ui, sans-serif;
    font-size: 12px;
    font-weight: 600;
    fill: var(--node-label-color);
    pointer-events: none;
    text-anchor: middle;
    dominant-baseline: central;
  }
  .node-id {
    font-size: 10px;
    fill: var(--text-muted);
    font-weight: 400;
    text-anchor: middle;
  }
  .edge-line { stroke: var(--edge-color); stroke-width: 1.8; fill: none; marker-end: url(#arrow); }
  .edge-line-dotted { stroke: var(--edge-color); stroke-width: 1.8; fill: none; stroke-dasharray: 5,4; }
  .edge-label {
    font-family: 'Segoe UI', system-ui, sans-serif;
    font-size: 13px;
    font-weight: 600;
    fill: var(--text-primary);
    text-anchor: middle;
  }

  #modal-overlay {
    display: none;
    position: fixed; inset: 0;
    background: rgba(0,0,0,.65);
    z-index: 100;
    align-items: center;
    justify-content: center;
  }
  #modal-overlay.open { display: flex; }
  #modal {
    background: var(--bg-panel);
    border: 2px solid var(--border-strong);
    border-radius: 12px;
    padding: 28px 32px;
    max-width: 480px;
    width: 90%;
    box-shadow: 0 24px 60px rgba(0,0,0,.6);
  }

  /* Edge click hit-area: invisible until edit mode, always non-interfering
     with the visible thin line drawn alongside it. */
  .edge-hit { stroke: transparent; stroke-width: 16; fill: none; cursor: default; pointer-events: stroke; }
  body.edit-mode .edge-hit { cursor: pointer; }
  body.edit-mode .edge-hit:hover { stroke: var(--accent-hover-glow); }
  body.edit-mode .node-shape:hover { filter: brightness(1.25) drop-shadow(0 0 5px var(--accent-strong)); }
  body.edit-mode #flowchart-svg { cursor: crosshair; }

  /* A small glowing dot inside Save/Discard, visible whenever there are
     unsaved changes (i.e. whenever the buttons themselves are enabled).
     Replaces a separate "Unsaved changes" badge that used to sit between
     the toolbar toggles and these buttons — toggling that badge's display
     shifted every button after it left/right (and could shift the whole
     toolbar's wrap point) each time changes were made or saved. Living
     inside the always-present buttons avoids that entirely. */
  .unsaved-light {
    display: inline-block;
    width: 7px;
    height: 7px;
    border-radius: 50%;
    background: var(--warn);
    box-shadow: 0 0 6px 1px var(--warn);
    margin-right: 6px;
    vertical-align: middle;
    opacity: 0;
    transition: opacity .15s;
  }
  .btn:not(:disabled) .unsaved-light { opacity: 1; }
  .btn:disabled { opacity: .4; cursor: not-allowed; }
  .btn-danger { background: var(--danger-bg); color: var(--danger-text); }
  .btn-ghost { background: var(--border); color: var(--text-primary); }

  /* ── XML EDITOR — HARDCODED COLORS ────────────────────────────────────
     This modal intentionally does NOT use the theme CSS variables above.
     It always uses this fixed dark palette so it stays readable no matter
     which app theme (Bold/Boardroom/Technical/Monochrome/etc.) is active. */
  #xml-editor-overlay {
    display: none;
    position: fixed; inset: 0;
    background: rgba(0,0,0,.65);
    z-index: 200;
    align-items: center;
    justify-content: center;
  }
  #xml-editor-overlay.open { display: flex; }
  #xml-editor-modal {
    background: #1a1d27;
    border: 2px solid #4a5280;
    border-radius: 12px;
    padding: 22px 26px 20px;
    max-width: 720px;
    width: 92%;
    box-shadow: 0 24px 60px rgba(0,0,0,.6);
  }
  #xml-editor-title { font-size: 15px; font-weight: 700; color: #a5b4fc; margin-bottom: 4px; }
  #xml-editor-subtitle { font-size: 12px; color: #64748b; margin-bottom: 12px; }
  #xml-editor-modal { max-height: 88vh; overflow-y: auto; }
  #xml-editor-form-node, #xml-editor-form-edge { display: none; }
  .xf-row { display: flex; gap: 12px; margin-bottom: 10px; }
  .xf-field { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
  .xf-field.full { flex: 1 1 100%; }
  .xf-field.sm { flex: 0 1 100px; }
  .xf-field.md { flex: 0 1 180px; }
  .xf-field label {
    font-size: 11px;
    font-weight: 600;
    letter-spacing: .04em;
    text-transform: uppercase;
    color: #64748b;
  }
  .xf-label-note { text-transform: none; font-weight: 400; letter-spacing: 0; }
  .xf-field input, .xf-field select, .xf-field textarea {
    background: #0f1117;
    color: #e2e8f0;
    border: 1px solid #2d3148;
    border-radius: 6px;
    padding: 6px 9px;
    font-size: 13px;
    font-family: inherit;
    width: 100%;
  }
  .xf-field input::placeholder, .xf-field textarea::placeholder { color: #64748b; }
  .xf-field input:focus, .xf-field select:focus, .xf-field textarea:focus {
    outline: none;
    border-color: #6366f1;
  }
  .xf-field textarea { resize: vertical; min-height: 56px; line-height: 1.5; }
  #f-node-foreign, #f-edge-foreign {
    font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
    font-size: 12.5px;
    min-height: 70px;
  }
  .xf-hint { font-size: 11.5px; color: #64748b; margin: -2px 0 10px; }
  #xml-editor-error {
    display: none;
    margin-top: 10px;
    font-size: 12.5px;
    color: #fecaca;
    background: #7f1d1d33;
    border: 1px solid #7f1d1d;
    border-radius: 6px;
    padding: 8px 12px;
    white-space: pre-line;
  }
  #xml-editor-error.warn {
    color: #fbbf24;
    background: #fbbf2422;
    border: 1px solid #fbbf2455;
  }
  #xml-editor-actions {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-top: 16px;
  }
  #xml-editor-actions .right { display: flex; gap: 8px; }
  #xml-editor-actions .left { display: flex; gap: 8px; }

  #add-object-overlay {
    display: none;
    position: fixed; inset: 0;
    background: rgba(0,0,0,.65);
    z-index: 150;
    align-items: center;
    justify-content: center;
  }
  #add-object-overlay.open { display: flex; }
  #add-object-modal {
    background: var(--bg-panel);
    border: 2px solid var(--border-strong);
    border-radius: 12px;
    padding: 22px 24px;
    max-width: 320px;
    width: 90%;
    box-shadow: 0 24px 60px rgba(0,0,0,.6);
  }
  #add-object-title { font-size: 15px; font-weight: 700; color: var(--accent); margin-bottom: 14px; }
  #add-object-actions { display: flex; flex-direction: column; gap: 8px; }
  #add-object-actions .btn { width: 100%; text-align: center; }

  /* ── PRINT ─────────────────────────────────────────────────────────
     Only the diagram itself gets printed — topbar, panels, and any open
     modals are hidden. The page background is forced to white regardless
     of the active theme, so e.g. printing while on Bold/Technical doesn't
     waste ink on one giant dark rectangle behind the diagram; node/edge
     colors themselves still print as-is (pick the Boardroom theme first
     for genuinely ink-economical pure black-and-white output). */
  @media print {
    body * { visibility: hidden; }
    #svg-container, #svg-container * { visibility: visible; }
    #svg-container {
      position: absolute;
      top: 0; left: 0;
      width: 100%;
      height: auto;
      padding: 0;
      margin: 0;
      overflow: visible;
      background: #fff !important;
    }
    #flowchart-svg { max-width: 100%; height: auto; }
    html, body { background: #fff !important; height: auto; overflow: visible; }
  }
</style>
</head>
<body>

<div id="topbar">
  <h1>⬡ MAGEC Flow Diagram</h1>
  <label for="theme-select" style="font-size:12px;color:var(--text-muted);display:flex;align-items:center;gap:6px;">
    🎨
    <select id="theme-select" onchange="onThemeSelectChange(this.value)">
      <option value="bold">Bold (default)</option>
      <option value="boardroom">Boardroom (B&amp;W print)</option>
      <option value="technical">Technical</option>
      <option value="monochrome">Monochrome</option>
    </select>
  </label>
  <label for="edit-mode-toggle" style="font-size:12px;color:var(--text-muted);display:flex;align-items:center;gap:6px;cursor:pointer;">
    <input type="checkbox" id="edit-mode-toggle" onchange="toggleEditMode()"/>
    ✏️ Edit mode
  </label>
  <label for="select-mode-toggle" id="select-mode-label" style="font-size:12px;color:var(--text-muted);display:flex;align-items:center;gap:6px;cursor:pointer;" title="Drag a rectangle around several nodes to move them together. Enable Edit mode first.">
    <input type="checkbox" id="select-mode-toggle" onchange="toggleSelectMode()" disabled/>
    🔲 Move nodes
  </label>
  <button class="btn btn-ghost" id="save-changes-btn" onclick="saveChangesToServer()" disabled><span class="unsaved-light"></span>💾 Save Changes</button>
  <button class="btn btn-ghost" id="discard-changes-btn" onclick="discardChanges()" disabled><span class="unsaved-light"></span>↩ Discard Changes</button>
  <button class="btn btn-ghost" onclick="window.print()">🖨️ Print</button>
  <button class="btn btn-ghost" onclick="exitApp()">🚪 Exit</button>
  <label for="export-width-select" style="font-size:12px;color:var(--text-muted);display:flex;align-items:center;gap:6px;">
    Export width:
    <select id="export-width-select" style="background:var(--bg-input);color:var(--text-primary);border:1px solid var(--border);border-radius:6px;padding:4px 8px;font-size:13px;" onchange="onExportWidthChange()">
<?php $standardWidths = [1200, 900, 600]; ?>
<?php foreach ($standardWidths as $w): ?>
      <option value="<?php echo $w; ?>"<?php echo ($w === $detectedWidth) ? ' selected' : ''; ?>><?php echo $w; ?>px</option>
<?php endforeach; ?>
<?php if (!in_array($detectedWidth, $standardWidths, true)): ?>
      <option value="<?php echo $detectedWidth; ?>" selected><?php echo $detectedWidth; ?>px</option>
<?php endif; ?>
    </select>
  </label>
  <label for="logo-select" style="font-size:12px;color:var(--text-muted);display:flex;align-items:center;gap:6px;" title="Images are read from a 'logos' folder next to this script">
    Logo:
    <select id="logo-select" style="background:var(--bg-input);color:var(--text-primary);border:1px solid var(--border);border-radius:6px;padding:4px 8px;font-size:13px;" onchange="onLogoChange()">
      <option value="">None</option>
<?php foreach ($logoFiles as $lf): ?>
      <option value="<?php echo htmlspecialchars($lf, ENT_QUOTES); ?>"<?php echo ($lf === $defaultLogo) ? ' selected' : ''; ?>><?php echo htmlspecialchars($lf, ENT_QUOTES); ?></option>
<?php endforeach; ?>
<?php if (empty($logoFiles)): ?>
      <option value="_none_found_" disabled>(add images to /logos)</option>
<?php endif; ?>
    </select>
  </label>
  <label for="grid-toggle" style="font-size:12px;color:var(--text-muted);display:flex;align-items:center;gap:6px;cursor:pointer;">
    <input type="checkbox" id="grid-toggle" checked onchange="onGridToggleChange()"/>
    Show grid
  </label>
  <button class="btn btn-primary" onclick="exportAll()">📤 Export SVG + Image Map</button>
  <button class="btn" id="emoji-btn" onclick="toggleEmojiMenu()" style="background:var(--border);color:var(--text-primary);">😀 Emoji Menu</button>
</div>

<div id="emoji-panel">
  <div id="emoji-panel-header">
    Click an emoji to copy it — then paste into a label or narrative
    <span id="emoji-copied-msg"></span>
  </div>
  <div id="emoji-grid"></div>
</div>

<div id="main">
  <div id="chart-panel">
    <div class="panel-header">
      <span>📊</span> Live Preview
      <span id="edit-mode-hint" style="font-size:13px; color:var(--text-dim);">Click nodes for details</span>
      <span id="chart-title"></span>
    </div>
    <div id="svg-container">
      <svg id="flowchart-svg"></svg>
    </div>
  </div>
</div>

<!-- MODAL -->
<div id="modal-overlay" onclick="closeModal(event)">
  <div id="modal">
    <button onclick="closeModal()" style="float:right;background:none;border:none;color:var(--text-dim);font-size:22px;cursor:pointer;">✕</button>
    <div id="modal-badge" style="display:inline-block;padding:2px 10px;border-radius:999px;font-size:10px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;margin-bottom:10px;"></div>
    <h2 id="modal-title" style="margin:8px 0 16px;font-size:18px;"></h2>
    <div id="modal-body" style="font-size:13.5px;line-height:1.7;color:var(--text-muted);"></div>
    <div id="modal-cobol-wrap" style="margin-top:14px;display:none;">
      <div style="font-size:10px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--text-dim);margin-bottom:6px;">COBOL Paragraph</div>
      <pre id="modal-cobol" style="margin:0;font-family:'SFMono-Regular',Consolas,'Liberation Mono',Menlo,monospace;font-size:12.5px;line-height:1.6;color:var(--mono-text);background:var(--bg-app);border:1px solid var(--border);border-radius:8px;padding:10px 12px;white-space:pre-wrap;word-break:break-word;"></pre>
    </div>
    <div id="modal-link-wrap" style="margin-top:14px;display:none;">
      <a id="modal-link" href="#"
         style="display:inline-block;font-size:13px;font-weight:600;color:var(--accent);text-decoration:none;border:1px solid var(--border-strong);border-radius:6px;padding:6px 12px;">
        🔗 Open link
      </a>
    </div>
  </div>
</div>

<!-- XML EDITOR MODAL (edit mode only) -->
<div id="xml-editor-overlay">
  <div id="xml-editor-modal">
    <div id="xml-editor-title">Edit</div>
    <div id="xml-editor-subtitle">Changes apply to this session only — click "Save Changes" in the toolbar to write them to the file.</div>

    <div id="xml-editor-form-node">
      <div class="xf-row">
        <div class="xf-field sm"><label for="f-node-id">ID</label><input id="f-node-id" type="text"></div>
        <div class="xf-field"><label for="f-node-type">Type</label><select id="f-node-type"></select></div>
      </div>
      <div class="xf-row">
        <div class="xf-field full"><label for="f-node-label">Label</label><input id="f-node-label" type="text"></div>
      </div>
      <div class="xf-row">
        <div class="xf-field sm"><label for="f-node-width">Width</label><input id="f-node-width" type="number"></div>
        <div class="xf-field sm"><label for="f-node-height">Height</label><input id="f-node-height" type="number"></div>
        <div class="xf-field sm"><label for="f-node-fit">Fit</label><select id="f-node-fit"></select></div>
      </div>
      <div class="xf-row">
        <div class="xf-field full"><label for="f-node-src">Image Src <span class="xf-label-note">(used when type = image)</span></label><input id="f-node-src" type="text"></div>
      </div>
      <div class="xf-row">
        <div class="xf-field sm"><label for="f-node-x">Position X</label><input id="f-node-x" type="number"></div>
        <div class="xf-field sm"><label for="f-node-y">Position Y</label><input id="f-node-y" type="number"></div>
      </div>
      <div class="xf-row">
        <div class="xf-field full"><label for="f-node-narrative">Narrative</label><textarea id="f-node-narrative"></textarea></div>
      </div>
      <div class="xf-row">
        <div class="xf-field"><label for="f-node-link">Link</label><input id="f-node-link" type="text"></div>
        <div class="xf-field sm"><label for="f-node-target">Target</label><input id="f-node-target" type="text"></div>
      </div>
      <div class="xf-row">
        <div class="xf-field full"><label for="f-node-cobol">COBOL Paragraph</label><textarea id="f-node-cobol"></textarea></div>
      </div>
      <div class="xf-row">
        <div class="xf-field full">
          <label for="f-node-foreign">Unidentified Foreign Objects</label>
          <textarea id="f-node-foreign" spellcheck="false"></textarea>
        </div>
      </div>
      <div class="xf-hint">Attributes/elements here aren't recognized by this app — usually from a diagram edited elsewhere. If one is really meant to be a field above, move its value there by hand; otherwise leave this box as-is and it's carried through unchanged.</div>
    </div>

    <div id="xml-editor-form-edge">
      <div class="xf-row">
        <div class="xf-field md"><label for="f-edge-from">From (node id)</label><select id="f-edge-from"></select></div>
        <div class="xf-field md"><label for="f-edge-to">To (node id)</label><select id="f-edge-to"></select></div>
      </div>
      <div class="xf-row">
        <div class="xf-field full"><label for="f-edge-label">Label</label><input id="f-edge-label" type="text"></div>
      </div>
      <div class="xf-row">
        <div class="xf-field sm"><label for="f-edge-style">Style</label><select id="f-edge-style"></select></div>
      </div>
      <div class="xf-row">
        <div class="xf-field full">
          <label for="f-edge-foreign">Unidentified Foreign Objects</label>
          <textarea id="f-edge-foreign" spellcheck="false"></textarea>
        </div>
      </div>
      <div class="xf-hint">Attributes/elements here aren't recognized by this app — usually from a diagram edited elsewhere. If one is really meant to be a field above, move its value there by hand; otherwise leave this box as-is and it's carried through unchanged.</div>
    </div>

    <div id="xml-editor-error"></div>
    <div id="xml-editor-actions">
      <div class="left">
        <button class="btn btn-danger" id="delete-xml-btn" onclick="deleteXmlObject()">🗑 Delete</button>
        <button class="btn btn-ghost" id="duplicate-node-btn" onclick="duplicateNodeToNewId()">📋 Duplicate to new id</button>
      </div>
      <div class="right">
        <button class="btn btn-ghost" onclick="closeXmlEditor()">Cancel</button>
        <button class="btn btn-primary" onclick="applyXmlEdit()">Apply</button>
      </div>
    </div>
  </div>
</div>

<!-- ADD-OBJECT PICKER (background click in edit mode) -->
<div id="add-object-overlay">
  <div id="add-object-modal">
    <div id="add-object-title">Add new object here?</div>
    <div id="add-object-actions">
      <button class="btn btn-primary" onclick="startNewAside()">➕ Aside note</button>
      <button class="btn btn-ghost" onclick="startNewEdge()">➕ Edge (connect two nodes)</button>
      <button class="btn btn-ghost" onclick="closeAddObjectPicker()">Cancel</button>
    </div>
  </div>
</div>

<!-- BULK MOVE PANEL (appears once a marquee-drag or click selects 1+ nodes in Select mode) -->
<div id="bulk-move-panel">
  <div id="bulk-move-header">
    <span id="bulk-move-count">0 nodes selected</span>
    <button class="btn btn-ghost" onclick="clearBulkMoveValues()" style="padding:2px 8px;font-size:12px;">✕ Clear</button>
  </div>

  <div class="bulk-move-axis">
    <label class="bulk-move-axis-label">Nudge</label>
    <div id="compass-rose" title="Move the selection by the step size, in the clicked direction">
      <button type="button" class="compass-btn" onclick="nudgeBulkMove(-1,-1)" title="Nudge up-left">↖</button>
      <button type="button" class="compass-btn" onclick="nudgeBulkMove(0,-1)" title="Nudge up">↑</button>
      <button type="button" class="compass-btn" onclick="nudgeBulkMove(1,-1)" title="Nudge up-right">↗</button>
      <button type="button" class="compass-btn" onclick="nudgeBulkMove(-1,0)" title="Nudge left">←</button>
      <input type="number" id="compass-step" value="10" min="1" step="1" title="Step size (px) per click">
      <button type="button" class="compass-btn" onclick="nudgeBulkMove(1,0)" title="Nudge right">→</button>
      <button type="button" class="compass-btn" onclick="nudgeBulkMove(-1,1)" title="Nudge down-left">↙</button>
      <button type="button" class="compass-btn" onclick="nudgeBulkMove(0,1)" title="Nudge down">↓</button>
      <button type="button" class="compass-btn" onclick="nudgeBulkMove(1,1)" title="Nudge down-right">↘</button>
    </div>
  </div>

  <div class="bulk-move-axis">
    <label class="bulk-move-axis-label">X axis</label>
    <div class="bulk-move-row">
      <label><input type="radio" name="bulk-move-x-mode" value="relative" checked onchange="onBulkMoveModeChange()"> Shift by (px)</label>
      <input type="number" id="bulk-move-x-relative" value="0" step="1">
    </div>
    <div class="bulk-move-row">
      <label id="bulk-move-x-abs-label"><input type="radio" name="bulk-move-x-mode" value="absolute" id="bulk-move-x-abs-radio" onchange="onBulkMoveModeChange()"> Move to (px)</label>
      <input type="number" id="bulk-move-x-absolute" step="1" disabled>
      <span id="bulk-move-x-abs-note" class="bulk-move-note"></span>
    </div>
  </div>

  <div class="bulk-move-axis">
    <label class="bulk-move-axis-label">Y axis</label>
    <div class="bulk-move-row">
      <label><input type="radio" name="bulk-move-y-mode" value="relative" checked onchange="onBulkMoveModeChange()"> Shift by (px)</label>
      <input type="number" id="bulk-move-y-relative" value="0" step="1">
    </div>
    <div class="bulk-move-row">
      <label id="bulk-move-y-abs-label"><input type="radio" name="bulk-move-y-mode" value="absolute" id="bulk-move-y-abs-radio" onchange="onBulkMoveModeChange()"> Move to (px)</label>
      <input type="number" id="bulk-move-y-absolute" step="1" disabled>
      <span id="bulk-move-y-abs-note" class="bulk-move-note"></span>
    </div>
  </div>

  <div id="bulk-move-actions">
    <button class="btn btn-ghost" onclick="clearSelection()">Cancel</button>
    <button class="btn btn-primary" onclick="applyBulkMove()">Apply</button>
  </div>
</div>

<!-- UNDO TOAST (after a bulk move — stays until another edit, save, or reload) -->
<div id="undo-toast">
  <span id="undo-toast-msg"></span>
  <button type="button" onclick="undoLastBulkMove()">Undo</button>
</div>

<script>
// ── CONFIG (filenames resolved server-side from ?name= param) ──────────────
const XML_FILENAME = <?php echo json_encode($xmlFile); ?>;
const IS_NEW_REQUEST = <?php echo json_encode($isNewRequest); ?>;
const SVG_FILENAME = <?php echo json_encode($svgFile); ?>;
const BASE_NAME     = <?php echo json_encode($baseName); ?>;

// Single source of truth for the trademark/copyright notice text, reused
// across the live-preview SVG, the exported SVG, the saved XML (as a
// comment), and the HTML image map (as a comment). A function rather than a
// plain constant so the year is always whatever's current at the moment
// something is rendered/saved, not frozen at page-load time.
function copyrightNoticeLines() {
  const year = new Date().getFullYear();
  return [
    `MAGEC is a trademark of Al Lee & Associates, Inc. \u00A9 ${year} Al Lee & Associates, Inc. All rights reserved.`,
    `All programs, scripts, diagrams, and documentation are copyright protected.`
  ];
}
function copyrightNoticeText() {
  return copyrightNoticeLines().join(' ');
}
const FALLBACK_XML = `<?xml version="1.0" encoding="UTF-8"?>
<flowchart title="MAGEC Process">
  <nodes>
    <node id="1" type="start" label="Start">
      <position x="300" y="80"/>
      <narrative>Start of the process</narrative>
    </node>
  </nodes>
  <edges></edges>
</flowchart>`;

let currentData = null;
let currentXmlDoc = null;   // live XML Document — the source of truth once editing starts
let editMode = false;
let hasUnsavedChanges = false;
let currentOX = 0, currentOY = 0; // render offset of the current preview, needed to translate clicks into content-space coordinates
let currentNodeGroups = []; // parallel to currentData.nodes — each node's rendered <g>, rebuilt every renderSvg() call

// ── Multi-select / bulk move ────────────────────────────────────────────
let selectMode = false;
let selectedNodeIndices = new Set();
let marqueeStart = null;   // content-space {x,y} where the current marquee drag began, or null
let marqueeEl = null;      // the SVG <rect> visual for an in-progress marquee drag
// Records each node's position the *first* time it's moved during the
// current bulk-move session (Apply and compass nudges both feed into this).
// Keyed by node index; a node already present is left alone on subsequent
// moves, so this always holds "where things were when the session began" —
// letting Undo roll back an entire series of moves in one shot, not just
// the last one. Cleared whenever the session ends (panel closed, selection
// cleared, save, or reload).
let bulkMoveBaseline = new Map(); // idx -> {x, y}

// ── Click-to-cycle hit testing (picking a node hidden behind another) ────
// A couple of pixels of slack when deciding whether a new click landed "in
// the same spot" as the previous one, for cycling through stacked nodes.
const CYCLE_CLICK_TOLERANCE = 4;
let cycleClickState = null; // { x, y, hits: [nodeIdx,...] topmost-first, cursor } or null

// Non-color geometry/typography per node type — shared across all themes.
// Colors (fill/stroke/badge/fontColor) live in THEMES[name].nodeColors and
// are merged in by rebuildTypeCfg() whenever the theme changes, producing
// the actual TYPE_CFG/DEFAULT_CFG used everywhere else in the app.
const BASE_TYPE_CFG = {
  start:    { label: 'START',    w: 120, h: 44, rx: 22 },
  end:      { label: 'END',      w: 120, h: 44, rx: 22 },
  process:  { label: 'PROCESS',  w: 160, h: 52, rx: 6  },
  note:     { label: 'NOTE',     w: 160, h: 50, rx: 6  },
  elbow:    { label: '',         w: 10,  h: 10, rx: 5  },
  decision: { label: 'DECISION', w: 160, h: 60, rx: 0  },
  aside:    { label: 'ASIDE',    w: 150, h: 50, rx: 6, dashed: true },
  title:    { label: 'TITLE',    w: 460, h: 60, rx: 0, fontSize: 28, fontFamily: "Georgia, 'Times New Roman', Cambria, serif", fontWeight: '700' },
  comment:  { label: 'COMMENT',  w: 220, h: 40,  rx: 0, fontSize: 13, fontFamily: "'Segoe UI', system-ui, sans-serif", fontWeight: '600' },
  warning:  { label: 'WARNING',  w: 240, h: 90,  rx: 0, fontSize: 13, fontFamily: "'Segoe UI', system-ui, sans-serif", fontWeight: '600' },
  notice:   { label: 'NOTICE',   w: 240, h: 90,  rx: 0, fontSize: 13, fontFamily: "'Segoe UI', system-ui, sans-serif", fontWeight: '600' },
  image:    { label: 'IMAGE',    w: 120, h: 80,  rx: 0 },
};
const DEFAULT_TYPE_GEOMETRY = { label: 'NODE', w: 140, h: 48, rx: 6 };

// Populated by rebuildTypeCfg() below, once THEMES exists and a theme is
// applied — every other function just reads these two by reference, so
// reassigning them on a theme change propagates everywhere automatically.
let TYPE_CFG = {};
let DEFAULT_CFG = {};

// ── COLOR THEMES ─────────────────────────────────────────────────────────
// "Bold" is this app's original look (vivid indigo accent, saturated node
// colors) and stays the default. "Boardroom" is a muted, warm-charcoal /
// brass palette for a more understated presentation feel. "Technical" is
// a cool cyan-on-near-black schematic/blueprint look. Each theme supplies:
//   - css: values for the CSS custom properties defined in :root
//   - grid: { line, axis } colors used by drawGrid() (the orange x=0/x=W
//     boundary markers stay fixed across all themes — see GRID_BOUNDARY_COLOR)
//   - nodeColors: fill/stroke/badge (+ fontColor for text-only types) per
//     node type, merged onto BASE_TYPE_CFG's geometry by rebuildTypeCfg()
const THEMES = {
  bold: {
    label: 'Bold (default)',
    css: {
      '--bg-app': '#0f1117', '--bg-panel': '#1a1d27', '--bg-input': '#20232f',
      '--border': '#2d3148', '--border-strong': '#4a5280',
      '--text-primary': '#e2e8f0', '--text-muted': '#94a3b8', '--text-dim': '#64748b',
      '--accent': '#a5b4fc', '--accent-strong': '#6366f1', '--accent-hover-glow': 'rgba(99,102,241,.18)',
      '--success': '#4ade80',
      '--warn': '#fbbf24', '--warn-bg': '#fbbf2422', '--warn-border': '#fbbf2455',
      '--danger-bg': '#7f1d1d', '--danger-text': '#fecaca', '--danger-soft-bg': '#7f1d1d33',
      '--edge-color': '#8b95c4', '--mono-text': '#c7d2fe', '--node-label-color': '#ffffff',
    },
    grid: { line: '#252838', axis: '#4a5280' },
    nodeColors: {
      start:    { fill: '#166534', stroke: '#4ade80', badge: '#4ade80' },
      end:      { fill: '#7f1d1d', stroke: '#f87171', badge: '#f87171' },
      process:  { fill: '#1e3a5f', stroke: '#60a5fa', badge: '#60a5fa' },
      note:     { fill: '#555555', stroke: '#60a5fa', badge: '#60a5fa' },
      elbow:    { fill: '#999999', stroke: '#60a5fa', badge: '#60a5fa' },
      decision: { fill: '#431407', stroke: '#fb923c', badge: '#fb923c' },
      aside:    { fill: '#2d3b4d', stroke: '#8b95c4', badge: '#c084fc' },
      title:    { fill: 'none', stroke: 'none', badge: '#a5b4fc', fontColor: '#e2e8f0' },
      comment:  { fill: 'none', stroke: 'none', badge: '#94a3b8', fontColor: '#e2e8f0' },
      warning:  { fill: 'none', stroke: 'none', badge: '#f87171', fontColor: '#f87171' },
      notice:   { fill: 'none', stroke: 'none', badge: '#86efac', fontColor: '#86efac' },
      image:    { fill: 'none', stroke: 'none', badge: '#facc15' },
      default:  { fill: '#1e293b', stroke: '#94a3b8', badge: '#94a3b8' },
    }
  },
  boardroom: {
    label: 'Boardroom (B&W print)',
    css: {
      '--bg-app': '#ffffff', '--bg-panel': '#f7f7f7', '--bg-input': '#eeeeee',
      '--border': '#cccccc', '--border-strong': '#999999',
      '--text-primary': '#1a1a1a', '--text-muted': '#4d4d4d', '--text-dim': '#737373',
      '--accent': '#1a1a1a', '--accent-strong': '#404040', '--accent-hover-glow': 'rgba(0,0,0,.15)',
      '--success': '#2b2b2b',
      '--warn': '#333333', '--warn-bg': '#00000010', '--warn-border': '#00000035',
      '--danger-bg': '#1a1a1a', '--danger-text': '#ffffff', '--danger-soft-bg': '#00000012',
      '--edge-color': '#4d4d4d', '--mono-text': '#262626', '--node-label-color': '#1a1a1a',
    },
    // Pure white background, black/gray ink only — no color anywhere, so
    // printing this theme costs only black toner/ink, not color cartridges.
    grid: { line: '#e0e0e0', axis: '#999999' },
    nodeColors: {
      // Black text on white/near-white fills throughout — types are told
      // apart by their shape (pill/rectangle/diamond/dashed) rather than
      // color. A touch of fill variation (pure white vs. very light gray)
      // adds a second cue without needing enough ink to matter.
      start:    { fill: '#ffffff', stroke: '#000000', badge: '#000000' },
      end:      { fill: '#f0f0f0', stroke: '#000000', badge: '#000000' },
      process:  { fill: '#ffffff', stroke: '#1a1a1a', badge: '#1a1a1a' },
      note:     { fill: '#f5f5f5', stroke: '#333333', badge: '#333333' },
      elbow:    { fill: '#ffffff', stroke: '#595959', badge: '#595959' },
      decision: { fill: '#ffffff', stroke: '#000000', badge: '#000000' },
      aside:    { fill: '#f5f5f5', stroke: '#4d4d4d', badge: '#4d4d4d' },
      title:    { fill: 'none', stroke: 'none', badge: '#000000', fontColor: '#1a1a1a' },
      comment:  { fill: 'none', stroke: 'none', badge: '#4d4d4d', fontColor: '#333333' },
      warning:  { fill: 'none', stroke: 'none', badge: '#000000', fontColor: '#000000' },
      notice:   { fill: 'none', stroke: 'none', badge: '#595959', fontColor: '#404040' },
      image:    { fill: 'none', stroke: 'none', badge: '#333333' },
      default:  { fill: '#f5f5f5', stroke: '#4d4d4d', badge: '#4d4d4d' },
    }
  },
  technical: {
    label: 'Technical (schematic)',
    css: {
      '--bg-app': '#0b1414', '--bg-panel': '#0f1c1c', '--bg-input': '#142626',
      '--border': '#1f3838', '--border-strong': '#2f5252',
      '--text-primary': '#d3f5ea', '--text-muted': '#7fa89e', '--text-dim': '#4f6f68',
      '--accent': '#4fd1c5', '--accent-strong': '#14b8a6', '--accent-hover-glow': 'rgba(20,184,166,.2)',
      '--success': '#4ade80',
      '--warn': '#eab308', '--warn-bg': '#eab30822', '--warn-border': '#eab30855',
      '--danger-bg': '#7c2d2d', '--danger-text': '#fca5a5', '--danger-soft-bg': '#7c2d2d33',
      '--edge-color': '#4fd1c5', '--mono-text': '#a7f3ea', '--node-label-color': '#ffffff',
    },
    grid: { line: '#142626', axis: '#2f5252' },
    nodeColors: {
      start:    { fill: '#0f3d33', stroke: '#34d399', badge: '#34d399' },
      end:      { fill: '#4a1f1f', stroke: '#f87171', badge: '#f87171' },
      process:  { fill: '#0f2e3d', stroke: '#38bdf8', badge: '#38bdf8' },
      note:     { fill: '#24413f', stroke: '#5eead4', badge: '#5eead4' },
      elbow:    { fill: '#2f5252', stroke: '#5eead4', badge: '#5eead4' },
      decision: { fill: '#3d2e0f', stroke: '#facc15', badge: '#facc15' },
      aside:    { fill: '#14262a', stroke: '#4fd1c5', badge: '#67e8f9' },
      title:    { fill: 'none', stroke: 'none', badge: '#4fd1c5', fontColor: '#d3f5ea' },
      comment:  { fill: 'none', stroke: 'none', badge: '#7fa89e', fontColor: '#d3f5ea' },
      warning:  { fill: 'none', stroke: 'none', badge: '#f87171', fontColor: '#f87171' },
      notice:   { fill: 'none', stroke: 'none', badge: '#34d399', fontColor: '#34d399' },
      image:    { fill: 'none', stroke: 'none', badge: '#facc15' },
      default:  { fill: '#122424', stroke: '#7fa89e', badge: '#7fa89e' },
    }
  },
  monochrome: {
    label: 'Monochrome (B&W)',
    css: {
      '--bg-app': '#0a0a0a', '--bg-panel': '#161616', '--bg-input': '#202020',
      '--border': '#333333', '--border-strong': '#4d4d4d',
      '--text-primary': '#f5f5f5', '--text-muted': '#a0a0a0', '--text-dim': '#707070',
      '--accent': '#ffffff', '--accent-strong': '#808080', '--accent-hover-glow': 'rgba(255,255,255,.18)',
      '--success': '#cccccc',
      '--warn': '#e0e0e0', '--warn-bg': '#ffffff14', '--warn-border': '#ffffff40',
      '--danger-bg': '#000000', '--danger-text': '#ffffff', '--danger-soft-bg': '#00000066',
      '--edge-color': '#999999', '--mono-text': '#d0d0d0', '--node-label-color': '#ffffff',
    },
    // Note: the grid's regular lines/axis follow this theme's grays like
    // every other theme, but the x=0/x=W boundary markers stay orange
    // here too (see GRID_BOUNDARY_COLOR) — that's a deliberate, fixed
    // "this is the export edge" signal rather than a decorative color, so
    // it's intentionally the one non-gray mark on an otherwise B&W canvas.
    grid: { line: '#202020', axis: '#4d4d4d' },
    nodeColors: {
      // No hue to lean on, so types are distinguished mainly by value
      // (how light/dark the fill is) and stroke weight/brightness rather
      // than color — start/decision/warning lean toward white strokes
      // (attention), end leans darkest (terminal), the rest sit in
      // between on a straightforward gray scale.
      start:    { fill: '#3a3a3a', stroke: '#e8e8e8', badge: '#e8e8e8' },
      end:      { fill: '#1a1a1a', stroke: '#808080', badge: '#808080' },
      process:  { fill: '#2b2b2b', stroke: '#b0b0b0', badge: '#b0b0b0' },
      note:     { fill: '#454545', stroke: '#b0b0b0', badge: '#b0b0b0' },
      elbow:    { fill: '#666666', stroke: '#b0b0b0', badge: '#b0b0b0' },
      decision: { fill: '#202020', stroke: '#ffffff', badge: '#ffffff' },
      aside:    { fill: '#333333', stroke: '#999999', badge: '#cccccc' },
      title:    { fill: 'none', stroke: 'none', badge: '#ffffff', fontColor: '#f5f5f5' },
      comment:  { fill: 'none', stroke: 'none', badge: '#a0a0a0', fontColor: '#f5f5f5' },
      warning:  { fill: 'none', stroke: 'none', badge: '#ffffff', fontColor: '#ffffff' },
      notice:   { fill: 'none', stroke: 'none', badge: '#cccccc', fontColor: '#cccccc' },
      image:    { fill: 'none', stroke: 'none', badge: '#e0e0e0' },
      default:  { fill: '#2b2b2b', stroke: '#a0a0a0', badge: '#a0a0a0' },
    }
  },
};

// Fixed regardless of theme — a deliberate, constant "this is the export
// boundary" marker rather than a decorative theme color (see drawGrid()).
const GRID_BOUNDARY_COLOR = '#f97316';

let currentThemeName = 'bold';
let currentGridLineColor = THEMES.bold.grid.line;
let currentAxisLineColor = THEMES.bold.grid.axis;

function rebuildTypeCfg(themeName) {
  const theme = THEMES[themeName] || THEMES.bold;
  const merged = {};
  Object.keys(BASE_TYPE_CFG).forEach(type => {
    merged[type] = Object.assign({}, BASE_TYPE_CFG[type], theme.nodeColors[type] || {});
  });
  TYPE_CFG = merged;
  DEFAULT_CFG = Object.assign({}, DEFAULT_TYPE_GEOMETRY, theme.nodeColors.default);
}

function applyTheme(themeName, skipRender) {
  const theme = THEMES[themeName] ? themeName : 'bold';
  const t = THEMES[theme];

  Object.entries(t.css).forEach(([varName, value]) => {
    document.documentElement.style.setProperty(varName, value);
  });
  currentThemeName = theme;
  currentGridLineColor = t.grid.line;
  currentAxisLineColor = t.grid.axis;
  rebuildTypeCfg(theme);

  const select = document.getElementById('theme-select');
  if (select) select.value = theme;

  try { localStorage.setItem('flowchart-theme', theme); } catch (e) { /* private browsing, etc. — non-fatal */ }

  if (!skipRender && currentData) renderSvg(currentData); // no-op before any data is loaded, or during settings restore
}

// ── SETTINGS PERSISTENCE (in the XML itself) ─────────────────────────────
// Theme, export width, grid visibility, and logo selection are stored as a
// <settings> element in the saved XML (not just localStorage), so the
// diagram opens the same way for whoever opens the file next, on whatever
// browser. Changing any of these counts as an unsaved change exactly like
// editing a node/edge does — same badge, same Save/Discard flow.

function readSettingsFromDoc(doc) {
  const el = doc.querySelector('settings');
  if (!el) return null;
  return {
    theme: el.getAttribute('theme') || null,
    exportWidth: el.getAttribute('exportWidth') || null,
    grid: el.hasAttribute('grid') ? (el.getAttribute('grid') === 'true') : null,
    logo: el.hasAttribute('logo') ? el.getAttribute('logo') : null,
  };
}

// Ensures a <select> has an <option> for the given value (e.g. a saved
// export width that isn't one of the standard 1200/900/600 choices),
// mirroring what the PHP side already does for the map.html-detected width.
function ensureSelectOption(selectEl, value, labelSuffix) {
  if (!Array.from(selectEl.options).some(o => o.value === String(value))) {
    const opt = document.createElement('option');
    opt.value = value;
    opt.textContent = value + (labelSuffix || '');
    selectEl.appendChild(opt);
  }
}

// Applies whatever's saved in the XML's <settings> element (falling back
// to the locally-remembered theme for brand-new diagrams that don't have
// one yet) to the UI controls. Called from loadAndRender() before the
// first render, so nothing here should mark unsaved changes or render on
// its own — it's restoring already-saved state, not changing anything.
function applySettingsFromDoc() {
  const settings = readSettingsFromDoc(currentXmlDoc);

  applyTheme((settings && settings.theme) || getSavedTheme(), /* skipRender */ true);

  if (settings && settings.exportWidth) {
    const widthSel = document.getElementById('export-width-select');
    if (widthSel) {
      ensureSelectOption(widthSel, settings.exportWidth, 'px');
      widthSel.value = settings.exportWidth;
    }
  }
  if (settings && settings.grid !== null) {
    const gridToggle = document.getElementById('grid-toggle');
    if (gridToggle) gridToggle.checked = settings.grid;
  }
  if (settings && settings.logo !== null) {
    const logoSel = document.getElementById('logo-select');
    if (logoSel && Array.from(logoSel.options).some(o => o.value === settings.logo)) {
      logoSel.value = settings.logo;
    }
  }
}

// Inserts a newline immediately before the first occurrence of `marker` in
// `text`, unless one is already there (or the marker isn't found at all).
// Used to make saved XML a bit more readable without doing a full
// pretty-print of the whole document.
function ensureLeadingNewline(text, marker) {
  const idx = text.indexOf(marker);
  if (idx === -1) return text;
  if (idx === 0 || text[idx - 1] === '\n') return text;
  return text.slice(0, idx) + '\n' + text.slice(idx);
}

// Inserts (or refreshes) the copyright/trademark notice as an XML comment
// immediately before <flowchart>. Any previous copy of the comment is
// stripped first and a fresh one written in its place — same philosophy as
// <settings> being rewritten on every save — so the year in the notice
// always matches the save date instead of freezing at whenever it was
// first added to this particular file.
function upsertXmlCopyrightComment(xmlText) {
  xmlText = xmlText.replace(/<!--\s*MAGEC is a trademark[\s\S]*?-->\n?/, '');
  const idx = xmlText.indexOf('<flowchart');
  if (idx === -1) return xmlText;
  const commentText = `<!-- ${copyrightNoticeText()} -->\n`;
  return xmlText.slice(0, idx) + commentText + xmlText.slice(idx);
}

// Writes the current UI control values into currentXmlDoc's <settings>
// element (creating it if it doesn't exist yet).
function writeSettingsToDoc() {
  if (!currentXmlDoc) return;
  const root = currentXmlDoc.querySelector('flowchart');
  if (!root) return;

  let settingsEl = currentXmlDoc.querySelector('settings');
  if (!settingsEl) {
    settingsEl = currentXmlDoc.createElement('settings');
    root.insertBefore(settingsEl, root.firstChild);
  }

  const themeSel = document.getElementById('theme-select');
  const widthSel = document.getElementById('export-width-select');
  const gridToggle = document.getElementById('grid-toggle');
  const logoSel = document.getElementById('logo-select');

  if (themeSel) settingsEl.setAttribute('theme', themeSel.value);
  if (widthSel) settingsEl.setAttribute('exportWidth', widthSel.value);
  if (gridToggle) settingsEl.setAttribute('grid', gridToggle.checked ? 'true' : 'false');
  if (logoSel) settingsEl.setAttribute('logo', logoSel.value);

  // If a node of type="title" exists and carries a non-blank label, treat
  // it as the diagram's title of record — keeps <flowchart title="..."> in
  // sync with that node instead of requiring the title to be maintained
  // redundantly in two places. Blank/whitespace-only labels are ignored so
  // an empty title node doesn't wipe out an existing title.
  const titleLabel = Array.from(currentXmlDoc.querySelectorAll('node[type="title"]'))
    .map(n => (n.getAttribute('label') || '').trim())
    .find(label => label !== '');
  if (titleLabel) root.setAttribute('title', titleLabel);
}

// Called by the settings controls' onchange handlers below — persists the
// new value into currentXmlDoc and flags it as an unsaved change, same as
// editing a node/edge would.
function markSettingsChanged() {
  writeSettingsToDoc();
  hasUnsavedChanges = true;
  updateUnsavedUI();
}

function onThemeSelectChange(name) {
  applyTheme(name);
  markSettingsChanged();
}
function onExportWidthChange() {
  renderSvg(currentData);
  markSettingsChanged();
}
function onGridToggleChange() {
  renderSvg(currentData);
  markSettingsChanged();
}
function onLogoChange() {
  renderSvg(currentData);
  markSettingsChanged();
}

// Logo box size, in the same "content units" as node positions/sizes below.
// Because it's expressed in content units (not pixels), it scales together
// with the rest of the diagram when an export width is applied.
const LOGO_W = 239, LOGO_H = 70, LOGO_MARGIN = 8;

// Returns the effective {w, h} for a node: per-node width/height attributes
// (if given in the XML) override the type's default size in TYPE_CFG. This
// lets e.g. an 'image' node be sized per-instance (logo vs. screenshot)
// rather than locked to one fixed box for the whole type.
function getNodeSize(node) {
  const c = TYPE_CFG[node.type] || DEFAULT_CFG;
  return {
    w: (node.customW != null && !isNaN(node.customW)) ? node.customW : c.w,
    h: (node.customH != null && !isNaN(node.customH)) ? node.customH : c.h,
  };
}

// Converts the literal character sequence "- -" (dash, space, dash) into an
// explicit <br> line-break command. Applied to node labels and narratives
// (and edge labels) at parse time, so authors can type "- -" inline in the
// XML instead of the harder-to-type "<br>" tag, and everything downstream
// (wrapLabel, appendRichText, richLineToSvgMarkup, tooltip/modal HTML) keeps
// working exactly as it already does for <br>.
function applyDashBreaks(text) {
  if (!text) return text;
  return text.split('- -').join('<br>');
}

// ── LOAD & PARSE ─────────────────────────────────────────────────────────
async function loadAndRender() {
  try {
    const res = await fetch(XML_FILENAME, { cache: 'no-store' });
    const xmlText = res.ok ? await res.text() : FALLBACK_XML;
    currentXmlDoc = parseXmlDoc(xmlText);
    applySettingsFromDoc();
    currentData = dataFromDoc(currentXmlDoc);
    hasUnsavedChanges = false;
    updateUnsavedUI();
    selectedNodeIndices = new Set();
    bulkMoveBaseline.clear();
    hideUndoToast();
    closeBulkMovePanel();
    document.getElementById('chart-title').textContent = currentData.title + ' (' + XML_FILENAME + ')';
    renderSvg(currentData);
  } catch (e) {
    console.error('Load error:', e);
  }
}

// Parses an XML string into a Document, throwing a readable error if it's
// malformed (DOMParser doesn't throw natively — it embeds a <parsererror>
// element in the returned document instead, so we check for that).
function parseXmlDoc(xmlStr) {
  const parser = new DOMParser();
  const doc = parser.parseFromString(xmlStr, 'application/xml');
  const perr = doc.querySelector('parsererror');
  if (perr) {
    throw new Error('XML parse error: ' + perr.textContent.trim().split('\n')[0]);
  }
  return doc;
}

function parseXml(xmlStr) {
  return dataFromDoc(parseXmlDoc(xmlStr));
}

// Derives the render-friendly {title, nodes, edges} data from the live XML
// Document. Called after every edit so the preview always reflects
// currentXmlDoc exactly (which is what actually gets saved).
function dataFromDoc(doc) {
  const fc = doc.querySelector('flowchart');
  const title = fc ? (fc.getAttribute('title') || 'MAGEC Flowchart') : 'MAGEC Flowchart';

  const nodes = [];
  doc.querySelectorAll('node').forEach(n => {
    const pos = n.querySelector('position');
    const nar = n.querySelector('narrative');
    const lnk = n.querySelector('link');
    const tgt = n.querySelector('target');
    const cob = n.querySelector('cobolparagraph');
    const link = lnk ? lnk.textContent.trim() : '';
    // Default rule: if a link is present but no <target> is specified,
    // the target defaults to 'MAIN'. If <target> is explicitly given,
    // that value is used instead (whatever the user wants it to be).
    const target = tgt ? tgt.textContent.trim() : (link ? 'MAIN' : '');
    const wAttr = n.getAttribute('width');
    const hAttr = n.getAttribute('height');
    const parsedW = wAttr !== null ? parseFloat(String(wAttr).trim()) : null;
    const parsedH = hAttr !== null ? parseFloat(String(hAttr).trim()) : null;
    nodes.push({
      id:        n.getAttribute('id') || '',
      type:      (n.getAttribute('type') || 'process').toLowerCase(),
      label:     applyDashBreaks(n.getAttribute('label') || ''),
      src:       n.getAttribute('src') || '',
      fit:       (n.getAttribute('fit') || 'contain').toLowerCase(), // 'contain' (preserve aspect, default) or 'fill' (stretch to exact box)
      customW:   parsedW,
      customH:   parsedH,
      x:         pos ? parseFloat(pos.getAttribute('x')) : 100,
      y:         pos ? parseFloat(pos.getAttribute('y')) : 100,
      narrative: applyDashBreaks(nar ? nar.textContent.trim() : ''),
      link:      link,
      target:    target,
      cobolParagraph: cob ? cob.textContent.trim() : '',
    });
  });

  const edges = [];
  doc.querySelectorAll('edge').forEach(e => {
    edges.push({
      from:  e.getAttribute('from'),
      to:    e.getAttribute('to'),
      label: applyDashBreaks(e.getAttribute('label') || ''),
      style: (e.getAttribute('style') || 'solid').toLowerCase(), // 'solid' (default, arrowed) or 'dotted' (no arrowhead)
    });
  });

  return { title, nodes, edges };
}

// ── XML EDIT MODE ────────────────────────────────────────────────────────
// Lets the user click a node or edge in the preview and edit its raw XML
// directly, cPanel-style: edits are applied to the in-memory currentXmlDoc
// (and the preview re-renders immediately) but nothing touches the file on
// disk until "Save Changes" is clicked and confirmed. "Discard Changes"
// throws away every in-memory edit and reloads the last saved version.

function toggleEditMode() {
  editMode = document.getElementById('edit-mode-toggle').checked;
  document.body.classList.toggle('edit-mode', editMode);
  document.getElementById('edit-mode-hint').textContent = editMode
    ? 'Click a node or edge to edit its XML'
    : '_____Click nodes for details________';

  const selectToggle = document.getElementById('select-mode-toggle');
  selectToggle.disabled = !editMode;
  if (!editMode && selectMode) {
    selectToggle.checked = false;
    toggleSelectMode();
  }
}

// Turns rectangle/click multi-select on or off. Selection state (and the
// bulk-move panel it drives) only makes sense while this is on, so turning
// it off always clears whatever was selected.
function toggleSelectMode() {
  selectMode = document.getElementById('select-mode-toggle').checked;
  document.body.classList.toggle('select-mode', selectMode);
  if (!selectMode) clearSelection();
}

function findNodeXmlEl(index) {
  return currentXmlDoc.querySelectorAll('node')[index] || null;
}
function findEdgeXmlEl(index) {
  return currentXmlDoc.querySelectorAll('edge')[index] || null;
}

// ── VALIDATION ───────────────────────────────────────────────────────────
// Catches the typo-class mistakes that would otherwise fail silently (a
// misspelled type quietly falling back to a generic box, a stray letter in
// a number producing NaN, an edge pointing at a node id that no longer
// exists, etc.) before the edit is ever applied. Pulls valid types straight
// from TYPE_CFG so this can't drift out of sync with what's actually
// supported.
const VALID_NODE_TYPES = Object.keys(BASE_TYPE_CFG);
const VALID_FIT_VALUES = ['contain', 'fill'];
const VALID_EDGE_STYLES = ['solid', 'dotted'];

// Schema tables, kept next to the code that actually reads these
// attributes/elements in dataFromDoc() so they're easy to keep in sync.
const VALID_NODE_ATTRS = ['id', 'type', 'label', 'src', 'fit', 'width', 'height'];
const VALID_NODE_CHILDREN = ['position', 'narrative', 'link', 'target', 'cobolparagraph'];
const VALID_EDGE_ATTRS = ['from', 'to', 'label', 'style'];

function isValidNumber(str) {
  if (str === null || String(str).trim() === '') return false;
  return !isNaN(Number(String(str).trim()));
}

// Small Levenshtein distance implementation, used only to power "did you
// mean...?" suggestions for misspelled tag/attribute names — not meant to
// be fast or fancy, just good enough for short XML identifiers.
function levenshtein(a, b) {
  const dp = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
  for (let i = 0; i <= a.length; i++) dp[i][0] = i;
  for (let j = 0; j <= b.length; j++) dp[0][j] = j;
  for (let i = 1; i <= a.length; i++) {
    for (let j = 1; j <= b.length; j++) {
      dp[i][j] = a[i-1] === b[j-1] ? dp[i-1][j-1] : 1 + Math.min(dp[i-1][j-1], dp[i-1][j], dp[i][j-1]);
    }
  }
  return dp[a.length][b.length];
}
function closestMatch(input, candidates, maxDist = 2) {
  let best = null, bestDist = Infinity;
  const lower = String(input).toLowerCase();
  candidates.forEach(c => {
    const d = levenshtein(lower, c.toLowerCase());
    if (d < bestDist) { bestDist = d; best = c; }
  });
  return (best !== null && bestDist > 0 && bestDist <= maxDist) ? best : null;
}

// Validates a freshly-parsed <node> fragment. `selfEl` is the existing DOM
// element being replaced (excluded from the duplicate-id check) — pass
// null when validating a brand-new node.
function validateNodeElement(newEl, selfEl) {
  let errors = [];

  const id = (newEl.getAttribute('id') || '').trim();
  if (!id) {
    errors.push('Missing required "id" attribute.');
  } else {
    const dupe = Array.from(currentXmlDoc.querySelectorAll('node'))
      .find(n => n !== selfEl && n.getAttribute('id') === id);
    if (dupe) errors.push(`Node id "${id}" is already used by another node — ids must be unique.`);
  }

  const typeAttr = newEl.getAttribute('type');
  let effectiveType = 'process';
  if (typeAttr !== null) {
    const type = typeAttr.trim().toLowerCase();
    if (!type) {
      errors.push('The "type" attribute is present but empty. Remove it to use the default ("process"), or set a valid type.');
    } else if (!VALID_NODE_TYPES.includes(type)) {
      errors.push(`Unknown type "${typeAttr}". Valid types: ${VALID_NODE_TYPES.join(', ')}.`);
    } else {
      effectiveType = type;
    }
  }

  const pos = newEl.querySelector('position');
  if (!pos) {
    errors.push('Missing required <position x="…" y="…"/> child element.');
  } else {
    const xOk = isValidNumber(pos.getAttribute('x'));
    const yOk = isValidNumber(pos.getAttribute('y'));
    if (!xOk) errors.push(`<position> x="${pos.getAttribute('x')}" is not a valid number.`);
    if (!yOk) errors.push(`<position> y="${pos.getAttribute('y')}" is not a valid number.`);

    // Elbows are routing waypoints and legitimately coincide with other
    // points sometimes; every other node type sharing exact coordinates
    // with another node is almost always an accidental copy-paste.
    if (xOk && yOk && effectiveType !== 'elbow') {
      const px = Number(pos.getAttribute('x')), py = Number(pos.getAttribute('y'));
      const clash = Array.from(currentXmlDoc.querySelectorAll('node')).find(n => {
        if (n === selfEl) return false;
        const p2 = n.querySelector('position');
        if (!p2) return false;
        return Number(p2.getAttribute('x')) === px && Number(p2.getAttribute('y')) === py;
      });
      if (clash) {
        errors.push(`Position (${px}, ${py}) is already used by node "${clash.getAttribute('id')}" — duplicate coordinates usually mean a copy-paste typo.`);
      }
    }
  }

  ['width', 'height'].forEach(attr => {
    const v = newEl.getAttribute(attr);
    if (v !== null && v.trim() !== '') {
      if (!isValidNumber(v) || Number(v) <= 0) errors.push(`"${attr}" must be a positive number (got "${v}").`);
    }
  });

  const fit = newEl.getAttribute('fit');
  if (fit !== null && fit.trim() !== '' && !VALID_FIT_VALUES.includes(fit.trim().toLowerCase())) {
    errors.push(`"fit" must be one of: ${VALID_FIT_VALUES.join(', ')} (got "${fit}").`);
  }

  if (effectiveType === 'image' && !(newEl.getAttribute('src') || '').trim()) {
    errors.push('Image nodes require a non-empty "src" attribute.');
  }

  return errors;
}

// `selfEl` is the existing DOM edge element being replaced (excluded from
// duplicate/sibling checks) — pass null when validating a brand-new edge.
function validateEdgeElement(newEl, selfEl) {
  let errors = [];
  const nodeEls = Array.from(currentXmlDoc.querySelectorAll('node'));
  const nodeIds = new Set(nodeEls.map(n => n.getAttribute('id')));

  const from = (newEl.getAttribute('from') || '').trim();
  const to = (newEl.getAttribute('to') || '').trim();
  if (!from) errors.push('Missing required "from" attribute.');
  else if (!nodeIds.has(from)) errors.push(`"from" references node id "${from}", which doesn't exist.`);

  if (!to) errors.push('Missing required "to" attribute.');
  else if (!nodeIds.has(to)) errors.push(`"to" references node id "${to}", which doesn't exist.`);

  if (from && to && from === to) {
    errors.push(`"to" cannot be the same as "from" (both are "${from}"). Self-loop edges are not allowed.`);
  }

  const style = newEl.getAttribute('style');
  if (style !== null && style.trim() !== '' && !VALID_EDGE_STYLES.includes(style.trim().toLowerCase())) {
    errors.push(`"style" must be one of: ${VALID_EDGE_STYLES.join(', ')} (got "${style}").`);
  }

  // Decision nodes are expected to have distinctly-labeled branches (e.g.
  // one "Yes" and one "No") — flag a second outgoing edge with the same
  // label, since that's almost always a copy-paste mistake rather than an
  // intentional duplicate path.
  if (from && nodeIds.has(from)) {
    const fromNode = nodeEls.find(n => n.getAttribute('id') === from);
    const fromType = (fromNode.getAttribute('type') || 'process').trim().toLowerCase();
    if (fromType === 'decision') {
      const label = (newEl.getAttribute('label') || '').trim().toLowerCase();
      const clash = Array.from(currentXmlDoc.querySelectorAll('edge')).find(e =>
        e !== selfEl &&
        (e.getAttribute('from') || '').trim() === from &&
        (e.getAttribute('label') || '').trim().toLowerCase() === label
      );
      if (clash) {
        const shown = newEl.getAttribute('label') || '(no label)';
        errors.push(`Decision node "${from}" already has another outgoing edge labeled "${shown}" (to "${clash.getAttribute('to')}"). Branch labels from a decision should be distinct.`);
      }
    }
  }

  return errors;
}

// ── REASONABLENESS CHECKS (soft, overridable) ────────────────────────────
// Unlike validateNodeElement/validateEdgeElement above (hard blocks — the
// value is structurally wrong), these catch numbers that are technically
// valid but look like a likely typo: a width of "2" instead of "200", a
// position of "38500" instead of "385". They're shown as warnings the
// operator can dismiss by clicking Apply a second time unchanged — see
// reasonablenessAckText / applyXmlEdit — rather than blocking outright,
// since occasionally a node really is meant to be tiny, huge, or far
// from the rest of the diagram.
const REASONABLE_SIZE_MIN = 15;
const REASONABLE_SIZE_MAX = 2000;
const REASONABLE_COORD_MAX = 20000;
// If the new position/size overlaps another node by more than this
// fraction of the smaller node's area, it's likely to visually obscure it.
const REASONABLE_OVERLAP_FRACTION = 0.3;

// Effective on-canvas box for a <node> XML element: explicit width/height
// if given, else the type's current default (same fallback getNodeSize()
// uses for rendering) — so this matches what would actually be drawn.
function getEffectiveNodeBox(el) {
  const pos = el.querySelector('position');
  if (!pos) return null;
  const x = Number(pos.getAttribute('x'));
  const y = Number(pos.getAttribute('y'));
  if (isNaN(x) || isNaN(y)) return null;

  const type = (el.getAttribute('type') || 'process').trim().toLowerCase();
  const cfg = TYPE_CFG[type] || DEFAULT_CFG;
  const wAttr = el.getAttribute('width');
  const hAttr = el.getAttribute('height');
  const w = (wAttr !== null && isValidNumber(wAttr)) ? Number(wAttr) : cfg.w;
  const h = (hAttr !== null && isValidNumber(hAttr)) ? Number(hAttr) : cfg.h;
  return { x, y, w, h, type };
}

function rectOverlapArea(a, b) {
  const ax1 = a.x - a.w / 2, ax2 = a.x + a.w / 2;
  const ay1 = a.y - a.h / 2, ay2 = a.y + a.h / 2;
  const bx1 = b.x - b.w / 2, bx2 = b.x + b.w / 2;
  const by1 = b.y - b.h / 2, by2 = b.y + b.h / 2;
  const ox = Math.max(0, Math.min(ax2, bx2) - Math.max(ax1, bx1));
  const oy = Math.max(0, Math.min(ay2, by2) - Math.max(ay1, by1));
  return ox * oy;
}

// Warns when the node's new position/size would substantially overlap
// another existing node (excluding itself and plain routing elbows, which
// aren't meaningful to "obscure"). Soft/overridable, same as the rest of
// this section — two nodes deliberately stacked is rare but not illegal.
function checkNodeOverlap(newBox, selfEl) {
  const warnings = [];
  if (!newBox) return warnings;

  Array.from(currentXmlDoc.querySelectorAll('node')).forEach(otherEl => {
    if (otherEl === selfEl) return;
    const otherType = (otherEl.getAttribute('type') || 'process').trim().toLowerCase();
    if (otherType === 'elbow') return;

    const otherBox = getEffectiveNodeBox(otherEl);
    if (!otherBox) return;

    const overlap = rectOverlapArea(newBox, otherBox);
    if (overlap <= 0) return;
    const smallerArea = Math.min(newBox.w * newBox.h, otherBox.w * otherBox.h);
    const fraction = smallerArea > 0 ? overlap / smallerArea : 0;
    if (fraction > REASONABLE_OVERLAP_FRACTION) {
      const otherId = otherEl.getAttribute('id') || '(no id)';
      warnings.push(`This overlaps node "${otherId}" by about ${Math.round(fraction * 100)}% — it may visually obscure it.`);
    }
  });

  return warnings;
}

function checkNodeReasonableness(el, selfEl) {
  const warnings = [];
  const isElbow = (el.getAttribute('type') || '').trim().toLowerCase() === 'elbow';

  ['width', 'height'].forEach(attr => {
    const v = el.getAttribute(attr);
    if (v === null || v.trim() === '' || !isValidNumber(v)) return; // already flagged (or absent/default) elsewhere
    const n = Number(v);
    // Elbows are tiny routing waypoints by design (default 10x10), so the
    // usual "unusually small" threshold doesn't apply to them — only flag
    // an elbow if it's smaller than that, which is likely a real mistake.
    const smallThreshold = isElbow ? 10 : REASONABLE_SIZE_MIN;
    if (n < smallThreshold) {
      warnings.push(`${attr}="${n}" is unusually small — the node may end up too tiny to read.`);
    } else if (n > REASONABLE_SIZE_MAX) {
      warnings.push(`${attr}="${n}" is unusually large — double-check for an extra digit.`);
    }
  });

  const pos = el.querySelector('position');
  if (pos) {
    ['x', 'y'].forEach(attr => {
      const v = pos.getAttribute(attr);
      if (v === null || v.trim() === '' || !isValidNumber(v)) return;
      const n = Number(v);
      if (Math.abs(n) > REASONABLE_COORD_MAX) {
        warnings.push(`position ${attr}="${n}" is far from the rest of a typical diagram — double-check for an extra digit.`);
      }
    });
  }

  warnings.push(...checkNodeOverlap(getEffectiveNodeBox(el), selfEl));

  return warnings;
}

// Same soft/overridable tier as checkNodeReasonableness, for edges. A
// self-loop is well-formed and occasionally intentional (e.g. a decision
// that retries itself), so it warns rather than blocks.
function checkEdgeReasonableness(el) {
  const warnings = [];
  const from = (el.getAttribute('from') || '').trim();
  const to = (el.getAttribute('to') || '').trim();
  if (from && to && from === to) {
    warnings.push(`This edge connects node "${from}" to itself — double-check that a self-loop is really what you want.`);
  }
  return warnings;
}

// Finds edges whose from/to no longer resolve to any existing node —
// surfaced as a warning at save time rather than blocked outright, since
// it can legitimately happen mid-edit (e.g. deleting a node before its
// edges) and isn't destructive on its own (the edge just won't be drawn).
function findOrphanEdges() {
  const nodeIds = new Set(Array.from(currentXmlDoc.querySelectorAll('node')).map(n => n.getAttribute('id')));
  const orphans = [];
  currentXmlDoc.querySelectorAll('edge').forEach(e => {
    const from = e.getAttribute('from'), to = e.getAttribute('to');
    if (!nodeIds.has(from) || !nodeIds.has(to)) orphans.push(`${from} → ${to}`);
  });
  return orphans;
}

// Routes a click on a rendered node/edge to either the read-only detail
// modal (normal mode) or the XML editor (edit mode).
function handleNodeClick(n, cfg, idx) {
  if (editMode) openXmlEditor('node', idx);
  else openModal(n, cfg);
}
function handleEdgeClick(idx, edge) {
  if (editMode) openXmlEditor('edge', idx);
  // Edges have no read-only detail view outside edit mode.
}

// Every node <g>'s own click listener calls this instead of handleNodeClick
// directly, so both multi-select and stacked-node picking can intercept the
// click before it reaches the normal open-modal/open-editor behavior.
//
// `defaultIdx` is whichever node the browser's native hit-testing actually
// dispatched the click to (always the topmost one covering that pixel) —
// used as a fallback if the geometry re-check below somehow finds nothing,
// which shouldn't normally happen but costs nothing to guard against.
function handleNodeAreaClick(e, defaultIdx) {
  if (selectMode) {
    // Selecting is additive/toggle-based here rather than cycle-based —
    // the goal is building up a set of nodes, not picking one specific
    // node to act on, so there's nothing to cycle through.
    toggleNodeSelection(defaultIdx);
    return;
  }

  const svg = document.getElementById('flowchart-svg');
  const pt = clientToContentPoint(e, svg);
  const hits = nodesContainingPoint(pt); // topmost-first

  if (hits.length <= 1) {
    cycleClickState = null;
    const idx = hits.length ? hits[0] : defaultIdx;
    const n = currentData.nodes[idx];
    handleNodeClick(n, TYPE_CFG[n.type] || DEFAULT_CFG, idx);
    return;
  }

  // Multiple nodes overlap this exact spot — cycle through them top-to-
  // bottom instead of always landing on the topmost one, so a node that's
  // fully hidden behind another can still be reached. Clicking again at
  // (roughly) the same spot advances to the next one down; Alt+click
  // always advances immediately, even on the first click at a new spot,
  // as a one-step shortcut straight to "the one underneath."
  const sameSpot = cycleClickState
    && Math.abs(cycleClickState.x - pt.x) <= CYCLE_CLICK_TOLERANCE
    && Math.abs(cycleClickState.y - pt.y) <= CYCLE_CLICK_TOLERANCE
    && cycleClickState.hits.length === hits.length
    && cycleClickState.hits.every((v, i) => v === hits[i]);

  let cursor;
  if (e.altKey) {
    cursor = sameSpot ? (cycleClickState.cursor + 1) % hits.length : 1 % hits.length;
  } else if (sameSpot) {
    cursor = (cycleClickState.cursor + 1) % hits.length;
  } else {
    cursor = 0;
  }
  cycleClickState = { x: pt.x, y: pt.y, hits, cursor };

  const idx = hits[cursor];
  const n = currentData.nodes[idx];
  handleNodeClick(n, TYPE_CFG[n.type] || DEFAULT_CFG, idx);
}

// All nodes (by index into currentData.nodes) whose bounding box contains
// content-space point `pt`, ordered topmost-first (later document order
// paints on top, so the array is built bottom-first then reversed).
function nodesContainingPoint(pt) {
  const hits = [];
  currentData.nodes.forEach((n, idx) => {
    const { w, h } = getNodeSize(n);
    if (pt.x >= n.x - w/2 && pt.x <= n.x + w/2 && pt.y >= n.y - h/2 && pt.y <= n.y + h/2) {
      hits.push(idx);
    }
  });
  return hits.reverse();
}

// ── Multi-select / bulk move ────────────────────────────────────────────

function toggleNodeSelection(idx) {
  if (selectedNodeIndices.has(idx)) selectedNodeIndices.delete(idx);
  else selectedNodeIndices.add(idx);
  applySelectionHighlight();
  if (selectedNodeIndices.size > 0) openBulkMovePanel();
  else closeBulkMovePanel();
}

function applySelectionHighlight() {
  currentNodeGroups.forEach((g, idx) => {
    if (g) g.classList.toggle('node-selected', selectedNodeIndices.has(idx));
  });
}

function clearSelection() {
  selectedNodeIndices = new Set();
  applySelectionHighlight();
  closeBulkMovePanel();
  bulkMoveBaseline.clear();
  hideUndoToast();
}

// Zeroes out the 4 value fields (X/Y relative + X/Y absolute) without
// touching the selection itself — distinct from Cancel/clearSelection(),
// which drops the selection and closes the whole panel.
function clearBulkMoveValues() {
  document.getElementById('bulk-move-x-relative').value = 0;
  document.getElementById('bulk-move-x-absolute').value = 0;
  document.getElementById('bulk-move-y-relative').value = 0;
  document.getElementById('bulk-move-y-absolute').value = 0;
}

function openBulkMovePanel() {
  document.getElementById('bulk-move-panel').classList.add('open');
  updateBulkMovePanelFields();
}

function closeBulkMovePanel() {
  document.getElementById('bulk-move-panel').classList.remove('open');
}

// Refreshes the panel's node count, and enables/disables each axis's
// "Move to (absolute)" option depending on whether the current selection
// is actually aligned on that axis — moving a scattered selection to one
// absolute coordinate on an axis wouldn't be meaningful, so that option is
// only offered when it is.
function updateBulkMovePanelFields() {
  const count = selectedNodeIndices.size;
  document.getElementById('bulk-move-count').textContent =
    count === 0 ? '0 nodes selected' : `${count} node${count === 1 ? '' : 's'} selected`;
  if (count === 0) return;

  const nodes = Array.from(selectedNodeIndices).map(idx => currentData.nodes[idx]).filter(Boolean);
  const xs = nodes.map(n => n.x);
  const ys = nodes.map(n => n.y);
  const xAligned = xs.every(v => v === xs[0]);
  const yAligned = ys.every(v => v === ys[0]);

  setupBulkMoveAxis('x', xAligned, xAligned ? xs[0] : null);
  setupBulkMoveAxis('y', yAligned, yAligned ? ys[0] : null);
  onBulkMoveModeChange();
}

function setupBulkMoveAxis(axis, aligned, sharedValue) {
  const absRadio = document.getElementById(`bulk-move-${axis}-abs-radio`);
  const absLabel = document.getElementById(`bulk-move-${axis}-abs-label`);
  const absInput = document.getElementById(`bulk-move-${axis}-absolute`);
  const absNote = document.getElementById(`bulk-move-${axis}-abs-note`);
  absRadio.disabled = !aligned;
  absLabel.classList.toggle('bulk-move-option-disabled', !aligned);
  if (aligned) {
    absInput.value = sharedValue;
    absNote.textContent = '';
  } else {
    absNote.textContent = "nodes aren't aligned on this axis";
    if (absRadio.checked) {
      absRadio.checked = false;
      document.querySelector(`input[name="bulk-move-${axis}-mode"][value="relative"]`).checked = true;
    }
  }
}

// Keeps each axis's two number inputs' disabled state in sync with which
// radio (relative vs. absolute) is currently selected for that axis.
function onBulkMoveModeChange() {
  ['x', 'y'].forEach(axis => {
    const mode = document.querySelector(`input[name="bulk-move-${axis}-mode"]:checked`).value;
    document.getElementById(`bulk-move-${axis}-relative`).disabled = mode !== 'relative';
    const absRadio = document.getElementById(`bulk-move-${axis}-abs-radio`);
    document.getElementById(`bulk-move-${axis}-absolute`).disabled = mode !== 'absolute' || absRadio.disabled;
  });
}

function applyBulkMove() {
  if (selectedNodeIndices.size === 0) return;

  const xMode = document.querySelector('input[name="bulk-move-x-mode"]:checked').value;
  const yMode = document.querySelector('input[name="bulk-move-y-mode"]:checked').value;
  const xVal = Number(document.getElementById(xMode === 'absolute' ? 'bulk-move-x-absolute' : 'bulk-move-x-relative').value);
  const yVal = Number(document.getElementById(yMode === 'absolute' ? 'bulk-move-y-absolute' : 'bulk-move-y-relative').value);
  if (!isFinite(xVal) || !isFinite(yVal)) {
    alert('Enter a valid number for both axes.');
    return;
  }
  if (xMode === 'relative' && xVal === 0 && yMode === 'relative' && yVal === 0) {
    alert("That won't move anything — enter a non-zero shift, or switch an axis to \"Move to\".");
    return;
  }

  applyMoveToSelection(
    (curX, curY) => ({
      x: xMode === 'absolute' ? xVal : curX + xVal,
      y: yMode === 'absolute' ? yVal : curY + yVal,
    }),
    count => `Moved ${count} node${count === 1 ? '' : 's'}.`
  );
}

// The compass-rose nudge buttons: each click moves the whole selection by
// one step (from the "Step (px)" field) in that direction, applied
// immediately rather than just populating the Shift-by fields — a quick
// way to nudge repeatedly, the same way arrow-key nudging works in most
// design tools. dx/dy are -1/0/1; e.g. (0,-1) is "up".
function nudgeBulkMove(dx, dy) {
  if (selectedNodeIndices.size === 0) return;
  const step = Number(document.getElementById('compass-step').value);
  if (!isFinite(step) || step <= 0) {
    alert('Enter a valid positive step size first.');
    return;
  }
  applyMoveToSelection(
    (curX, curY) => ({ x: curX + dx * step, y: curY + dy * step }),
    count => `Nudged ${count} node${count === 1 ? '' : 's'}.`
  );
}

// Shared by applyBulkMove and nudgeBulkMove: moves every selected node
// according to computeNewPos(curX, curY) -> {x, y}, recording each node's
// pre-move position into the session baseline the first time it's touched
// (see bulkMoveBaseline) so Undo can always get back to where things were
// when this bulk-move session started, no matter how many moves happened
// in between.
function applyMoveToSelection(computeNewPos, undoMessage) {
  const nodeEls = currentXmlDoc.querySelectorAll('node');
  let movedCount = 0;
  selectedNodeIndices.forEach(idx => {
    const nEl = nodeEls[idx];
    const posEl = nEl && nEl.querySelector('position');
    if (!posEl) return;
    const curX = Number(posEl.getAttribute('x'));
    const curY = Number(posEl.getAttribute('y'));
    recordBaselineIfNeeded(idx, curX, curY);
    const { x: newX, y: newY } = computeNewPos(curX, curY);
    posEl.setAttribute('x', String(Math.round(newX)));
    posEl.setAttribute('y', String(Math.round(newY)));
    movedCount++;
  });
  if (!movedCount) return;

  commitXmlDocChange();
  updateBulkMovePanelFields(); // positions changed — alignment/absolute-option eligibility may have too
  showUndoToast(undoMessage(movedCount));
}

function recordBaselineIfNeeded(idx, curX, curY) {
  if (!bulkMoveBaseline.has(idx)) bulkMoveBaseline.set(idx, { x: curX, y: curY });
}

// Reverts every node touched since this bulk-move session began — i.e.
// since the panel was last opened from a clean state — back to its
// starting position, undoing the *entire* series of Applies/nudges in one
// shot rather than stepping back through them one at a time.
function undoLastBulkMove() {
  if (bulkMoveBaseline.size === 0) return;
  const nodeEls = currentXmlDoc.querySelectorAll('node');
  bulkMoveBaseline.forEach(({ x, y }, idx) => {
    const posEl = nodeEls[idx] && nodeEls[idx].querySelector('position');
    if (!posEl) return;
    posEl.setAttribute('x', String(x));
    posEl.setAttribute('y', String(y));
  });
  bulkMoveBaseline.clear();
  commitXmlDocChange();
  updateBulkMovePanelFields();
  hideUndoToast();
}

function showUndoToast(message) {
  document.getElementById('undo-toast-msg').textContent = message;
  document.getElementById('undo-toast').classList.add('open');
}
function hideUndoToast() {
  document.getElementById('undo-toast').classList.remove('open');
}

// Lets the user drag a rectangle around several nodes at once (Select mode
// only) rather than having to click/edit each one individually. A rectangle
// only picks up nodes fully contained within it — partial overlap doesn't
// count — since "fully inside the box I drew" is the least surprising rule.
// A drag too small to be a real rectangle (i.e. it was actually just a
// click) clears the selection instead of trying to select anything.
function initMarqueeHandlers() {
  const svg = document.getElementById('flowchart-svg');
  svg.addEventListener('mousedown', (evt) => {
    if (!selectMode) return;
    if (evt.target.closest('.node-shape')) return; // let node clicks toggle selection instead
    evt.preventDefault();

    marqueeStart = clientToContentPoint(evt, svg);
    if (marqueeEl) { marqueeEl.remove(); marqueeEl = null; }
    marqueeEl = el('rect', { id: 'marquee-rect', x: marqueeStart.x + currentOX, y: marqueeStart.y + currentOY, width: 0, height: 0 });
    svg.appendChild(marqueeEl);

    const onMove = (mv) => {
      const cur = clientToContentPoint(mv, svg);
      const x0 = Math.min(marqueeStart.x, cur.x), x1 = Math.max(marqueeStart.x, cur.x);
      const y0 = Math.min(marqueeStart.y, cur.y), y1 = Math.max(marqueeStart.y, cur.y);
      marqueeEl.setAttribute('x', x0 + currentOX);
      marqueeEl.setAttribute('y', y0 + currentOY);
      marqueeEl.setAttribute('width', x1 - x0);
      marqueeEl.setAttribute('height', y1 - y0);
    };
    const onUp = (up) => {
      document.removeEventListener('mousemove', onMove);
      document.removeEventListener('mouseup', onUp);
      const cur = clientToContentPoint(up, svg);
      const x0 = Math.min(marqueeStart.x, cur.x), x1 = Math.max(marqueeStart.x, cur.x);
      const y0 = Math.min(marqueeStart.y, cur.y), y1 = Math.max(marqueeStart.y, cur.y);
      if (marqueeEl) { marqueeEl.remove(); marqueeEl = null; }
      marqueeStart = null;
      finalizeMarqueeSelection(x0, y0, x1, y1);
    };
    document.addEventListener('mousemove', onMove);
    document.addEventListener('mouseup', onUp);
  });
}

function finalizeMarqueeSelection(x0, y0, x1, y1) {
  if (x1 - x0 < 3 && y1 - y0 < 3) { // too small to be a real drag — treat as "click empty space to deselect"
    clearSelection();
    return;
  }
  const picked = new Set();
  currentData.nodes.forEach((n, idx) => {
    const { w, h } = getNodeSize(n);
    const nx0 = n.x - w/2, nx1 = n.x + w/2, ny0 = n.y - h/2, ny1 = n.y + h/2;
    if (nx0 >= x0 && nx1 <= x1 && ny0 >= y0 && ny1 <= y1) picked.add(idx);
  });
  selectedNodeIndices = picked;
  applySelectionHighlight();
  if (picked.size > 0) openBulkMovePanel();
  else closeBulkMovePanel();
}

// Quick, dependency-free XML pretty-printer: puts each tag on its own line
// and indents by nesting depth. Good enough for the small node/edge
// fragments being edited here (not meant for arbitrary documents).
function prettyPrintXml(xml) {
  const PADDING = '  ';
  const withBreaks = xml.replace(/(>)(<)(\/*)/g, '$1\n$2$3');
  let pad = 0;
  return withBreaks.split('\n').map(line => {
    let indent = 0;
    if (/^<\/\w/.test(line)) {
      pad = Math.max(0, pad - 1);
    } else if (/^<\w[^>]*[^\/]>.*$/.test(line) && !/<\/\w[^>]*>$/.test(line)) {
      indent = 1;
    }
    const out = PADDING.repeat(pad) + line;
    pad += indent;
    return out;
  }).join('\n');
}

let editingTarget = null; // { kind: 'node'|'edge', key: id/index or null, isNew: bool, defaultsShown?: {width, height} }

// Set by populateNodeForm() each time it runs, consumed immediately by
// whichever function called it — see stripEmptyOptionalNodeFields for why
// this matters (telling "operator left the shown default untouched" apart
// from "operator deliberately typed this exact number").
let lastNodeTemplateDefaults = null;

// Tracks the exact textarea content the operator last saw a reasonableness
// warning for (see checkNodeReasonableness / applyXmlEdit). Clicking Apply
// again with that exact same text is treated as "yes, I meant it" and lets
// it through; editing the text first clears this and re-checks fresh.
let reasonablenessAckText = null;

function openXmlEditor(kind, key) {
  const target = kind === 'node' ? findNodeXmlEl(key) : findEdgeXmlEl(key);
  if (!target) { alert('Could not locate that ' + kind + ' in the XML.'); return; }

  editingTarget = { kind, key, isNew: false };
  let titleText;
  if (kind === 'node') {
    const idAttr = target.getAttribute('id');
    const typeAttr = (target.getAttribute('type') || 'process');
    titleText = idAttr ? `Edit node "${idAttr}"` : `Edit ${typeAttr} node (no id set)`;
  } else {
    titleText = `Edit edge (${target.getAttribute('from')} → ${target.getAttribute('to')})`;
  }
  document.getElementById('xml-editor-title').textContent = titleText;

  document.getElementById('xml-editor-form-node').style.display = kind === 'node' ? 'block' : 'none';
  document.getElementById('xml-editor-form-edge').style.display = kind === 'edge' ? 'block' : 'none';

  if (kind === 'node') {
    populateNodeForm(target);
    editingTarget.defaultsShown = lastNodeTemplateDefaults;
  } else {
    populateEdgeForm(target);
  }

  document.getElementById('xml-editor-error').style.display = 'none';
  document.getElementById('xml-editor-error').classList.remove('warn');
  document.getElementById('delete-xml-btn').style.display = '';
  document.getElementById('duplicate-node-btn').style.display = kind === 'node' ? '' : 'none';
  document.getElementById('duplicate-node-btn').disabled = false;
  reasonablenessAckText = null;
  document.getElementById('xml-editor-overlay').classList.add('open');
}

// Opens the same editor pre-filled with starter values for a brand-new
// node/edge, rather than an existing element to replace. `overrides` seeds
// specific fields (e.g. a clicked position for a new aside node); Apply
// appends the result instead of replacing something, and there's nothing
// to Delete yet.
function openXmlEditorNew(kind, overrides, titleText) {
  editingTarget = { kind, key: null, isNew: true };

  document.getElementById('xml-editor-title').textContent = titleText;
  document.getElementById('xml-editor-form-node').style.display = kind === 'node' ? 'block' : 'none';
  document.getElementById('xml-editor-form-edge').style.display = kind === 'edge' ? 'block' : 'none';

  if (kind === 'node') {
    populateNodeForm(null, overrides || {});
    editingTarget.defaultsShown = lastNodeTemplateDefaults;
  } else {
    populateEdgeForm(null, overrides || {});
  }

  document.getElementById('xml-editor-error').style.display = 'none';
  document.getElementById('xml-editor-error').classList.remove('warn');
  document.getElementById('delete-xml-btn').style.display = 'none';
  document.getElementById('duplicate-node-btn').style.display = kind === 'node' ? '' : 'none';
  document.getElementById('duplicate-node-btn').disabled = false;
  reasonablenessAckText = null;
  document.getElementById('xml-editor-overlay').classList.add('open');
}

function closeXmlEditor() {
  document.getElementById('xml-editor-overlay').classList.remove('open');
  document.getElementById('duplicate-node-btn').disabled = false;
  editingTarget = null;
  reasonablenessAckText = null;
  // A node/edge may have just been added or deleted, which shifts every
  // index after it — safest to drop the multi-select rather than risk it
  // silently pointing at the wrong nodes afterward.
  clearSelection();
}

function showXmlEditorError(msg, isWarning) {
  const e = document.getElementById('xml-editor-error');
  e.textContent = msg;
  e.classList.toggle('warn', !!isWarning);
  e.style.display = 'block';
}

// Concatenates every current form field's value into one string, used only
// to detect "operator changed something since the last warning" — not for
// parsing. Swapped in for the old "raw textarea text" comparison now that
// the editor is a structured form instead of one big text blob.
function getFormSnapshot(kind) {
  const ids = kind === 'node'
    ? ['f-node-id','f-node-type','f-node-label','f-node-width','f-node-height','f-node-fit',
       'f-node-src','f-node-x','f-node-y','f-node-narrative','f-node-link','f-node-target',
       'f-node-cobol','f-node-foreign']
    : ['f-edge-from','f-edge-to','f-edge-label','f-edge-style','f-edge-foreign'];
  return JSON.stringify(ids.map(id => document.getElementById(id).value));
}

function applyXmlEdit() {
  if (!editingTarget) return;
  const kind = editingTarget.kind;

  let oldEl = null;
  if (!editingTarget.isNew) {
    oldEl = kind === 'node' ? findNodeXmlEl(editingTarget.key) : findEdgeXmlEl(editingTarget.key);
    if (!oldEl) { showXmlEditorError('That element no longer exists (it may have been deleted).'); return; }
  }

  // Build the element directly from the form fields — no tag/attribute
  // name can be mistyped this way, since the form controls which names
  // exist at all. Only the free-form "Unidentified Foreign Objects" box
  // (merged in below) still needs XML parsing, since it exists precisely
  // to hold whatever this app doesn't have a field for.
  const newEl = currentXmlDoc.createElement(kind);
  const softNotices = [];
  const foreignBoxId = kind === 'node' ? 'f-node-foreign' : 'f-edge-foreign';
  const validAttrs = kind === 'node' ? VALID_NODE_ATTRS : VALID_EDGE_ATTRS;
  const validChildren = kind === 'node' ? VALID_NODE_CHILDREN : [];

  if (kind === 'node') {
    newEl.setAttribute('id', document.getElementById('f-node-id').value.trim());
    newEl.setAttribute('type', document.getElementById('f-node-type').value);
    newEl.setAttribute('label', document.getElementById('f-node-label').value);
    newEl.setAttribute('width', document.getElementById('f-node-width').value.trim());
    newEl.setAttribute('height', document.getElementById('f-node-height').value.trim());
    newEl.setAttribute('fit', document.getElementById('f-node-fit').value);
    newEl.setAttribute('src', document.getElementById('f-node-src').value);

    const posEl = currentXmlDoc.createElement('position');
    posEl.setAttribute('x', document.getElementById('f-node-x').value.trim());
    posEl.setAttribute('y', document.getElementById('f-node-y').value.trim());
    newEl.appendChild(posEl);

    const narEl = currentXmlDoc.createElement('narrative');
    narEl.textContent = document.getElementById('f-node-narrative').value;
    newEl.appendChild(narEl);

    const linkEl = currentXmlDoc.createElement('link');
    linkEl.textContent = document.getElementById('f-node-link').value;
    newEl.appendChild(linkEl);

    const targetEl = currentXmlDoc.createElement('target');
    targetEl.textContent = document.getElementById('f-node-target').value;
    newEl.appendChild(targetEl);

    const cobolEl = currentXmlDoc.createElement('cobolparagraph');
    cobolEl.textContent = document.getElementById('f-node-cobol').value;
    newEl.appendChild(cobolEl);
  } else {
    newEl.setAttribute('from', document.getElementById('f-edge-from').value.trim());
    newEl.setAttribute('to', document.getElementById('f-edge-to').value.trim());
    newEl.setAttribute('label', document.getElementById('f-edge-label').value);
    newEl.setAttribute('style', document.getElementById('f-edge-style').value);
  }

  let foreign;
  try {
    foreign = parseForeignPortion(document.getElementById(foreignBoxId).value, validAttrs, validChildren);
  } catch (e) {
    showXmlEditorError(
      'Unidentified Foreign Objects box: ' + e.message +
      '\n\n(It must be well-formed XML, e.g. <foreign-objects attr="value"><someTag>text</someTag></foreign-objects>.)'
    );
    return;
  }
  foreign.attrs.forEach(([name, value]) => newEl.setAttribute(name, value));
  foreign.children.forEach(childEl => newEl.appendChild(currentXmlDoc.importNode(childEl, true)));
  if (foreign.ignored.length) {
    softNotices.push(
      `The Unidentified Foreign Objects box included ${foreign.ignored.join(', ')}, which duplicate a field above — ` +
      `that entry was ignored. If you meant to change it, edit the field itself instead.`
    );
  }

  const errors = kind === 'node'
    ? validateNodeElement(newEl, oldEl)
    : validateEdgeElement(newEl, oldEl);
  if (errors.length) {
    showXmlEditorError(errors.map(e => '• ' + e).join('\n'));
    return;
  }

  if (kind === 'node') softNotices.push(...checkNodeReasonableness(newEl, oldEl));
  else softNotices.push(...checkEdgeReasonableness(newEl));

  const snapshot = getFormSnapshot(kind);
  if (softNotices.length && reasonablenessAckText !== snapshot) {
    reasonablenessAckText = snapshot;
    showXmlEditorError(
      softNotices.map(w => '⚠ ' + w).join('\n') +
      '\n\nThis isn\'t blocked — click Apply again to use it as-is, or adjust the value(s).',
      /* isWarning */ true
    );
    return;
  }
  reasonablenessAckText = null; // proceeding — a later edit gets a fresh check

  if (kind === 'node') stripEmptyOptionalNodeFields(newEl, editingTarget.defaultsShown);
  else stripEmptyOptionalEdgeFields(newEl);

  if (editingTarget.isNew) {
    const containerTag = kind === 'node' ? 'nodes' : 'edges';
    let container = currentXmlDoc.querySelector(containerTag);
    if (!container) {
      container = currentXmlDoc.createElement(containerTag);
      currentXmlDoc.querySelector('flowchart').appendChild(container);
    }
    container.appendChild(newEl);
  } else {
    oldEl.parentNode.replaceChild(newEl, oldEl);
  }

  commitXmlDocChange();
  closeXmlEditor();
}

function deleteXmlObject() {
  if (!editingTarget || editingTarget.isNew) return;

  const el = editingTarget.kind === 'node'
    ? findNodeXmlEl(editingTarget.key)
    : findEdgeXmlEl(editingTarget.key);
  if (!el) { showXmlEditorError('That element no longer exists (it may have already been deleted).'); return; }

  if (editingTarget.kind === 'node') {
    const nodeId = el.getAttribute('id');
    const affected = nodeId
      ? Array.from(currentXmlDoc.querySelectorAll('edge'))
          .filter(e => e.getAttribute('from') === nodeId || e.getAttribute('to') === nodeId)
      : [];
    if (affected.length) {
      showXmlEditorError(
        `Can't delete node "${nodeId}" — ${affected.length} edge(s) still connect to it:\n`
        + affected.map(e => `• ${e.getAttribute('from')} → ${e.getAttribute('to')}`).join('\n')
        + `\n\nDelete or repoint those edges first (edges pointing at a node that no longer exists won't show up in the preview, so this is blocked rather than just a warning).`
      );
      return;
    }
  }

  const label = editingTarget.kind === 'node'
    ? `node "${el.getAttribute('id') || '(no id)'}"`
    : 'this edge';
  if (!confirm(`Delete ${label}? This only affects the in-memory copy until you click "Save Changes".`)) return;

  el.parentNode.removeChild(el);

  commitXmlDocChange();
  closeXmlEditor();
}

// Re-derives currentData from the (now-modified) currentXmlDoc, re-renders
// the preview, and flags that there are changes pending a save.
function commitXmlDocChange() {
  currentData = dataFromDoc(currentXmlDoc);
  renderSvg(currentData);
  hasUnsavedChanges = true;
  updateUnsavedUI();
}

// ── ADD NEW OBJECT (click on empty background) ──────────────────────────
// In edit mode, clicking anywhere that isn't a node or edge is treated as
// "I want to add something here." A small picker asks aside-node vs. edge,
// then opens the same XML editor pre-filled with a starter template for
// the operator to fill in — appended on Apply rather than replacing
// anything.

let pendingClickPoint = null;

function escapeXmlAttr(str) {
  return String(str).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
}
function escapeXmlText(str) {
  return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

// ── FOREIGN (UNRECOGNIZED) XML PASSTHROUGH ───────────────────────────────
// A diagram from another source might carry attributes/elements this app
// doesn't know about. Rather than silently dropping them — or blocking
// the whole edit — anything not in the known schema is pulled into its
// own "Unidentified Foreign Objects" box, shown as XML wrapped in a
// synthetic <foreign-objects> tag so the operator can read it and, if they
// recognize what it actually means, move it into a real field above by
// hand. Left alone, it's carried straight back onto the saved element.

function extractForeignPortion(existingEl, validAttrs, validChildren) {
  if (!existingEl) return '';
  const attrParts = [];
  Array.from(existingEl.attributes).forEach(a => {
    if (!validAttrs.includes(a.name)) attrParts.push(`${a.name}="${escapeXmlAttr(a.value)}"`);
  });
  const childParts = [];
  Array.from(existingEl.children).forEach(c => {
    if (!validChildren.includes(c.tagName)) childParts.push(new XMLSerializer().serializeToString(c));
  });
  if (!attrParts.length && !childParts.length) return '';
  const attrsStr = attrParts.length ? ' ' + attrParts.join(' ') : '';
  if (childParts.length) {
    return prettyPrintXml(`<foreign-objects${attrsStr}>${childParts.join('')}</foreign-objects>`);
  }
  return `<foreign-objects${attrsStr}/>`;
}

// Parses the (possibly operator-edited) foreign-objects box back into
// {attrs: [[name,value],...], children: [Element,...], ignored: [string,...]}.
// Throws a readable error if it isn't well-formed XML. Anything matching a
// known field name is filtered out into `ignored` rather than merged —
// the real form field always wins, so there's never ambiguity about which
// value is actually in effect; the operator disambiguates by moving the
// value into that field themselves.
function parseForeignPortion(text, validAttrs, validChildren) {
  const trimmed = text.trim();
  if (!trimmed) return { attrs: [], children: [], ignored: [] };
  const doc = parseXmlDoc(trimmed);
  const root = doc.documentElement;
  const ignored = [];
  const attrs = [];
  Array.from(root.attributes).forEach(a => {
    if (validAttrs.includes(a.name)) ignored.push(`attribute "${a.name}"`);
    else attrs.push([a.name, a.value]);
  });
  const children = [];
  Array.from(root.children).forEach(c => {
    if (validChildren.includes(c.tagName)) ignored.push(`<${c.tagName}>`);
    else children.push(c);
  });
  return { attrs, children, ignored };
}

// Fills the node form fields from an existing element (or overrides, for
// a brand-new node). width/height show the actual effective size in play
// — an explicit override if present, otherwise the type's current
// default — rather than a blank field, so what's really being used is
// never a mystery. lastNodeTemplateDefaults records which of those were
// auto-filled (vs. genuinely explicit) so Apply can revert an untouched
// default back to "inherited" instead of baking it in forever — see
// stripEmptyOptionalNodeFields.
function populateNodeForm(existingEl, overrides) {
  overrides = overrides || {};
  const attr = (name, fallback) => {
    if (overrides[name] !== undefined) return overrides[name];
    const v = existingEl ? existingEl.getAttribute(name) : null;
    return v !== null ? v : fallback;
  };
  const child = (tag) => {
    if (overrides[tag] !== undefined) return overrides[tag];
    const c = existingEl ? existingEl.querySelector(tag) : null;
    return c ? c.textContent : '';
  };
  const pos = existingEl ? existingEl.querySelector('position') : null;
  const posX = overrides.posX !== undefined ? overrides.posX : (pos ? pos.getAttribute('x') : '100');
  const posY = overrides.posY !== undefined ? overrides.posY : (pos ? pos.getAttribute('y') : '100');

  const type = attr('type', 'process');
  const typeCfg = TYPE_CFG[type] || DEFAULT_CFG;

  const explicitWidth = existingEl ? existingEl.getAttribute('width') : null;
  const explicitHeight = existingEl ? existingEl.getAttribute('height') : null;
  const widthValue = overrides.width !== undefined ? overrides.width : (explicitWidth !== null ? explicitWidth : typeCfg.w);
  const heightValue = overrides.height !== undefined ? overrides.height : (explicitHeight !== null ? explicitHeight : typeCfg.h);

  lastNodeTemplateDefaults = {
    width: (overrides.width === undefined && explicitWidth === null) ? typeCfg.w : null,
    height: (overrides.height === undefined && explicitHeight === null) ? typeCfg.h : null,
  };

  document.getElementById('f-node-id').value = attr('id', '');
  document.getElementById('f-node-type').value = VALID_NODE_TYPES.includes(type) ? type : 'process';
  document.getElementById('f-node-label').value = attr('label', '');
  document.getElementById('f-node-width').value = widthValue;
  document.getElementById('f-node-height').value = heightValue;
  document.getElementById('f-node-fit').value = VALID_FIT_VALUES.includes(attr('fit', 'contain')) ? attr('fit', 'contain') : 'contain';
  document.getElementById('f-node-src').value = attr('src', '');
  document.getElementById('f-node-x').value = posX;
  document.getElementById('f-node-y').value = posY;
  document.getElementById('f-node-narrative').value = child('narrative');
  document.getElementById('f-node-link').value = child('link');
  document.getElementById('f-node-target').value = child('target');
  document.getElementById('f-node-cobol').value = child('cobolparagraph');
  document.getElementById('f-node-foreign').value = extractForeignPortion(existingEl, VALID_NODE_ATTRS, VALID_NODE_CHILDREN);
}

// Fills a <select> with every current node id, plus a blank
// "(choose a node)" option, and — if the edge currently points at an id
// that no longer exists — an extra option showing that dangling value so
// it stays visible/selectable rather than silently snapping to something
// else. Rebuilt fresh every time the edge editor opens, since the node
// list changes over time (unlike the static type/fit/style dropdowns).
function populateNodeIdSelect(selectEl, currentValue) {
  selectEl.innerHTML = '';
  const blankOpt = document.createElement('option');
  blankOpt.value = '';
  blankOpt.textContent = '(choose a node)';
  selectEl.appendChild(blankOpt);

  const ids = Array.from(currentXmlDoc.querySelectorAll('node'))
    .map(n => n.getAttribute('id'))
    .filter(id => id);
  ids.forEach(id => {
    const opt = document.createElement('option');
    opt.value = id;
    opt.textContent = id;
    selectEl.appendChild(opt);
  });

  if (currentValue && !ids.includes(currentValue)) {
    const opt = document.createElement('option');
    opt.value = currentValue;
    opt.textContent = currentValue + ' (missing!)';
    selectEl.appendChild(opt);
  }
  selectEl.value = currentValue || '';
}

function populateEdgeForm(existingEl, overrides) {
  overrides = overrides || {};
  const attr = (name, fallback) => {
    if (overrides[name] !== undefined) return overrides[name];
    const v = existingEl ? existingEl.getAttribute(name) : null;
    return v !== null ? v : fallback;
  };
  populateNodeIdSelect(document.getElementById('f-edge-from'), attr('from', ''));
  populateNodeIdSelect(document.getElementById('f-edge-to'), attr('to', ''));
  document.getElementById('f-edge-label').value = attr('label', '');
  const style = attr('style', 'solid');
  document.getElementById('f-edge-style').value = VALID_EDGE_STYLES.includes(style) ? style : 'solid';
  document.getElementById('f-edge-foreign').value = extractForeignPortion(existingEl, VALID_EDGE_ATTRS, []);
}

// Called right before an edited/new node is imported into the document.
// Strips attributes/children that were left as empty placeholders (or at
// their default value) so the saved XML stays exactly as clean as if the
// operator had typed only what they actually wanted — no <target></target>
// clutter left behind just because the editor showed it as an option.
// This also preserves existing default-inference behavior, e.g. an
// explicitly-empty <target> would otherwise override the "defaults to
// MAIN when a link is present" rule in dataFromDoc().
function stripEmptyOptionalNodeFields(el, defaultsShown) {
  ['width', 'height', 'src'].forEach(a => {
    const v = el.getAttribute(a);
    if (v !== null && v.trim() === '') el.removeAttribute(a);
  });

  // width/height that were pre-filled with the type's current default
  // (rather than being explicit in the source XML) get reverted back to
  // "inherited" if left unchanged, so opening/applying a node's edit
  // doesn't silently lock it to today's default forever. A value that was
  // already explicit in the source stays explicit either way.
  if (defaultsShown) {
    ['width', 'height'].forEach(a => {
      const shown = defaultsShown[a];
      if (shown == null) return;
      const v = el.getAttribute(a);
      if (v !== null && v.trim() !== '' && Number(v) === Number(shown)) {
        el.removeAttribute(a);
      }
    });
  }

  const fit = el.getAttribute('fit');
  if (fit !== null && (fit.trim() === '' || fit.trim().toLowerCase() === 'contain')) el.removeAttribute('fit');

  ['narrative', 'link', 'target', 'cobolparagraph'].forEach(tag => {
    const c = el.querySelector(tag);
    if (c && c.textContent.trim() === '') c.parentNode.removeChild(c);
  });
}
function stripEmptyOptionalEdgeFields(el) {
  const label = el.getAttribute('label');
  if (label !== null && label.trim() === '') el.removeAttribute('label');
  const style = el.getAttribute('style');
  if (style !== null && (style.trim() === '' || style.trim().toLowerCase() === 'solid')) el.removeAttribute('style');
}

function generateUniqueNodeId(prefix) {
  const ids = new Set(Array.from(currentXmlDoc.querySelectorAll('node')).map(n => n.getAttribute('id')));
  let i = 1;
  while (ids.has(prefix + i)) i++;
  return prefix + i;
}

// Like generateUniqueNodeId, but tries the base string as-is first (e.g.
// "step3_copy") before falling back to "step3_copy2", "step3_copy3", ... —
// used by duplicateNodeToNewId, where the base is already meaningful on
// its own rather than a generic prefix.
function generateUniqueIdFromBase(base) {
  const ids = new Set(Array.from(currentXmlDoc.querySelectorAll('node')).map(n => n.getAttribute('id')));
  if (!ids.has(base)) return base;
  let i = 2;
  while (ids.has(base + i)) i++;
  return base + i;
}

// "Duplicate to new id" — clones everything currently in the node form
// (including the Unidentified Foreign Objects box) into a fresh new-node
// editor with an auto-generated distinct id, offset slightly from the
// original position so it doesn't land exactly on top of it. Works
// whether editing an existing node or still drafting a brand-new one —
// either way it just reads whatever's currently in the form fields.
function duplicateNodeToNewId() {
  if (!editingTarget || editingTarget.kind !== 'node') return;

  const origId = document.getElementById('f-node-id').value.trim() || 'node';
  const newId = generateUniqueIdFromBase(origId + '_copy');
  const origX = Number(document.getElementById('f-node-x').value) || 0;
  const origY = Number(document.getElementById('f-node-y').value) || 0;
  const foreignText = document.getElementById('f-node-foreign').value;

  const overrides = {
    id: newId,
    type: document.getElementById('f-node-type').value,
    label: document.getElementById('f-node-label').value,
    width: document.getElementById('f-node-width').value,
    height: document.getElementById('f-node-height').value,
    fit: document.getElementById('f-node-fit').value,
    src: document.getElementById('f-node-src').value,
    posX: origX + 30,
    posY: origY + 30,
    narrative: document.getElementById('f-node-narrative').value,
    link: document.getElementById('f-node-link').value,
    target: document.getElementById('f-node-target').value,
    cobolparagraph: document.getElementById('f-node-cobol').value,
  };

  openXmlEditorNew('node', overrides, `Duplicate of "${origId}" → new node "${newId}"`);
  // The foreign-objects box isn't part of `overrides` (populateNodeForm
  // only extracts it from a real existing element), so carry it over here.
  document.getElementById('f-node-foreign').value = foreignText;

  // Prevent duplicating-a-duplicate before this draft is dealt with: the
  // button stays disabled until the resulting new-node editor is applied,
  // cancelled, or deleted (see closeXmlEditor).
  document.getElementById('duplicate-node-btn').disabled = true;
}

// Converts a mouse click on the preview into the diagram's local content
// coordinates (i.e. what belongs in <position x y>), accounting for the
// current pan/zoom transform and the OX/OY render offset.
function clientToContentPoint(evt, svg) {
  const pt = svg.createSVGPoint();
  pt.x = evt.clientX;
  pt.y = evt.clientY;
  const ctm = svg.getScreenCTM();
  if (!ctm) return { x: 100, y: 100 };
  const svgP = pt.matrixTransform(ctm.inverse());
  return { x: Math.round(svgP.x - currentOX), y: Math.round(svgP.y - currentOY) };
}

function initBackgroundClickHandler() {
  const svg = document.getElementById('flowchart-svg');
  svg.addEventListener('click', (evt) => {
    if (!editMode || selectMode) return;
    // Node/edge click handlers call stopPropagation(), so if we're seeing
    // this at all it's a genuine click on empty background.
    pendingClickPoint = clientToContentPoint(evt, svg);
    document.getElementById('add-object-overlay').classList.add('open');
  });
}

function closeAddObjectPicker() {
  document.getElementById('add-object-overlay').classList.remove('open');
  pendingClickPoint = null;
}

function startNewAside() {
  const pt = pendingClickPoint || { x: 100, y: 100 };
  closeAddObjectPicker();
  const id = generateUniqueNodeId('aside');
  openXmlEditorNew('node', { id, type: 'aside', label: 'New note', posX: pt.x, posY: pt.y }, `New aside node at (${pt.x}, ${pt.y})`);
}

function startNewEdge() {
  closeAddObjectPicker();
  openXmlEditorNew('edge', {}, 'New edge');
}

function updateUnsavedUI() {
  document.getElementById('save-changes-btn').disabled = !hasUnsavedChanges;
  document.getElementById('discard-changes-btn').disabled = !hasUnsavedChanges;
}

async function saveChangesToServer() {
  if (!hasUnsavedChanges) return;

  let msg = `Save all changes to ${XML_FILENAME} on the server? This will overwrite the file.`;
  const orphans = findOrphanEdges();
  if (orphans.length) {
    msg += `\n\nWarning: ${orphans.length} edge(s) reference a node id that no longer exists and won't be drawn:\n`
      + orphans.map(o => '  ' + o).join('\n');
  }
  if (!confirm(msg)) return;

  // Make sure <settings> reflects the current UI state before saving, even
  // if the user never touched theme/export-width/grid/logo this session —
  // otherwise a brand-new diagram (or one saved before those controls were
  // touched) would be written with no <settings> element at all.
  writeSettingsToDoc();

  let xmlText = new XMLSerializer().serializeToString(currentXmlDoc);
  xmlText = upsertXmlCopyrightComment(xmlText);
  // Cosmetic only: XMLSerializer emits everything on one line, so nudge
  // <flowchart and <settings onto their own lines for readability if a
  // saved file is ever opened directly. Left alone if already true.
  xmlText = ensureLeadingNewline(xmlText, '<settings');
  xmlText = ensureLeadingNewline(xmlText, '<flowchart');
  try {
    const resp = await fetch(window.location.pathname + window.location.search, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: 'action=save&name=' + encodeURIComponent(BASE_NAME) + '&xml=' + encodeURIComponent(xmlText)
    });
    const data = await resp.json();
    if (data.ok) {
      hasUnsavedChanges = false;
      updateUnsavedUI();
      bulkMoveBaseline.clear();
      hideUndoToast();
      alert(`Saved ${data.file} (${data.bytes} bytes) to the server.`);
    } else {
      alert('Save failed: ' + data.error);
    }
  } catch (e) {
    console.error('Save error:', e);
    alert('Save failed: ' + e.message);
  }
}

function discardChanges() {
  if (!hasUnsavedChanges) return;
  if (!confirm('Discard all unsaved changes and reload the last saved version from the server?')) return;
  loadAndRender();
}

let intentionalExit = false;

function exitApp() {
  if (hasUnsavedChanges && !confirm('You have unsaved changes that will be lost if you exit. Exit anyway?')) return;
  intentionalExit = true;
  history.go(-1);
  // Note: window.close() only works on tabs/windows that were opened via
  // script — most browsers silently ignore it on a normally-navigated tab.
  setTimeout(() => window.close(), 1000);
}

window.addEventListener('beforeunload', (e) => {
  if (hasUnsavedChanges && !intentionalExit) {
    e.preventDefault();
    e.returnValue = '';
  }
});

// Geometry helpers (same as before)
function getNodeMap(nodes) { const m = {}; nodes.forEach(n => m[n.id] = n); return m; }
function edgeEndpoints(src, dst, nodeMap) {
  const s = nodeMap[src], d = nodeMap[dst];
  if (!s || !d) return null;
  const sc = TYPE_CFG[s.type] || DEFAULT_CFG;
  const dc = TYPE_CFG[d.type] || DEFAULT_CFG;
  const dx = d.x - s.x, dy = d.y - s.y;
  const ang = Math.atan2(dy, dx);
  const p1 = shapeEdgePoint(s, sc, ang);
  const p2 = shapeEdgePoint(d, dc, ang + Math.PI);
  return { x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y };
}
function shapeEdgePoint(node, cfg, ang) {
  const { w, h } = getNodeSize(node);
  if (node.type === 'decision') {
    const hw = w / 2, hh = h / 2;
    const pts = [{x:node.x,y:node.y-hh},{x:node.x+hw,y:node.y},{x:node.x,y:node.y+hh},{x:node.x-hw,y:node.y}];
    for (let i = 0; i < 4; i++) {
      const a = pts[i], b = pts[(i+1)%4];
      const res = raySegIntersect(node.x, node.y, ang, a.x, a.y, b.x, b.y);
      if (res) return res;
    }
  }
  const hw = w / 2, hh = h / 2;
  const cos = Math.cos(ang), sin = Math.sin(ang);
  let t = Infinity;
  if (Math.abs(cos) > 1e-9) t = Math.min(t, Math.abs(hw / cos));
  if (Math.abs(sin) > 1e-9) t = Math.min(t, Math.abs(hh / sin));
  return { x: node.x + cos * t, y: node.y + sin * t };
}
function raySegIntersect(rx, ry, ang, ax, ay, bx, by) {
  const dx = Math.cos(ang), dy = Math.sin(ang);
  const ex = bx - ax, ey = by - ay;
  const denom = dx * ey - dy * ex;
  if (Math.abs(denom) < 1e-9) return null;
  const t = ((ax - rx) * ey - (ay - ry) * ex) / denom;
  const u = ((ax - rx) * dy - (ay - ry) * dx) / denom;
  if (t > 0 && u >= 0 && u <= 1) return { x: rx + dx * t, y: ry + dy * t };
  return null;
}
// ── EDGE CROSSING "BRIDGE" HELPERS ─────────────────────────────────────────
// Edges are drawn as quadratic Beziers whose control point sits exactly on
// the midpoint of the two endpoints, which makes them mathematically
// straight lines. That lets us treat every edge as a plain segment for
// crossing detection instead of doing general curve/curve intersection.
//
// When edge B is drawn after edge A and their segments cross, B "bridges"
// over A with a small hop (like a wire jump in a circuit diagram) so the
// crossing reads clearly instead of the two lines just touching.
function segIntersect(x1, y1, x2, y2, x3, y3, x4, y4) {
  const d1x = x2 - x1, d1y = y2 - y1;
  const d2x = x4 - x3, d2y = y4 - y3;
  const denom = d1x * d2y - d1y * d2x;
  if (Math.abs(denom) < 1e-9) return null; // parallel / collinear
  const t = ((x3 - x1) * d2y - (y3 - y1) * d2x) / denom;
  const u = ((x3 - x1) * d1y - (y3 - y1) * d1x) / denom;
  if (t > 1e-6 && t < 1 - 1e-6 && u > 1e-6 && u < 1 - 1e-6) {
    return { t, x: x1 + d1x * t, y: y1 + d1y * t };
  }
  return null;
}
// Given the straight-line endpoints of every edge (in draw order), returns,
// for each edge index, the sorted list of t-values (0..1 along that edge)
// where an *earlier* edge crosses it. Only earlier edges are considered so
// each crossing is "owned" by exactly one of the two edges (the later one,
// since it's painted on top) and gets exactly one bridge hop.
function computeEdgeCrossings(segs) {
  const crossings = segs.map(() => []);
  for (let j = 0; j < segs.length; j++) {
    const ej = segs[j];
    if (!ej) continue;
    for (let i = 0; i < j; i++) {
      const ei = segs[i];
      if (!ei) continue;
      const res = segIntersect(ej.x1, ej.y1, ej.x2, ej.y2, ei.x1, ei.y1, ei.x2, ei.y2);
      if (res) crossings[j].push(res.t);
    }
  }
  crossings.forEach(arr => arr.sort((a, b) => a - b));
  return crossings;
}
// Builds the "d" attribute for an edge, inserting a small semicircular hop
// at each crossing t-value so the line visibly bridges over whatever it
// crosses. Falls back to the original straight-line Q command when there
// are no crossings to bridge.
// Returns the rotation angle (degrees) that aligns edge-label text with the
// line from (x1,y1) to (x2,y2). Normalized to (-90, 90] so labels never end
// up upside-down or hard to read, regardless of which direction the edge
// happens to point.
// Returns where to draw an edge's label and how far to rotate it so the
// text both aligns with the line and sits a consistent distance off to one
// side of it — at ANY angle, including perfectly vertical edges.
//
// The offset is applied perpendicular to the line itself (not along a fixed
// vertical axis), which matters once rotation is involved: a fixed "move up
// 10px" offset is perpendicular to a horizontal line but runs parallel to a
// vertical one, so vertical edges ended up with the label practically on
// top of the line. Using the true perpendicular fixes that at every angle.
//
// The perpendicular's sign is flipped as needed so the label lands on the
// same visual side regardless of which direction the edge points (mirrors
// the hop-arc convention in edgePathD) — otherwise an edge drawn top-to-
// bottom vs. bottom-to-top would place the label on opposite sides.
function edgeLabelPlacement(x1, y1, x2, y2, offset) {
  offset = offset == null ? 10 : offset;
  const dx = x2 - x1, dy = y2 - y1;
  const len = Math.hypot(dx, dy) || 1;
  const ux = dx / len, uy = dy / len;
  let px = -uy, py = ux; // perpendicular, unit length
  if (py > 1e-9 || (Math.abs(py) <= 1e-9 && px < 0)) { px = -px; py = -py; }

  let angle = Math.atan2(dy, dx) * 180 / Math.PI;
  if (angle > 90) angle -= 180;
  else if (angle <= -90) angle += 180;

  return {
    tx: (x1 + x2) / 2 + px * offset,
    ty: (y1 + y2) / 2 + py * offset,
    angle,
  };
}

function edgePathD(x1, y1, x2, y2, crossingTs, hopR) {
  hopR = hopR || 6;
  const dx = x2 - x1, dy = y2 - y1;
  const len = Math.hypot(dx, dy);
  if (len < 1e-6 || !crossingTs || crossingTs.length === 0) {
    const cx = (x1 + x2) / 2, cy = (y1 + y2) / 2;
    return `M${x1},${y1} Q${cx},${cy} ${x2},${y2}`;
  }
  const ux = dx / len, uy = dy / len;
  let px = -uy, py = ux; // perpendicular, unit length
  // Keep the hop direction visually consistent (arcing "up" on screen)
  // regardless of which way the edge itself points.
  if (py > 1e-9 || (Math.abs(py) <= 1e-9 && px < 0)) { px = -px; py = -py; }

  const centers = crossingTs.map(t => t * len);
  const minGap = 4; // px gap kept between adjacent hops
  const halfWidths = centers.map((c, idx) => {
    let hwi = hopR;
    if (idx > 0) hwi = Math.min(hwi, (c - centers[idx - 1]) / 2 - minGap / 2);
    if (idx < centers.length - 1) hwi = Math.min(hwi, (centers[idx + 1] - c) / 2 - minGap / 2);
    hwi = Math.min(hwi, c - 2, len - c - 2);
    return Math.max(hwi, 1.5);
  });

  let d = `M${x1},${y1}`;
  centers.forEach((c, idx) => {
    const hwi = halfWidths[idx];
    const startD = c - hwi, endD = c + hwi;
    const sx = x1 + ux * startD, sy = y1 + uy * startD;
    const ex = x1 + ux * endD, ey = y1 + uy * endD;
    const bumpH = hwi * 1.4;
    const ctrlx = x1 + ux * c + px * bumpH;
    const ctrly = y1 + uy * c + py * bumpH;
    d += ` L${sx},${sy} Q${ctrlx},${ctrly} ${ex},${ey}`;
  });
  d += ` L${x2},${y2}`;
  return d;
}
function wrapLabel(text, maxChars) {
  // Respect explicit <br> / <br/> as hard line breaks first, then
  // word-wrap each resulting segment to maxChars.
  const segments = text.split(/<br\s*\/?>/i);
  const lines = [];
  segments.forEach(seg => {
    seg = seg.trim();
    if (seg.length <= maxChars) { lines.push(seg); return; }
    const words = seg.split(' ');
    let cur = '';
    words.forEach(w => {
      if ((cur + ' ' + w).trim().length > maxChars && cur) {
        lines.push(cur.trim());
        cur = w;
      } else cur = (cur + ' ' + w).trim();
    });
    if (cur) lines.push(cur.trim());
  });
  return lines;
}
// Appends a line of text to an SVG <text> element, honoring <b>...</b> as
// a bold <tspan>. Anything else is inserted as plain (escaped) text.
function appendRichText(textEl, line) {
  let lastIndex = 0;
  const re = /<b>([\s\S]*?)<\/b>/gi;
  let m;
  while ((m = re.exec(line)) !== null) {
    if (m.index > lastIndex) textEl.appendChild(document.createTextNode(line.slice(lastIndex, m.index)));
    const tspan = el('tspan', {'font-weight': 'bold'});
    tspan.textContent = m[1];
    textEl.appendChild(tspan);
    lastIndex = re.lastIndex;
  }
  if (lastIndex < line.length) textEl.appendChild(document.createTextNode(line.slice(lastIndex)));
}
function escapeXml(s) {
  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// Same as appendRichText but returns an SVG markup string (for the
// string-based export builder), escaping everything except <b> tags.
function richLineToSvgMarkup(line) {
  let out = '';
  let lastIndex = 0;
  const re = /<b>([\s\S]*?)<\/b>/gi;
  let m;
  while ((m = re.exec(line)) !== null) {
    out += escapeXml(line.slice(lastIndex, m.index));
    out += `<tspan font-weight="bold">${escapeXml(m[1])}</tspan>`;
    lastIndex = re.lastIndex;
  }
  out += escapeXml(line.slice(lastIndex));
  return out;
}
function getLinkDisplayName(link) {
  if (!link) return '';
  try {
    let frag = '';
    let withoutFrag = link;
    if (link.includes('#')) {
      const parts = link.split('#');
      withoutFrag = parts[0];
      frag = parts[1] || '';
    }
    if (!withoutFrag) return frag || link;
    withoutFrag = withoutFrag.split('?')[0];
    const segments = withoutFrag.split('/');
    let last = segments[segments.length - 1] || '';
    last = last.replace(/\.[^./]+$/, ''); // strip extension
    return last || frag || link;
  } catch (e) {
    return link;
  }
}

function el(tag, attrs = {}) {
  const e = document.createElementNS('http://www.w3.org/2000/svg', tag);
  Object.entries(attrs).forEach(([k,v]) => e.setAttribute(k, v));
  return e;
}

// ── GRID (preview only) ─────────────────────────────────────────────────
// Draws light reference lines spaced every 50 *exported pixels*, plus a
// highlighted x-axis and y-axis at the diagram's local (0,0), which lives
// at (OX, OY) in the current SVG's coordinate space.
//
// The live preview is drawn in the diagram's native content units, which
// have no fixed relationship to pixels — the "Export width" dropdown is
// what maps content units to real pixels on export. So the grid's spacing
// in content units is derived from that dropdown (contentW / exportWidth
// gives units-per-pixel), meaning the number of grid lines you see updates
// when you change the export width, rather than always reflecting whatever
// the diagram's raw content-unit width happens to be.
const GRID_STEP_PX = 50;

function drawGrid(svg, W, H, OX, OY) {
  const g = el('g', {class: 'grid-layer'});

  const GRID_COLOR = currentGridLineColor;
  const AXIS_COLOR = currentAxisLineColor;

  const exportWidth = Number(document.getElementById('export-width-select').value) || 600;
  const unitsPerPixel = W / exportWidth;
  const GRID_STEP = GRID_STEP_PX * unitsPerPixel;

  // Vertical lines, aligned so one falls exactly on the y-axis (x = OX).
  let startX = OX % GRID_STEP;
  if (startX < 0) startX += GRID_STEP;
  for (let x = startX; x <= W; x += GRID_STEP) {
    g.appendChild(el('line', {
      x1: x, y1: 0, x2: x, y2: H,
      stroke: GRID_COLOR, 'stroke-width': 1, 'shape-rendering': 'crispEdges'
    }));
  }

  // Horizontal lines, aligned so one falls exactly on the x-axis (y = OY).
  let startY = OY % GRID_STEP;
  if (startY < 0) startY += GRID_STEP;
  for (let y = startY; y <= H; y += GRID_STEP) {
    g.appendChild(el('line', {
      x1: 0, y1: y, x2: W, y2: y,
      stroke: GRID_COLOR, 'stroke-width': 1, 'shape-rendering': 'crispEdges'
    }));
  }

  // Highlighted axes through local (0,0), if visible within the canvas.
  if (OY >= 0 && OY <= H) {
    g.appendChild(el('line', {
      x1: 0, y1: OY, x2: W, y2: OY,
      stroke: AXIS_COLOR, 'stroke-width': 1.5, 'shape-rendering': 'crispEdges'
    }));
  }
  if (OX >= 0 && OX <= W) {
    g.appendChild(el('line', {
      x1: OX, y1: 0, x2: OX, y2: H,
      stroke: AXIS_COLOR, 'stroke-width': 1.5, 'shape-rendering': 'crispEdges'
    }));
  }

  // Boundary markers at the true left/right edges of the canvas (x=0 and
  // x=W) — these correspond exactly to pixel 0 and the full export width
  // in the exported image, regardless of where the ordinary 50px grid
  // lines happen to fall. Drawn last so they sit on top of everything else.
  [0, W].forEach(bx => {
    g.appendChild(el('line', {
      x1: bx, y1: 0, x2: bx, y2: H,
      stroke: GRID_BOUNDARY_COLOR, 'stroke-width': 2, 'shape-rendering': 'crispEdges'
    }));
  });

  svg.appendChild(g);
}

// ── RENDER LIVE ──────────────────────────────────────────────────────────
function renderSvg(data) {
  const svg = document.getElementById('flowchart-svg');
  svg.innerHTML = '';

  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
  data.nodes.forEach(n => {
    const { w, h } = getNodeSize(n);
    minX = Math.min(minX, n.x - w/2); maxX = Math.max(maxX, n.x + w/2);
    minY = Math.min(minY, n.y - h/2); maxY = Math.max(maxY, n.y + h/2);
  });
  const PAD = 60;
  const W = maxX - minX + PAD*2;
  const H = maxY - minY + PAD*2;
  const OX = -minX + PAD;
  const OY = -minY + PAD;
  currentOX = OX;
  currentOY = OY;

  svg.setAttribute('width', W);
  svg.setAttribute('height', H);
  svg.setAttribute('viewBox', `0 0 ${W} ${H}`);

  if (document.getElementById('grid-toggle').checked) {
    drawGrid(svg, W, H, OX, OY);
  }

  const defs = el('defs');
  const marker = el('marker', {id:'arrow', markerWidth:'8', markerHeight:'8', refX:'6', refY:'3', orient:'auto'});
  marker.appendChild(el('polygon', {points:'0 0, 8 3, 0 6', fill:'#8b95c4'}));
  defs.appendChild(marker);
  svg.appendChild(defs);

  const nodeMap = getNodeMap(data.nodes);

  // Pre-compute every edge's straight-line endpoints once, then find where
  // each edge crosses earlier-drawn edges so those crossings can be
  // rendered as bridge hops instead of plain overlaps.
  const edgeSegs = data.edges.map(edge => edgeEndpoints(edge.from, edge.to, nodeMap));
  const edgeCrossings = computeEdgeCrossings(edgeSegs);

  data.edges.forEach((edge, idx) => {
    const pts = edgeSegs[idx];
    if (!pts) return;
    const {x1, y1, x2, y2} = pts;
    const isDotted = edge.style === 'dotted';
    const d = edgePathD(x1 + OX, y1 + OY, x2 + OX, y2 + OY, edgeCrossings[idx]);
    svg.appendChild(el('path', {
      class: isDotted ? 'edge-line-dotted' : 'edge-line',
      d: d
    }));
    // Invisible wide hit-path, purely so edges are easy to click in edit
    // mode without needing pixel-perfect precision on the thin visible line.
    const hit = el('path', { class: 'edge-hit', d: d });
    hit.addEventListener('click', (e) => { e.stopPropagation(); handleEdgeClick(idx, edge); });
    svg.appendChild(hit);

    if (edge.label) {
      const place = edgeLabelPlacement(x1, y1, x2, y2);
      const tx = place.tx + OX;
      const ty = place.ty + OY;
      const t = el('text', {class:'edge-label', x: tx, y: ty, transform: `rotate(${place.angle} ${tx} ${ty})`});
      appendRichText(t, edge.label);
      svg.appendChild(t);
    }
  });

  currentNodeGroups = []; // parallel to data.nodes — filled in as each <g> is created below
  data.nodes.forEach((n, idx) => {
    const c = TYPE_CFG[n.type] || DEFAULT_CFG;
    const { w, h } = getNodeSize(n);
    const x = n.x + OX, y = n.y + OY;

    if (n.type === 'title' || n.type === 'comment' || n.type === 'warning' || n.type === 'notice') {
      const g = el('g', {class: 'node-shape'});
      g.addEventListener('click', (e) => { e.stopPropagation(); handleNodeAreaClick(e, idx); });
      const maxChars = n.type === 'title' ? 9999 : (n.type === 'comment' ? 30 : 28);
      const lines = wrapLabel(n.label, maxChars);
      const lineH = c.fontSize + 3;
      const totalH = lines.length * lineH;
      lines.forEach((line, i) => {
        const t = el('text', {x: x, y: y - totalH/2 + lineH/2 + i * lineH, 'text-anchor': 'middle', 'dominant-baseline': 'central',
          'font-family': c.fontFamily, 'font-size': c.fontSize, 'font-weight': c.fontWeight, fill: c.fontColor});
        appendRichText(t, line);
        g.appendChild(t);
      });
      svg.appendChild(g);
      currentNodeGroups[idx] = g;
      return;
    }

    if (n.type === 'image') {
      const g = el('g', {class: 'node-shape'});
      g.addEventListener('click', (e) => { e.stopPropagation(); handleNodeAreaClick(e, idx); });
      const par = n.fit === 'fill' ? 'none' : 'xMidYMid meet';
      const imgEl = el('image', {
        x: x - w/2, y: y - h/2, width: w, height: h,
        href: n.src, 'preserveAspectRatio': par
      });
      imgEl.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', n.src);
      g.appendChild(imgEl);
      svg.appendChild(g);
      currentNodeGroups[idx] = g;
      return;
    }

    const g = el('g', {class: 'node-shape'});
    g.addEventListener('click', (e) => { e.stopPropagation(); handleNodeAreaClick(e, idx); });

    if (n.type === 'decision') {
      const pts = `${x},${y-h/2} ${x+w/2},${y} ${x},${y+h/2} ${x-w/2},${y}`;
      g.appendChild(el('polygon', {points: pts, fill: c.fill, stroke: c.stroke, 'stroke-width': '2'}));
    } else {
      const rectAttrs = { x: x - w/2, y: y - h/2, width: w, height: h, rx: c.rx, fill: c.fill, stroke: c.stroke, 'stroke-width': '2' };
      if (c.dashed) rectAttrs['stroke-dasharray'] = '5,4';
      g.appendChild(el('rect', rectAttrs));
    }

    const lines = wrapLabel(n.label, n.type === 'decision' ? 14 : 18);
    const lineH = 14;
    const totalH = lines.length * lineH;
    lines.forEach((line, i) => {
      const t = el('text', {class: 'node-label', x: x, y: y - totalH/2 + lineH/2 + i * lineH});
      appendRichText(t, line);
      g.appendChild(t);
    });

    // ID shown inside the block, below the label
    g.appendChild(el('text', {class: 'node-id', x: x, y: y + h/2 - 8})).textContent = `[${n.id}]`;

    svg.appendChild(g);
    currentNodeGroups[idx] = g;
  });

  // Reapply persistent selection highlighting after the rebuild above —
  // renderSvg() wipes and recreates every <g> each call, so without this
  // a bulk-move Apply (which re-renders to show the new positions) would
  // otherwise silently drop the highlight on the very nodes just moved.
  applySelectionHighlight();

  // Flag nodes that substantially cover another node so the hidden one can
  // be spotted without manually dragging things around. A node later in
  // document order is drawn on top (SVG paints in source order), so when a
  // pair qualifies, it's the later one that's actually doing the
  // obscuring — that's the one we blink, since making the underneath node
  // blink wouldn't reveal anything (the node on top would still cover it
  // during the "visible" half). Elbow routing waypoints are excluded, same
  // as the existing edit-time overlap warning — they're tiny by design and
  // "obscuring" isn't a meaningful concept for them.
  for (let i = 0; i < data.nodes.length; i++) {
    const a = data.nodes[i];
    if (a.type === 'elbow' || !currentNodeGroups[i]) continue;
    const aSize = getNodeSize(a);
    const aBox = { x: a.x, y: a.y, w: aSize.w, h: aSize.h };

    for (let j = i + 1; j < data.nodes.length; j++) {
      const b = data.nodes[j];
      if (b.type === 'elbow' || !currentNodeGroups[j]) continue;
      const bSize = getNodeSize(b);
      const bBox = { x: b.x, y: b.y, w: bSize.w, h: bSize.h };

      const overlap = rectOverlapArea(aBox, bBox);
      if (overlap <= 0) continue;
      const smallerArea = Math.min(aBox.w * aBox.h, bBox.w * bBox.h);
      const fraction = smallerArea > 0 ? overlap / smallerArea : 0;
      if (fraction >= 0.75) {
        // j is later in document order, so it's drawn on top of i.
        currentNodeGroups[j].classList.add('node-blink-obscures');
      }
    }
  }

  // Logo (optional) — fixed size in content units, top-right corner, drawn
  // last so it sits above anything else near that corner.
  const logoFile = document.getElementById('logo-select').value;
  if (logoFile) {
    const lx = W - LOGO_MARGIN - LOGO_W;
    const ly = LOGO_MARGIN;
    const logoSrc = 'logos/' + logoFile;
    const logoImg = el('image', {
      x: lx, y: ly, width: LOGO_W, height: LOGO_H,
      href: logoSrc, preserveAspectRatio: 'xMaxYMin meet'
    });
    logoImg.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', logoSrc);
    svg.appendChild(logoImg);
  }

  // Small, unobtrusive trademark/copyright notice — always present, drawn
  // last so it's never covered by the diagram itself. Split across two
  // lines (via tspans) since the full text is too wide to read comfortably
  // as a single line at this font size on most diagrams.
  const copyrightLines = copyrightNoticeLines();
  const copyrightLineHeight = 12;
  const copyrightStartY = H - 8 - (copyrightLines.length - 1) * copyrightLineHeight;
  const copyrightEl = el('text', { class: 'copyright-notice', 'text-anchor': 'middle' });
  copyrightLines.forEach((line, i) => {
    const tspan = el('tspan', { x: W / 2, y: copyrightStartY + i * copyrightLineHeight });
    tspan.textContent = line;
    copyrightEl.appendChild(tspan);
  });
  svg.appendChild(copyrightEl);
}

// ── BUILD SVG MARKUP (pure builder, no I/O) ───────────────────────────────
function buildSvgMarkup(targetWidth) {
  if (!currentData) return '';
  targetWidth = Number(targetWidth) || 1200;

  const nodeMap = getNodeMap(currentData.nodes);
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
  currentData.nodes.forEach(n => {
    const { w, h } = getNodeSize(n);
    minX = Math.min(minX, n.x - w/2); maxX = Math.max(maxX, n.x + w/2);
    minY = Math.min(minY, n.y - h/2); maxY = Math.max(maxY, n.y + h/2);
  });
  const PAD = 60;
  const OX = -minX + PAD;
  const OY = -minY + PAD;

  // Content bounding box (in original layout units) defines the SVG's
  // internal coordinate system (viewBox). The outer width/height attributes
  // are then set to the user-selected export width exactly, with height
  // scaled to preserve the content's aspect ratio, so the whole diagram is
  // always visible at precisely that pixel width. Note: because contentW
  // varies per diagram, the resulting font/block scale (px per content
  // unit) also varies per diagram for the same width selection — a wide,
  // sprawling diagram will render smaller text at 600px than a compact one.
  // That's an accepted tradeoff in exchange for an exact output width.
  const contentW = (maxX - minX) + PAD * 2;
  const contentH = (maxY - minY) + PAD * 2;
  const exportW = targetWidth;
  const exportH = Math.round(contentH * (exportW / contentW));
  const themeCss = THEMES[currentThemeName].css;

  let svgContent = `<svg width="${exportW}" height="${exportH}" viewBox="0 0 ${contentW} ${contentH}" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">\n`;
  svgContent += `  <defs>\n    <marker id="arrow" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">\n      <polygon points="0 0, 8 3, 0 6" fill="${themeCss['--edge-color']}"/>\n    </marker>\n  </defs>\n`;

  // Background matches the active theme (e.g. white for Boardroom, not a
  // hardcoded dark color), so the exported file looks like the preview.
  svgContent += `  <rect width="${contentW}" height="${contentH}" fill="${themeCss['--bg-app']}"/>\n`;

  // Edges
  const edgeSegsExport = currentData.edges.map(edge => edgeEndpoints(edge.from, edge.to, nodeMap));
  const edgeCrossingsExport = computeEdgeCrossings(edgeSegsExport);
  currentData.edges.forEach((edge, edgeIdx) => {
    const pts = edgeSegsExport[edgeIdx];
    if (pts) {
      const {x1,y1,x2,y2} = pts;
      const isDotted = edge.style === 'dotted';
      const strokeAttrs = isDotted
        ? `stroke="${themeCss['--edge-color']}" stroke-width="2" stroke-dasharray="5,4" fill="none"`
        : `stroke="${themeCss['--edge-color']}" stroke-width="2" fill="none" marker-end="url(#arrow)"`;
      const dAttr = edgePathD(x1+OX, y1+OY, x2+OX, y2+OY, edgeCrossingsExport[edgeIdx]);
      svgContent += `  <path d="${dAttr}" ${strokeAttrs}/>\n`;
      if (edge.label) {
        const place = edgeLabelPlacement(x1, y1, x2, y2);
        const labelX = place.tx + OX;
        const labelY = place.ty + OY;
        svgContent += `  <text x="${labelX}" y="${labelY}" transform="rotate(${place.angle} ${labelX} ${labelY})" font-family="Segoe UI" font-size="13" font-weight="600" fill="${themeCss['--text-primary']}" text-anchor="middle">${richLineToSvgMarkup(edge.label)}</text>\n`;
      }
    }
  });

  // Nodes - box fill/stroke/text-color all come from the active theme
  currentData.nodes.forEach(n => {
    const c = TYPE_CFG[n.type] || DEFAULT_CFG;
    const { w, h } = getNodeSize(n);
    const x = n.x + OX, y = n.y + OY;

    if (n.type === 'title' || n.type === 'comment' || n.type === 'warning' || n.type === 'notice') {
      const maxChars = n.type === 'title' ? 9999 : (n.type === 'comment' ? 30 : 28);
      const lines = wrapLabel(n.label, maxChars);
      const lineH = c.fontSize + 3;
      const totalH = lines.length * lineH;
      lines.forEach((line, i) => {
        const ty = y - totalH/2 + lineH/2 + i * lineH;
        svgContent += `  <text x="${x}" y="${ty}" text-anchor="middle" dominant-baseline="central" font-family="${c.fontFamily}" font-size="${c.fontSize}" font-weight="${c.fontWeight}" fill="${c.fontColor}">${richLineToSvgMarkup(line)}</text>\n`;
      });
      return;
    }

    const escaped = n.narrative.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
    const tooltipText = n.link ? `${escaped}\n${n.link.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}` : escaped;
    const hasLink = !!n.link;
    const linkHrefEsc = hasLink ? n.link.replace(/&/g, '&amp;').replace(/"/g, '&quot;') : '';
    // target defaults to 'MAIN' (set during parsing) unless the XML
    // explicitly specified a different <target> value for this node.
    const linkTargetEsc = hasLink ? (n.target || 'MAIN').replace(/&/g, '&amp;').replace(/"/g, '&quot;') : '';

    svgContent += `  <g>\n`;
    if (hasLink) svgContent += `    <a href="${linkHrefEsc}" target="${linkTargetEsc}">\n`;
    svgContent += `    <title>${tooltipText}</title>\n`;

    if (n.type === 'image') {
      const srcEsc = (n.src || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;');
      const par = n.fit === 'fill' ? 'none' : 'xMidYMid meet';
      svgContent += `    <image x="${x-w/2}" y="${y-h/2}" width="${w}" height="${h}" href="${srcEsc}" xlink:href="${srcEsc}" preserveAspectRatio="${par}"/>\n`;
      if (hasLink) svgContent += `    </a>\n`;
      svgContent += `  </g>\n`;
      return;
    }

    if (n.type === 'decision') {
      const pts = `${x},${y-h/2} ${x+w/2},${y} ${x},${y+h/2} ${x-w/2},${y}`;
      svgContent += `    <polygon points="${pts}" fill="${c.fill}" stroke="${c.stroke}" stroke-width="2.5"/>\n`;
    } else {
      const dashAttr = c.dashed ? ` stroke-dasharray="5,4"` : '';
      svgContent += `    <rect x="${x-w/2}" y="${y-h/2}" width="${w}" height="${h}" rx="${c.rx}" fill="${c.fill}" stroke="${c.stroke}" stroke-width="2.5"${dashAttr}/>\n`;
    }

    const lines = wrapLabel(n.label, n.type === 'decision' ? 14 : 18);
    const lineH = 14;
    const totalH = lines.length * lineH;
    lines.forEach((line, i) => {
      const ty = y - totalH/2 + lineH/2 + i * lineH;
      svgContent += `    <text x="${x}" y="${ty}" text-anchor="middle" dominant-baseline="central" font-family="Segoe UI" font-size="12.5" font-weight="600" fill="${themeCss['--node-label-color']}">${richLineToSvgMarkup(line)}</text>\n`;
    });
    if (hasLink) svgContent += `    </a>\n`;
    svgContent += `  </g>\n`;
  });

  // Logo (optional) — same fixed content-unit box as the live preview, drawn
  // last so it layers above anything else near that corner.
  const logoFile = document.getElementById('logo-select').value;
  if (logoFile) {
    const lx = contentW - LOGO_MARGIN - LOGO_W;
    const ly = LOGO_MARGIN;
    const logoSrc = 'logos/' + logoFile;
    svgContent += `  <image x="${lx}" y="${ly}" width="${LOGO_W}" height="${LOGO_H}" href="${logoSrc}" xlink:href="${logoSrc}" preserveAspectRatio="xMaxYMin meet"/>\n`;
  }

  // Small, unobtrusive trademark/copyright notice — always present, drawn
  // last so it's never covered by the diagram itself. Split across two
  // lines (via tspans) since the full text is too wide to read comfortably
  // as a single line at this font size on most diagrams.
  const copyrightLines = copyrightNoticeLines().map(escapeXmlText);
  const copyrightLineHeight = 12;
  const copyrightStartY = contentH - 8 - (copyrightLines.length - 1) * copyrightLineHeight;
  svgContent += `  <text text-anchor="middle" font-family="Segoe UI" font-size="10" fill="${themeCss['--text-muted']}" opacity="0.85">\n`;
  copyrightLines.forEach((line, i) => {
    svgContent += `    <tspan x="${contentW / 2}" y="${copyrightStartY + i * copyrightLineHeight}">${line}</tspan>\n`;
  });
  svgContent += `  </text>\n`;

  svgContent += `</svg>`;
  return svgContent;
}

// ── HTML IMAGE MAP (rect/poly <area> per node, mirrors SVG tooltip+click) ──
// Coordinates are scaled to match whatever export width was selected (1200
// or 900px) when buildSvgMarkup() generated the paired SVG, so the map is
// meant to be used together with that exported SVG/PNG via:
//   <img src="name.svg" usemap="#flowchartmap" width="{exportWidth}" height="{exportHeight}">
function buildImageMapHtml(targetWidth) {
  if (!currentData) return '';
  targetWidth = Number(targetWidth) || 1200;
  const nodeMap = getNodeMap(currentData.nodes);
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
  currentData.nodes.forEach(n => {
    const { w, h } = getNodeSize(n);
    minX = Math.min(minX, n.x - w/2); maxX = Math.max(maxX, n.x + w/2);
    minY = Math.min(minY, n.y - h/2); maxY = Math.max(maxY, n.y + h/2);
  });
  const PAD = 60;
  const OX = -minX + PAD;
  const OY = -minY + PAD;
  const mapName = BASE_NAME + 'map';

  // The exported SVG is displayed (via <img>) at the user-selected width,
  // scaled proportionally from the content bounding box. Image map <area>
  // coordinates must be given in the image's *displayed* pixel size, so we
  // scale every coordinate below by the same factor used for the SVG export.
  const contentW = (maxX - minX) + PAD * 2;
  const contentH = (maxY - minY) + PAD * 2;
  const scale = targetWidth / contentW;
  const exportW = targetWidth;
  const exportH = Math.round(contentH * scale);

  const esc = s => (s || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
  // In the image-map file specifically, <br> line-break tags are flattened
  // to a single space rather than kept as a break, since the tooltip here
  // reads from HTML attributes (alt/data-*) where a literal line break is
  // less useful than a normal run of text. Collapses any resulting doubled
  // spaces left behind by adjacent/consecutive <br> tags.
  const brToSpace = s => (s || '').replace(/<br\s*\/?>/gi, ' ').replace(/ {2,}/g, ' ').trim();

  let html = `<map name="${mapName}" id="${mapName}">\n`;
  currentData.nodes.forEach(n => {
    if (n.type === 'title' || n.type === 'comment' || n.type === 'warning' || n.type === 'notice') return;
    const { w, h } = getNodeSize(n);
    const x = (n.x + OX) * scale, y = (n.y + OY) * scale;
    const sw = w * scale, sh = h * scale;
    const href = n.link ? esc(n.link) : `#${BASE_NAME}`;
    // target defaults to 'MAIN' (set during parsing) unless the XML
    // explicitly specified a different <target> value for this node.
    const target = n.link ? ` target="${esc(n.target || 'MAIN')}"` : '';
    const flatLabel = brToSpace(n.label);
    const flatNarrative = brToSpace(n.narrative);
    // Native title attribute intentionally omitted — replaced by a custom
    // tooltip (see script below) built from these data attributes.
    const dataAttrs = ` data-node-id="${esc(n.id)}" data-label="${esc(flatLabel)}" data-narrative="${esc(flatNarrative)}" data-link="${esc(n.link)}" data-target="${esc(n.target || '')}"`;

    if (n.type === 'decision') {
      const hw = sw/2, hh = sh/2;
      const coords = [
        Math.round(x), Math.round(y-hh),
        Math.round(x+hw), Math.round(y),
        Math.round(x), Math.round(y+hh),
        Math.round(x-hw), Math.round(y)
      ].join(',');
      html += `  <area shape="poly" coords="${coords}" href="${href}"${target} alt="${esc(flatLabel)}"${dataAttrs}>\n`;
    } else {
      const coords = [
        Math.round(x - sw/2), Math.round(y - sh/2),
        Math.round(x + sw/2), Math.round(y + sh/2)
      ].join(',');
      html += `  <area shape="rect" coords="${coords}" href="${href}"${target} alt="${esc(flatLabel)}"${dataAttrs}>\n`;
    }
  });
  html += `</map>\n`;

  // Logo overlay (optional) — rendered as a plain HTML <img>, absolutely
  // positioned on top of the diagram <img>, rather than relying on the
  // SVG's own embedded <image href="logos/..."> to paint. The SVG here is
  // loaded via <img src="name.svg">, and once an SVG is consumed that way
  // (as opposed to inlined in the DOM) external raster references inside
  // it are not reliably fetched/rendered by every browser or SVG-to-PNG
  // pipeline. An HTML overlay sidesteps that entirely.
  //
  // Sizing is deliberately based on exportW alone (the export width the
  // user picked), NOT on `scale`/`contentW`. contentW is the diagram's own
  // content-unit bounding box, which differs per diagram depending on the
  // node layout — using it would make the logo a different pixel size on
  // two diagrams exported at the *same* target width just because their
  // layouts differ. LOGO_W/LOGO_H were authored as pixel dimensions at a
  // LOGO_REF_WIDTH-wide export; logoScale rescales them for whatever
  // export width was actually chosen, so the logo is always the same
  // pixel size for a given export width, regardless of diagram content.
  const LOGO_REF_WIDTH = 1200;
  const logoFile = document.getElementById('logo-select').value;
  let logoHtml = '';
  if (logoFile) {
    const logoScale = exportW / LOGO_REF_WIDTH;
    const logoW = Math.round(LOGO_W * logoScale);
    const logoH = Math.round(LOGO_H * logoScale);
    const logoMargin = Math.round(LOGO_MARGIN * logoScale);
    const logoLeft = exportW - logoMargin - logoW;
    const logoTop = logoMargin;
    const logoSrc = 'logos/' + logoFile;
    logoHtml = `  <img src="${logoSrc}" alt="logo" style="position:absolute; left:${logoLeft}px; top:${logoTop}px; width:${logoW}px; height:${logoH}px; object-fit:contain; object-position:right top; pointer-events:none;">\n`;
  }

  const imgTag = `<div class="magec-diagram-wrap" style="position:relative; display:inline-block; line-height:0;">\n`
    + `  <img src="${BASE_NAME}.svg" usemap="#${mapName}" id="${BASE_NAME}" width="${exportW}" height="${exportH}" alt="${esc(currentData.title)}">\n`
    + logoHtml
    + `</div>\n`;

  // Self-contained tooltip styling + behavior, since this file is meant to
  // be dropped into other pages via SSI include and must carry its own CSS/JS.
  const tooltipAssets = `
<style>
  .magec-tip {
    position: absolute;
    z-index: 9999;
    max-width: 280px;
    background: #1a1d27;
    color: #e2e8f0;
    border: 2px solid silver;
    border-radius: 10px;
    padding: 12px 14px;
    font-family: 'Segoe UI', system-ui, sans-serif;
    font-size: 13px;
    line-height: 1.6;
    box-shadow: 0 12px 32px rgba(0,0,0,.45);
    display: none;
  }
  .magec-tip.open { display: block; }
  .magec-tip .magec-tip-close {
    position: absolute;
    top: 4px;
    right: 8px;
    background: none;
    border: none;
    color: #64748b;
    font-size: 16px;
    cursor: pointer;
    line-height: 1;
  }
  .magec-tip .magec-tip-close:hover { color: #e2e8f0; }
  .magec-tip-title {
    font-weight: 700;
    font-size: 13.5px;
    margin: 0 18px 6px 0;
    color: #f1f5f9;
  }
  .magec-tip-body { color: #94a3b8; }
  .magec-tip-link {
    display: inline-block;
    margin-top: 10px;
    font-size: 12.5px;
    font-weight: 600;
    color: #a5b4fc;
    text-decoration: none;
    border: 1px solid #4a5280;
    border-radius: 6px;
    padding: 4px 10px;
  }
  .magec-tip-link:hover { opacity: .85; }
</style>
<div class="magec-tip" id="${BASE_NAME}-tip">
  <button class="magec-tip-close" type="button" aria-label="Close">&times;</button>
  <div class="magec-tip-title"></div>
  <div class="magec-tip-body"></div>
</div>
<script>
(function() {
  var tip = document.getElementById(${JSON.stringify(BASE_NAME + '-tip')});
  if (!tip) return;
  var titleEl = tip.querySelector('.magec-tip-title');
  var bodyEl = tip.querySelector('.magec-tip-body');
  var closeBtn = tip.querySelector('.magec-tip-close');

  function hideTip() { tip.classList.remove('open'); }
  closeBtn.addEventListener('click', hideTip);
  document.addEventListener('click', function(e) {
    if (tip.classList.contains('open') && !tip.contains(e.target) && e.target.tagName !== 'AREA') hideTip();
  });

  function getLinkDisplayName(link) {
    if (!link) return '';
    var frag = '', withoutFrag = link;
    if (link.indexOf('#') !== -1) {
      var parts = link.split('#');
      withoutFrag = parts[0];
      frag = parts[1] || '';
    }
    if (!withoutFrag) return frag || link;
    withoutFrag = withoutFrag.split('?')[0];
    var segments = withoutFrag.split('/');
    var last = segments[segments.length - 1] || '';
    last = last.replace(/\.[^./]+$/, '');
    return last || frag || link;
  }

  document.querySelectorAll('map[name=${JSON.stringify(mapName)}] area').forEach(function(area) {
    area.addEventListener('click', function(e) {
      var narrative = area.getAttribute('data-narrative') || '';
      var link = area.getAttribute('data-link') || '';
      var label = area.getAttribute('data-label') || '';
      var target = area.getAttribute('data-target') || 'MAIN';

      // Only intercept when there's something useful to show; otherwise let
      // the href behave normally (e.g. the same-page anchor fallback).
      if (!narrative && !link) return;
      e.preventDefault();

      titleEl.textContent = label;
      bodyEl.innerHTML = narrative ? narrative : '<em>No narrative provided.</em>';
      if (link) {
        bodyEl.innerHTML += '<br><a class="magec-tip-link" href="' + link.replace(/"/g, '&quot;') + '" target="' + target.replace(/"/g, '&quot;') + '">🔗 See ' + getLinkDisplayName(link).replace(/</g, '&lt;') + '</a>';
      }

      tip.classList.add('open');
      var pageX = e.pageX, pageY = e.pageY;
      var vw = window.innerWidth, vh = window.innerHeight;
      tip.style.left = '0px'; tip.style.top = '0px';
      var rect = tip.getBoundingClientRect();
      var left = pageX + 14;
      var top = pageY + 10;
      if (left + rect.width > window.scrollX + vw - 12) left = pageX - rect.width - 14;
      if (top + rect.height > window.scrollY + vh - 12) top = pageY - rect.height - 10;
      tip.style.left = Math.max(8, left) + 'px';
      tip.style.top = Math.max(8, top) + 'px';
    });
  });
})();
<\/script>`;

  // As an HTML comment, not a visible element — this fragment is designed
  // to be dropped into other pages via SSI include (see tooltipAssets
  // comment below), so a rendered caption here would print on every host
  // page that embeds it. The comment keeps the notice in the source
  // without doing that.
  const copyrightComment = `<!-- ${copyrightNoticeText()} -->\n`;

  return copyrightComment + imgTag + html + tooltipAssets;
}

async function exportAll() {
  if (!currentData) return alert("No diagram loaded yet.");

  const exportWidth = Number(document.getElementById('export-width-select').value) || 1200;
  const svgContent = buildSvgMarkup(exportWidth);
  const mapHtml = buildImageMapHtml(exportWidth);

  try {
    const resp = await fetch(window.location.pathname + window.location.search, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: 'action=save_both&name=' + encodeURIComponent(BASE_NAME)
        + '&svg=' + encodeURIComponent(svgContent)
        + '&map=' + encodeURIComponent(mapHtml)
    });
    const data = await resp.json();
    if (data.ok) {
      alert(`Saved ${data.svg_file} (${data.svg_bytes} bytes) and ${data.map_file} (${data.map_bytes} bytes) to the server.`);
    } else {
      alert('Save failed: ' + data.error);
    }
  } catch (e) {
    console.error('Save error:', e);
    alert('Save failed: ' + e.message);
  }
}


function openModal(node, cfg) {
  const badge = document.getElementById('modal-badge');
  badge.textContent = cfg.label;
  badge.style.background = cfg.badge + '22';
  badge.style.color = cfg.badge;
  badge.style.border = `1px solid ${cfg.badge}55`;

  document.getElementById('modal-title').textContent = node.label;
  document.getElementById('modal-body').innerHTML = node.narrative || '<em>No narrative provided.</em>';

  const cobolWrap = document.getElementById('modal-cobol-wrap');
  if (node.cobolParagraph) {
    document.getElementById('modal-cobol').textContent = node.cobolParagraph;
    cobolWrap.style.display = 'block';
  } else {
    cobolWrap.style.display = 'none';
  }

  const linkWrap = document.getElementById('modal-link-wrap');
  const linkEl = document.getElementById('modal-link');
  if (node.link) {
    linkEl.href = node.link;
    // target defaults to 'MAIN' (set during parsing) unless the XML
    // explicitly specified a different <target> value for this node.
    linkEl.target = node.target || 'MAIN';
    linkEl.textContent = '🔗 See ' + getLinkDisplayName(node.link);
    linkWrap.style.display = 'block';
  } else {
    linkWrap.style.display = 'none';
  }

  document.getElementById('modal-overlay').classList.add('open');
}

function closeModal(e) {
  if (!e || e.target.id === 'modal-overlay' || e.currentTarget.tagName === 'BUTTON') {
    document.getElementById('modal-overlay').classList.remove('open');
  }
}

// ── EMOJI MENU ───────────────────────────────────────────────────────────
const EMOJI_LIST = [
  '✅','❌','⚠️','🚫','🔴','🟢','🟡','🟠','🔵','⭐',
  '❗','❓','ℹ️','🔍','🔎','📌','📍','🔧','⚙️','🛠️',
  '🔒','🔓','🔑','🚀','⏱️','⏳','📅','🔁','➡️','⬅️',
  '⬆️','⬇️','📈','📉','📊','💡','🔥','🧩','🧠','📝',
  '📂','📄','📧','💬','👤','👥','🏁','🎯','✋','👍',
  '👎','💰','🛑','✔️','✨','🐛','🧪','📦','🌐','☁️',
];

function buildEmojiGrid() {
  const grid = document.getElementById('emoji-grid');
  EMOJI_LIST.forEach(em => {
    const cell = document.createElement('div');
    cell.className = 'emoji-cell';
    cell.textContent = em;
    cell.title = 'Click to copy';
    cell.addEventListener('click', () => copyEmoji(em));
    grid.appendChild(cell);
  });
}

function toggleEmojiMenu() {
  document.getElementById('emoji-panel').classList.toggle('open');
}

async function copyEmoji(em, displayAs) {
  const msg = document.getElementById('emoji-copied-msg');
  try {
    await navigator.clipboard.writeText(em);
    msg.textContent = `Copied ${displayAs || em}`;
  } catch (e) {
    msg.textContent = 'Copy failed — select and copy manually.';
  }
  setTimeout(() => { msg.textContent = ''; }, 1500);
}
buildEmojiGrid();

// If no name was given (or it was literally "new"), offer to create a
// fresh flowchart under an operator-chosen name rather than silently
// falling back to a pre-existing 'flowchart.xml'. Declining just proceeds
// with whatever the default resolves to, same as before this existed.
// Returns true if a redirect was triggered (so callers can skip loading
// the old default while that navigation is in flight).
function maybePromptForNewFlowchart() {
  if (!IS_NEW_REQUEST) return false;
  if (!confirm('No flowchart name was specified. Would you like to create a new flowchart?')) return false;

  let name = prompt('Name for the new flowchart (letters, numbers, underscore, hyphen only):', '');
  if (name === null) return false; // cancelled
  name = name.trim().replace(/\.(xml|svg)$/i, '').replace(/[^A-Za-z0-9_\-]/g, '');
  if (!name) {
    alert('No valid name entered — continuing with the default flowchart instead.');
    return false;
  }
  window.location.href = window.location.pathname + '?name=' + encodeURIComponent(name);
  return true;
}

function getSavedTheme() {
  try { return localStorage.getItem('flowchart-theme') || 'bold'; } catch (e) { return 'bold'; }
}

// Populates the form's type/fit/style <select> dropdowns straight from the
// schema constants (VALID_NODE_TYPES etc.), so they can never drift out of
// sync with what the app actually supports.
function populateEditorDropdowns() {
  const typeSel = document.getElementById('f-node-type');
  VALID_NODE_TYPES.forEach(t => {
    const opt = document.createElement('option');
    opt.value = t;
    opt.textContent = t;
    typeSel.appendChild(opt);
  });

  const fitSel = document.getElementById('f-node-fit');
  VALID_FIT_VALUES.forEach(f => {
    const opt = document.createElement('option');
    opt.value = f;
    opt.textContent = f;
    fitSel.appendChild(opt);
  });

  const styleSel = document.getElementById('f-edge-style');
  VALID_EDGE_STYLES.forEach(s => {
    const opt = document.createElement('option');
    opt.value = s;
    opt.textContent = s;
    styleSel.appendChild(opt);
  });
}

applyTheme(getSavedTheme());
populateEditorDropdowns();
if (!maybePromptForNewFlowchart()) {
  initBackgroundClickHandler();
  initMarqueeHandlers();
  loadAndRender();
}
</script>
</body>
</html>