/* global React, ReactDOM */
// ====================================================================
// AETHER WEB SHAPER — a toy for hand-crafting the C-style node graph.
// Drag to move · Shift-drag node→node to link/unlink · click a line to
// select it · Del to delete. Tag roles (launch / vessel / destination).
// Each node now carries FOUR types — one per time-of-day slot
// (Night / Morning / Afternoon / Evening) — so a node can read as a
// hazard at one slot and a rescue at another. The board shows one slot
// at a time; the slot strip up top picks which. Exports drop-in code in
// C's coordinate space. Autosaves.
// v4: edges are first-class — direction (two-way / one-way) and a
// time-of-day availability mask per line. Click a line to edit it.
// ====================================================================
const { useState, useRef, useEffect, useLayoutEffect, useCallback } = React;
const NT = window.NT;
const ShipIcon = window.Vessel;

const BOARD_W = 1300, BOARD_H = 340;     // canonical C coordinate space
const STORE_KEY = 'sh-web-shaper-v5';    // v5 = per-ToD magnitude + the day-redesign (v4 = directed/ToD edges)

const NODE_TYPES = ['forage', 'gate', 'rescue', 'hazard', 'event'];
const DEST_TYPES = ['geode', 'wreck', 'lee'];

// the day's four slots, in clock order (Night opens the day — see economy.md)
const PHASES = [
  { key: 'night', label: 'Night' },
  { key: 'morning', label: 'Morning' },
  { key: 'afternoon', label: 'Afternoon' },
  { key: 'evening', label: 'Evening' },
];
// data stays night-indexed (export contract: [night, morning, afternoon, evening]),
// but the UI reads the day morning → afternoon → evening → night.
const PHASE_ORDER = [1, 2, 3, 0];

// element affinities — the eight veins (ring order; colours per app.jsx)
const ELEMENTS = {
  fire:      { name: 'Fire',      glyph: '△', color: 'var(--el-fire)'  },
  water:     { name: 'Water',     glyph: '~', color: 'var(--el-water)' },
  earth:     { name: 'Earth',     glyph: '□', color: 'var(--el-earth)' },
  nature:    { name: 'Nature',    glyph: '✿', color: '#5d7a3f'         },
  ice:       { name: 'Ice',       glyph: '◊', color: '#5a7d9a'         },
  lightning: { name: 'Lightning', glyph: '╱', color: '#9a7d2a'         },
  wind:      { name: 'Wind',      glyph: '≋', color: 'var(--el-air)'   },
  holy:      { name: 'Holy',      glyph: '✦', color: '#8c7a3a'         },
};
const EL_ORDER = ['fire', 'water', 'earth', 'nature', 'ice', 'lightning', 'wind', 'holy'];

// synthetic magnitude — drives node size; 1 (faint) … 6 (loud). Per-slot (v5):
// a node can swell at the hour its content wakes (predator at Night, bloom at Evening).
const MAG_MIN = 1, MAG_MAX = 6, MAG_DEFAULT = 2;
const magScale = (m) => 0.82 + 0.085 * (m == null ? MAG_DEFAULT : m);
const nodeDia = (node, isDest, phase) => Math.round((isDest ? 58 : 46) * magScale(magAt(node, phase)));

// every node holds types[0..3] + affinity[0..3] (element key | null) + magnitude
const uni = (t) => [t, t, t, t];
const typeAt = (n, p) => (n.types ? n.types[p] : n.type) || 'forage';
const affAt = (n, p) => (n.affinity ? n.affinity[p] : null) || null;
const magAt = (n, p) => (Array.isArray(n.mag) ? n.mag[p] : (typeof n.mag === 'number' ? n.mag : MAG_DEFAULT));
const normNode = (n) => ({
  ...n,
  types: n.types || uni(n.type || 'forage'),
  affinity: n.affinity || uni(null),
  mag: Array.isArray(n.mag) ? n.mag.slice() : uni(typeof n.mag === 'number' ? n.mag : MAG_DEFAULT),
});

// edges (v4): { a, b, oneWay, tod } — oneWay = travel a → b only;
// tod = night-indexed [n, m, a, e] booleans, false = line closed that slot.
const TOD_ALL = () => [true, true, true, true];
const normEdge = (e) => Array.isArray(e)
  ? { a: e[0], b: e[1], oneWay: false, tod: TOD_ALL() }
  : { oneWay: false, ...e, tod: (e.tod && e.tod.length === 4 ? e.tod.slice() : TOD_ALL()) };
const normModel = (m) => ({ ...m, nodes: m.nodes.map(normNode), edges: (m.edges || []).map(normEdge) });

// ---- starting template = the current C graph -----------------------
function templateC() {
  const COORDS = {
    launch: [50, 168],
    a: [224, 51], b: [198, 240], c: [440, 50], d: [217, 162],
    f: [647, 52], h: [452, 164], g: [634, 292],
    i: [851, 57], j: [847, 172], k: [840, 285],
    end1: [1196, 57], end2: [1187, 172], end3: [1187, 279], n1: [1003, 286],
  };
  const TYPES = {
    launch: 'forage',
    a: 'event', b: 'hazard', c: 'forage', d: 'gate',
    f: 'forage', h: 'rescue', g: 'forage',
    i: 'event', j: 'forage', k: 'forage',
    end1: 'rescue', end2: 'wreck', end3: 'geode', n1: 'hazard',
  };
  // LAUNCH = null · VESSEL = launch · DESTINATIONS = end2, end3
  const ROLE = { launch: 'vessel', end2: 'destination', end3: 'destination' };
  const nodes = Object.keys(COORDS).map(id => normNode({
    id, x: COORDS[id][0], y: COORDS[id][1],
    role: ROLE[id] || 'normal', types: uni(TYPES[id]),
  }));
  const edges = [
    ['launch', 'a'], ['launch', 'b'],
    ['a', 'c'], ['c', 'd'], ['c', 'f'],
    ['f', 'i'], ['f', 'j'], ['g', 'j'], ['i', 'j'], ['j', 'k'],
    ['i', 'end1'], ['i', 'end2'], ['j', 'end2'],
    ['h', 'd'], ['k', 'g'], ['k', 'n1'], ['n1', 'end3'],
    ['d', 'launch'], ['b', 'j'],
  ];
  return normModel({ nodes, edges, counter: 2 });   // n1 exists → next add is n2
}

// ---- Z0 draft = the human's hand-shaped web + the day-redesign (2026-06-10).
// One temporal HEADLINE per slot (a type that appears/vanishes), badges only in an
// element's home slot, magnitude swelling at the hour the content wakes.
// Ice/Lightning affinity teasers dropped (human call) — the T2 ice hint now lives
// narratively in j's Night glint event instead of as a dead check.
// Arrays are night-indexed: [night, morning, afternoon, evening].
function templateZ0() {
  const F = 'forage', H = 'hazard', R = 'rescue', E = 'event', G = 'gate';
  const N = (id, x, y, types, affinity, mag, role = 'normal') =>
    normNode({ id, x, y, role, types, affinity, mag });
  const nodes = [
    N('launch', 50, 168, uni('launch'), uni(null), 2, 'launch'),
    N('a', 223, 53, uni(F), [null, 'fire', null, null], [1, 2, 1, 1]),        // Fire farm — louder in its Morning hour
    N('d', 217, 162, [G, F, G, G], [null, 'earth', null, null], 2),           // MORNING headline: the tide-gate stands open
    N('b', 384, 287, uni(H), uni('fire'), 2),                                 // gentle teach + paid shortcut (force Fire / finesse Water)
    N('h', 417, 143, [F, F, F, R], [null, null, null, 'water'], [1, 1, 1, 3]),// EVENING headline: the rescue surfaces — faint forage till dusk
    N('c', 440, 50, uni(F), [null, null, null, 'water'], 2),
    N('f', 647, 52, [F, F, H, F], [null, null, 'wind', null], [2, 2, 4, 2]),  // AFTERNOON headline: the squall — wind-keyed force locked, finesse/pay for now
    N('g', 711, 232, [H, F, F, F], [null, null, null, 'water'], [4, 2, 2, 2]),// NIGHT headline: the predator wakes on calm ground
    N('j', 756, 125, [E, F, F, F], uni(null), [3, 2, 2, 2]),                  // NIGHT one-time: the ice-glint event (the T2 hint)
    N('i', 855, 47, uni(F), [null, 'fire', null, null], 2),
    N('k', 877, 194, uni(F), uni(null), 2),                                   // tail entry — plain ground (Lightning teaser dropped)
    N('n1', 814, 291, uni(H), uni('earth'), 5),                               // the real danger — constant, day and night
    N('end1', 1196, 57, uni(R), uni(null), 3),                                // moderate recruit — reads highest element
    N('end2', 1187, 172, uni(F), [null, null, null, 'nature'], [3, 3, 3, 4]), // rich corner — blooms at Evening
    N('end3', 1187, 279, uni(R), uni(null), 6),                               // the gateway gate — highest ≥ 100
  ];
  const ed = (a, b, oneWay = false) => ({ a, b, oneWay, tod: TOD_ALL() });
  const edges = [
    ed('launch', 'a'), ed('launch', 'd'), ed('launch', 'b'),
    ed('a', 'c'), ed('d', 'c'), ed('d', 'h'), ed('h', 'g'),
    ed('c', 'f'), ed('f', 'i'), ed('f', 'j'),
    ed('b', 'j', true), ed('b', 'n1', true),     // the shortcut runs one way
    ed('g', 'j'), ed('g', 'k'),
    ed('i', 'j', true),                          // drop into the basin — no climbing back
    ed('i', 'end1'), ed('i', 'end2'), ed('j', 'end2'),
    ed('k', 'n1'), ed('n1', 'end3'),
  ];
  return { nodes, edges, counter: 4 };
}
function blankGraph() {
  return {
    nodes: [
      { id: 'launch', x: 60, y: 170, role: 'launch', types: uni('launch') },
      { id: 'end1', x: 1180, y: 70, role: 'destination', types: uni('geode') },
      { id: 'end2', x: 1190, y: 170, role: 'destination', types: uni('wreck') },
      { id: 'end3', x: 1175, y: 270, role: 'destination', types: uni('lee') },
    ].map(normNode),
    edges: [], counter: 1,
  };
}

// web-style curved edge
function webPath(a, b) {
  const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
  return `M ${a.x} ${a.y} Q ${mx} ${my} ${b.x} ${b.y}`;
}
const eqEdge = (e, x, y) => (e.a === x && e.b === y) || (e.a === y && e.b === x);

// clipboard — navigator.clipboard is often blocked in a sandboxed frame, so
// fall back to a hidden textarea + execCommand.
function copyText(text) {
  try {
    if (navigator.clipboard && window.isSecureContext) {
      navigator.clipboard.writeText(text).catch(() => fallbackCopy(text));
      return;
    }
  } catch {}
  fallbackCopy(text);
}
function fallbackCopy(text) {
  const ta = document.createElement('textarea');
  ta.value = text;
  ta.style.position = 'fixed'; ta.style.top = '0'; ta.style.left = '0'; ta.style.opacity = '0';
  document.body.appendChild(ta); ta.focus(); ta.select();
  try { document.execCommand('copy'); } catch {}
  document.body.removeChild(ta);
}

// ====================================================================
function Shaper() {
  const [model, setModel] = useState(() => {
    try { const s = JSON.parse(localStorage.getItem(STORE_KEY)); if (s && s.nodes) return normModel(s); } catch {}
    return templateZ0();
  });
  const [selNode, setSelNode] = useState(null);
  const [selEdge, setSelEdge] = useState(null);
  const [phase, setPhase] = useState(0);          // which time-of-day slot the board shows
  const [quad, setQuad] = useState(false);        // show all four slots at once (quadrant nodes)
  const [rubber, setRubber] = useState(null);     // {from, x, y} while shift-dragging a link

  const boardRef = useRef(null);
  const worldRef = useRef(null);
  const [scale, setScale] = useState(0.7);

  // persist (debounced)
  useEffect(() => {
    const t = setTimeout(() => { try { localStorage.setItem(STORE_KEY, JSON.stringify(model)); } catch {} }, 200);
    return () => clearTimeout(t);
  }, [model]);

  // fit board to container width — guard against a 0 width read (which would
  // collapse the board and starve the observer) and recover via rAF / resize.
  useLayoutEffect(() => {
    const el = boardRef.current; if (!el) return;
    let raf;
    const fit = () => {
      const w = el.clientWidth, h = el.clientHeight;
      if (w > 0 && h > 0) { setScale(Math.min(w / BOARD_W, h / BOARD_H)); return true; }
      return false;
    };
    const measure = () => { if (!fit()) raf = requestAnimationFrame(measure); };
    measure();
    const ro = new ResizeObserver(fit);
    ro.observe(el);
    window.addEventListener('resize', measure);
    return () => { ro.disconnect(); window.removeEventListener('resize', measure); cancelAnimationFrame(raf); };
  }, []);

  const byId = Object.fromEntries(model.nodes.map(n => [n.id, n]));
  const sel = selNode ? byId[selNode] : null;

  // screen → world coords
  const toWorld = useCallback((clientX, clientY) => {
    const r = worldRef.current.getBoundingClientRect();
    const s = r.width / BOARD_W;
    return { x: Math.round((clientX - r.left) / s), y: Math.round((clientY - r.top) / s) };
  }, []);

  // ---- node drag / shift-link ----
  function onNodePointerDown(e, node) {
    e.stopPropagation();
    if (e.shiftKey) {
      // start a link rubber-band
      const p = toWorld(e.clientX, e.clientY);
      setRubber({ from: node.id, x: p.x, y: p.y });
      const move = (ev) => { const q = toWorld(ev.clientX, ev.clientY); setRubber(r => r && ({ ...r, x: q.x, y: q.y })); };
      const up = (ev) => {
        document.removeEventListener('pointermove', move);
        document.removeEventListener('pointerup', up);
        const tgt = document.elementFromPoint(ev.clientX, ev.clientY);
        const tn = tgt && tgt.closest('[data-node]');
        const toId = tn && tn.getAttribute('data-node');
        if (toId && toId !== node.id) toggleEdge(node.id, toId);
        setRubber(null);
      };
      document.addEventListener('pointermove', move);
      document.addEventListener('pointerup', up);
      return;
    }
    // move-drag
    setSelNode(node.id); setSelEdge(null);
    const move = (ev) => {
      const p = toWorld(ev.clientX, ev.clientY);
      const x = Math.max(14, Math.min(BOARD_W - 14, p.x));
      const y = Math.max(14, Math.min(BOARD_H - 14, p.y));
      setModel(m => ({ ...m, nodes: m.nodes.map(n => n.id === node.id ? { ...n, x, y } : n) }));
    };
    const up = () => { document.removeEventListener('pointermove', move); document.removeEventListener('pointerup', up); };
    document.addEventListener('pointermove', move);
    document.addEventListener('pointerup', up);
  }

  function toggleEdge(x, y) {
    setModel(m => {
      const has = m.edges.some(e => eqEdge(e, x, y));
      return { ...m, edges: has ? m.edges.filter(e => !eqEdge(e, x, y)) : [...m.edges, normEdge([x, y])] };
    });
  }

  // ---- edge properties (direction / ToD availability) ----
  function setEdgeProp(patch) {
    setModel(m => ({ ...m, edges: m.edges.map((e, i) => i === selEdge ? { ...e, ...patch } : e) }));
  }
  function swapEdge() {
    setModel(m => ({ ...m, edges: m.edges.map((e, i) => i === selEdge ? { ...e, a: e.b, b: e.a } : e) }));
  }
  function toggleEdgeTod(slot) {
    setModel(m => ({ ...m, edges: m.edges.map((e, i) => {
      if (i !== selEdge) return e;
      const tod = e.tod.slice(); tod[slot] = !tod[slot];
      if (!tod.some(t => t)) return e;   // a line is never closed in all four slots
      return { ...e, tod };
    }) }));
  }

  function addNode() {
    setModel(m => {
      const id = 'n' + m.counter;
      const cx = 480 + (m.counter % 5) * 26, cy = 150 + (m.counter % 4) * 30;
      return { ...m, counter: m.counter + 1, nodes: [...m.nodes, normNode({ id, x: cx, y: cy, role: 'normal', types: uni('forage') })] };
    });
    setSelNode('n' + model.counter); setSelEdge(null);
  }

  function deleteSel() {
    if (selNode) {
      setModel(m => ({ ...m, nodes: m.nodes.filter(n => n.id !== selNode), edges: m.edges.filter(e => e.a !== selNode && e.b !== selNode) }));
      setSelNode(null);
    } else if (selEdge != null) {
      setModel(m => ({ ...m, edges: m.edges.filter((_, i) => i !== selEdge) }));
      setSelEdge(null);
    }
  }

  // set the type for ONE slot (defaults to the selected node)
  function setTypeAt(p, t, id = selNode) {
    setModel(m => ({ ...m, nodes: m.nodes.map(n => {
      if (n.id !== id) return n;
      const types = n.types.slice(); types[p] = t; return { ...n, types };
    }) }));
  }
  // copy a type into all four slots
  function setTypeAll(t) {
    setModel(m => ({ ...m, nodes: m.nodes.map(n => n.id === selNode ? { ...n, types: uni(t) } : n) }));
  }
  // element affinity (per slot) + magnitude (per node)
  function setAffinityAt(p, el, id = selNode) {
    setModel(m => ({ ...m, nodes: m.nodes.map(n => {
      if (n.id !== id) return n;
      const affinity = n.affinity.slice(); affinity[p] = el; return { ...n, affinity };
    }) }));
  }
  function setAffinityAll(el) {
    setModel(m => ({ ...m, nodes: m.nodes.map(n => n.id === selNode ? { ...n, affinity: uni(el) } : n) }));
  }
  function setMagAt(p, v, id = selNode) {
    const mm = Math.max(MAG_MIN, Math.min(MAG_MAX, v));
    setModel(m => ({ ...m, nodes: m.nodes.map(n => {
      if (n.id !== id) return n;
      const mag = n.mag.slice(); mag[p] = mm; return { ...n, mag };
    }) }));
  }
  function setMagAll(v) {
    const mm = Math.max(MAG_MIN, Math.min(MAG_MAX, v));
    setModel(m => ({ ...m, nodes: m.nodes.map(n => n.id === selNode ? { ...n, mag: uni(mm) } : n) }));
  }

  function cycleType(node) {
    setSelNode(node.id); setSelEdge(null);
    const opts = node.role === 'destination' ? DEST_TYPES : node.role === 'launch' ? ['launch'] : NODE_TYPES;
    const cur = typeAt(node, phase);
    const next = opts[(opts.indexOf(cur) + 1) % opts.length];
    setTypeAt(phase, next, node.id);
  }
  function setRole(role) {
    setModel(m => ({ ...m, nodes: m.nodes.map(n => {
      if (n.id !== selNode) return n;
      let types = n.types.slice();
      if (role === 'launch') types = uni('launch');
      else if (role === 'destination') types = types.map(t => DEST_TYPES.includes(t) ? t : 'geode');
      else types = types.map(t => (t === 'launch' || DEST_TYPES.includes(t)) ? 'forage' : t);
      return { ...n, role, types };
    }) }));
  }

  // keyboard delete
  useEffect(() => {
    const k = (e) => {
      if ((e.key === 'Delete' || e.key === 'Backspace') && document.activeElement.tagName !== 'INPUT') { e.preventDefault(); deleteSel(); }
    };
    document.addEventListener('keydown', k);
    return () => document.removeEventListener('keydown', k);
  });

  return (
    <div style={{ position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column', background: 'var(--paper)' }}>
      <TopBar
        onAdd={addNode}
        onZ0v2={() => { setModel(templateZ0()); setSelNode(null); setSelEdge(null); }}
        onTemplate={() => { setModel(templateC()); setSelNode(null); setSelEdge(null); }}
        onBlank={() => { setModel(blankGraph()); setSelNode(null); setSelEdge(null); }}
        counts={{ nodes: model.nodes.length, edges: model.edges.length }}
      />
      <div style={{ flex: 1, padding: '12px 24px 10px', display: 'flex', flexDirection: 'column', minHeight: 0, minWidth: 0 }}>
        {/* time-of-day slot selector — drives what the board shows */}
        <div className="row" style={{ gap: 12, marginBottom: 12, alignItems: 'center', flexWrap: 'wrap' }}>
          <span className="label label-ink">Time of day</span>
          <div className="row" style={{ gap: 4 }}>
            {PHASE_ORDER.map((i) => { const ph = PHASES[i]; return (
              <button key={ph.key} onClick={() => setPhase(i)} className="elite"
                style={{ ...segBtn(phase === i), flex: 'none', padding: '5px 16px' }}>{ph.label}</button>
            ); })}
          </div>
          <button onClick={() => setQuad(q => !q)} className="elite"
            style={{ flex: 'none', padding: '5px 14px', fontSize: 11, letterSpacing: '0.06em', cursor: 'pointer',
              border: `1.5px solid ${quad ? 'var(--stamp-amber)' : 'var(--ink-faded)'}`,
              background: quad ? 'var(--stamp-amber)' : 'transparent', color: quad ? 'var(--paper-light)' : 'var(--ink)' }}>◴ All slots</button>
          <span className="elite" style={{ fontSize: 11, color: 'var(--sepia)' }}>
            {quad ? 'each node split into its four slots · the selected slot is the live quarter (right-click cycles it)' : 'board shows this slot · right-click a node cycles its type for this slot'}
          </span>
        </div>
        {/* board */}
          <div ref={boardRef} className="panel brackets" style={{ position: 'relative', overflow: 'hidden', flex: 1, minHeight: 0, width: '100%' }}>
            <div style={{ position: 'absolute', inset: 0 }}
                 onPointerDown={() => { setSelNode(null); setSelEdge(null); }}>
              <div ref={worldRef} style={{ position: 'absolute', top: '50%', left: '50%', width: BOARD_W, height: BOARD_H, transform: `translate(-50%, -50%) scale(${scale})` }}>
                {/* faint current field */}
                <svg viewBox={`0 0 ${BOARD_W} 380`} preserveAspectRatio="none" style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', opacity: 0.14, pointerEvents: 'none' }}>
                  {[60, 130, 200, 270, 340].map((base, r) => (
                    <path key={r} fill="none" stroke={r % 2 ? 'var(--el-air)' : 'var(--stamp-amber)'} strokeWidth="1.2"
                      d={`M 0 ${base} ${Array.from({ length: 13 }).map((_, i) => `Q ${i * 100 + 50} ${base + (i % 2 ? -22 : 22)} ${(i + 1) * 100} ${base}`).join(' ')}`} />
                  ))}
                </svg>
                {/* edges */}
                <svg viewBox={`0 0 ${BOARD_W} ${BOARD_H}`} width={BOARD_W} height={BOARD_H} style={{ position: 'absolute', inset: 0, pointerEvents: 'none', overflow: 'visible' }}>
                  {model.edges.map((e, i) => {
                    const a = byId[e.a], b = byId[e.b]; if (!a || !b) return null;
                    const dPath = webPath(a, b);
                    const on = selEdge === i;
                    const open = e.tod[phase];                       // travellable on the shown slot?
                    const restricted = e.tod.some(t => !t);
                    const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
                    const ang = Math.atan2(b.y - a.y, b.x - a.x) * 180 / Math.PI;
                    const stroke = on ? 'var(--stamp-amber)' : 'var(--ink-faded)';
                    return (
                      <g key={i} opacity={open ? 1 : 0.28}>
                        <path d={dPath} fill="none" stroke="transparent" strokeWidth="16" style={{ pointerEvents: 'stroke', cursor: 'pointer' }}
                          onPointerDown={(ev) => { ev.stopPropagation(); setSelEdge(i); setSelNode(null); }} />
                        <path d={dPath} fill="none" stroke={stroke} strokeWidth={on ? 2.4 : 1.3}
                          strokeDasharray={on ? '' : (open ? '4 6' : '2 5')} opacity={on ? 0.95 : 0.7} strokeLinecap="round" style={{ pointerEvents: 'none' }} />
                        {e.oneWay && (
                          <g transform={`translate(${mx}, ${my}) rotate(${ang})`} style={{ pointerEvents: 'none' }}>
                            <path d="M -5 -4.5 L 6 0 L -5 4.5 Z" fill={stroke} opacity="0.9" />
                          </g>
                        )}
                        {restricted && (
                          <g transform={`translate(${mx + (e.oneWay ? 15 : 0)}, ${my})`} style={{ pointerEvents: 'none' }}>
                            <circle r="7" fill="var(--paper)" stroke={open ? 'var(--stamp-amber)' : 'var(--ink-faded)'} strokeWidth="1.2" />
                            <text textAnchor="middle" dominantBaseline="central" style={{ fontSize: 8, fill: open ? 'var(--stamp-amber)' : 'var(--sepia)' }}>◴</text>
                          </g>
                        )}
                      </g>
                    );
                  })}
                  {rubber && byId[rubber.from] && (
                    <path d={webPath(byId[rubber.from], rubber)} fill="none" stroke="var(--stamp-amber)" strokeWidth="2" strokeDasharray="3 4" opacity="0.8" style={{ pointerEvents: 'none' }} />
                  )}
                </svg>
                {/* nodes */}
                {model.nodes.map(n => (
                  quad
                    ? <AllSlotsNode key={n.id} node={n} phase={phase} selected={selNode === n.id} onDown={onNodePointerDown} onCycle={cycleType} />
                    : <ShaperNode key={n.id} node={n} phase={phase} selected={selNode === n.id} onDown={onNodePointerDown} onCycle={cycleType} />
                ))}
              </div>
            </div>
          </div>
          <div className="row" style={{ gap: 16, marginTop: 12, color: 'var(--sepia)', flexWrap: 'wrap' }}>
            <Hint k="Drag" v="move a node" />
            <Hint k="Right-click" v="cycle type · this slot" />
            <Hint k="Shift-drag" v="node → node to link / unlink" />
            <Hint k="Click a line" v="direction / ToD" />
            <Hint k="Del" v="delete selection" />
          </div>
        </div>
        {/* contextual editor + export, beneath the board */}
        <div style={{ height: 336, flexShrink: 0, borderTop: '1.5px solid var(--ink)', background: 'rgba(244,236,214,0.55)', display: 'flex', minHeight: 0 }}>
          <div style={{ width: 640, flexShrink: 0, padding: '12px 18px', overflowY: 'auto', borderRight: '1.5px solid var(--ink)' }}>
            {sel ? <NodeEditor node={sel} onRole={setRole}
                     onTypeAt={(p, t) => setTypeAt(p, t)} onTypeAll={setTypeAll}
                     onAffinityAt={(p, el) => setAffinityAt(p, el)} onAffinityAll={setAffinityAll}
                     onMagAt={(p, v) => setMagAt(p, v)} onMagAll={setMagAll}
                     onDelete={deleteSel}
                     edgeCount={model.edges.filter(e => e.a === sel.id || e.b === sel.id).length} />
                 : selEdge != null ? <EdgeEditor edge={model.edges[selEdge]} onSet={setEdgeProp} onSwap={swapEdge} onTod={toggleEdgeTod} onDelete={deleteSel} />
                 : <Empty />}
          </div>
          <div style={{ flex: 1, minWidth: 0, display: 'flex' }}>
            <ExportPanel model={model} />
          </div>
        </div>
    </div>
  );
}

function Hint({ k, v }) {
  return (
    <span className="row gap-6" style={{ fontSize: 11 }}>
      <span className="elite" style={{ border: '1px solid var(--ink-faded)', padding: '1px 6px', color: 'var(--ink)' }}>{k}</span>
      <span className="elite" style={{ color: 'var(--sepia)' }}>{v}</span>
    </span>
  );
}

// ---- element affinity badge ----------------------------------------
// affinity is shown with the element's own icon art (assets/icons/<el>.png),
// on a small paper disc rimmed in the element colour — readable on the map
// where the eight element colours alone collide.
function AffBadge({ el, size = 22, style }) {
  const m = ELEMENTS[el]; const r = size / 2; const pad = size * 0.17;
  return (
    <svg width={size} height={size} style={{ pointerEvents: 'none', overflow: 'visible', ...style }}>
      <title>{m.name + ' affinity'}</title>
      <circle cx={r} cy={r} r={r - 1} style={{ fill: 'var(--paper)', stroke: m.color, strokeWidth: 1.5 }} />
      <image href={`assets/icons/${el}.png`} x={pad} y={pad} width={size - 2 * pad} height={size - 2 * pad} preserveAspectRatio="xMidYMid meet" />
    </svg>
  );
}

// ---- a node on the board (editor look = always revealed) -----------
function ShaperNode({ node, phase, selected, onDown, onCycle }) {
  const tp = typeAt(node, phase);
  const meta = NT[tp] || NT.forage;
  const isDest = node.role === 'destination';
  const isLaunch = node.role === 'launch';
  const isVessel = node.role === 'vessel';
  const d = nodeDia(node, isDest, phase);
  const color = meta.color;
  const glyph = tp === 'unknown' ? '?' : meta.glyph;
  const aff = affAt(node, phase);
  const am = aff ? ELEMENTS[aff] : null;
  // does this node read differently across the day? (type, element, or magnitude)
  const shifts = (node.types && !node.types.every(t => t === node.types[0]))
    || (node.affinity && !node.affinity.every(x => x === node.affinity[0]))
    || (node.mag && !node.mag.every(x => x === node.mag[0]));

  return (
    <div data-node={node.id}
      onPointerDown={(e) => onDown(e, node)}
      onContextMenu={(e) => { e.preventDefault(); onCycle(node); }}
      style={{ position: 'absolute', left: node.x, top: node.y, transform: 'translate(-50%,-50%)', cursor: 'grab', zIndex: selected ? 9 : (isDest ? 5 : 4), touchAction: 'none' }}>
      {isVessel && <div style={{ position: 'absolute', left: '50%', bottom: 'calc(100% + 2px)', transform: 'translateX(-50%)', pointerEvents: 'none' }}><ShipIcon size={40} /></div>}
      {selected && <div style={{ position: 'absolute', inset: -8, border: '2px dashed var(--ink)', borderRadius: '50%' }} />}
      {(isDest || isVessel || isLaunch) && <div style={{ position: 'absolute', inset: -6, border: `1px solid ${isVessel ? 'var(--stamp-amber)' : color}`, borderRadius: '50%', opacity: 0.5, pointerEvents: 'none' }} />}
      <div style={{
        width: d, height: d, borderRadius: '50%', border: `${isDest ? 2 : 1.5}px solid ${color}`,
        background: tp === 'unknown' ? 'rgba(244,236,214,0.4)' : `color-mix(in srgb, ${color} 12%, var(--paper))`,
        color, display: 'flex', alignItems: 'center', justifyContent: 'center',
        fontFamily: 'IM Fell English SC, serif', fontSize: isDest ? 26 : 21, boxShadow: '0 1px 4px rgba(0,0,0,0.12)',
      }}>{glyph}</div>
      {shifts && <div title="shifts by time of day" style={{ position: 'absolute', top: -3, right: -3, width: 13, height: 13, borderRadius: '50%', background: 'var(--paper)', border: '1.5px solid var(--stamp-amber)', color: 'var(--stamp-amber)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 9, lineHeight: 1, pointerEvents: 'none' }}>◐</div>}
      {am && <AffBadge el={aff} size={22} style={{ position: 'absolute', top: -6, left: -6 }} />}
      <div style={{ position: 'absolute', top: 'calc(100% + 5px)', left: '50%', transform: 'translateX(-50%)', whiteSpace: 'nowrap', textAlign: 'center', pointerEvents: 'none' }}>
        <div className="fell" style={{ fontSize: isDest ? 12 : 11, lineHeight: 1.1, color: 'var(--ink)' }}><span className="mono" style={{ fontSize: 9, fontWeight: 600, color: 'var(--ink)', marginRight: 5 }}>{node.id}</span>{meta.label}<span className="mono" style={{ fontSize: 8, color: 'var(--sepia)', marginLeft: 4 }}>m{magAt(node, phase)}</span></div>
        {am && <div className="mono" style={{ fontSize: 8, lineHeight: 1.2, color: am.color }}>{am.name}</div>}
        {isDest && <div className="label" style={{ fontSize: 7.5, color }}>{meta.tag}</div>}
      </div>
    </div>
  );
}

// ---- a node showing all four slots at once (quadrant pie) ----------
function AllSlotsNode({ node, phase, selected, onDown, onCycle }) {
  const isDest = node.role === 'destination';
  const isLaunch = node.role === 'launch';
  const isVessel = node.role === 'vessel';
  const d = nodeDia(node, isDest, phase);
  const r = d / 2;
  // quadrants, clockwise from top-left: Night · Morning · Afternoon · Evening.
  // each carries its outer arc too, so we can rim it in the slot's affinity.
  const Q = [
    { d: `M ${r},${r} L 0,${r} A ${r},${r} 0 0 1 ${r},0 Z`, gx: 0.55 * r, gy: 0.55 * r, arc: `M 0,${r} A ${r},${r} 0 0 1 ${r},0` },
    { d: `M ${r},${r} L ${r},0 A ${r},${r} 0 0 1 ${2 * r},${r} Z`, gx: 1.45 * r, gy: 0.55 * r, arc: `M ${r},0 A ${r},${r} 0 0 1 ${2 * r},${r}` },
    { d: `M ${r},${r} L ${2 * r},${r} A ${r},${r} 0 0 1 ${r},${2 * r} Z`, gx: 1.45 * r, gy: 1.45 * r, arc: `M ${2 * r},${r} A ${r},${r} 0 0 1 ${r},${2 * r}` },
    { d: `M ${r},${r} L ${r},${2 * r} A ${r},${r} 0 0 1 0,${r} Z`, gx: 0.55 * r, gy: 1.45 * r, arc: `M ${r},${2 * r} A ${r},${r} 0 0 1 0,${r}` },
  ];
  const activeMeta = NT[typeAt(node, phase)] || NT.forage;
  const gsz = isDest ? 14 : 11.5;

  return (
    <div data-node={node.id}
      onPointerDown={(e) => onDown(e, node)}
      onContextMenu={(e) => { e.preventDefault(); onCycle(node); }}
      style={{ position: 'absolute', left: node.x, top: node.y, transform: 'translate(-50%,-50%)', cursor: 'grab', zIndex: selected ? 9 : (isDest ? 5 : 4), touchAction: 'none' }}>
      {isVessel && <div style={{ position: 'absolute', left: '50%', bottom: 'calc(100% + 2px)', transform: 'translateX(-50%)', pointerEvents: 'none' }}><ShipIcon size={40} /></div>}
      {selected && <div style={{ position: 'absolute', inset: -8, border: '2px dashed var(--ink)', borderRadius: '50%' }} />}
      <svg width={d} height={d} viewBox={`0 0 ${d} ${d}`} style={{ display: 'block', overflow: 'visible', filter: 'drop-shadow(0 1px 4px rgba(0,0,0,0.12))' }}>
        {Q.map((q, i) => {
          const t = typeAt(node, i); const m = NT[t] || NT.forage; const on = i === phase;
          return (
            <g key={i}>
              <path d={q.d} fill={t === 'unknown' ? 'rgba(244,236,214,0.5)' : `color-mix(in srgb, ${m.color} ${on ? 26 : 13}%, var(--paper))`}
                stroke="var(--paper)" strokeWidth="1" />
              <text x={q.gx} y={q.gy} textAnchor="middle" dominantBaseline="central"
                style={{ fontFamily: 'IM Fell English SC, serif', fontSize: gsz, fill: m.color, opacity: on ? 1 : 0.78 }}>
                {t === 'unknown' ? '?' : m.glyph}</text>
            </g>
          );
        })}
        {/* outer ring + active-quarter outline */}
        <circle cx={r} cy={r} r={r - 0.75} fill="none" stroke="var(--ink-faded)" strokeWidth="1.5" />
        <path d={Q[phase].d} fill="none" stroke="var(--ink)" strokeWidth="2" />
        {/* affinity — the element's icon at each slot's outer corner */}
        {PHASES.map((ph, i) => {
          const a = affAt(node, i); if (!a) return null;
          const u = [[-1, -1], [1, -1], [1, 1], [-1, 1]][i];
          const cxp = r + 1.18 * r * u[0] * 0.707, cyp = r + 1.18 * r * u[1] * 0.707;
          const br = r * 0.34;
          return (
            <g key={'aff' + i}>
              <circle cx={cxp} cy={cyp} r={br} style={{ fill: 'var(--paper)', stroke: ELEMENTS[a].color, strokeWidth: 1.2 }} />
              <image href={`assets/icons/${a}.png`} x={cxp - br * 0.74} y={cyp - br * 0.74} width={br * 1.48} height={br * 1.48} preserveAspectRatio="xMidYMid meet" />
            </g>
          );
        })}
      </svg>
      <div style={{ position: 'absolute', top: 'calc(100% + 5px)', left: '50%', transform: 'translateX(-50%)', whiteSpace: 'nowrap', textAlign: 'center', pointerEvents: 'none' }}>
        <div className="fell" style={{ fontSize: isDest ? 12 : 11, lineHeight: 1.1, color: 'var(--ink)' }}>{PHASES[phase].label} · {activeMeta.label}</div>
        <div className="mono" style={{ fontSize: 8, color: 'var(--sepia)' }}>{node.id} · m{magAt(node, phase)}{affAt(node, phase) ? ' · ' + ELEMENTS[affAt(node, phase)].name : ''}</div>
      </div>
    </div>
  );
}

// ---- side panels ---------------------------------------------------
function Empty() {
  return (
    <div className="col gap-12">
      <div className="fell" style={{ fontSize: 20 }}>Aether Web Shaper</div>
      <div className="elite" style={{ fontSize: 12.5, color: 'var(--ink-soft)', lineHeight: 1.6 }}>
        Sketch the shape of the expedition web. Select a node or a line to edit it. Each node can read differently across the day's four slots. Roles drive how the run reads:
      </div>
      <RoleKey />
      <div className="elite" style={{ fontSize: 11.5, color: 'var(--sepia)', lineHeight: 1.55, marginTop: 4 }}>
        A node marked <span style={{ color: 'var(--stamp-amber)' }}>◐</span> shifts by time of day. Select a node to set its <em>type</em>, <em>element affinity</em> and <em>magnitude</em> (drives node size) per slot. Your work autosaves; the export below drops straight into the C screen.
      </div>
    </div>
  );
}
function RoleKey() {
  const rows = [
    ['Launch', 'where the vessel starts', 'var(--ink)'],
    ['Vessel', 'current position (optional)', 'var(--stamp-amber)'],
    ['Destination', 'an endpoint / goal', 'var(--gold)'],
    ['Node', 'an ordinary stop', 'var(--el-earth)'],
  ];
  return (
    <div className="col gap-6" style={{ marginTop: 4 }}>
      {rows.map(([n, d, c]) => (
        <div key={n} className="row gap-8" style={{ fontSize: 12 }}>
          <span style={{ width: 10, height: 10, borderRadius: '50%', border: `1.5px solid ${c}`, background: `color-mix(in srgb, ${c} 20%, transparent)`, flexShrink: 0 }} />
          <span className="fell" style={{ fontSize: 13, width: 96 }}>{n}</span>
          <span className="elite" style={{ fontSize: 11, color: 'var(--sepia)' }}>{d}</span>
        </div>
      ))}
    </div>
  );
}

// D · STACKED SLOT CARDS — all four time-of-day slots open and editable at
// once. Each row is a self-contained slot editor (type chips + element),
// so a "shifts by day" node reads top-to-bottom with no click-through.
function NodeEditor({ node, onRole, onTypeAt, onTypeAll, onAffinityAt, onAffinityAll, onMagAt, onMagAll, onDelete, edgeCount }) {
  const isDest = node.role === 'destination';
  const isLaunch = node.role === 'launch';
  const typeOpts = isDest ? DEST_TYPES : NODE_TYPES;
  const roles = [['launch', 'Launch'], ['vessel', 'Vessel'], ['destination', 'Dest.'], ['normal', 'Node']];
  const uniform = node.types.every(t => t === node.types[0]) && node.affinity.every(a => a === node.affinity[0]) && node.mag.every(m => m === node.mag[0]);
  return (
    <div className="col gap-10">
      <div className="row" style={{ justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
        <div className="row gap-8" style={{ alignItems: 'baseline' }}>
          <span className="fell" style={{ fontSize: 19 }}>Node</span>
          <span className="mono" style={{ fontSize: 11, color: 'var(--sepia)' }}>· {node.id}</span>
          {!isLaunch && !uniform && <span className="elite" style={{ fontSize: 10, color: 'var(--stamp-amber)', whiteSpace: 'nowrap' }}>◐ shifts by day</span>}
        </div>
        <div className="row" style={{ gap: 4 }}>
          {roles.map(([r, lab]) => (
            <button key={r} onClick={() => onRole(r)} className="elite"
              style={{ padding: '5px 11px', fontSize: 10.5, letterSpacing: '0.05em', cursor: 'pointer',
                border: `1.5px solid ${node.role === r ? 'var(--ink)' : 'var(--ink-faded)'}`,
                background: node.role === r ? 'var(--ink)' : 'transparent',
                color: node.role === r ? 'var(--paper-light)' : 'var(--ink)' }}>{lab}</button>
          ))}
        </div>
      </div>

      {isLaunch ? (
        <div className="elite" style={{ fontSize: 12, color: 'var(--sepia)', padding: '14px 0', borderTop: '1px dashed var(--ink-faded)', borderBottom: '1px dashed var(--ink-faded)' }}>
          Launch node — fixed type, no time-of-day variation. The run starts here.
        </div>
      ) : (
        <>
          {/* column captions */}
          <div className="row" style={{ alignItems: 'flex-end', gap: 10, paddingRight: 2 }}>
            <span style={{ width: 54, flexShrink: 0 }} />
            <span className="label" style={{ flex: 1 }}>Type</span>
            <span style={{ width: 1 }} />
            <span className="label" style={{ width: 226, flexShrink: 0 }}>Element key</span>
            <span className="label" style={{ width: 64, flexShrink: 0, textAlign: 'center' }}>Mag</span>
            <span style={{ width: 26, flexShrink: 0 }} />
          </div>
          {/* one row-card per slot */}
          <div className="col" style={{ gap: 5 }}>
            {PHASE_ORDER.map((i) => { const ph = PHASES[i];
              const ty = typeAt(node, i); const aff = affAt(node, i); const mg = magAt(node, i);
              return (
                <div key={ph.key} className="row" style={{ gap: 10, alignItems: 'center', padding: '5px 9px', border: '1px solid var(--ink-faded)', background: 'var(--paper)' }}>
                  <div className="row" style={{ width: 54, flexShrink: 0, alignItems: 'center', gap: 5 }}>
                    <span className="label" style={{ fontSize: 9, letterSpacing: '0.06em' }}>{ph.label}</span>
                  </div>
                  {/* type chips — icon only, the whole slot's type set */}
                  <div className="row" style={{ gap: 4, flex: 1 }}>
                    {typeOpts.map(t => {
                      const on = ty === t;
                      return (
                        <button key={t} onClick={() => onTypeAt(i, t)} title={NT[t].label}
                          style={{ width: 30, height: 30, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
                            border: `1.5px solid ${on ? NT[t].color : 'var(--ink-trace)'}`,
                            background: on ? `color-mix(in srgb, ${NT[t].color} 16%, transparent)` : 'transparent' }}>
                          <span style={{ fontFamily: 'IM Fell English SC, serif', fontSize: 16, lineHeight: 1, color: NT[t].color }}>{t === 'unknown' ? '?' : NT[t].glyph}</span>
                        </button>
                      );
                    })}
                  </div>
                  <div style={{ width: 1, alignSelf: 'stretch', background: 'var(--ink-trace)' }} />
                  {/* element swatches — ∅ + the eight veins */}
                  <div className="row" style={{ gap: 3, width: 226, flexShrink: 0, flexWrap: 'wrap' }}>
                    <button onClick={() => onAffinityAt(i, null)} title="No element"
                      style={{ width: 22, height: 22, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 10,
                        border: `1.5px solid ${aff === null ? 'var(--ink)' : 'var(--ink-trace)'}`, background: aff === null ? 'rgba(26,46,74,0.06)' : 'transparent', color: aff === null ? 'var(--ink)' : 'var(--sepia)' }}>∅</button>
                    {EL_ORDER.map(k => {
                      const on = aff === k;
                      return (
                        <button key={k} onClick={() => onAffinityAt(i, k)} title={ELEMENTS[k].name}
                          style={{ width: 22, height: 22, padding: 0, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
                            border: `1.5px solid ${on ? ELEMENTS[k].color : 'var(--ink-trace)'}`, background: on ? `color-mix(in srgb, ${ELEMENTS[k].color} 16%, transparent)` : 'transparent' }}>
                          <img src={`assets/icons/${k}.png`} alt="" style={{ width: 14, height: 14, objectFit: 'contain', display: 'block', opacity: on ? 1 : 0.62 }} />
                        </button>
                      );
                    })}
                  </div>
                  {/* magnitude — per slot */}
                  <div className="row" style={{ gap: 2, width: 64, flexShrink: 0, alignItems: 'center', justifyContent: 'center' }}>
                    <button onClick={() => onMagAt(i, mg - 1)} style={{ ...stepBtn, width: 19, height: 19, fontSize: 12 }}>−</button>
                    <span className="mono" style={{ fontSize: 10, width: 20, textAlign: 'center', color: 'var(--ink)' }}>m{mg}</span>
                    <button onClick={() => onMagAt(i, mg + 1)} style={{ ...stepBtn, width: 19, height: 19, fontSize: 12 }}>+</button>
                  </div>
                  {/* copy this slot across the whole day */}
                  <button onClick={() => { onTypeAll(ty); onAffinityAll(aff); onMagAll(mg); }} title="Apply this slot to all day"
                    className="elite" style={{ width: 26, height: 26, flexShrink: 0, cursor: 'pointer', fontSize: 13, lineHeight: 1, border: '1px solid var(--ink-faded)', background: 'transparent', color: 'var(--sepia)' }}>⎘</button>
                </div>
              );
            })}
          </div>
        </>
      )}

      {/* footer — meta, delete */}
      <div className="row" style={{ justifyContent: 'space-between', alignItems: 'center', gap: 18 }}>
        <span className="elite" style={{ flex: 1, fontSize: 10.5, color: 'var(--sepia)', lineHeight: 1.4 }}>Mag drives node size, per slot — a node can swell at the hour its content wakes.</span>
        <div className="row" style={{ gap: 14, alignItems: 'center', fontSize: 12, color: 'var(--sepia)' }}>
          <span className="elite">Pos <span className="mono" style={{ color: 'var(--ink)' }}>{node.x},{node.y}</span></span>
          <span className="elite">Links <span className="mono" style={{ color: 'var(--ink)' }}>{edgeCount}</span></span>
          <button onClick={onDelete} className="elite" style={{ ...dangerBtn, marginTop: 0, padding: '5px 12px' }}>Delete ✕</button>
        </div>
      </div>
    </div>
  );
}

function EdgeEditor({ edge, onSet, onSwap, onTod, onDelete }) {
  return (
    <div className="col gap-12">
      <div className="row gap-10" style={{ alignItems: 'baseline' }}>
        <span className="fell" style={{ fontSize: 19 }}>Current</span>
        <span className="elite" style={{ fontSize: 13, color: 'var(--ink-soft)' }}>
          <span className="mono" style={{ color: 'var(--ink)' }}>{edge.a}</span> {edge.oneWay ? '⟶' : '⟷'} <span className="mono" style={{ color: 'var(--ink)' }}>{edge.b}</span>
        </span>
      </div>
      <div className="col gap-6">
        <span className="label">Direction</span>
        <div className="row" style={{ gap: 4, maxWidth: 340 }}>
          <button className="elite" onClick={() => onSet({ oneWay: false })} style={segBtn(!edge.oneWay)}>⟷ Both ways</button>
          <button className="elite" onClick={() => onSet({ oneWay: true })} style={segBtn(edge.oneWay)}>⟶ One-way</button>
          {edge.oneWay && <button className="elite" onClick={onSwap} title="Reverse the direction" style={{ ...segBtn(false), flex: 'none', padding: '6px 12px' }}>⇄ Swap</button>}
        </div>
        {edge.oneWay && <span className="elite" style={{ fontSize: 10.5, color: 'var(--sepia)' }}>Travels <span className="mono" style={{ color: 'var(--ink)' }}>{edge.a} → {edge.b}</span> only.</span>}
      </div>
      <div className="col gap-6">
        <span className="label">Open in slots</span>
        <div className="row" style={{ gap: 4, maxWidth: 340 }}>
          {PHASE_ORDER.map((i) => (
            <button key={i} className="elite" onClick={() => onTod(i)} style={segBtn(edge.tod[i])}>{PHASES[i].label}</button>
          ))}
        </div>
        <span className="elite" style={{ fontSize: 10.5, color: 'var(--sepia)' }}>A closed slot = this line can't be travelled at that time of day. At least one slot stays open; a restricted line wears a ◴ on the board.</span>
      </div>
      <div className="elite" style={{ fontSize: 11.5, color: 'var(--sepia)' }}>Shift-drag the same two nodes to remove, or:</div>
      <button onClick={onDelete} className="elite" style={{ ...dangerBtn, alignSelf: 'flex-start' }}>Delete line ✕</button>
    </div>
  );
}

function ExportPanel({ model }) {
  const [copied, setCopied] = useState(false);
  const code = buildExport(model);
  return (
    <div style={{ borderTop: '1.5px solid var(--ink)', background: 'rgba(26,46,74,0.04)' }}>
      <div className="row" style={{ justifyContent: 'space-between', padding: '8px 14px', borderBottom: '1px solid var(--ink-faded)' }}>
        <span className="label label-ink">Export · drop-in for C</span>
        <button className="elite" style={{ ...segBtn(false), padding: '3px 12px' }}
          onClick={() => { copyText(code); setCopied(true); setTimeout(() => setCopied(false), 1400); }}>
          {copied ? 'Copied ✓' : 'Copy'}
        </button>
      </div>
      <pre className="mono" style={{ margin: 0, padding: '10px 14px', fontSize: 9.5, lineHeight: 1.45, color: 'var(--ink)', maxHeight: 286, overflow: 'auto', whiteSpace: 'pre', background: 'transparent' }}>{code}</pre>
    </div>
  );
}

function buildExport(model) {
  const coords = model.nodes.map(n => `  ${n.id}: [${n.x}, ${n.y}],`).join('\n');
  const types = model.nodes.map(n => {
    const ts = n.types || uni(n.type);
    const uniform = ts.every(t => t === ts[0]);
    const val = uniform ? `'${ts[0]}'` : `[${ts.map(t => `'${t}'`).join(', ')}]`;
    return `  ${n.id}: ${val},`;
  }).join('\n');
  const affs = model.nodes.map(n => {
    const as = n.affinity || uni(null);
    const fmt = a => a ? `'${a}'` : 'null';
    const uniform = as.every(a => a === as[0]);
    const val = uniform ? fmt(as[0]) : `[${as.map(fmt).join(', ')}]`;
    return `  ${n.id}: ${val},`;
  }).join('\n');
  const mags = model.nodes.map(n => {
    const ms = Array.isArray(n.mag) ? n.mag : uni(n.mag == null ? MAG_DEFAULT : n.mag);
    const uniform = ms.every(m => m === ms[0]);
    return `  ${n.id}: ${uniform ? ms[0] : `[${ms.join(', ')}]`},`;
  }).join('\n');
  const edges = model.edges.map(e => `  ['${e.a}', '${e.b}'${e.oneWay ? ", '>'" : ''}],`).join('\n');
  const todLines = model.edges.filter(e => e.tod && e.tod.some(t => !t))
    .map(e => `  '${e.a}>${e.b}': [${e.tod.map((t, i) => t ? `'${PHASES[i].key}'` : null).filter(Boolean).join(', ')}],`).join('\n');
  const launch = model.nodes.find(n => n.role === 'launch');
  const vessel = model.nodes.find(n => n.role === 'vessel');
  const dests = model.nodes.filter(n => n.role === 'destination').map(n => n.id);
  return `// Aether Web · coordinate space ${BOARD_W} × ${BOARD_H}
const C_COORDS = {
${coords}
};
// type per node — a single value, or [night, morning, afternoon, evening] when it shifts by time of day
const C_TYPES = {
${types}
};
// element affinity per node — a key (or null), or [night, morning, afternoon, evening] when it shifts
const C_AFFINITY = {
${affs}
};
// synthetic magnitude per node (${MAG_MIN}–${MAG_MAX}) — single, or [night, morning, afternoon, evening] when it swells by time of day
const C_MAGNITUDE = {
${mags}
};
// a third entry '>' marks a one-way line (travel a → b only)
const C_EDGES = [
${edges}
];
// time-of-day availability — only restricted lines listed; value = slots when open
const C_EDGE_TOD = {
${todLines}
};
// roles
const LAUNCH = ${launch ? `'${launch.id}'` : 'null'};
const VESSEL = ${vessel ? `'${vessel.id}'` : 'null'};
const DESTINATIONS = [${dests.map(d => `'${d}'`).join(', ')}];`;
}

// ---- shared button styles ------------------------------------------
function segBtn(on) {
  return { flex: 1, padding: '6px 4px', fontSize: 11, letterSpacing: '0.06em', cursor: 'pointer',
    border: `1.5px solid ${on ? 'var(--ink)' : 'var(--ink-faded)'}`, background: on ? 'var(--ink)' : 'transparent',
    color: on ? 'var(--paper-light)' : 'var(--ink)' };
}
function phaseCell(on) {
  return { flex: 1, padding: '5px 2px', cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2,
    border: `1.5px solid ${on ? 'var(--ink)' : 'var(--ink-faded)'}`, background: on ? 'var(--ink)' : 'transparent' };
}
function affChip(on, meta) {
  const c = meta ? meta.color : 'var(--ink-faded)';
  return { minWidth: 30, height: 30, padding: '2px 4px', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
    border: `1.5px solid ${on ? c : 'var(--ink-faded)'}`, background: on ? `color-mix(in srgb, ${c} 14%, transparent)` : 'transparent',
    color: meta ? 'var(--ink)' : 'var(--sepia)', fontSize: 13, lineHeight: 1 };
}
const stepBtn = { width: 26, height: 26, flexShrink: 0, cursor: 'pointer', border: '1.5px solid var(--ink-faded)', background: 'transparent', color: 'var(--ink)', fontSize: 15, lineHeight: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' };
const ghostMini = { fontSize: 10, padding: '2px 8px', cursor: 'pointer', border: '1px solid var(--ink-faded)', background: 'transparent', color: 'var(--ink)' };
function typeBtn(on, meta) {
  return { padding: '5px 9px', cursor: 'pointer', border: `1.5px solid ${on ? meta.color : 'var(--ink-faded)'}`,
    background: on ? `color-mix(in srgb, ${meta.color} 12%, transparent)` : 'transparent', color: 'var(--ink)', alignItems: 'center' };
}
const dangerBtn = { marginTop: 6, padding: '8px 14px', cursor: 'pointer', border: '1.5px solid var(--crit)', background: 'transparent', color: 'var(--crit)', fontSize: 12, letterSpacing: '0.08em' };

// ---- top bar -------------------------------------------------------
function TopBar({ onAdd, onZ0v2, onTemplate, onBlank, counts }) {
  return (
    <div className="row" style={{ justifyContent: 'space-between', padding: '12px 24px', borderBottom: '1.5px solid var(--ink)', background: 'rgba(244,236,214,0.6)' }}>
      <div className="row gap-12" style={{ alignItems: 'baseline' }}>
        <span className="fell" style={{ fontSize: 22 }}>Aether Web Shaper</span>
        <span className="stamp stamp-blue tilt-l" style={{ fontSize: 8 }}>EXPEDITION · DIRECTION C</span>
        <span className="mono" style={{ fontSize: 11, color: 'var(--sepia)' }}>{counts.nodes} nodes · {counts.edges} lines</span>
      </div>
      <div className="row" style={{ gap: 8 }}>
        <button className="btn" style={{ fontSize: 13, padding: '8px 14px' }} onClick={onAdd}>+ Add Node</button>
        <button className="btn" style={{ fontSize: 13, padding: '8px 14px' }} onClick={onZ0v2}>Z0 draft</button>
        <button className="btn" style={{ fontSize: 13, padding: '8px 14px' }} onClick={onTemplate}>Reset to C</button>
        <button className="btn" style={{ fontSize: 13, padding: '8px 14px' }} onClick={onBlank}>Blank</button>
      </div>
    </div>
  );
}

ReactDOM.createRoot(document.getElementById('root')).render(<Shaper />);
