// studio.jsx — Social Agent Studio (Paper Studio · widget-version Godot)

const { useState, useRef, useEffect, useMemo, useCallback } = React;

// ─── GUIDED-TOUR COORDINATION (hands-on onboarding) ─────────────────────
// A tiny module-level pub/sub the deeply-nested UI reads WITHOUT prop
// drilling, so the hands-on tour can: (a) blank the rail/template-library
// before a "reveal" step, and (b) drop GHOST PLACEHOLDER text into the REAL
// inputs for the active step (never a committed value, never text in the
// tour card). The tour DRIVES the real UI — it never performs the action.
//
//   TOUR.state = { active, step, revealed }
//   • active   — tour is open.
//   • step     — current step index (TOUR_STEPS).
//   • revealed — the built-in templates have been REVEALED (step 4). Until
//                then the rail template area and the template library list
//                are BLANK even though the default templates still EXIST.
//
// useTour() subscribes a component to changes; tourSet() is called by the
// GuidedTour as it advances so the rest of the UI re-renders in lock-step.
const TOUR = {
  state: { active: false, step: 0, revealed: false },
  subs: new Set(),
};
function tourSet(patch) {
  TOUR.state = { ...TOUR.state, ...patch };
  TOUR.subs.forEach(fn => { try { fn(TOUR.state); } catch (e) {} });
  // QA mirror so headless tests can read the gate without DOM scraping.
  try {
    window.__HABITAT_TOUR = { ...TOUR.state };
  } catch (e) {}
}
function useTour() {
  const [, force] = useState(0);
  useEffect(() => {
    const fn = () => force(x => x + 1);
    TOUR.subs.add(fn);
    return () => { TOUR.subs.delete(fn); };
  }, []);
  return TOUR.state;
}
// True while the tour is active AND the built-in templates are still hidden
// (the rail template area + the library list must be BLANK pre-reveal).
function tourTemplatesHidden() {
  return TOUR.state.active && !TOUR.state.revealed;
}

// ─── THEMES ──────────────────────────────────────────────────────────
const THEMES = {
  paper: {
    label: "Day",
    paper:"#efe8d6", paperEdge:"#dccfa9", paperSoft:"#f6f1e1", paperDeep:"#e6dcbe", paperWarm:"#fafaf2",
    ink:"#1d2238", inkMuted:"#5a5e75", inkFaint:"#8a8fa3",
    chrome:"#e6dcbe", chromeInk:"#1d2238", chromeDim:"#5a5e75",
    rule:"#cfc09a", ruleSoft:"#dccfa9",
    accent:"#c97a3a", accent2:"#3a5d8f",
    scene:"#5a8f3e", sceneSoft:"#e3ecd7",
    sceneFloorA:"#e2d2a3", sceneFloorB:"#d4c08c", sceneWall:"#6e4a2b",
    object:"#c9a23a", objectSoft:"#f0e7c8",
    agent:"#3a5d8f", agentSoft:"#dee6f1",
    action:"#7d4a9e", actionSoft:"#ebdff3",
    danger:"#a13c3c",
    canvas:"#cad6b1", canvasTuft:"#7a8a5b",
  },
  night: {
    label: "Night",
    paper:"#1f2230", paperEdge:"#151823", paperSoft:"#272b3c", paperDeep:"#1a1d2a", paperWarm:"#2d3144",
    ink:"#efe8d6", inkMuted:"#9aa0b8", inkFaint:"#6a6f88",
    chrome:"#151823", chromeInk:"#efe8d6", chromeDim:"#7a809a",
    rule:"#3a3f55", ruleSoft:"#2d3144",
    accent:"#e8a368", accent2:"#7aa3d8",
    scene:"#7aa3d8", sceneSoft:"#2a3550",
    sceneFloorA:"#3a3422", sceneFloorB:"#2e2a1c", sceneWall:"#1a1308",
    object:"#d3a86e", objectSoft:"#3a3526",
    agent:"#7aa3d8", agentSoft:"#28324a",
    action:"#b48ad8", actionSoft:"#2e2840",
    danger:"#d86868",
    canvas:"#2a3a2e", canvasTuft:"#46583e",
  },
};

let T = THEMES.paper;

// ─── FACTORIES ────────────────────────────────────────────────────────
let _idc = 0;
const uid = (p) => `${p}_${++_idc}`;
function bumpIdCounter(entities) {
  let m = 0;
  for (const e of entities) {
    const n = parseInt(String(e.id).split("_").pop());
    if (Number.isFinite(n) && n > m) m = n;
  }
  if (_idc < m) _idc = m;
}

const AGENT_PALETTE = [
  { skin:"#f4c79a", shirt:"#c2543b", hair:"#2b1810" },
  { skin:"#d9a070", shirt:"#3b6fb5", hair:"#1a0e08" },
  { skin:"#f8d7b3", shirt:"#5a8f3e", hair:"#5a3920" },
  { skin:"#e8b48a", shirt:"#a37bd1", hair:"#3a2418" },
  { skin:"#eac9a4", shirt:"#3e8a8a", hair:"#241410" },
  { skin:"#d4a574", shirt:"#b8533e", hair:"#3a1e0e" },
];

const OBJECT_ICONS = ["▢","◇","≡","⊞","╤","◍","⊠","✦","◐","⌬","✲","⊙"];  // text-presentation marks (no emoji)

const MIN_SIZE = {
  scene:  { w: 144, h: 120 },  // 6×5 tiles min (grass + wall + floor + wall + grass)
  object: { w: 48,  h: 48 },
  agent:  { w: 24,  h: 24 },   // 1 tile
  action: { w: 120, h: 80 },
};
const KIND_COLOR = {
  scene: "#5a8f3e", object: "#c9a23a", agent: "#3a5d8f", action: "#7d4a9e",
};

// ─── TEMPLATES (Phase 0) ──────────────────────────────────────────────
// Entities now carry a `template` field as the source of truth for what
// they are (human / animal / item / room). `kind` is still set for
// backwards-compatible rendering paths (scene/object/agent/action) and
// is derived 1:1 from template. Action stays its own kind.
const TEMPLATE_BY_KIND = {
  scene: "room", object: "item", agent: "human", action: "action",
};
const KIND_BY_TEMPLATE = {
  room: "scene", item: "object", human: "agent", animal: "agent", action: "action",
};
function templateOf(e) {
  if (!e) return null;
  if (e.template) return e.template;
  return TEMPLATE_BY_KIND[e.kind] || "item";
}
function kindOf(e) {
  if (!e) return null;
  return e.kind || KIND_BY_TEMPLATE[templateOf(e)] || "object";
}
// Normalize an entity to the v2 shape: ensure both `kind` and `template`
// are present. Safe to call repeatedly. Used in import + seed paths.
function normalizeEntity(e) {
  if (!e) return e;
  const tpl = e.template || TEMPLATE_BY_KIND[e.kind] || "item";
  const k = e.kind || KIND_BY_TEMPLATE[tpl] || "object";
  const log = Array.isArray(e.log) ? e.log : [];
  const status = e.status && typeof e.status === "object" ? e.status : {};
  const perception = (e.perception && typeof e.perception === "object")
    ? e.perception : DEFAULT_PERCEPTION[k] || DEFAULT_PERCEPTION.object;
  const out = { ...e, kind: k, template: tpl, log, status, perception };
  // `brain` is an entity knob (engine): llm | rule | human | none. It now
  // round-trips through load/save (authored via BrainSelector). Only sanitize
  // unknown values; absent ⇒ engine default (llm for agents). `none` marks an
  // inert "prop" — only status, never proposes; actuated by others.
  const BRAINS = ["llm", "rule", "human", "none"];
  if (e.brain !== undefined) {
    if (BRAINS.includes(e.brain)) out.brain = e.brain;
    else delete out.brain;
  }
  // `perception_mode` is an entity knob (engine): rule | llm. Picks how the
  // Environment composes this object's perception — cheap rule projector vs.
  // the LLMCompose focus set (pay-per-LLM). Round-trips through load/save
  // (authored via PerceptionSelector). Keep only rule|llm; absent ⇒ "rule".
  // Distinct from `perception` (the vision/hearing override blob above).
  if (e.perception_mode !== undefined) {
    if (e.perception_mode === "rule" || e.perception_mode === "llm") out.perception_mode = e.perception_mode;
    else delete out.perception_mode;
  }
  // `cadence` is an entity knob (engine): integer VIRTUAL SECONDS between
  // invocations — a throttle. 0 or absent ⇒ the object runs every clock-stop
  // (default). A throttled object is skipped entirely between due times; the
  // event-driven clock jumps the gaps. This is how "process agents" (weather,
  // metabolism, slow processes) are authored. Round-trips through load/save
  // (authored via CadenceField). Coerce to a non-negative integer.
  {
    const c = Math.floor(Number(e.cadence));
    out.cadence = Number.isFinite(c) && c > 0 ? c : 0;
  }
  if (k === "agent") {
    out.open_vocab = !!e.open_vocab;
  }
  return out;
}
// Default perception fields by kind. Surface-edited via the Advanced tab.
// Env-LLM consults these as the baseline; user rules and the LLM itself
// have final say.
const DEFAULT_PERCEPTION = {
  agent:  { vision_range: 8, hearing_range: 6, vision_walls_block: true,  interruptible_by: "" },
  object: { vision_range: 0, hearing_range: 0, vision_walls_block: true,  interruptible_by: "" },
  scene:  { vision_range: 0, hearing_range: 0, vision_walls_block: true,  interruptible_by: "" },
  action: { vision_range: 0, hearing_range: 0, vision_walls_block: true,  interruptible_by: "" },
};
// Initialize the status block from a template's status schema.
function statusFromTemplate(tpl) {
  if (!tpl || !Array.isArray(tpl.statuses)) return {};
  const s = {};
  for (const f of tpl.statuses) {
    s[f.key] = f.default !== undefined ? f.default : (f.type === "bool" ? false : f.type === "int" ? 0 : "");
  }
  return s;
}

// Built-in templates. Each entry: default field set, default status schema,
// default durations (seconds), capability flags, render hint. Users can
// fork these via the Template editor.
const DEFAULT_TEMPLATES = {
  human: {
    id: "human", label: "Human", kindHint: "agent", builtin: true,
    fields: [
      { key: "profile",    type: "text",  default: "" },
      { key: "persona",    type: "text",  default: "" },
      { key: "goal",       type: "text",  default: "" },
      { key: "background", type: "text",  default: "" },
      { key: "bias",       type: "int",   default: 2 },
    ],
    statuses: [
      { key: "mood",     type: "enum", values: ["neutral","happy","sad","tense","tired"], default: "neutral" },
      { key: "energy",   type: "int",  default: 80, min: 0, max: 100 },
      { key: "busy",     type: "bool", default: false },
      { key: "location", type: "text", default: "" },
    ],
    durations: { sleep: 480, eat: 30, talk: 5, walk: 5, work: 60, idle: 10, read: 30, cook: 45 },
    hasMemory: true, hasBelief: true,
  },
  animal: {
    id: "animal", label: "Animal", kindHint: "agent", builtin: true,
    fields: [
      { key: "species", type: "text", default: "" },
      { key: "persona", type: "text", default: "" },
    ],
    statuses: [
      { key: "hunger", type: "int", default: 40, min: 0, max: 100 },
      { key: "energy", type: "int", default: 80, min: 0, max: 100 },
      { key: "mood",   type: "enum", values: ["calm","alert","playful","anxious"], default: "calm" },
    ],
    durations: { sleep: 240, eat: 15, roam: 20, idle: 8 },
    hasMemory: true, hasBelief: false,
  },
  item: {
    id: "item", label: "Item", kindHint: "object", builtin: true,
    fields: [
      { key: "note", type: "text", default: "" },
    ],
    statuses: [
      { key: "condition", type: "enum", values: ["new","good","worn","broken"], default: "good" },
      { key: "owner",     type: "text", default: "" },
      { key: "occupied",  type: "bool", default: false },
    ],
    durations: {},
    hasMemory: false, hasBelief: false,
  },
  room: {
    id: "room", label: "Scenes", kindHint: "scene", builtin: true,
    fields: [
      { key: "rules",    type: "text", default: "" },
    ],
    statuses: [
      { key: "occupancy", type: "int", default: 0, min: 0, max: 99 },
      { key: "lit",       type: "bool", default: true },
    ],
    durations: {},
    hasMemory: false, hasBelief: false,
  },
  action: {
    id: "action", label: "Action", kindHint: "action", builtin: true,
    fields: [
      { key: "module",   type: "text", default: "iv" },
      { key: "describe", type: "text", default: "" },
      { key: "strict",   type: "text", default: "" },
      { key: "soft",     type: "text", default: "" },
    ],
    statuses: [],
    durations: {},
    hasMemory: false, hasBelief: false,
  },
};
function loadTemplates() {
  try {
    const raw = localStorage.getItem("studio-templates");
    if (raw) {
      const parsed = JSON.parse(raw);
      if (parsed && typeof parsed === "object") {
        // Merge stored over defaults so user edits to built-ins persist.
        // Built-ins stay built-in (so Delete is still blocked) but every
        // other field is editable; a 'Reset to default' button restores.
        const merged = { ...DEFAULT_TEMPLATES };
        for (const [k, v] of Object.entries(parsed)) {
          if (!v || typeof v !== "object") continue;
          if (DEFAULT_TEMPLATES[k]) {
            merged[k] = { ...DEFAULT_TEMPLATES[k], ...v, builtin: true, id: k };
          } else if (!v.builtin && (v.label || "").trim()) {
            // Auto-clean: DROP legacy blank-label templates (older builds saved
            // label:"") instead of renaming them — they were the stale "untitled
            // scene" cruft. Only keep non-builtin templates that have a real name.
            merged[k] = v;
          }
        }
        return merged;
      }
    }
  } catch (e) {}
  return { ...DEFAULT_TEMPLATES };
}
function resetTemplateToDefault(id) {
  return DEFAULT_TEMPLATES[id] ? { ...DEFAULT_TEMPLATES[id] } : null;
}
function saveTemplates(reg) {
  try { localStorage.setItem("studio-templates", JSON.stringify(reg)); } catch (e) {}
}

// ─── PROJECTS (one project = one world) ───────────────────────────────
// A project bundles the whole world — entities + project-scoped templates +
// world-book + rules/components + worldSettings — under a name. Stored as a map
// in localStorage; New/Open/Save-As/Duplicate operate on it. The current
// project's working state still mirrors to the legacy per-key autosave, so a
// reload never loses edits. Portable as a .habitat JSON via Export/Import.
const PROJECTS_KEY = "studio-projects";
const CURRENT_PROJECT_KEY = "studio-current-project";
function loadProjects() {
  try {
    const raw = localStorage.getItem(PROJECTS_KEY);
    if (raw) { const p = JSON.parse(raw); if (p && typeof p === "object") return p; }
  } catch (e) {}
  return {};
}
function saveProjects(map) {
  try { localStorage.setItem(PROJECTS_KEY, JSON.stringify(map)); } catch (e) {}
}
function newProjectId() {
  return `proj_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
}
// The snapshot that IS a project's world (authoring state, not a runnable trace).
function makeProjectData({ entities = [], templates = {}, worldBook = [], rules = [],
                           components = [], worldSettings = {}, snapshots = [] }) {
  // Embed copies of every non-builtin template so a .habitat file is SELF-CONTAINED
  // (it opens on any machine without the author's global Library). Built-ins are
  // reseeded from DEFAULT_TEMPLATES on load; on open an embedded copy WINS over the
  // default (two-tier precedence: workspace overrides Library/default).
  const userTpls = {};
  for (const [k, v] of Object.entries(templates || {})) {
    if (v && !v.builtin) userTpls[k] = v;
    else if (v && v.builtin) userTpls[k] = { ...v };  // keep per-project edits to built-ins too
  }
  return { entities, templates: userTpls, worldBook, rules, components, worldSettings,
           snapshots: Array.isArray(snapshots) ? snapshots : [] };
}
// Seed a fresh project: editable copies of the built-in starter classes, nothing else.
function seedProjectData(name) {
  return { name: name || "Untitled project",
           data: { entities: [], templates: { ...DEFAULT_TEMPLATES },
                   worldBook: [], rules: [], components: [],
                   worldSettings: { language: "English" }, snapshots: [] } };
}

// ─── ENVIRONMENT RULES (Phase 2) ──────────────────────────────────────
// A rule is { id, name, kind, text? }.
// kind:
//   "text" — free-form social norm; surfaced to agent prompts
const RULE_KINDS = ["text"];
function loadRules() {
  try {
    const raw = localStorage.getItem("studio-rules");
    if (raw) {
      const parsed = JSON.parse(raw);
      if (Array.isArray(parsed)) return parsed;
    }
  } catch (e) {}
  return [];
}
function saveRules(rules) {
  try { localStorage.setItem("studio-rules", JSON.stringify(rules)); } catch (e) {}
}
// ─── SNAPSHOTS (Phase 7) ──────────────────────────────────────────────
// Snapshot tree backed by localStorage. Each entry is a frozen copy of
// the world that the user can revert to or branch from.
function loadSnapshots() {
  try {
    const raw = localStorage.getItem("studio-snapshots");
    if (raw) {
      const parsed = JSON.parse(raw);
      if (Array.isArray(parsed)) return parsed;
    }
  } catch (e) {}
  return [];
}
function saveSnapshots(arr) {
  try { localStorage.setItem("studio-snapshots", JSON.stringify(arr)); } catch (e) {}
}
function makeSnapshot({ label, parentId, entities, rules, tickSec }) {
  return {
    id: `snap_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`,
    parentId: parentId || null,
    t: tickSec || 0,
    createdAt: new Date().toISOString(),
    label: label || "untitled",
    payload: { entities, rules, tickSec },
  };
}

// ─── SOUL: reasoning, memory, belief (Phase 4) ────────────────────────
// Stub reasoning generator — turns the agent's goal + persona + action
// into a short first-person justification. Replace with a real LLM call
// later; the API surface stays the same.
function genReasoning(agent, verb, target) {
  const goal = agent.goal || "be present";
  const persona = (agent.persona || "").split(/[,.]/)[0].toLowerCase();
  if (verb === "walk" || verb === "move") {
    return `Heading to ${target || "another room"} — ${goal.toLowerCase()}.`;
  }
  if (verb === "talk") {
    return `${persona ? persona + "; " : ""}I should say something while the moment is right.`;
  }
  if (verb === "sleep") return `Energy is low. Resting before I lose my nerve.`;
  if (verb === "eat" || verb === "cook") return `Food is grounding. ${persona ? "(" + persona + ")" : ""}`;
  return `${verb}${target ? " toward " + target : ""} feels right for ${goal.toLowerCase()}.`;
}
// Append a short memory observation. Agents in the same scene observe
// each other's actions.
function appendMemory(memory, entry) {
  const next = [...(memory || []), entry];
  // cap at 30 to keep prompts bounded
  return next.length > 30 ? next.slice(next.length - 30) : next;
}
// Periodically refresh the agent's worldview text by summarizing recent
// memory in first-person prose. Replaces the older structured-claim
// model — beliefs are long descriptive thoughts, not key/value rows.
function refreshSoul(agent, entities) {
  const mem = agent.memory || [];
  if (mem.length === 0) return agent.soul || "";
  // Group observations by other agent.
  const byActor = new Map();
  for (const m of mem) {
    if (!m.actorId || m.actorId === agent.id) continue;
    if (!byActor.has(m.actorId)) byActor.set(m.actorId, []);
    byActor.get(m.actorId).push(m);
  }
  const lines = [];
  // Self-line, anchored by the goal / persona.
  if (agent.persona || agent.goal) {
    const self = agent.persona ? agent.persona.split(/[,.]/)[0].toLowerCase() : "";
    const goal = agent.goal ? agent.goal.toLowerCase() : "";
    lines.push(`I am ${self || "trying to figure myself out"}${goal ? ", still wanting to " + goal : ""}.`);
  }
  for (const [actorId, obs] of byActor) {
    const actor = entities.find(e => e.id === actorId);
    const name = actor?.name || obs[0]?.actorName || actorId;
    // most frequent verb
    const counts = {};
    for (const o of obs) counts[o.verb] = (counts[o.verb] || 0) + 1;
    const verb = Object.entries(counts).sort((a, b) => b[1] - a[1])[0]?.[0];
    if (!verb) continue;
    if (verb === "walk" || verb === "move-to") {
      lines.push(`${name} keeps moving around — restless, or maybe avoiding something.`);
    } else if (verb === "talk" || verb === "talk-to-agent") {
      lines.push(`${name} talks more than they listen. I'm still deciding whether that's warm or rude.`);
    } else if (verb === "hug-agent") {
      lines.push(`${name} reaches out physically. It catches me off-guard each time.`);
    } else if (verb === "compliment") {
      lines.push(`${name} compliments easily. Generous, or strategic — I'm not sure yet.`);
    } else {
      lines.push(`${name} keeps doing ${verb}. I'm reading it as a tell.`);
    }
  }
  // Only append lines we haven't said before. Avoids the "Suki keeps moving
  // around … Suki keeps moving around" runaway.
  const prev = agent.soul || "";
  const fresh = lines.filter(l => !prev.includes(l));
  if (fresh.length === 0) return prev;
  const para = fresh.join(" ");
  return prev ? prev.replace(/\n*$/, "\n\n") + para : para;
}

// ─── DURATIONS (Phase 5) ──────────────────────────────────────────────
// Look up an action's default duration (seconds) in the agent's template,
// falling back to a sensible default. Honors per-entity overrides via
// `entity.durations[verb]`.
function lookupDuration(templates, agent, verb, rules) {
  if (!verb) return 5;
  // Env-rule override wins (highest priority — user's stated intent for "this verb takes N seconds globally").
  if (Array.isArray(rules)) {
    const dRule = rules.find(r => r.kind === "duration" && r.verb === verb);
    if (dRule && Number(dRule.seconds) > 0) return Number(dRule.seconds);
  }
  if (agent?.durations && agent.durations[verb] != null) return Number(agent.durations[verb]);
  const tpl = templates?.[agent?.template] || DEFAULT_TEMPLATES[agent?.template];
  if (tpl?.durations && tpl.durations[verb] != null) return Number(tpl.durations[verb]);
  // generic fallback by verb prefix
  if (verb.startsWith("sleep")) return 480;
  if (verb.startsWith("eat") || verb.startsWith("cook")) return 30;
  if (verb.startsWith("walk") || verb.startsWith("move")) return 5;
  if (verb.startsWith("talk")) return 8;
  return 10;
}

// ── PERCEPTION / PARALLEL-TICK helpers (P4) ──
// Build a perception payload per entity, respecting per-entity overrides.
// Same-scene = full visibility. Other entities within hearing_range tiles
// are 'heard' (verb-level signal only). Mock env keeps it simple; a real
// env-LLM would override these.
function buildPerceptions(ents) {
  const byScene = new Map();
  for (const e of ents) {
    if (!e.placedIn) continue;
    if (!byScene.has(e.placedIn)) byScene.set(e.placedIn, []);
    byScene.get(e.placedIn).push(e);
  }
  const out = new Map();
  for (const e of ents) {
    if (e.kind !== "agent") continue;
    const p = e.perception || DEFAULT_PERCEPTION[e.kind];
    const sees = e.placedIn
      ? (byScene.get(e.placedIn) || []).filter(o => o.id !== e.id)
        .map(o => ({ id: o.id, name: o.name, kind: o.kind, busy: !!o.busy }))
      : [];
    const hears = [];
    if (p.hearing_range > 0 && e.placedIn) {
      for (const o of ents) {
        if (o.id === e.id || !o.busy) continue;
        if (o.placedIn === e.placedIn) continue; // already in sees
        const dx = (o.x || 0) - (e.x || 0);
        const dy = (o.y || 0) - (e.y || 0);
        const dist = Math.sqrt(dx * dx + dy * dy) / TILE;
        if (dist <= p.hearing_range) hears.push({ id: o.id, verb: o.busy.verb });
      }
    }
    out.set(e.id, { sees, hears, touches: [] });
  }
  return out;
}

// Decide whether an agent wants to move-to-scene or do an in-place act.
// Returns a candidate {actorId, actorName, verb, target, ...} or null.
function pickCandidateAction(a, ents, templates, now, rules) {
  const scenes = ents.filter(e => e.kind === "scene");
  // 40% chance of move-scene (if neighbors exist), else act-in-place.
  if (Math.random() < 0.4 && a.placedIn) {
    const cur = scenes.find(s => s.id === a.placedIn);
    const nb = (cur?.connects || []).map(id => scenes.find(s => s.id === id)).filter(Boolean);
    if (nb.length) {
      const target = nb[Math.floor(Math.random() * nb.length)];
      const verb = "walk";
      const duration = lookupDuration(templates, a, verb, rules);
      const actionId = `${now}-${a.id}-${Math.floor(Math.random() * 1e6)}`;
      // Compute target position; capture as side effect.
      const occupied = new Set();
      for (const e of ents) {
        if (e.id !== a.id && e.placedIn === target.id) {
          const tx = Math.round((e.x - target.x) / TILE);
          const ty = Math.round((e.y - target.y) / TILE);
          occupied.add(`${tx},${ty}`);
        }
      }
      const pos = snapInScene(
        target,
        target.x + target.w / 2 + (Math.random() - 0.5) * target.w * 0.5,
        target.y + target.h / 2 + (Math.random() - 0.5) * target.h * 0.5,
        occupied
      );
      return {
        actorId: a.id, actorName: a.name,
        verb, target: target.name,
        targetSceneId: target.id,
        description: `${verb} → ${target.name}`,
        reasoning: genReasoning(a, verb, target.name),
        duration, actionId, fx: "move",
        sideEffects: { placedIn: target.id, x: pos.x, y: pos.y, w: TILE, h: TILE },
        statusPatch: { location: target.name },
        observerScene: a.placedIn,
      };
    }
  }
  // Act-in-place via genEvent.
  const ev = genEvent(ents, 0, now);
  const actor = ents.find(e => e.kind === "agent" && e.name === ev.actorName);
  if (!actor || actor.id !== a.id) return null;
  const verb = (ev.verb || "act").split(/\s+/)[0];
  const duration = lookupDuration(templates, actor, verb, rules);
  const actionId = `${now}-${actor.id}-${Math.floor(Math.random() * 1e6)}`;
  const description = ev.line
    ? `${verb}: "${ev.line}"`
    : `${verb}${ev.target ? " → " + ev.target : ""}`;
  return {
    actorId: actor.id, actorName: actor.name,
    verb, target: ev.target || null,
    description, line: ev.line || null,
    reasoning: genReasoning(actor, verb, ev.target),
    duration, actionId,
    fx: ev.line ? "talk" : "act",
    observerScene: actor.placedIn,
  };
}

// Mock env-LLM. Real engine would: read perceptions, candidates, rules,
// world state; return ordered accepted/rejected/interrupt set. This stub
// processes candidates in random order and lets
// everything else through.
function mockEnvResolve(candidates, ents, rules) {
  // Local stub used only as a fallback when the live engine WS isn't reachable.
  // No capacity check anymore — that field was deleted. We just accept all.
  const accepted = [];
  const rejected = [];
  const order = [...candidates].sort(() => Math.random() - 0.5);
  for (const c of order) {
    if (c.targetSceneId) {
      const violation = checkPlacementRules(rules, ents, c.actorId, c.targetSceneId);
      if (violation) { rejected.push({ ...c, reason: violation.reason }); continue; }
    }
    accepted.push(c);
  }
  return { accepted, rejected, interrupts: [] };
}

// ── v6 / studio-1.1 schema bridge ─────────────────────────────────────
// The engine prefers a components[] palette over our closed rules[] enum.
// These helpers desugar in both directions so old Studio files keep loading
// and new engine consumers see the richer shape.
function rulesToComponents(rules) {
  const out = [];
  for (const r of rules || []) {
    if (!r || typeof r !== "object") continue;
    if (r.kind === "duration") {
      out.push({ type: "action", verb: r.verb, duration: r.seconds,
        id: r.id, name: r.name });
    } else if (r.kind === "text") {
      out.push({ type: "norm", text: r.text || "",
        id: r.id, name: r.name });
    } else if (r.kind === "event") {
      out.push({ type: "event", at: r.at, effect: r.effect,
        target: r.target, text: r.text,
        id: r.id, name: r.name });
    } else {
      // Unknown rule kind — pass through so we don't drop data.
      out.push({ type: "raw", ...r });
    }
  }
  return out;
}
function componentsToRules(components) {
  const out = [];
  for (const c of components || []) {
    if (!c || typeof c !== "object") continue;
    if (c.type === "action" && c.verb && c.duration != null) {
      out.push({ kind: "duration", verb: c.verb, seconds: c.duration,
        id: c.id, name: c.name });
    } else if (c.type === "norm") {
      out.push({ kind: "text", text: c.text || "",
        id: c.id, name: c.name });
    } else if (c.type === "event") {
      out.push({ kind: "event", at: c.at, effect: c.effect,
        target: c.target, text: c.text,
        id: c.id, name: c.name });
    } else if (c.type === "raw" && c.kind) {
      out.push(c);
    }
    // Other component types (observer drives, middleware, adjudicator,
    // free-form code) have no rule equivalent; they're engine-side only.
  }
  return out;
}

// v7: a component is "first-class" (engine-only, no rules[] equivalent)
// if it represents an engine concept beyond the legacy four kinds.
function isFirstClassComponent(c) {
  if (!c || typeof c !== "object") return false;
  if (c.type === "observer" && c.preset === "drive") return true;
  if (c.type === "middleware") return true;
  if (c.type === "action" && !c.id?.startsWith?.("rule_")) return true; // custom action verbs
  if (c.type === "raw_code") return true;
  if (c.type === "adjudicator") return true;
  if (c.type === "trigger") return true;          // 0.7.0 B3
  if (c.type === "attribute") return true;        // 0.7.0 B5
  if (c.type === "perception_rule") return true;  // 0.8.0 hearing/sight/anonymize
  return false;
}
// HH:MM ↔ seconds-of-day helpers used by event rules in the UI.
function secsToHHMM(s) {
  const n = Math.max(0, Math.min(86399, Number(s) || 0));
  const h = Math.floor(n / 3600);
  const m = Math.floor((n % 3600) / 60);
  return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}
function hhmmToSecs(str, fallback = 0) {
  const m = String(str || "").match(/^(\d{1,2}):(\d{2})$/);
  if (!m) return fallback;
  const h = Math.min(23, Math.max(0, +m[1]));
  const mm = Math.min(59, Math.max(0, +m[2]));
  return h * 3600 + mm * 60;
}

// ── TRACE REPLAY helpers (v5) ─────────────────────────────────────────
// Build initial entities from a trace's cast[] + world_0 block.
// Auto-layouts scenes in a grid since the engine doesn't carry x/y.
function seedFromTrace(trace) {
  const cast = trace.cast || [];
  const world0 = trace.world_0 || {};
  const scenes = cast.filter(c => c.kind === "scene" && (s => s.name || s.id)(c));
  const cols = Math.max(1, Math.ceil(Math.sqrt(scenes.length)));
  // Scale scene size with how many there are so a single-scene demo (social)
  // doesn't render as a tiny postage stamp on a huge canvas. UX-REVIEW #3.
  const sceneW = scenes.length === 1 ? 720
              : scenes.length <= 4 ? 480
              : 320;
  const sceneH = Math.round(sceneW * 0.66);
  const SC = sceneW + 80;          // pitch between cells
  const ents = [];
  scenes.forEach((s, i) => {
    const col = i % cols, row = Math.floor(i / cols);
    ents.push(normalizeEntity({
      id: s.id, kind: "scene", template: "room",
      name: s.name || s.id, w: sceneW, h: sceneH,
      x: 60 + col * SC, y: 60 + row * SC,
      connects: s.connects || [],
    }));
  });
  // map scene NAME → scene_id (engine sometimes refers to scenes by name in
  // status.location; cast[].name matches the world_0 location value).
  const sceneByName = new Map(scenes.map(s => [s.name || s.id, s.id]));
  // Index agents per scene so each gets a stable distinct slot when the engine
  // didn't supply x/y. (Was: `within` = count of others in scene → identical
  // for everyone → all stacking on one tile.)
  const agentCast = cast.filter(c => c.kind === "agent");
  const agentIdxInScene = new Map();
  {
    const counters = new Map();
    for (const a of agentCast) {
      const loc = (world0[a.id] || {}).location;
      const sid = loc && (sceneByName.get(loc) || loc);
      const next = (counters.get(sid) || 0);
      agentIdxInScene.set(a.id, next);
      counters.set(sid, next + 1);
    }
  }
  for (const e of agentCast) {
    const init = world0[e.id] || {};
    const loc = init.location;
    const sceneId = loc && (sceneByName.get(loc) || loc);
    const scene = ents.find(x => x.id === sceneId);
    const idx = agentIdxInScene.get(e.id) || 0;
    // Bug 1 — Prefer engine-supplied cast.x/y (0..1 normalized, already
    // non-overlapping). Scale into the scene box, else into the canvas.
    let ax, ay;
    if (typeof e.x === "number" && typeof e.y === "number") {
      if (scene) {
        ax = scene.x + 18 + e.x * (scene.w - 36);
        ay = scene.y + 24 + e.y * (scene.h - 48);
      } else {
        ax = 60 + e.x * 800;
        ay = 60 + e.y * 600;
      }
    } else {
      ax = scene ? scene.x + 30 + (idx % 5) * 30 : 200 + (idx % 5) * 30;
      ay = scene ? scene.y + 36 + Math.floor(idx / 5) * 30 : 200;
    }
    // Bug 2 — Color from cast.color (preferred) or palette by index, so
    // Sprite has real skin/shirt/hair and doesn't fill with `undefined` → black.
    const palette = AGENT_PALETTE[idx % AGENT_PALETTE.length];
    const look = e.color
      ? { skin: palette.skin, shirt: e.color, hair: palette.hair }
      : palette;
    // Bug 3 — Carry persona / background / goal from cast straight into the
    // entity so the inspector shows who they are.
    ents.push(normalizeEntity({
      id: e.id, kind: "agent", template: "human",
      name: e.name || e.id, w: 24, h: 24,
      x: ax, y: ay,
      placedIn: scene ? sceneId : null,
      profile: "",
      persona: e.persona || "",
      goal: e.goal || "",
      background: e.background || "",
      soul: e.soul || "",
      // Slice 2b — carry the authored vector appearance so a placed AGENT can
      // render its visual as the body on stage (resolveVisual still falls back
      // to the agent's class template when absent).
      ...(e.visual ? { visual: e.visual } : {}),
      // B2/B4 — layered descriptions + visibility + relationships ride the
      // cast now; carry them so the inspector shows them during replay.
      appearance: e.appearance || "",
      hidden: e.hidden || "",
      visibility: e.visibility || {},
      relationships: e.relationships || undefined,
      ...look,
      pickedActions: [],
      // Bug 6 — keep the seeded fields (engine puts e.g. obedience_pressure:0
      // into world_0) so the inspector lists them from t0, not just location.
      status: { ...init, location: loc || "", busy: false },
    }));
  }
  for (const e of cast.filter(c => c.kind === "object")) {
    const init = world0[e.id] || {};
    const loc = init.location;
    const sceneId = loc && (sceneByName.get(loc) || loc);
    const scene = ents.find(x => x.id === sceneId);
    ents.push(normalizeEntity({
      id: e.id, kind: "object", template: e.template || "item",
      name: e.name || e.id, w: 24, h: 24,
      x: scene ? scene.x + scene.w - 50 : 250,
      y: scene ? scene.y + scene.h - 50 : 300,
      placedIn: scene ? sceneId : null,
      note: "",
      appearance: e.appearance || "",
      hidden: e.hidden || "",
      visibility: e.visibility || {},
      // Carry the authored vector appearance (base SVG + overlay rules) so a
      // placed object re-renders its visual as its status changes during
      // replay. Instance value; resolveVisual still falls back to templates.
      ...(e.visual ? { visual: e.visual } : {}),
      // Seed initial KV status from world_0 so overlay rules (e.g. open==…)
      // have a value at t0 (the trace's later ticks then flip it).
      status: { ...init },
    }));
  }
  return ents;
}
// Apply one tick of a trace to entities: append log entries to each entity,
// merge status, advance tickSec.
// Merge a tick's log[] into human-readable event rows. Each row pairs an
// agent's *decision* with its *outcome*: reasoning text + the verb/sentence
// it produced + the final outcome. Memory entries (no actionId) become their
// own row tagged with the channel they arrived on. Timestamp = simulator time.
function summarizeTickLog(tickObj, entities) {
  const log = tickObj.log || [];
  const t = tickObj.tickSec ?? tickObj.now ?? 0;
  const nameOf = (id) => (entities.find(e => e.id === id) || {}).name || id;
  const buckets = new Map(); // actionId -> {entries…}
  const rows = [];
  for (const e of log) {
    if (e.kind === "action" && e.actionId) {
      let b = buckets.get(e.actionId);
      if (!b) {
        b = { t, actionId: e.actionId, actorId: e.entity,
              actorName: nameOf(e.entity), kind: "decision",
              reasoning: "", verb: null, target: null, line: null, outcome: null };
        buckets.set(e.actionId, b);
        rows.push(b);
      }
      if (e.state === "intend") {
        if (e.verb) b.verb = e.verb;
        // Bug 4 — prefer engine's clean target_name (e.g. "Subject") over the
        // raw id ("agent_1"), and use the clean `content` over `description`
        // (which used to be pre-wrapped with the speaker name + 「」).
        if (e.target_name || e.target) b.target = e.target_name || e.target;
        const clean = e.content || e.description;
        if (clean && !b.line) b.line = clean;
      } else if (e.state === "end") {
        if (e.outcome) b.outcome = e.outcome;
        const clean = e.content || e.description;
        if (clean && !b.line) b.line = clean;
      }
    } else if (e.kind === "reasoning" && e.actionId) {
      let b = buckets.get(e.actionId);
      if (!b) {
        b = { t, actionId: e.actionId, actorId: e.entity,
              actorName: nameOf(e.entity), kind: "decision",
              reasoning: "", verb: null, target: null, line: null, outcome: null };
        buckets.set(e.actionId, b);
        rows.push(b);
      }
      b.reasoning = b.reasoning ? `${b.reasoning} ${e.text || ""}` : (e.text || "");
    } else if (e.kind === "reasoning") {
      rows.push({ t, kind: "thought", actorId: e.entity,
                  actorName: nameOf(e.entity), reasoning: e.text || "" });
    } else if (e.kind === "memory" && (e.channel === "audio" || e.channel === "phone")) {
      rows.push({ t, kind: "perceive", actorId: e.entity,
                  actorName: nameOf(e.entity),
                  channel: e.channel, line: e.content || e.text || "",
                  from: e.from ? nameOf(e.from) : null });
    } else if (e.kind === "memory") {
      // BUGFIX (Live run unobservable) — the engine also emits perception
      // beats on channel:'' (and other channels like 'env'/'vision'); these
      // used to be filtered out entirely, so a live mock run that only
      // perceives (no LLM-emitted actions) showed live·N with an empty Event
      // log. Surface every memory beat as a readable "senses" row so the log
      // is never silently empty while the engine is ticking.
      const line = e.content || e.text || "";
      if (line) {
        rows.push({ t, kind: "sense", actorId: e.entity,
                    actorName: nameOf(e.entity),
                    channel: e.channel || "perceives",
                    line, from: e.from ? nameOf(e.from) : null });
      }
    } else if (e.kind === "action" && !e.actionId) {
      // BUGFIX (Live run unobservable) — action intend/during/end beats that
      // arrive WITHOUT an actionId never reach the bucketed decision path
      // above. Surface them directly so every action beat is at least one
      // readable "<actor> <verb/what happened>" row.
      const verb = e.verb || (e.state === "end" ? "finished" : e.state === "during" ? "is" : "acts");
      const line = e.content || e.description || e.text || "";
      rows.push({ t, kind: "decision", actorId: e.entity,
                  actorName: nameOf(e.entity),
                  verb, target: e.target_name || (e.target ? nameOf(e.target) : null),
                  line, outcome: e.outcome || null });
    } else if (e.kind === "trigger" || (e.payload && e.payload.trigger)) {
      // F5 — fired triggers are first-class beats (engine 5d792de). Amber ⚡.
      rows.push({ t, kind: "trigger", actorId: e.entity,
                  actorName: e.trigger || e.payload?.trigger || "trigger",
                  line: e.summary || e.text || e.description || "" });
    } else if (e.reveal === true || (e.payload && e.payload.reveal)) {
      // B2 — revelation events: a hidden truth enters the world. Dramatic beat.
      rows.push({ t, kind: "reveal", actorId: e.entity,
                  actorName: nameOf(e.entity),
                  line: e.summary || e.text || e.description || "" });
    } else if (e.kind === "effect" && Array.isArray(e.caused)) {
      // Causal-link row (UX-REVIEW #6 / TODO #10). Surface engine's
      // adjudicated effect-on-others: "Vance's action → Cole backing_away +2 aggression".
      rows.push({ t, kind: "causal", actorId: e.entity,
                  actorName: nameOf(e.entity),
                  caused: e.caused.map(c => ({
                    target: nameOf(c.entity),
                    change: c.change || {},
                  })),
                  description: e.description || "" });
    }
  }
  // BUGFIX (Live run unobservable) — guarantee that ANY tick frame carrying a
  // log produces at least one readable row. If every beat above was empty or
  // an unrecognised kind, fall back to surfacing the raw beats so the Event
  // log can never stay at 0 while the engine reports live·N.
  if (rows.length === 0 && log.length > 0) {
    for (const e of log) {
      const line = e.content || e.text || e.description || e.summary || "";
      rows.push({ t, kind: "sense", actorId: e.entity,
                  actorName: nameOf(e.entity),
                  channel: e.kind || "perceives",
                  line: line || `${e.state || e.kind || "beat"}`,
                  from: e.from ? nameOf(e.from) : null });
    }
  }
  // Annotate every row so the Event-log View panel can filter by who acted and
  // whether the beat came from an LLM. actorKind: agent | object | scene | null(env).
  // source: 'llm' (real model reasoning) vs 'rule' (engine stub, prefixed "rule-based:").
  for (const r of rows) {
    const ent = entities.find(e => e.id === r.actorId);
    r.actorKind = ent ? ent.kind : null;
    if ((r.kind === "decision" || r.kind === "thought") && r.reasoning) {
      r.source = /^\s*rule-based:/i.test(r.reasoning) ? "rule" : "llm";
    }
  }
  return rows;
}

function applyTraceTick(entities, tickObj) {
  const log = tickObj.log || [];
  const status = tickObj.status || {};
  const t = tickObj.tickSec ?? tickObj.now ?? 0;
  // Bucket log entries by entity id (the receiver/owner).
  const byEntity = new Map();
  for (const e of log) {
    const id = e.entity;
    if (!id) continue;
    if (!byEntity.has(id)) byEntity.set(id, []);
    byEntity.get(id).push({
      t,
      kind: e.kind,
      state: e.state,
      actionId: e.actionId,
      verb: e.verb,
      target: e.target,
      outcome: e.outcome,
      rule_id: e.rule_id,
      from: e.from,
      channel: e.channel,
      text: e.text || e.description || e.summary || "",
    });
  }
  return entities.map(ent => {
    const newLog = byEntity.get(ent.id);
    const newStatus = status[ent.id];
    if (!newLog && !newStatus) return ent;
    const merged = { ...ent };
    if (newLog) merged.log = [...(ent.log || []), ...newLog];
    if (newStatus) {
      merged.status = { ...(ent.status || {}), ...newStatus };
      // Move agent into the scene named by status.location (engine controls
      // placement during replay; topology comes from the trace).
      if (ent.kind === "agent" && newStatus.location !== undefined) {
        const target = entities.find(e => e.kind === "scene" &&
          (e.id === newStatus.location || e.name === newStatus.location));
        if (target && target.id !== ent.placedIn) {
          merged.placedIn = target.id;
          merged.x = target.x + 30 + Math.floor(Math.random() * 100);
          merged.y = target.y + 30 + Math.floor(Math.random() * 80);
        }
      }
      merged.activities = newStatus.activities || [];
      merged.busy = newStatus.busy_until && newStatus.busy_until > t
        ? { verb: newStatus.doing || "busy", until: newStatus.busy_until }
        : null;
      // v7: roll a small history of numeric drive-like keys so the mood
      // sparkline + drive bars have data to chart. Capped per series.
      if (typeof newStatus === "object") {
        const prev = ent.driveHistory || {};
        const next = { ...prev };
        for (const k of ["mood_valence","tipsy","fatigue","hunger","stamina","paid"]) {
          if (typeof newStatus[k] === "number") {
            const series = next[k] ? next[k].slice() : [];
            const last = series[series.length - 1];
            if (!last || last.v !== newStatus[k] || last.t !== t) {
              series.push({ t, v: newStatus[k] });
            }
            if (series.length > 80) series.shift();
            next[k] = series;
          }
        }
        merged.driveHistory = next;
      }
    }
    return merged;
  });
}

function checkPlacementRules(_rules, _entities, _entityId, _sceneId) {
  // Capacity was removed. No client-side placement rules block anymore — the
  // engine is the only authority on rejection (and it streams them via WS).
  return null;
}

const TILE = 24;
// Simulated seconds advanced per real-time tick (interval below is 900ms).
// At 30 sim-sec/tick the demo's eat (30s) takes one tick, sleep (480s)
// takes ~16 ticks — enough for noticeable busy periods without making the
// sim feel slow.
const SIM_SEC_PER_TICK = 30;
// Diagonal paper hatch — matches paper.jsx PaperWorldView background.
function hatchBG(paper, edge) {
  return `repeating-linear-gradient(135deg, ${paper} 0 12px, ${paper} 12px 13px, ${edge} 13px 14px)`;
}
// Snap a canvas-space (cx,cy) to the nearest interior floor tile of `scene`.
// Floor tiles live at tx in [2 .. cols-3], ty in [2 .. rows-3] (grass + wall rings).
function snapInScene(scene, cx, cy, occupied = new Set()) {
  const cols = Math.max(5, Math.floor(scene.w / TILE));
  const rows = Math.max(5, Math.floor(scene.h / TILE));
  const lx = cx - scene.x;
  const ly = cy - scene.y;
  let tx = Math.max(2, Math.min(cols - 3, Math.floor(lx / TILE)));
  let ty = Math.max(2, Math.min(rows - 3, Math.floor(ly / TILE)));
  if (occupied.has(`${tx},${ty}`)) {
    // BFS for nearest free tile
    const seen = new Set([`${tx},${ty}`]);
    const queue = [[tx, ty]];
    while (queue.length) {
      const [px, py] = queue.shift();
      for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) {
        const nx = px + dx, ny = py + dy;
        if (nx < 2 || ny < 2 || nx > cols - 3 || ny > rows - 3) continue;
        const k = `${nx},${ny}`;
        if (seen.has(k)) continue;
        seen.add(k);
        if (!occupied.has(k)) { tx = nx; ty = ny; queue.length = 0; break; }
        queue.push([nx, ny]);
      }
    }
  }
  return { x: scene.x + tx * TILE, y: scene.y + ty * TILE };
}

// Find a non-overlapping top-left for a new free-floating (unplaced) entity.
// Spirals outward from a desired anchor on a grid sized to the entity, skipping
// any cell whose center lands inside an already-occupied bbox. This keeps batch
// adds (e.g. "Add 5") from collapsing onto one coordinate. `boxes` is the list
// of existing {x,y,w,h} to avoid.
function scatterFreePos(ax, ay, w, h, boxes) {
  const step = Math.max(28, Math.max(w, h) + 16);
  const overlaps = (x, y) => boxes.some(b => {
    const bw = b.w || 24, bh = b.h || 24;
    return x < b.x + bw && x + w > b.x && y < b.y + bh && y + h > b.y;
  });
  // ring-by-ring spiral search around the anchor cell
  for (let ring = 0; ring < 24; ring++) {
    if (ring === 0) {
      if (!overlaps(ax, ay)) return { x: Math.round(ax), y: Math.round(ay) };
      continue;
    }
    for (let dx = -ring; dx <= ring; dx++) {
      for (let dy = -ring; dy <= ring; dy++) {
        if (Math.max(Math.abs(dx), Math.abs(dy)) !== ring) continue; // ring edge only
        const x = ax + dx * step, y = ay + dy * step;
        if (!overlaps(x, y)) return { x: Math.round(x), y: Math.round(y) };
      }
    }
  }
  // give up — offset by a deterministic jitter so it still doesn't stack exactly
  return { x: Math.round(ax + (boxes.length % 6) * step),
           y: Math.round(ay + Math.floor(boxes.length / 6) * step) };
}

const ACTION_MODULES = [
  { id: "iv", label: "individual → verbal" },
  { id: "in", label: "individual → nonverbal" },
  { id: "cv", label: "interactive → verbal" },
  { id: "cn", label: "interactive → nonverbal" },
];

const BUILTIN_ACTIONS = [
  { name: "jump",          module: "in", icon: "↥",
    describe: "An agent jumps in place — a simple nonverbal signal of energy or excitement.",
    strict: "", soft: "Use sparingly; reads as childlike if overused." },
  { name: "move-to",       module: "in", icon: "→",
    describe: "An agent walks to a specific location (scene or object).",
    strict: "Target must exist in the current world. Path must be reachable.",
    soft: "Avoid pacing back and forth without purpose." },
  { name: "talk-to-agent", module: "cv", icon: "❝",
    describe: "An agent verbally addresses another agent.",
    strict: "Both agents must be in the same scene.",
    soft: "Keep utterances under two sentences unless asked." },
  { name: "hug-agent",     module: "cn", icon: "❀",
    describe: "An agent embraces another agent — a nonverbal interactive gesture.",
    strict: "Both agents must be in the same scene. Target must consent.",
    soft: "Reserved for established rapport." },
];

function makeEntity(kind, x, y, extra = {}) {
  const id = uid(kind);
  const template = TEMPLATE_BY_KIND[kind] || "item";
  const tplDef = DEFAULT_TEMPLATES[template];
  const base = {
    id, kind, template,
    x: Math.round(x), y: Math.round(y),
    log: [],
    status: statusFromTemplate(tplDef),
  };
  if (kind === "scene") {
    return { ...base, w: 240, h: 168,  // 10 × 7 tiles
      name: "Untitled Scene", rules: "", connects: [], ...extra };
  }
  if (kind === "object") {
    const icon = OBJECT_ICONS[Math.floor(Math.random() * OBJECT_ICONS.length)];
    return { ...base, w: 64, h: 64, placedIn: null,
      name: "Untitled Object", icon, note: "", ...extra };
  }
  if (kind === "agent") {
    const p = AGENT_PALETTE[Math.floor(Math.random() * AGENT_PALETTE.length)];
    return { ...base, w: 76, h: 92, placedIn: null,
      name: "New Agent", profile: "", persona: "", goal: "",
      background: "", bias: 2, pickedActions: [],
      ...p, ...extra };
  }
  if (kind === "action") {
    return { ...base, w: 168, h: 110,
      name: "untitled-action", module: "iv", icon: "✦",
      describe: "", strict: "", soft: "", ...extra };
  }
}

// Friendly, human-readable default name for a freshly added entity, unique
// within its kind so a first-timer never sees "Untitled" or duplicate
// "New Agent". `existing` is the live entity list (incl. any added earlier in
// the same batch). We scan for the lowest N whose "<Label> N" isn't taken, so
// names survive deletions/renames without colliding. Names remain editable.
const FRIENDLY_KIND_LABEL = {
  agent: "Person", object: "Item", scene: "Room", action: "Action",
};
function friendlyEntityName(kind, existing = []) {
  const label = FRIENDLY_KIND_LABEL[kind] || "Object";
  const taken = new Set(
    (existing || [])
      .filter(e => e && e.kind === kind)
      .map(e => String(e.name || "").trim().toLowerCase())
  );
  let n = 1;
  while (taken.has(`${label} ${n}`.toLowerCase())) n++;
  return `${label} ${n}`;
}

// Build a WORLD banner theme from an imported scenario/trace so the red WORLD
// chip reflects the freshly-loaded world instead of staying on the previously
// loaded one. We look for a human title across the shapes the engine/studio
// emit (top-level title/name, meta.title/genre/scenario, file name), then fall
// back to a neutral label. Styling fields are neutral defaults — a known
// WORLD_CARDS slug (bundled demos) keeps its rich theme via loadBundledDemo.
function titleFromImport(data, fallbackName) {
  const meta = (data && typeof data.meta === "object") ? data.meta : {};
  const cand = [
    data && data.title, data && data.name,
    meta.title, meta.name, meta.scenario, meta.genre,
    data && data.scenario && data.scenario.title,
    data && data.scenario && data.scenario.name,
  ].find(s => typeof s === "string" && s.trim());
  if (cand) return cand.trim();
  if (fallbackName) {
    // strip a trailing .json / .ndjson and turn slugs into Title Case
    const base = String(fallbackName).replace(/\.[a-z0-9]+$/i, "")
      .replace(/[_-]+/g, " ").trim();
    if (base) return base.replace(/\b\w/g, c => c.toUpperCase());
  }
  return "Imported world";
}
function bannerThemeFromImport(data, fallbackName) {
  return {
    slug: null, title: titleFromImport(data, fallbackName),
    ground: T.paper, inkOn: T.ink,
    accent: T.accent2, accent2: T.accent,
    grain: hatchBG(T.paper, T.paperEdge), serif: false,
  };
}

// ─── DEMO SEED ────────────────────────────────────────────────────────
function seedDemo() {
  _idc = 0;
  // Actions row near the bottom
  const actionEnts = [];
  let ax = 60;
  for (const a of BUILTIN_ACTIONS) {
    actionEnts.push(makeEntity("action", ax, 600, { ...a }));
    ax += 188;
  }
  const allActionIds = actionEnts.map(a => a.id);

  // Scenes
  const livingRoom = makeEntity("scene", 72, 72, {
    name: "Living Room", w: 312, h: 216,  // 13 × 9 tiles
    rules: "No phones on the table. Lamp must stay on after sunset.",
  });
  const study = makeEntity("scene", 432, 72, {
    name: "Study", w: 264, h: 192,         // 11 × 8 tiles
    rules: "Quiet voices only. Door stays cracked.",
  });
  const kitchen = makeEntity("scene", 216, 336, {
    name: "Kitchen", w: 288, h: 192,       // 12 × 8 tiles
    rules: "Whoever cooks chooses the music.",
  });
  livingRoom.connects = [study.id, kitchen.id];
  study.connects = [livingRoom.id];
  kitchen.connects = [livingRoom.id];

  // Objects placed inside scenes — tile-aligned. (sx + tx*TILE, sy + ty*TILE)
  // livingRoom 13×9 tiles → floor tx[2..10], ty[2..6]
  const sofa     = makeEntity("object", 72 + 4*TILE,  72 + 6*TILE, { w: TILE, h: TILE, name: "Sofa", icon: "≡", placedIn: livingRoom.id });
  const coffeeTb = makeEntity("object", 72 + 6*TILE,  72 + 5*TILE, { w: TILE, h: TILE, name: "Coffee table", icon: "▢", placedIn: livingRoom.id });
  const rug      = makeEntity("object", 72 + 8*TILE,  72 + 6*TILE, { w: TILE, h: TILE, name: "Rug", icon: "◇", placedIn: livingRoom.id });
  // study 11×8 tiles → tx[2..8], ty[2..5]
  const chair    = makeEntity("object", 432 + 3*TILE, 72 + 5*TILE, { w: TILE, h: TILE, name: "Chair", icon: "⊥", placedIn: study.id });
  const desk     = makeEntity("object", 432 + 5*TILE, 72 + 5*TILE, { w: TILE, h: TILE, name: "Desk", icon: "╤", placedIn: study.id });
  const bookshlf = makeEntity("object", 432 + 7*TILE, 72 + 5*TILE, { w: TILE, h: TILE, name: "Bookshelf", icon: "≣", placedIn: study.id });
  // kitchen 12×8 tiles → tx[2..9], ty[2..5]
  const stove    = makeEntity("object", 216 + 3*TILE, 336 + 5*TILE, { w: TILE, h: TILE, name: "Stove", icon: "⊞", placedIn: kitchen.id });
  const counter  = makeEntity("object", 216 + 6*TILE, 336 + 5*TILE, { w: TILE, h: TILE, name: "Counter", icon: "━", placedIn: kitchen.id });

  // Agents — tile-aligned starting tiles
  const mara = makeEntity("agent", 72 + 2*TILE, 72 + 3*TILE, { w: TILE, h: TILE,
    name: "Mara",  skin:"#f4c79a", shirt:"#c2543b", hair:"#2b1810",
    profile: "Curator, age 34",
    persona: "Warm, observant, slightly anxious in groups",
    goal: "Curate a small gallery opening",
    background: "Grew up in a museum-archive family",
    bias: 2, placedIn: livingRoom.id, pickedActions: [...allActionIds],
    personaVector: { warmth: 0.82, assertive: 0.41, openness: 0.66, "risk-taking": 0.28, honesty: 0.74 },
  });
  const oren = makeEntity("agent", 72 + 4*TILE, 72 + 3*TILE, { w: TILE, h: TILE,
    name: "Oren",  skin:"#d9a070", shirt:"#3b6fb5", hair:"#1a0e08",
    profile: "Sous-chef, age 29",
    persona: "Quietly confident, dry humor",
    goal: "Borrow a recipe from Mara",
    background: "Trained abroad, recently homesick",
    bias: 1, placedIn: livingRoom.id, pickedActions: [...allActionIds],
    personaVector: { warmth: 0.55, assertive: 0.62, openness: 0.49, "risk-taking": 0.38, honesty: 0.81 },
  });
  const suki = makeEntity("agent", 432 + 2*TILE, 72 + 3*TILE, { w: TILE, h: TILE,
    name: "Suki",  skin:"#f8d7b3", shirt:"#5a8f3e", hair:"#5a3920",
    profile: "PhD student, age 26",
    persona: "Bookish, contrarian, loyal",
    goal: "Avoid Oren without seeming rude",
    background: "Childhood friend turned rival",
    bias: 3, placedIn: study.id, pickedActions: [...allActionIds],
    personaVector: { warmth: 0.38, assertive: 0.71, openness: 0.55, "risk-taking": 0.44, honesty: 0.86 },
  });
  const theo = makeEntity("agent", 216 + 2*TILE, 336 + 3*TILE, { w: TILE, h: TILE,
    name: "Theo",  skin:"#e8b48a", shirt:"#a37bd1", hair:"#3a2418",
    profile: "Designer, age 31",
    persona: "Mediator, light flirt, late riser",
    goal: "Throw an impromptu dinner",
    background: "Hosts a podcast on group dynamics",
    bias: 2, placedIn: kitchen.id, pickedActions: [...allActionIds],
    personaVector: { warmth: 0.71, assertive: 0.52, openness: 0.83, "risk-taking": 0.61, honesty: 0.65 },
  });

  return [
    livingRoom, study, kitchen,
    sofa, coffeeTb, rug, chair, desk, bookshlf, stove, counter,
    mara, oren, suki, theo,
    ...actionEnts,
  ];
}

// ─── SPRITE ───────────────────────────────────────────────────────────
function Sprite({ agent, scale = 3, mood }) {
  const { skin, shirt, hair } = agent;
  const px = 16 * scale;
  return (
    <svg width={px} height={px} viewBox="0 0 16 16" shapeRendering="crispEdges"
         style={{ overflow: "visible" }}>
      <ellipse cx="8" cy="14.5" rx="3.2" ry="0.6" fill="rgba(0,0,0,0.18)"/>
      <rect x="5" y="2"  width="6" height="3" fill={hair}/>
      <rect x="4" y="3"  width="1" height="2" fill={hair}/>
      <rect x="11" y="3" width="1" height="2" fill={hair}/>
      <rect x="5" y="5"  width="6" height="3" fill={skin}/>
      <rect x="6" y="6"  width="1" height="1" fill="#1c120a"/>
      <rect x="9" y="6"  width="1" height="1" fill="#1c120a"/>
      <rect x="5" y="4"  width="6" height="1" fill={hair}/>
      <rect x="7" y="8"  width="2" height="1" fill={skin}/>
      <rect x="4" y="9"  width="8" height="3" fill={shirt}/>
      <rect x="3" y="10" width="1" height="2" fill={shirt}/>
      <rect x="12" y="10" width="1" height="2" fill={shirt}/>
      <rect x="3" y="9"  width="1" height="1" fill={skin}/>
      <rect x="12" y="9" width="1" height="1" fill={skin}/>
      <rect x="5" y="12" width="2" height="2" fill="#3a2a1a"/>
      <rect x="9" y="12" width="2" height="2" fill="#3a2a1a"/>
      {mood === "talk" && (
        <g>
          {/* pixel speech bubble — matches shell.jsx Sprite mood-talk indicator */}
          <rect x="11" y="0" width="5" height="3" fill="#fff" stroke="#222" strokeWidth="0.3"/>
          <rect x="12" y="1" width="1" height="1" fill="#222"/>
          <rect x="14" y="1" width="1" height="1" fill="#222"/>
        </g>
      )}
      {/* v7: mood halo — colored ring around the sprite based on mood_valence */}
      {typeof agent?.status?.mood_valence === "number" && Math.abs(agent.status.mood_valence) > 0.05 && (
        <circle cx="8" cy="8" r="7.4" fill="none"
          stroke={moodTint(agent.status.mood_valence)}
          strokeWidth="0.6" opacity="0.75"/>
      )}
    </svg>
  );
}

// ─── OBJECT SPRITES ───────────────────────────────────────────────────
// Each object is a hand-pixeled 16×16 SVG, same grid as the agent Sprite.
// Keyed by entity.name (lowercased); falls back to a generic crate.
const OBJECT_SPRITES = {
  sofa: (
    <g>
      <rect x="1"  y="7"  width="14" height="6" fill="#8a5a3a"/>
      <rect x="1"  y="6"  width="14" height="1" fill="#a87248"/>
      <rect x="1"  y="5"  width="2"  height="7" fill="#6e4226"/>
      <rect x="13" y="5"  width="2"  height="7" fill="#6e4226"/>
      <rect x="3"  y="8"  width="4"  height="3" fill="#c98a55"/>
      <rect x="9"  y="8"  width="4"  height="3" fill="#c98a55"/>
      <rect x="1"  y="13" width="2"  height="1" fill="#3a2418"/>
      <rect x="13" y="13" width="2"  height="1" fill="#3a2418"/>
    </g>
  ),
  "coffee table": (
    <g>
      <rect x="2"  y="8"  width="12" height="2" fill="#8a5a3a"/>
      <rect x="2"  y="7"  width="12" height="1" fill="#a87248"/>
      <rect x="3"  y="10" width="2"  height="4" fill="#6e4226"/>
      <rect x="11" y="10" width="2"  height="4" fill="#6e4226"/>
      <rect x="5"  y="6"  width="6"  height="1" fill="#d9c19a"/>
    </g>
  ),
  rug: (
    <g>
      <rect x="1"  y="7"  width="14" height="6" fill="#c2543b"/>
      <rect x="1"  y="7"  width="14" height="1" fill="#8a3a26"/>
      <rect x="1"  y="12" width="14" height="1" fill="#8a3a26"/>
      <rect x="3"  y="9"  width="2"  height="2" fill="#e8b48a"/>
      <rect x="7"  y="9"  width="2"  height="2" fill="#e8b48a"/>
      <rect x="11" y="9"  width="2"  height="2" fill="#e8b48a"/>
      <rect x="0"  y="8"  width="1"  height="4" fill="#8a3a26"/>
      <rect x="15" y="8"  width="1"  height="4" fill="#8a3a26"/>
    </g>
  ),
  chair: (
    <g>
      <rect x="4"  y="2"  width="8"  height="6" fill="#8a5a3a"/>
      <rect x="4"  y="2"  width="8"  height="1" fill="#a87248"/>
      <rect x="3"  y="8"  width="10" height="3" fill="#6e4226"/>
      <rect x="4"  y="11" width="2"  height="3" fill="#3a2418"/>
      <rect x="10" y="11" width="2"  height="3" fill="#3a2418"/>
      <rect x="5"  y="4"  width="6"  height="1" fill="#5a3920"/>
    </g>
  ),
  desk: (
    <g>
      <rect x="1"  y="6"  width="14" height="3" fill="#8a5a3a"/>
      <rect x="1"  y="6"  width="14" height="1" fill="#a87248"/>
      <rect x="2"  y="9"  width="2"  height="5" fill="#6e4226"/>
      <rect x="12" y="9"  width="2"  height="5" fill="#6e4226"/>
      <rect x="4"  y="3"  width="2"  height="3" fill="#c97a3a"/>
      <rect x="4"  y="2"  width="2"  height="1" fill="#efe8d6"/>
      <rect x="9"  y="4"  width="4"  height="2" fill="#3a5d8f"/>
    </g>
  ),
  bookshelf: (
    <g>
      <rect x="2"  y="1"  width="12" height="13" fill="#6e4226"/>
      <rect x="3"  y="2"  width="10" height="3"  fill="#d4c08c"/>
      <rect x="3"  y="6"  width="10" height="3"  fill="#d4c08c"/>
      <rect x="3"  y="10" width="10" height="3"  fill="#d4c08c"/>
      <rect x="3"  y="2"  width="2"  height="3"  fill="#c2543b"/>
      <rect x="5"  y="2"  width="2"  height="3"  fill="#3a5d8f"/>
      <rect x="8"  y="2"  width="2"  height="3"  fill="#5a8f3e"/>
      <rect x="11" y="2"  width="2"  height="3"  fill="#c97a3a"/>
      <rect x="3"  y="6"  width="2"  height="3"  fill="#7d4a9e"/>
      <rect x="6"  y="6"  width="2"  height="3"  fill="#c97a3a"/>
      <rect x="9"  y="6"  width="2"  height="3"  fill="#3a5d8f"/>
      <rect x="11" y="6"  width="2"  height="3"  fill="#a13c3c"/>
      <rect x="4"  y="10" width="2"  height="3"  fill="#5a8f3e"/>
      <rect x="7"  y="10" width="2"  height="3"  fill="#c2543b"/>
      <rect x="10" y="10" width="2"  height="3"  fill="#3a5d8f"/>
    </g>
  ),
  stove: (
    <g>
      <rect x="1"  y="3"  width="14" height="11" fill="#bfbfbf"/>
      <rect x="1"  y="3"  width="14" height="1"  fill="#e0e0e0"/>
      <rect x="1"  y="13" width="14" height="1"  fill="#7a7a7a"/>
      <rect x="3"  y="5"  width="4"  height="3"  fill="#1d2238"/>
      <rect x="9"  y="5"  width="4"  height="3"  fill="#1d2238"/>
      <rect x="4"  y="6"  width="2"  height="1"  fill="#c2543b"/>
      <rect x="10" y="6"  width="2"  height="1"  fill="#c2543b"/>
      <rect x="2"  y="10" width="4"  height="3"  fill="#3a3a3a"/>
      <rect x="8"  y="10" width="6"  height="2"  fill="#3a3a3a"/>
    </g>
  ),
  counter: (
    <g>
      <rect x="0"  y="5"  width="16" height="3"  fill="#d9c19a"/>
      <rect x="0"  y="5"  width="16" height="1"  fill="#efe8d6"/>
      <rect x="0"  y="8"  width="16" height="6"  fill="#8a5a3a"/>
      <rect x="0"  y="8"  width="16" height="1"  fill="#6e4226"/>
      <rect x="4"  y="9"  width="2"  height="4"  fill="#6e4226"/>
      <rect x="10" y="9"  width="2"  height="4"  fill="#6e4226"/>
      <rect x="2"  y="11" width="1"  height="2"  fill="#3a2418"/>
      <rect x="13" y="11" width="1"  height="2"  fill="#3a2418"/>
    </g>
  ),
};

// v7: detect an "aggregate / crowd" object — engine packs many micro-agents
// into one entity. Heuristic: status.count or name contains crowd-like text.
function isCrowdEntity(entity) {
  if (!entity || entity.kind !== "object") return false;
  if (typeof entity.status?.count === "number" && entity.status.count > 1) return true;
  const n = (entity.name || "").toLowerCase();
  return /crowd|其他客人|众人|onlookers|aggregate/.test(n);
}
function CrowdSprite({ entity, scale = 1.5 }) {
  const count = entity.status?.count != null ? entity.status.count : 8;
  const heat = Math.min(1, count / 20);
  const px = 16 * scale;
  // Render a heatmap-style cluster of small dots.
  const dots = [];
  const N = Math.min(9, Math.max(3, Math.round(count / 1.5)));
  for (let i = 0; i < N; i++) {
    const a = (i / N) * Math.PI * 2;
    const r = 4 + (i % 2) * 1.5;
    dots.push(<rect key={i}
      x={(8 + Math.cos(a) * r) - 0.5}
      y={(8 + Math.sin(a) * r) - 0.5}
      width={1.2} height={1.2}
      fill={`rgba(${Math.round(180 + heat * 50)}, ${Math.round(120 - heat * 30)}, ${Math.round(80 + heat * 40)}, 0.85)`}/>);
  }
  return (
    <svg width={px} height={px} viewBox="0 0 16 16" shapeRendering="crispEdges"
         style={{ overflow: "visible" }}>
      <ellipse cx="8" cy="14.5" rx="5" ry="0.7" fill="rgba(0,0,0,0.18)"/>
      <circle cx="8" cy="8" r="6.5" fill={`rgba(232, 144, 96, ${0.1 + heat * 0.2})`}/>
      {dots}
    </svg>
  );
}
// ─── OBJECT APPEARANCE (visual) ───────────────────────────────────────
// A per-object visual = ONE base SVG + status-overlay RULES that re-style
// it as the object's KV `status` changes (e.g. a window open vs closed).
// SEPARATE from the NL `appearance` text the engine reads — this is pure
// authoring/render data. Shape:
//   visual: {
//     baseSvg: "<svg viewBox='0 0 100 100'>…ids/classes…</svg>",
//     rules: [ { when: "open==false",
//                set:  [{ sel: "#pane", attr: "fill", val: "#888" }],
//                show: ["#closedLayer"], hide: ["#openLayer"] } ]
//   }
// Lives on the class/template; instances inherit (instance override = slice 2).

// Resolve an entity's effective visual: instance value, else walk the
// template (class) chain root→leaf, taking the nearest one that defines a
// baseSvg. Mirrors how components inherit (classComponents / templateChain).
function resolveVisual(entity, templates) {
  if (!entity) return null;
  if (entity.visual && entity.visual.baseSvg) return entity.visual;
  if (!templates) return null;
  const chain = templateChain(entity.template, templates); // root → leaf
  let found = null;
  for (const tn of chain) {
    const v = templates[tn] && templates[tn].visual;
    if (v && v.baseSvg) found = v; // leaf-most wins
  }
  return found;
}

// Strip dangerous nodes/attrs from an SVG string before injecting it inline
// (so #id / .class elements stay addressable but no script can run).
function sanitizeSvg(svg) {
  if (typeof svg !== "string" || !svg.trim()) return "";
  let out = svg;
  // Drop <script>…</script> and <foreignObject>…</foreignObject> blocks.
  out = out.replace(/<script[\s\S]*?<\/script\s*>/gi, "");
  out = out.replace(/<script[^>]*\/>/gi, "");
  out = out.replace(/<foreignObject[\s\S]*?<\/foreignObject\s*>/gi, "");
  out = out.replace(/<foreignObject[^>]*\/>/gi, "");
  // Drop on* event handler attributes (onload=, onclick=, …).
  out = out.replace(/\son[a-z]+\s*=\s*"[^"]*"/gi, "");
  out = out.replace(/\son[a-z]+\s*=\s*'[^']*'/gi, "");
  out = out.replace(/\son[a-z]+\s*=\s*[^\s>]+/gi, "");
  // Neutralize javascript: URLs.
  out = out.replace(/javascript:/gi, "");
  return out;
}

// Parse the set of element ids declared in an SVG string (for the editor's
// target dropdown). Returns plain ids without the leading '#'.
function extractSvgIds(svg) {
  if (typeof svg !== "string") return [];
  const ids = [];
  const re = /\bid\s*=\s*["']([^"']+)["']/g;
  let m;
  while ((m = re.exec(svg))) { if (!ids.includes(m[1])) ids.push(m[1]); }
  return ids;
}

// Built-in appearance presets for the Library tab. Each is a small SVG with
// id'd elements + open/closed (or on/off) layers an overlay rule can toggle.
const VISUAL_PRESETS = [
  {
    id: "window", label: "Window",
    baseSvg: "<svg viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'>" +
      "<rect id='frame' x='14' y='14' width='72' height='72' fill='#7a5a3a'/>" +
      "<rect id='pane' x='20' y='20' width='60' height='60' fill='#bfe3f0'/>" +
      "<g id='closedLayer'><line x1='50' y1='20' x2='50' y2='80' stroke='#7a5a3a' stroke-width='4'/>" +
      "<line x1='20' y1='50' x2='80' y2='50' stroke='#7a5a3a' stroke-width='4'/></g>" +
      "<g id='openLayer' style='display:none'><rect x='20' y='20' width='30' height='60' fill='#8fd0e6'/>" +
      "<rect x='20' y='20' width='6' height='60' fill='#5a93a8'/></g>" +
      "</svg>",
    suggestedRules: [
      { when: "open==true",  show: ["#openLayer"], hide: ["#closedLayer"], set: [] },
      { when: "open==false", show: ["#closedLayer"], hide: ["#openLayer"], set: [] },
    ],
  },
  {
    id: "door", label: "Door",
    baseSvg: "<svg viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'>" +
      "<rect id='frame' x='22' y='10' width='56' height='84' fill='#5a3a22'/>" +
      "<g id='closedLayer'><rect id='leaf' x='26' y='14' width='48' height='76' fill='#8a5a3a'/>" +
      "<circle id='knob' cx='66' cy='52' r='3' fill='#e8c14a'/></g>" +
      "<g id='openLayer' style='display:none'><rect x='26' y='14' width='10' height='76' fill='#6e4226'/>" +
      "<rect x='36' y='14' width='38' height='76' fill='#1d2238'/></g>" +
      "</svg>",
    suggestedRules: [
      { when: "open==true",  show: ["#openLayer"], hide: ["#closedLayer"], set: [] },
      { when: "open==false", show: ["#closedLayer"], hide: ["#openLayer"], set: [] },
    ],
  },
  {
    id: "box", label: "Box / Crate",
    baseSvg: "<svg viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'>" +
      "<rect id='body' x='18' y='34' width='64' height='52' fill='#a87248'/>" +
      "<g id='closedLayer'><rect id='lid' x='14' y='26' width='72' height='12' fill='#8a5a3a'/></g>" +
      "<g id='openLayer' style='display:none'><rect x='14' y='14' width='72' height='12' fill='#8a5a3a'/>" +
      "<rect x='22' y='38' width='56' height='44' fill='#3a2418'/></g>" +
      "</svg>",
    suggestedRules: [
      { when: "open==true",  show: ["#openLayer"], hide: ["#closedLayer"], set: [] },
      { when: "open==false", show: ["#closedLayer"], hide: ["#openLayer"], set: [] },
    ],
  },
  {
    id: "lamp", label: "Lamp",
    baseSvg: "<svg viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'>" +
      "<rect id='pole' x='47' y='40' width='6' height='46' fill='#555'/>" +
      "<rect id='base' x='38' y='84' width='24' height='6' fill='#333'/>" +
      "<circle id='bulb' cx='50' cy='32' r='16' fill='#777'/>" +
      "<g id='glow' style='display:none'><circle cx='50' cy='32' r='26' fill='#ffe08a' opacity='0.45'/></g>" +
      "</svg>",
    suggestedRules: [
      { when: "on==true",  set: [{ sel: "#bulb", attr: "fill", val: "#ffe24a" }], show: ["#glow"], hide: [] },
      { when: "on==false", set: [{ sel: "#bulb", attr: "fill", val: "#777" }],   show: [],        hide: ["#glow"] },
    ],
  },
  {
    id: "panel", label: "Status panel",
    baseSvg: "<svg viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'>" +
      "<rect id='case' x='16' y='28' width='68' height='44' rx='4' fill='#26303f'/>" +
      "<rect id='screen' x='24' y='36' width='52' height='28' fill='#0f1620'/>" +
      "<circle id='led' cx='50' cy='50' r='8' fill='#3a5d8f'/>" +
      "</svg>",
    suggestedRules: [
      { when: "energy<20", set: [{ sel: "#led", attr: "fill", val: "#c2543b" }], show: [], hide: [] },
      { when: "energy>=20", set: [{ sel: "#led", attr: "fill", val: "#5a8f3e" }], show: [], hide: [] },
    ],
  },
];

// Pure client-side `when` evaluator. Mirrors the engine trigger grammar:
//   FIELD OP VALUE, ops > >= < <= == != ; AND-list (array, or joined by
//   && / " and "). FIELD reads the entity's own status; a leading `self.`
//   is accepted. Numeric compare when VALUE parses as a number, else string.
//   Unknown field → that clause is false. No eval(); ~one regex per clause.
function evalWhenClause(clause, status) {
  const s = (clause || "").trim();
  if (!s) return true;
  const m = s.match(/^(.+?)\s*(>=|<=|==|!=|>|<)\s*(.+)$/);
  if (!m) return false;
  let field = m[1].trim();
  const op = m[2];
  let rawVal = m[3].trim();
  if (field.startsWith("self.")) field = field.slice(5);
  if (!status || !(field in status)) return false;
  let lhs = status[field];
  // Strip surrounding quotes off a string literal.
  if ((rawVal.startsWith('"') && rawVal.endsWith('"')) ||
      (rawVal.startsWith("'") && rawVal.endsWith("'"))) {
    rawVal = rawVal.slice(1, -1);
  }
  const rhsNum = Number(rawVal);
  const numericVal = rawVal !== "" && !Number.isNaN(rhsNum);
  // Booleans compare by string ("true"/"false") so `open==false` works.
  if (typeof lhs === "boolean") {
    const lb = String(lhs);
    if (op === "==") return lb === rawVal;
    if (op === "!=") return lb !== rawVal;
    return false;
  }
  if (numericVal && (typeof lhs === "number" || !Number.isNaN(Number(lhs)))) {
    const ln = Number(lhs);
    switch (op) {
      case ">":  return ln >  rhsNum;
      case ">=": return ln >= rhsNum;
      case "<":  return ln <  rhsNum;
      case "<=": return ln <= rhsNum;
      case "==": return ln === rhsNum;
      case "!=": return ln !== rhsNum;
      default:   return false;
    }
  }
  const ls = String(lhs);
  if (op === "==") return ls === rawVal;
  if (op === "!=") return ls !== rawVal;
  return false; // ordered compare on non-numeric strings → false
}
function evalWhen(when, status) {
  if (when == null || when === "") return true;
  let clauses;
  if (Array.isArray(when)) clauses = when;
  else clauses = String(when).split(/\s*&&\s*|\s+and\s+/i);
  return clauses.every(c => evalWhenClause(c, status));
}

// Render the resolved visual inline + sanitized, then apply matching overlay
// rules against the live status. Re-applies whenever status changes (effect
// keyed on a JSON snapshot of status). Wrapped so the sprite bob/glyph badge
// positioning still work in EntityNode.
function VisualSprite({ visual, status, scale = 1.5 }) {
  const ref = React.useRef(null);
  const safeSvg = React.useMemo(() => sanitizeSvg(visual && visual.baseSvg), [visual && visual.baseSvg]);
  const statusKey = React.useMemo(() => {
    try { return JSON.stringify(status || {}); } catch { return ""; }
  }, [status]);
  React.useEffect(() => {
    const root = ref.current;
    if (!root) return;
    const svgEl = root.querySelector("svg");
    if (!svgEl) return;
    // Reset any prior overlay edits, then re-apply from scratch.
    svgEl.querySelectorAll("[data-vis-touched]").forEach(el => {
      const orig = el.getAttribute("data-vis-orig");
      if (orig != null) {
        try {
          const map = JSON.parse(orig);
          for (const k of Object.keys(map)) {
            if (map[k] === null) el.removeAttribute(k);
            else el.setAttribute(k, map[k]);
          }
        } catch { /* ignore */ }
      }
      el.removeAttribute("data-vis-orig");
      el.removeAttribute("data-vis-touched");
    });
    const remember = (el, attr) => {
      let map = {};
      const existing = el.getAttribute("data-vis-orig");
      if (existing) { try { map = JSON.parse(existing); } catch { map = {}; } }
      if (!(attr in map)) map[attr] = el.hasAttribute(attr) ? el.getAttribute(attr) : null;
      el.setAttribute("data-vis-orig", JSON.stringify(map));
      el.setAttribute("data-vis-touched", "1");
    };
    const rules = (visual && Array.isArray(visual.rules)) ? visual.rules : [];
    for (const rule of rules) {
      if (!rule) continue;
      if (!evalWhen(rule.when, status)) continue;
      for (const s of (rule.set || [])) {
        if (!s || !s.sel || !s.attr) continue;
        let el; try { el = svgEl.querySelector(s.sel); } catch { el = null; }
        if (!el) continue;
        remember(el, s.attr);
        el.setAttribute(s.attr, s.val != null ? s.val : "");
      }
      for (const sel of (rule.hide || [])) {
        let el; try { el = svgEl.querySelector(sel); } catch { el = null; }
        if (!el) continue;
        remember(el, "style");
        el.style.display = "none";
      }
      for (const sel of (rule.show || [])) {
        let el; try { el = svgEl.querySelector(sel); } catch { el = null; }
        if (!el) continue;
        remember(el, "style");
        el.style.display = "";
      }
    }
  }, [safeSvg, statusKey, visual]);
  const px = 16 * scale;
  return (
    <div
      ref={ref}
      style={{ width: px, height: px, lineHeight: 0, overflow: "visible" }}
      dangerouslySetInnerHTML={{ __html: safeSvg }}
    />
  );
}

function ObjectSprite({ entity, scale = 1.5, templates = null }) {
  if (isCrowdEntity(entity)) {
    return <CrowdSprite entity={entity} scale={scale}/>;
  }
  // 0) Status-driven vector appearance (base SVG + overlay rules). Takes
  // precedence over the icon library when authored on the class/instance.
  const visual = resolveVisual(entity, templates);
  if (visual && visual.baseSvg) {
    return <VisualSprite visual={visual} status={entity.status} scale={scale}/>;
  }
  // 1) Per-entity pixel sprite override (from sprite editor).
  if (entity.spritePixels) {
    return <PixelSprite pixels={entity.spritePixels} scale={scale}/>;
  }
  // 2) Named-art lookup.
  const key = (entity.name || "").toLowerCase();
  const art = OBJECT_SPRITES[key] || (
    <g>
      <rect x="2" y="4"  width="12" height="10" fill="#a87248"/>
      <rect x="2" y="4"  width="12" height="1"  fill="#c98a55"/>
      <rect x="2" y="13" width="12" height="1"  fill="#6e4226"/>
      <rect x="2" y="8"  width="12" height="1"  fill="#6e4226"/>
      <rect x="7" y="5"  width="2"  height="3"  fill="#6e4226"/>
    </g>
  );
  const px = 16 * scale;
  return (
    <svg width={px} height={px} viewBox="0 0 16 16" shapeRendering="crispEdges"
         style={{ overflow: "visible" }}>
      <ellipse cx="8" cy="14.5" rx="4.5" ry="0.6" fill="rgba(0,0,0,0.18)"/>
      {art}
    </svg>
  );
}

// Render a pixel-grid sprite as inline SVG <rect> cells.
// `pixels`: 2D array of color strings (or null/falsy for transparent).
function PixelSprite({ pixels, scale = 1 }) {
  if (!Array.isArray(pixels) || pixels.length === 0) return null;
  const h = pixels.length;
  const w = pixels[0].length;
  const px = 16 * scale; // keep tile-equivalent sizing
  const cells = [];
  for (let y = 0; y < h; y++) {
    for (let x = 0; x < w; x++) {
      const c = pixels[y][x];
      if (!c) continue;
      cells.push(<rect key={`${x},${y}`} x={x} y={y} width={1} height={1} fill={c}/>);
    }
  }
  return (
    <svg width={px} height={px} viewBox={`0 0 ${w} ${h}`} shapeRendering="crispEdges"
         style={{ overflow: "visible" }}>
      <ellipse cx={w / 2} cy={h - 0.5} rx={w * 0.28} ry={0.6} fill="rgba(0,0,0,0.18)"/>
      {cells}
    </svg>
  );
}

// Tile-by-tile floor + wall renderer for a scene. Renders explicit
// 24x24 rects (no CSS pattern), so each cell is a discrete tile and
// snap-to-tile resizing reads correctly.
function SceneTiles({ w, h }) {
  const cols = Math.max(5, Math.floor(w / TILE));
  const rows = Math.max(5, Math.floor(h / TILE));
  const tiles = [];
  const tufts = [];
  for (let ty = 0; ty < rows; ty++) {
    for (let tx = 0; tx < cols; tx++) {
      const isGrass = tx === 0 || ty === 0 || tx === cols - 1 || ty === rows - 1;
      const isWall  = !isGrass && (tx === 1 || ty === 1 || tx === cols - 2 || ty === rows - 2);
      let fill;
      if (isGrass) fill = T.canvas;
      else if (isWall) fill = T.sceneWall;
      else fill = ((tx + ty) % 2 ? T.sceneFloorB : T.sceneFloorA);
      tiles.push(
        <rect key={`${tx},${ty}`}
          x={tx * TILE} y={ty * TILE}
          width={TILE} height={TILE} fill={fill}/>
      );
      // sprinkle grass tufts on some grass cells (deterministic pattern)
      if (isGrass && (tx * 7 + ty * 3) % 11 === 0) {
        tufts.push(
          <rect key={`t${tx},${ty}`}
            x={tx * TILE + 9} y={ty * TILE + 11}
            width={2} height={2} fill={T.canvasTuft}/>
        );
      }
    }
  }
  return (
    <svg width={cols * TILE} height={rows * TILE}
      shapeRendering="crispEdges"
      style={{
        position: "absolute", left: 0, top: 0,
        pointerEvents: "none", imageRendering: "pixelated",
      }}>
      {tiles}{tufts}
    </svg>
  );
}

// Head-and-shoulders icon — for lists, log, pick-locations.
function MiniAvatar({ agent, size = 24 }) {
  const { skin, hair, shirt } = agent;
  return (
    <svg width={size} height={size} viewBox="0 0 16 16" shapeRendering="crispEdges"
         style={{ display: "block" }}>
      <rect x="4"  y="2" width="8"  height="2" fill={hair}/>
      <rect x="3"  y="3" width="1"  height="3" fill={hair}/>
      <rect x="12" y="3" width="1"  height="3" fill={hair}/>
      <rect x="4"  y="4" width="8"  height="4" fill={skin}/>
      <rect x="5"  y="6" width="1"  height="1" fill="#1c120a"/>
      <rect x="10" y="6" width="1"  height="1" fill="#1c120a"/>
      <rect x="3"  y="8" width="10" height="6" fill={shirt}/>
    </svg>
  );
}

// Resolve the engine WebSocket URL. Precedence: ?engine= query param (shareable
// links) > window.HABITAT_ENGINE_URL (set in index.html for a Vercel deploy) >
// saved Settings value (studio-engine-url) > localhost default (local dev).
function engineWsUrl() {
  try {
    const q = new URLSearchParams(location.search).get("engine");
    if (q) return q;
    if (typeof window !== "undefined" && window.HABITAT_ENGINE_URL) return window.HABITAT_ENGINE_URL;
    const saved = localStorage.getItem("studio-engine-url");
    if (saved) return saved;
  } catch (e) {}
  return "ws://localhost:8765";
}

// ─── APP ──────────────────────────────────────────────────────────────
function App() {
  const [themeId, setThemeIdState] = useState(() => {
    let id = "paper";
    try { id = localStorage.getItem("studio-theme") || "paper"; } catch (e) {}
    if (!THEMES[id]) id = "paper";
    T = THEMES[id];
    return id;
  });
  const setTheme = (id) => {
    if (!THEMES[id]) return;
    T = THEMES[id];
    try { localStorage.setItem("studio-theme", id); } catch (e) {}
    setThemeIdState(id);
  };
  const [entities, setEntities] = useState(() => seedDemo());
  useEffect(() => { bumpIdCounter(entities); /* idempotent */ }, []);
  const [selectedId, setSelectedId] = useState(null);
  const [rightPanelOpen, setRightPanelOpen] = useState(true);
  const [windows, setWindows] = useState([]);
  const [pan, setPan] = useState({ x: 0, y: 0 });
  const [zoom, setZoom] = useState(1);
  const [panning, setPanning] = useState(false);
  const [pendingConn, setPendingConn] = useState(null);
  const [ctxMenu, setCtxMenu] = useState(null);
  const [playing, setPlaying] = useState(false);
  const [tickSec, setTickSec] = useState(0);
  const [events, setEvents] = useState([]);
  const [dockH, setDockH] = useState(220);
  const [dockTab, setDockTab] = useState("log");
  const [dockMin, setDockMin] = useState(false);
  const [railW, setRailW] = useState(280);
  const [templates, setTemplatesState] = useState(() => loadTemplates());
  const setTemplates = (next) => {
    setTemplatesState(next);
    saveTemplates(next);
  };
  const [templateEditorOpen, setTemplateEditorOpen] = useState(false);
  const [rules, setRulesState] = useState(() => loadRules());
  // Two-tier storage: config is WORKSPACE state — the per-project autosave effect
  // persists it into projects[currentProjectId].data. (No more shared flat keys,
  // which used to bleed one project's rules/lore/components into the next.)
  const setRules = (next) => { setRulesState(next); };
  // v7: first-class components[] for engine concepts that don't fit the
  // closed rules[] enum (drives, mood/memory middleware, custom adjudicator,
  // raw code escape hatches). Persisted separately.
  const [customComponents, setCustomComponentsState] = useState(() => {
    try {
      const raw = localStorage.getItem("studio-components");
      return raw ? JSON.parse(raw) : [];
    } catch { return []; }
  });
  const setCustomComponents = (next) => { setCustomComponentsState(next); };
  const [rulesEditorOpen, setRulesEditorOpen] = useState(false);
  const [snapshots, setSnapshotsState] = useState(() => loadSnapshots());
  const [currentSnapshotId, setCurrentSnapshotId] = useState(null);
  const [snapshotsOpen, setSnapshotsOpen] = useState(false);
  const [exportOpen, setExportOpen] = useState(false);
  // GLOBAL settings (user-level, shared across all projects — the VSCode "user" tier).
  const [settingsOpen, setSettingsOpen] = useState(false);
  const [defaultModel, setDefaultModelState] = useState(() => {
    try { return localStorage.getItem("studio-model") || ""; } catch (e) { return ""; }
  });
  const setDefaultModel = (m) => {
    setDefaultModelState(m);
    try { m ? localStorage.setItem("studio-model", m) : localStorage.removeItem("studio-model"); } catch (e) {}
  };
  const [engineUrl, setEngineUrlState] = useState(() => {
    try { return localStorage.getItem("studio-engine-url") || ""; } catch (e) { return ""; }
  });
  const setEngineUrl = (u) => {
    setEngineUrlState(u);
    try { u ? localStorage.setItem("studio-engine-url", u) : localStorage.removeItem("studio-engine-url"); } catch (e) {}
  };
  const [welcomeOpen, setWelcomeOpen] = useState(false);   // Start screen (Recent/New/Open/Learn)
  const [activeWalkthrough, setActiveWalkthrough] = useState("milgram");   // which Learn walkthrough the tour runs
  // #24 — first-run guided tour. Opens once (persisted), reopenable from "?".
  // P4: it is now a HANDS-ON build tutorial — the user builds a mini obedience
  // world from scratch, so starting it wipes the canvas to a blank author state.
  const [tourOpen, setTourOpen] = useState(false);
  const startTour = (opts) => {
    // BUGFIX (DATA-LOSS via "?" tour) — the tour builds a mini world from
    // scratch, so it wants a blank canvas. Previously it ALWAYS wiped
    // entities/rules/run, silently destroying a world the user had built (the
    // outro even tells them to reopen via "?"). Now: only wipe when the canvas
    // is already empty, or after an explicit confirm; `force` (first-run
    // onboarding over the seed demo) keeps the original silent-reset behaviour.
    const force = opts && opts.force;
    const hasWorld = (entitiesRef.current || []).length > 0;
    if (hasWorld && !force) {
      const ok = window.confirm(
        "This will clear your current world and restart the tutorial — continue?");
      if (!ok) {
        // Open the walkthrough without wiping — still gate the rail/library so
        // the hands-on flow is consistent.
        tourSet({ active: true, step: 0, revealed: false });
        setTourOpen(true);
        return;
      }
    }
    // Blank world so the user builds from scratch (steps key off live state).
    setEntities([]);
    setRules([]);
    setCustomComponents([]);
    setWorldBook([]);
    setSelectedId(null);
    setWindows([]);
    resetRun();
    setReplayState(null);
    // Reset the hands-on tour gate up front: tour active, on step 0, BUILT-IN
    // templates HIDDEN (rail + library blank) until the reveal step flips it.
    tourSet({ active: true, step: 0, revealed: false });
    setTourOpen(true);
  };
  useEffect(() => {
    // The Welcome/Start screen is now the entry point (Recent / New / Open /
    // Learn). On first run it opens and offers the Milgram walkthrough under
    // Learn — so the tour is launched from there, not auto-forced over the canvas.
    let seen = null;
    try { seen = localStorage.getItem("habitat_welcome_seen"); } catch (e) {}
    if (!seen) setWelcomeOpen(true);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
  const closeTour = () => {
    setTourOpen(false);
    // Clear the tour gate so the rail + template library return to normal
    // (all built-in templates visible) — the app stays fully usable.
    tourSet({ active: false, revealed: true });
    try { localStorage.setItem("habitat_tour_done", "1"); } catch (e) {}
  };
  // F3 — world-book (engine B1, SillyTavern semantics). constant entries are
  // injected into every mind + the adjudicator; keyed entries fire only when
  // a keyword appears in the perceived context.
  const [worldBook, setWorldBookState] = useState(() => {
    try {
      const raw = localStorage.getItem("studio-worldbook");
      if (raw) { const p = JSON.parse(raw); if (Array.isArray(p)) return p; }
    } catch (e) {}
    return [];
  });
  const setWorldBook = (next) => { setWorldBookState(next); };
  const [worldBookOpen, setWorldBookOpen] = useState(false);
  const [worldPanelOpen, setWorldPanelOpen] = useState(false);   // read-only "how the world is built" view
  // When a named world is loaded, stamp its accent + ground so the canvas
  // background and a world chip carry the genre. Quiet shell, world brings color.
  const [worldTheme, setWorldTheme] = useState(null);
  // World-level scenario settings the engine consumes (studio-1.1 top-level fields).
  // Today: language (per ENGINE STUDIO_TASKS P1-1 / manifest.world_fields.language).
  const [worldSettings, setWorldSettings] = useState({ language: "English" });
  const setSnapshots = (next) => { setSnapshotsState(next); };
  const saveVersion = (label) => {
    const snap = makeSnapshot({
      label: label || `version @ ${fmtT(tickRef.current || 0)}`,
      parentId: currentSnapshotId,
      entities: entitiesRef.current,
      rules: rulesRef.current,
      tickSec: tickRef.current || 0,
    });
    setSnapshots([...snapshots, snap]);
    setCurrentSnapshotId(snap.id);
    return snap;
  };
  const loadVersion = (id) => {
    const snap = snapshots.find(s => s.id === id);
    if (!snap) return;
    const ents = (snap.payload.entities || []).map(normalizeEntity);
    setEntities(ents);
    bumpIdCounter(ents);
    if (snap.payload.rules) setRules(snap.payload.rules);
    tickRef.current = snap.payload.tickSec || 0;
    setTickSec(tickRef.current);
    setCurrentSnapshotId(id);
    setSelectedId(null);
    setWindows([]);
    setEvents([]);
    setPlaying(false);
  };
  const removeVersion = (id) => {
    setSnapshots(snapshots.filter(s => s.id !== id));
    if (currentSnapshotId === id) setCurrentSnapshotId(null);
  };
  const [dropHover, setDropHover] = useState(null); // {kind:"scene"|"agent", id}
  const [fx, setFx] = useState({}); // {agentId: 'talk'|'move'|'act'} — last-tick activity

  const panStart = useRef(null);
  const zRef = useRef(10);
  const canvasRef = useRef(null);

  // Zoom-to-fit: after a demo loads, pan+zoom so the scene bbox fills the
  // canvas with margin. UX-REVIEW #3 (canvas wasted ~80% of space).
  const fitToContent = useCallback((entitiesForFit) => {
    const ents = entitiesForFit || entitiesRef.current || [];
    const fittable = ents.filter(e => e.kind === "scene" || e.kind === "object" || e.kind === "agent");
    if (fittable.length === 0) return;
    const minX = Math.min(...fittable.map(e => e.x));
    const minY = Math.min(...fittable.map(e => e.y));
    const maxX = Math.max(...fittable.map(e => e.x + (e.w || 24)));
    const maxY = Math.max(...fittable.map(e => e.y + (e.h || 24)));
    const bw = Math.max(1, maxX - minX);
    const bh = Math.max(1, maxY - minY);
    const el = canvasRef.current;
    if (!el) return;
    const cw = el.clientWidth, ch = el.clientHeight;
    const margin = 80;
    const z = Math.min((cw - margin) / bw, (ch - margin) / bh, 1.4);
    const cx = (minX + maxX) / 2;
    const cy = (minY + maxY) / 2;
    setZoom(z);
    setPan({ x: cw / 2 - cx * z, y: ch / 2 - cy * z });
  }, []);
  const fileInputRef = useRef(null);
  const tickRef = useRef(0);
  const entitiesRef = useRef(entities);
  useEffect(() => { entitiesRef.current = entities; }, [entities]);

  const screenToCanvas = useCallback((sx, sy) => {
    const r = canvasRef.current?.getBoundingClientRect();
    if (!r) return { x: sx, y: sy };
    return { x: (sx - r.left - pan.x) / zoom, y: (sy - r.top - pan.y) / zoom };
  }, [pan, zoom]);

  const canvasCenter = () => {
    const r = canvasRef.current?.getBoundingClientRect();
    if (!r) return { x: 200, y: 200 };
    return { x: (r.width / 2 - pan.x) / zoom, y: (r.height / 2 - pan.y) / zoom };
  };

  const scenes  = useMemo(() => entities.filter(e => e.kind === "scene"),  [entities]);
  const objects = useMemo(() => entities.filter(e => e.kind === "object"), [entities]);
  const agents  = useMemo(() => entities.filter(e => e.kind === "agent"),  [entities]);
  const actions = useMemo(() => entities.filter(e => e.kind === "action"), [entities]);
  const childrenOf = useCallback((sceneId) =>
    entities.filter(e => (e.kind === "agent" || e.kind === "object") && e.placedIn === sceneId)
  , [entities]);
  const unplacedObjects = objects.filter(o => !o.placedIn);
  const unplacedAgents  = agents.filter(a => !a.placedIn);

  const sceneAt = (x, y) => scenes.find(s =>
    x >= s.x && x < s.x + s.w && y >= s.y && y < s.y + s.h);
  const agentAt = (x, y) => agents.find(a =>
    x >= a.x && x < a.x + a.w && y >= a.y && y < a.y + a.h);

  // ── entity ops ──
  // Add one or more entities of `kind`. New entities are scattered so successive
  // adds never collapse onto a single coordinate: if the drop point is inside a
  // scene, agents/objects tile across that scene's floor (snapInScene); otherwise
  // they spiral outward from the anchor, skipping occupied cells (scatterFreePos).
  // `count` (default 1) drops a batch in one call for the multi-add affordance.
  const addEntity = (kind, atScreenPt, count = 1) => {
    runTouchedRef.current = false;   // authoring edit — autosave may include entities again
    let cx, cy;
    if (atScreenPt) {
      const pt = screenToCanvas(atScreenPt.x, atScreenPt.y);
      cx = pt.x; cy = pt.y;
    } else {
      const c = canvasCenter(); cx = c.x; cy = c.y;
    }
    const n = Math.max(1, Math.min(50, Math.round(count) || 1));
    const created = [];
    // Live working list so each new entity avoids the ones added earlier in
    // this same batch (state updates are async, so we can't read `entities`).
    const live = [...entitiesRef.current];
    // For agents/objects: prefer the scene under the drop point. For a MULTI-add
    // with no explicit drop point (the rail "Add N" + template buttons pass
    // null), fall back to the selected scene — or, failing that, the first
    // scene — so the batch LANDS on the canvas as visible scene occupants
    // instead of silently piling into the off-screen UNPLACED region.
    // (Previously agents scattered to canvas-center coords that weren't inside
    // any scene, so they got placedIn=null and a non-coder saw "nothing
    // happened".) Gated to n>1 so a single rail add keeps its prior behaviour.
    let targetScene = (kind === "agent" || kind === "object")
      ? sceneAt(cx, cy) : null;
    // Rail-added beings/objects (no explicit drop point) land INSIDE a scene — the
    // selected scene, else the first one — so two people can actually meet & talk
    // out of the box (UNPLACED agents perceive no one). Adversarial-test fix.
    if (!targetScene && !atScreenPt && (kind === "agent" || kind === "object")) {
      const sel = live.find(s => s.id === selectedId && s.kind === "scene");
      targetScene = sel || live.find(s => s.kind === "scene") || null;
      if (targetScene) {
        // Anchor the in-scene tiling at the scene's own centre so snapInScene
        // spreads across that scene's floor (not around the viewport centre).
        cx = targetScene.x + targetScene.w / 2;
        cy = targetScene.y + targetScene.h / 2;
      }
    }
    for (let i = 0; i < n; i++) {
      let e;
      if (targetScene) {
        // tile inside the scene floor, avoiding cells already taken by other
        // placed occupants (incl. ones from earlier in this batch).
        const occupied = new Set();
        for (const o of live) {
          if ((o.kind === "agent" || o.kind === "object") && o.placedIn === targetScene.id) {
            occupied.add(`${Math.round((o.x - targetScene.x) / TILE)},${Math.round((o.y - targetScene.y) / TILE)}`);
          }
        }
        const pos = snapInScene(targetScene, cx, cy, occupied);
        e = makeEntity(kind, pos.x, pos.y);
        if (kind === "agent" || kind === "object") {
          e.placedIn = targetScene.id; e.w = TILE; e.h = TILE;
        }
      } else {
        // free-floating: spiral out from the anchor, skipping occupied space.
        const probe = makeEntity(kind, 0, 0);
        const boxes = live.map(o => ({ x: o.x, y: o.y, w: o.w, h: o.h }));
        const pos = scatterFreePos(cx - probe.w / 2, cy - probe.h / 2,
          probe.w, probe.h, boxes);
        e = makeEntity(kind, pos.x, pos.y);
      }
      // Friendly, unique default name per kind ("Person 1", "Item 2", "Room 3",
      // "Action 1") so a first-timer never sees "Untitled"/"New Agent". We pick
      // the lowest N not already used by an entity of this kind (counting the
      // live batch so a multi-add doesn't collide). Stays fully editable.
      e.name = friendlyEntityName(kind, live);
      created.push(e);
      live.push(e);
    }
    setEntities(p => [...p, ...created]);
    setSelectedId(created[created.length - 1].id);
    // Multi-add via the rail buttons (no explicit drop point): make the new
    // entities unmissable — pan/zoom so they're in view and surface a brief
    // toast. Without this, a "+5" of people that scattered (or even placed)
    // off the current viewport reads as "nothing happened" to a non-coder.
    if (!atScreenPt && n > 1 && (kind === "agent" || kind === "object")) {
      const nextEnts = [...live]; // includes the freshly-created batch
      requestAnimationFrame(() => fitToContent(nextEnts));
      const noun = kind === "agent" ? "people" : "items";
      setToasts(prev => [...prev,
        { id: Date.now() + Math.random(),
          text: targetScene
            ? `Added ${n} ${noun} to “${targetScene.name || "scene"}”.`
            : `Added ${n} ${noun} to the canvas.` }]);
    }
    return n === 1 ? created[0] : created;
  };
  const updateEntity = (id, patch) => {
    runTouchedRef.current = false;
    setEntities(p => p.map(e => e.id === id ? { ...e, ...patch } : e));
  };
  // QA-only read hook: mirror a tiny entity summary onto window so headless
  // tests can assert the world state without scraping the canvas DOM. Purely
  // read-only; never consumed by app code.
  useEffect(() => {
    try {
      window.__HABITAT_QA = {
        count: entities.length,
        kinds: entities.map(e => e.kind),
        agentsWithPersona: entities.filter(
          e => e.kind === "agent" && (e.persona || "").trim()).length,
        scenes: entities.filter(e => e.kind === "scene").length,
        objects: entities.filter(e => e.kind === "object").length,
        // Per-entity placement, so headless QA can assert that new adds scatter
        // (distinct positions) instead of stacking. Read-only mirror.
        entities: entities.map(e => ({
          id: e.id, kind: e.kind, name: e.name,
          x: Math.round(e.x), y: Math.round(e.y), placedIn: e.placedIn || null,
        })),
        tourOpen,
        // How many CUSTOM (user-forked) templates exist — the tour's step-2
        // scene class lands here; built-ins are excluded.
        customTemplates: Object.values(templates || {}).filter(t => t && !t.builtin).length,
      };
    } catch (e) {}
  }, [entities, tourOpen, templates]);
  // QA-only read mirror: the first-class components[] (attributes, manifestation
  // rules, middleware…) so headless QA can assert that a hidden attribute +
  // its manifestation persist on an entity after being authored in the DOCKED
  // panel. Read-only mirror; never consumed by app code.
  useEffect(() => {
    try { window.__HABITAT_QA_COMPONENTS = customComponents; } catch (e) {}
  }, [customComponents]);
  // Append a log entry to a specific entity. Used by rule enforcement,
  // user-intervention, and (later) the LLM reasoning trace.
  const appendEntityLog = (id, entry) => {
    setEntities(p => p.map(e =>
      e.id === id ? { ...e, log: [...(e.log || []), entry] } : e
    ));
  };
  const removeEntity = (id) => {
    runTouchedRef.current = false;
    setEntities(p => p.filter(e => e.id !== id).map(e => {
      if (e.kind === "scene")
        return { ...e, connects: (e.connects || []).filter(cid => cid !== id) };
      if (e.kind === "agent" || e.kind === "object") {
        let n = e;
        if (e.placedIn === id) n = { ...n, placedIn: null };
        if (e.kind === "agent" && (e.pickedActions || []).includes(id))
          n = { ...n, pickedActions: e.pickedActions.filter(a => a !== id) };
        return n;
      }
      return e;
    }));
    setWindows(p => p.filter(w => w.entityId !== id));
    if (selectedId === id) setSelectedId(null);
  };
  const duplicateEntity = (id) => {
    const src = entities.find(e => e.id === id);
    if (!src) return;
    const copy = { ...src, id: uid(src.kind), x: src.x + 30, y: src.y + 30,
      name: src.name + " (copy)" };
    setEntities(p => [...p, copy]);
    setSelectedId(copy.id);
  };

  // ── window ops ──
  const openWindow = (entityId) => {
    setWindows(p => {
      const existing = p.find(w => w.entityId === entityId);
      if (existing) return p.map(w =>
        w.entityId === entityId ? { ...w, z: ++zRef.current } : w);
      return [...p, {
        id: uid("win"), entityId,
        x: 320 + (p.length * 28), y: 100 + (p.length * 28),
        z: ++zRef.current,
      }];
    });
  };
  const closeWindow = (id) => setWindows(p => p.filter(w => w.id !== id));
  // Selecting an entity for inspection now drives the right panel only —
  // no floating window pops up by default. Use the pop-out (↗) on the
  // right panel header to spawn a floating inspector.
  const inspectEntity = (id) => { setSelectedId(id); setRightPanelOpen(true); };
  const focusWindow = (id) =>
    setWindows(p => p.map(w => w.id === id ? { ...w, z: ++zRef.current } : w));
  const moveWindow = (id, x, y) =>
    setWindows(p => p.map(w => w.id === id ? { ...w, x, y } : w));

  // ── connect / drag-drop placement ──
  const toggleConnect = (aId, bId) => {
    if (aId === bId) return;
    setEntities(p => p.map(e => {
      if (e.id === aId && e.kind === "scene") {
        const next = (e.connects || []).includes(bId)
          ? e.connects.filter(c => c !== bId)
          : [...(e.connects || []), bId];
        return { ...e, connects: next };
      }
      if (e.id === bId && e.kind === "scene") {
        const next = (e.connects || []).includes(aId)
          ? e.connects.filter(c => c !== aId)
          : [...(e.connects || []), aId];
        return { ...e, connects: next };
      }
      return e;
    }));
  };
  const startConn = (fromId, sx, sy) => {
    const pt = screenToCanvas(sx, sy);
    setPendingConn({ from: fromId, mouse: pt });
  };
  const cancelConn = () => setPendingConn(null);

  // moveEntity: smart — scene drag moves children with it (free-positioning).
  // Phase 6: if the sim is playing, any user move auto-pauses and records
  // a user-override entry on the entity's log.
  const moveEntity = (id, x, y) => {
    runTouchedRef.current = false;
    if (playingRef.current && !userMoveRef.current.has(id)) {
      userMoveRef.current.add(id);
      setPlaying(false);
      const ent = entitiesRef.current.find(e => e.id === id);
      if (ent) {
        appendEntityLog(id, {
          t: tickRef.current,
          kind: "user-override",
          text: `user moved ${ent.name || ent.id} (sim paused)`,
        });
        // If the agent was in the middle of a long action, the drag
        // interrupts it — record the end:interrupted lifecycle entry
        // and clear busy so they're eligible again on resume.
        if (ent.busy) {
          const a = ent.busy;
          setEntities(p => p.map(e => e.id !== id ? e : ({
            ...e, busy: null,
            status: { ...(e.status || {}), busy: false },
            log: [...(e.log || []), {
              t: tickRef.current, kind: "action", state: "end",
              actionId: a.actionId, verb: a.verb, target: a.target,
              outcome: "interrupted",
              text: `interrupted: ${a.description || a.verb} (user)`,
            }],
          })));
        }
      }
    }
    setEntities(p => {
      const target = p.find(e => e.id === id);
      if (!target) return p;
      if (target.kind === "scene") {
        const dx = x - target.x, dy = y - target.y;
        return p.map(e => {
          if (e.id === id) return { ...e, x, y };
          if ((e.kind === "agent" || e.kind === "object") && e.placedIn === id) {
            return { ...e, x: e.x + dx, y: e.y + dy };
          }
          return e;
        });
      }
      return p.map(e => e.id === id ? { ...e, x, y } : e);
    });
  };

  // resize any widget (right/bottom corner)
  const resizeEntity = (id, w, h) =>
    setEntities(p => p.map(e => e.id === id ? { ...e, w: Math.round(w), h: Math.round(h) } : e));

  // place / attach based on drag end
  const placeAt = (entityId, cx, cy) => {
    const ent = entitiesRef.current.find(e => e.id === entityId);
    if (!ent) return;
    if (ent.kind === "agent" || ent.kind === "object") {
      const sc = sceneAt(cx, cy);
      if (sc) {
        // collect occupied floor tiles in this scene
        const occupied = new Set();
        for (const e of entitiesRef.current) {
          if (e.id !== entityId && e.placedIn === sc.id) {
            const tx = Math.round((e.x - sc.x) / TILE);
            const ty = Math.round((e.y - sc.y) / TILE);
            occupied.add(`${tx},${ty}`);
          }
        }
        const pos = snapInScene(sc, cx, cy, occupied);
        updateEntity(entityId, { placedIn: sc.id, x: pos.x, y: pos.y, w: TILE, h: TILE });
      } else if (ent.placedIn) {
        updateEntity(entityId, { placedIn: null });
      }
    } else if (ent.kind === "action") {
      const ag = agentAt(cx, cy);
      if (ag) {
        const list = ag.pickedActions || [];
        if (!list.includes(ent.id)) {
          updateEntity(ag.id, { pickedActions: [...list, ent.id] });
        }
      }
    }
  };

  // Auto-arrange / Tidy layout: re-spread any pile of overlapping entities into
  // a non-overlapping grid. Placed agents/objects are tiled across their own
  // scene's floor (snapInScene); unplaced agents/objects/actions are laid out
  // in a clean grid across open canvas below the scenes. Scenes themselves and
  // the sim are left alone. Idempotent — safe to click repeatedly.
  const tidyLayout = () => {
    const all = entitiesRef.current;
    const sceneList = all.filter(e => e.kind === "scene");
    // 1) per-scene floor tiling for placed occupants
    const perScene = {};
    for (const e of all) {
      if ((e.kind === "agent" || e.kind === "object") && e.placedIn) {
        (perScene[e.placedIn] || (perScene[e.placedIn] = [])).push(e);
      }
    }
    const patch = new Map(); // id -> {x,y}
    for (const sc of sceneList) {
      const occ = perScene[sc.id]; if (!occ) continue;
      const cols = Math.max(1, Math.floor(sc.w / TILE) - 4);
      const occupied = new Set();
      occ.forEach((e, i) => {
        const col = i % cols, row = Math.floor(i / cols);
        const k = `${col + 2},${row + 2}`;
        occupied.add(k);
        patch.set(e.id, { x: sc.x + (col + 2) * TILE, y: sc.y + (row + 2) * TILE });
      });
    }
    // 2) grid layout for unplaced agents/objects/actions below the scene band
    const unplaced = all.filter(e =>
      (e.kind === "agent" || e.kind === "object" || e.kind === "action") && !e.placedIn);
    if (unplaced.length) {
      const baseY = sceneList.length
        ? Math.max(...sceneList.map(s => s.y + s.h)) + 48
        : 80;
      const baseX = sceneList.length ? Math.min(...sceneList.map(s => s.x)) : 80;
      const cell = 120, perRow = 8;
      unplaced.forEach((e, i) => {
        const col = i % perRow, row = Math.floor(i / perRow);
        patch.set(e.id, { x: baseX + col * cell, y: baseY + row * cell });
      });
    }
    if (patch.size === 0) return;
    setEntities(p => p.map(e => patch.has(e.id) ? { ...e, ...patch.get(e.id) } : e));
  };

  // QA-only test hook: expose stackAll() so headless QA can deliberately
  // collapse every agent/object onto one coordinate, then click "Tidy layout"
  // and assert positions spread back out. Mutation hook, gated behind the
  // __HABITAT_QA_TEST namespace; never called by app code.
  useEffect(() => {
    window.__HABITAT_QA_TEST = {
      stackAll: () => {
        setEntities(p => p.map(e =>
          (e.kind === "agent" || e.kind === "object")
            ? { ...e, x: 400, y: 300, placedIn: null }
            : e));
      },
      // Select an entity into the DOCKED inspector (selectedId + open panel),
      // so headless QA can drive the docked panel without coordinate-clicking
      // a canvas sprite. Read/drive hook only; never called by app code.
      select: (id) => { inspectEntity(id); },
    };
  }, []);

  // ── pan/zoom ──
  // React's synthetic onWheel is passive in modern browsers, so preventDefault()
  // there is a no-op and the page scrolls instead of zooming. Attach a native,
  // non-passive listener on the canvas element via useEffect so wheel-zoom works.
  const zoomRef = useRef(zoom); const panRef = useRef(pan);
  useEffect(() => { zoomRef.current = zoom; }, [zoom]);
  useEffect(() => { panRef.current = pan; }, [pan]);
  useEffect(() => {
    const el = canvasRef.current;
    if (!el) return;
    const handler = (e) => {
      e.preventDefault();
      const rect = el.getBoundingClientRect();
      const z = zoomRef.current, p = panRef.current;
      if (e.shiftKey) {
        setPan({ x: p.x - e.deltaX, y: p.y - e.deltaY });
        return;
      }
      const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
      const next = Math.max(0.3, Math.min(2.5, z * factor));
      const cx = e.clientX - rect.left, cy = e.clientY - rect.top;
      const nx = cx - (cx - p.x) * (next / z);
      const ny = cy - (cy - p.y) * (next / z);
      setZoom(next); setPan({ x: nx, y: ny });
    };
    el.addEventListener("wheel", handler, { passive: false });
    return () => el.removeEventListener("wheel", handler);
  }, []);

  const onCanvasMouseDown = (e) => {
    if (ctxMenu) setCtxMenu(null);
    // Only fire on the canvas itself (empty background), not on bubbling from a widget.
    const hitBackground = e.target === e.currentTarget;
    if (e.button === 1 || (e.button === 0 && hitBackground && e.altKey) ||
        (e.button === 0 && hitBackground)) {
      // left-click / middle / alt-click on empty canvas → pan
      setPanning(true);
      panStart.current = { mx: e.clientX, my: e.clientY, px: pan.x, py: pan.y };
      e.preventDefault();
      if (e.button === 0 && hitBackground && !e.altKey) {
        if (pendingConn) cancelConn();
        setSelectedId(null);
      }
    }
  };

  const onCanvasContextMenu = (e) => {
    e.preventDefault();
    setCtxMenu({ kind: "bg", x: e.clientX, y: e.clientY,
      canvasPt: { x: e.clientX, y: e.clientY } });
  };

  // ── globals ──
  useEffect(() => {
    if (!pendingConn) return;
    const mv = (e) => {
      const pt = screenToCanvas(e.clientX, e.clientY);
      setPendingConn(p => p ? { ...p, mouse: pt } : null);
    };
    window.addEventListener("mousemove", mv);
    return () => window.removeEventListener("mousemove", mv);
  }, [pendingConn, screenToCanvas]);

  useEffect(() => {
    if (!panning) return;
    const mv = (e) => {
      const s = panStart.current;
      setPan({ x: s.px + (e.clientX - s.mx), y: s.py + (e.clientY - s.my) });
    };
    const up = () => setPanning(false);
    window.addEventListener("mousemove", mv);
    window.addEventListener("mouseup", up);
    return () => {
      window.removeEventListener("mousemove", mv);
      window.removeEventListener("mouseup", up);
    };
  }, [panning]);

  // Latest-callback ref so the global keydown handler (Ctrl/Cmd+S) always calls the
  // current saveCurrentProject without re-subscribing on every render.
  const saveRef = useRef(null);

  useEffect(() => {
    const onKey = (e) => {
      const tag = e.target?.tagName;
      const editing = tag === "INPUT" || tag === "TEXTAREA";
      if (e.key === "Escape") {
        // User control & freedom: Escape always provides an exit — cancel a
        // pending connection / menu first, else dismiss the topmost open modal.
        if (pendingConn) { cancelConn(); return; }
        if (ctxMenu) { setCtxMenu(null); return; }
        if (welcomeOpen) { setWelcomeOpen(false);
          try { localStorage.setItem("habitat_welcome_seen", "1"); } catch (err) {} return; }
        if (templateEditorOpen) { setTemplateEditorOpen(false); return; }
        if (rulesEditorOpen) { setRulesEditorOpen(false); return; }
        if (worldBookOpen) { setWorldBookOpen(false); return; }
        if (worldPanelOpen) { setWorldPanelOpen(false); return; }
        if (snapshotsOpen) { setSnapshotsOpen(false); return; }
        if (exportOpen) { setExportOpen(false); return; }
        if (settingsOpen) { setSettingsOpen(false); return; }
      }
      // Ctrl/Cmd+S — Save the current project (VSCode muscle memory).
      if ((e.metaKey || e.ctrlKey) && (e.key === "s" || e.key === "S")) {
        e.preventDefault();
        if (saveRef.current) saveRef.current();
        return;
      }
      if ((e.key === "Delete" || e.key === "Backspace") && selectedId && !editing) {
        removeEntity(selectedId);
      }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [selectedId, pendingConn, ctxMenu, welcomeOpen, templateEditorOpen, rulesEditorOpen,
      worldBookOpen, worldPanelOpen, snapshotsOpen, exportOpen, settingsOpen]);

  // ── sim ──
  const simTick = useCallback(() => {
    // LIVE: the engine drives the world and streams tick frames (ws.onmessage). The local
    // JS simulator must NOT run too, or it injects phantom reasoning-less beats alongside the
    // real ones. Play already sends op:play to the engine; here we just no-op the local loop.
    if (liveRef.current?.status === "open") return;
    runTouchedRef.current = true;   // a run mutates entities → autosave won't persist run-gunk
    // In replay mode, advance through the trace instead of running the stub sim.
    const r = replayRef.current;
    if (r) {
      if (r.tickIdx >= r.trace.ticks.length) { setPlaying(false); return; }
      const tick = r.trace.ticks[r.tickIdx];
      const t = tick.tickSec ?? tick.now ?? tickRef.current;
      tickRef.current = t;
      setTickSec(t);
      setEntities(p => applyTraceTick(p, tick));
      const rows = summarizeTickLog(tick, entitiesRef.current)
        .map(r => ({ ...r, t: fmtT(r.t) }));
      if (rows.length > 0) setEvents(prev => [...prev, ...rows].slice(-2000));
      setReplayState(s => s ? { ...s, tickIdx: s.tickIdx + 1 } : s);
      return;
    }
    // Event-driven sim time advancement. When at least one agent is busy
    // with a long action (e.g. sleep, cook), we jump sim time to the next
    // busy.until — so an 8-minute sleep takes one tick to finish instead
    // of ticking 16 times at 30s each. When nobody is busy, advance by the
    // baseline step.
    let ents = entitiesRef.current;
    const upcoming = ents
      .filter(e => e.kind === "agent" && e.busy && (e.busy.until || 0) > tickRef.current)
      .map(e => e.busy.until);
    if (upcoming.length > 0) {
      const nextEnd = Math.min(...upcoming);
      // Cap forward jump so the user still sees ticks even in absurdly
      // long sleeps; SIM_SEC_PER_TICK is the default idle stride.
      tickRef.current = Math.min(nextEnd, tickRef.current + Math.max(SIM_SEC_PER_TICK, 600));
    } else {
      tickRef.current += SIM_SEC_PER_TICK;
    }
    setTickSec(tickRef.current);
    let now = tickRef.current;
    // ── SCHEDULED EVENT PHASE ──
    // Process any "event" rules whose `at` time-of-day has been crossed
    // since last tick. skip_to jumps forward, broadcast logs a global event.
    const evRules = (rulesRef.current || []).filter(r => r.kind === "event");
    if (evRules.length > 0) {
      const prevSec = (tickRef.lastEventCheck ?? 0);
      const dayLen = 86400;
      for (const er of evRules) {
        const at = er.at | 0;
        // window of sim-seconds we just stepped over (handles day-wrap)
        const lastDay = Math.floor(prevSec / dayLen);
        const nowDay = Math.floor(now / dayLen);
        const triggers = [];
        for (let d = lastDay; d <= nowDay; d++) {
          const t = d * dayLen + at;
          if (t > prevSec && t <= now) triggers.push(t);
        }
        if (triggers.length === 0) continue;
        if (er.effect === "skip_to" && er.target != null) {
          const tgt = (er.target | 0);
          const baseDay = Math.floor(now / dayLen);
          // Find next occurrence of target time at-or-after now.
          let nextT = baseDay * dayLen + tgt;
          if (nextT <= now) nextT += dayLen;
          tickRef.current = nextT;
          now = nextT;
          setTickSec(now);
          setEvents(prev => [...prev, {
            t: fmtT(now), actorIdx: 0, actorName: "ENV",
            verb: er.name || "scheduled event", target: secsToHHMM(tgt),
            line: er.text || `skipped to ${secsToHHMM(tgt)}`,
            state: "event",
          }].slice(-2000));
        } else if (er.effect === "broadcast") {
          setEvents(prev => [...prev, {
            t: fmtT(now), actorIdx: 0, actorName: "ENV",
            verb: er.name || "broadcast", target: null,
            line: er.text || "(no message)",
            state: "event",
          }].slice(-2000));
        }
      }
      tickRef.lastEventCheck = now;
    }
    // ── END-PHASE ──
    // Any agent whose busy action has elapsed transitions to state:"end"
    // with outcome "completed". This finalizes the third lifecycle step.
    const expired = ents.filter(e =>
      e.kind === "agent" && e.busy && (e.busy.until || 0) <= now
    );
    if (expired.length > 0) {
      setEntities(p => p.map(e => {
        if (!(e.kind === "agent" && e.busy && (e.busy.until || 0) <= now)) return e;
        const a = e.busy;
        const desc = a.description || `${a.verb}${a.target ? " → " + a.target : ""}`;
        return {
          ...e, busy: null,
          status: { ...(e.status || {}), busy: false },
          log: [...(e.log || []), {
            t: now, kind: "action", state: "end",
            actionId: a.actionId, verb: a.verb, target: a.target,
            outcome: "completed",
            text: `end (${desc})`,
          }],
        };
      }));
      // Surface the end events too so the dock shows the lifecycle close.
      setEvents(prev => {
        const adds = expired.map((e, i) => ({
          t: fmtT(now), actorIdx: 0,
          actorName: e.name,
          verb: "completed",
          target: e.busy?.verb || "",
          line: null,
          state: "end",
        }));
        return [...prev, ...adds].slice(-2000);
      });
      // Refresh local snapshot for downstream logic in this same tick.
      ents = ents.map(e => {
        if (!(e.kind === "agent" && e.busy && (e.busy.until || 0) <= now)) return e;
        return { ...e, busy: null, status: { ...(e.status || {}), busy: false } };
      });
    }

    // ── PERCEIVE PHASE ──
    // Build perception payloads using static defaults + entity.perception overrides.
    // Mock env keeps the static rule "same scene = full visibility; hearing
    // by tile distance". A real env-LLM would override these.
    const perceptions = buildPerceptions(ents);

    // ── THINK PHASE ──
    // Each idle agent independently decides whether to propose this tick.
    // Agents in `during` state are skipped (busy thread, not eligible for
    // scheduler — they're only woken by env-LLM interrupts, not here).
    const idleAgents = ents.filter(e =>
      e.kind === "agent" && (!e.busy || (e.busy.until || 0) <= now)
    );
    if (idleAgents.length === 0) {
      maybeRefreshSouls(now);
      return;
    }
    const candidates = [];
    for (const a of idleAgents) {
      if (Math.random() > 0.45) continue;
      const c = pickCandidateAction(a, ents, templatesRef.current, now, rulesRef.current);
      if (c) candidates.push(c);
    }
    if (candidates.length === 0) {
      maybeRefreshSouls(now);
      return;
    }

    // ── RESOLVE PHASE — local fallback only ──
    // The real adjudicator lives in the engine and is reached via the WS bridge
    // (round 3). This stub is left in for offline authoring so the canvas isn't
    // dead when there's no engine; it just accepts every candidate.
    const resolved = mockEnvResolve(candidates, ents, rulesRef.current);

    // ── APPLY PHASE ──
    if (resolved.accepted.length === 0 && resolved.rejected.length === 0) {
      maybeRefreshSouls(now);
      return;
    }
    const fxMap = {};
    for (const a of resolved.accepted) {
      fxMap[a.actorId] = a.fx || "act";
    }
    setFx(fxMap);
    setEntities(p => {
      let next = p;
      // rejections → rule-block log entries
      for (const r of resolved.rejected) {
        next = next.map(e => e.id !== r.actorId ? e : ({
          ...e, log: [...(e.log || []), {
            t: now, kind: "rule-block",
            text: `${r.reason} — wanted ${r.description}`,
          }],
        }));
      }
      // accepted → reasoning + intend + during entries, busy + sideEffects
      for (const a of resolved.accepted) {
        next = next.map(e => {
          if (e.id === a.actorId) {
            const status = { ...(e.status || {}), busy: true,
              ...(a.statusPatch || {}) };
            const updates = { ...e, status,
              busy: { verb: a.verb, target: a.target, description: a.description,
                      actionId: a.actionId, startedAt: now, until: now + a.duration },
              log: [...(e.log || []), {
                t: now, kind: "reasoning", text: a.reasoning,
              }, {
                t: now, kind: "action", state: "intend", actionId: a.actionId,
                verb: a.verb, target: a.target, text: `intend: ${a.description}`,
              }, {
                t: now, kind: "action", state: "during", actionId: a.actionId,
                verb: a.verb, target: a.target,
                text: `during: ${a.description} (${a.duration}s)`,
              }],
            };
            if (a.sideEffects) Object.assign(updates, a.sideEffects);
            return updates;
          }
          // Co-located observers — write memory entry for what they saw,
          // and mirror it as a log entry of kind:"memory" so the per-entity
          // log panel shows episodic observations alongside actions.
          if (e.kind === "agent" && e.placedIn && e.placedIn === a.observerScene) {
            if (e.id === a.actorId) return e;
            const memEntry = {
              t: now, actorId: a.actorId, actorName: a.actorName,
              verb: a.verb, target: a.target,
            };
            return {
              ...e,
              memory: appendMemory(e.memory, memEntry),
              log: [...(e.log || []), {
                t: now, kind: "memory",
                text: `saw ${a.actorName} ${a.verb}${a.target ? " → " + a.target : ""}`,
              }],
            };
          }
          return e;
        });
      }
      return next;
    });
    setEvents(prev => {
      const adds = resolved.accepted.map((a) => ({
        t: fmtT(now), actorIdx: 0,
        actorName: a.actorName,
        verb: a.verb,
        target: a.target,
        line: a.line || null,
        state: "during",
      }));
      return [...prev, ...adds].slice(-2000);
    });

    maybeRefreshSouls(now);
    return;
  }, []);

  // Periodic belief refresh — every 10 real ticks (≈5 sim-minutes).
  // Pulled out of simTick so the new pipeline can early-return cleanly.
  const maybeRefreshSouls = useCallback((now) => {
    beliefTickRef.current = (beliefTickRef.current || 0) + 1;
    if (beliefTickRef.current < 10) return;
    beliefTickRef.current = 0;
    setEntities(p => p.map(e => {
      if (!(e.kind === "agent" && e.template === "human")) return e;
      const nextSoul = refreshSoul(e, p);
      if (nextSoul === e.soul) return e;
      return {
        ...e, soul: nextSoul,
        log: [...(e.log || []), {
          t: now, kind: "soul", text: "worldview updated",
        }],
      };
    }));
  }, []);
  const beliefTickRef = useRef(0);
  // playingRef stays in sync with `playing` state so handlers can check
  // it without closing over a stale value. userMoveRef tracks which
  // entity IDs have already triggered a pause-on-move this run, so we
  // don't spam log entries for every mousemove event.
  const playingRef = useRef(false);
  const userMoveRef = useRef(new Set());
  useEffect(() => {
    playingRef.current = playing;
    if (playing) userMoveRef.current = new Set();
  }, [playing]);
  // Refs so the sim closure always sees the latest rules & templates
  // without re-creating the interval on every change.
  const rulesRef = useRef(rules);
  const templatesRef = useRef(templates);
  useEffect(() => { rulesRef.current = rules; }, [rules]);
  useEffect(() => { templatesRef.current = templates; }, [templates]);

  const [speed, setSpeed] = useState(1); // 1, 2, 4, 16
  useEffect(() => {
    if (!playing) { setFx({}); return; }
    // Replay mode can have zero agents at tick-0 (e.g. world spawns later);
    // skip the empty-agents short-circuit while a trace is loaded.
    if (agents.length === 0 && !replayState) { setPlaying(false); return; }
    const interval = Math.max(40, Math.round(900 / speed));
    const iv = setInterval(simTick, interval);
    return () => clearInterval(iv);
  }, [playing, simTick, agents.length, speed, replayState]);

  // Skip-to-next-event: fast-forward up to N ticks at minimum interval,
  // so React commits + entitiesRef updates between each tick. Stops on a
  // detected lifecycle transition (a new 'end' log entry) or the cap.
  const skipToNext = useCallback(() => {
    if (agents.length === 0) return;
    const wasPlaying = playingRef.current;
    setPlaying(false);
    const MAX_TICKS = 40;
    let i = 0;
    const baselineEnds = (entitiesRef.current || []).reduce((acc, e) => {
      const ends = (e.log || []).filter(l => l.kind === "action" && l.state === "end").length;
      acc[e.id] = ends; return acc;
    }, {});
    const dispatch = () => {
      if (i >= MAX_TICKS) {
        if (wasPlaying) setPlaying(true);
        return;
      }
      i++;
      simTick();
      setTimeout(() => {
        // Check if any agent has a new 'end' since baseline → stop.
        const nowEnts = entitiesRef.current || [];
        const newEnd = nowEnts.some(e => {
          const ends = (e.log || []).filter(l => l.kind === "action" && l.state === "end").length;
          return ends > (baselineEnds[e.id] || 0);
        });
        if (newEnd) {
          if (wasPlaying) setPlaying(true);
          return;
        }
        dispatch();
      }, 30);
    };
    dispatch();
  }, [agents.length, simTick]);

  // inject sprite animation keyframes once
  useEffect(() => {
    if (document.getElementById("studio-anim")) return;
    const s = document.createElement("style");
    s.id = "studio-anim";
    s.textContent = `
      /* gentle idle breathing */
      @keyframes spriteBob {
        0%, 100% { transform: translateY(0) scaleY(1); }
        50%      { transform: translateY(-1px) scaleY(1.02); }
      }
      /* slow, swaying walk — Animal-Crossing-style waddle */
      @keyframes spriteWalk {
        0%   { transform: translateY(0)    rotate(-4deg); }
        25%  { transform: translateY(-2px) rotate( 0deg); }
        50%  { transform: translateY(0)    rotate( 4deg); }
        75%  { transform: translateY(-2px) rotate( 0deg); }
        100% { transform: translateY(0)    rotate(-4deg); }
      }
      /* bouncy chatter — squash/stretch head-bob */
      @keyframes spriteTalk {
        0%, 100% { transform: translateY(0)    scaleY(1)   scaleX(1); }
        25%      { transform: translateY(-2px) scaleY(1.06) scaleX(0.96); }
        50%      { transform: translateY(0)    scaleY(0.95) scaleX(1.05); }
        75%      { transform: translateY(-1px) scaleY(1.03) scaleX(0.98); }
      }
      /* bubble pop with overshoot */
      @keyframes fxBubble {
        0%   { opacity: 0; transform: translate(-50%, 4px) scale(0.4); }
        55%  { opacity: 1; transform: translate(-50%, -5px) scale(1.18); }
        80%  { opacity: 1; transform: translate(-50%, -2px) scale(0.95); }
        100% { opacity: 1; transform: translate(-50%, -3px) scale(1.0);  }
      }
      /* action glyph: pop in with a little overshoot, then HOLD (stays visible on the tick) */
      @keyframes glyphFloat {
        0%   { opacity: 0; transform: translateX(-50%) translateY(8px)  scale(0.3); }
        60%  { opacity: 1; transform: translateX(-50%) translateY(-3px) scale(1.2);  }
        100% { opacity: 1; transform: translateX(-50%) translateY(0px)  scale(1.0);  }
      }
      /* roly-poly (不倒翁) wobble when a figure SPEAKS: gentle side-to-side tilt that settles. */
      @keyframes figWobbleSpeak {
        0%   { transform: rotate(0deg); }
        20%  { transform: rotate(-4deg); }
        45%  { transform: rotate(3deg); }
        70%  { transform: rotate(-1.5deg); }
        100% { transform: rotate(0deg); }
      }
      /* sharper bob/squash when a figure ACTS (spark/footsteps/etc): scaleY dip + small jump, settles. */
      @keyframes figWobbleAct {
        0%   { transform: translateY(0)    scaleY(1)    scaleX(1);    }
        25%  { transform: translateY(0)    scaleY(0.82) scaleX(1.12); }
        55%  { transform: translateY(-6px) scaleY(1.08) scaleX(0.94); }
        80%  { transform: translateY(0)    scaleY(0.96) scaleX(1.03); }
        100% { transform: translateY(0)    scaleY(1)    scaleX(1);    }
      }
    `;
    document.head.appendChild(s);
  }, []);

  const stepOnce = simTick;
  const resetRun = () => { setEvents([]); setTickSec(0); tickRef.current = 0; setPlaying(false); setFx({}); };

  // ── docking resize ──
  const dockResizing = useRef(false);
  const onDockResizeDown = (e) => {
    if (e.button !== 0) return;
    e.preventDefault();
    dockResizing.current = { my: e.clientY, dh: dockH };
  };
  const railResizing = useRef(null);
  const onRailResizeDown = (e) => {
    if (e.button !== 0) return;
    e.preventDefault();
    railResizing.current = { mx: e.clientX, rw: railW };
  };
  useEffect(() => {
    const mv = (e) => {
      if (!dockResizing.current) return;
      const s = dockResizing.current;
      const next = Math.min(600, Math.max(40, s.dh + (s.my - e.clientY)));
      setDockH(next);
    };
    const up = () => { dockResizing.current = null; };
    window.addEventListener("mousemove", mv);
    window.addEventListener("mouseup", up);
    return () => {
      window.removeEventListener("mousemove", mv);
      window.removeEventListener("mouseup", up);
    };
  }, []);
  useEffect(() => {
    const mv = (e) => {
      if (!railResizing.current) return;
      const s = railResizing.current;
      const next = Math.min(560, Math.max(180, s.rw + (e.clientX - s.mx)));
      setRailW(next);
    };
    const up = () => { railResizing.current = null; };
    window.addEventListener("mousemove", mv);
    window.addEventListener("mouseup", up);
    return () => {
      window.removeEventListener("mousemove", mv);
      window.removeEventListener("mouseup", up);
    };
  }, []);

  // ── import / export / reset ──
  const exportJSON = () => {
    const blob = new Blob(
      [JSON.stringify({ version: 2, exportedAt: new Date().toISOString(), entities }, null, 2)],
      { type: "application/json" });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = `agent-studio-${Date.now()}.json`;
    document.body.appendChild(a);
    a.click();
    setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100);
  };
  const importFile = (file) => {
    const r = new FileReader();
    r.onload = () => {
      try {
        const data = JSON.parse(r.result);
        // Detect a habitat-engine trace by its top-level shape and dispatch
        // to the replay loader; otherwise treat as a studio scenario.
        if (data && typeof data === "object" && Array.isArray(data.ticks) && Array.isArray(data.cast)) {
          loadTrace(data, { fileName: file?.name });
          return;
        }
        const raw = Array.isArray(data) ? data : data.entities;
        if (!Array.isArray(raw)) throw new Error("no entities[]");
        // VALIDATE before touching state: drop null / non-object / id-less junk so a
        // malformed file can neither crash the render (a null entity) nor silently
        // overwrite + autosave over the world (an array of numbers → NaN props). The
        // throw below fires BEFORE setEntities, so a bad import leaves the world intact.
        const ents = raw.map(normalizeEntity)
          .filter(e => e && typeof e === "object" && e.id != null);
        if (ents.length === 0) throw new Error(raw.length ? "no valid entities in file" : "no entities[]");
        ents.forEach(e => {                         // no NaN geometry from partial objects → NaN SVG
          if (!Number.isFinite(e.x)) e.x = 0;
          if (!Number.isFinite(e.y)) e.y = 0;
          if (!Number.isFinite(e.w)) e.w = e.kind === "scene" ? 240 : 32;
          if (!Number.isFinite(e.h)) e.h = e.kind === "scene" ? 180 : 32;
        });
        setEntities(ents);
        bumpIdCounter(ents);
        // Refresh the WORLD banner from the imported scenario so it reflects the
        // new world (title/name/meta) rather than the previously loaded one.
        setWorldTheme(bannerThemeFromImport(data, file?.name));
        // Restore the file's EMBEDDED template copies (self-contained .habitat): merge
        // over the Library with embedded winning, but don't write the global Library.
        if (data.templates && typeof data.templates === "object" && !Array.isArray(data.templates))
          setTemplatesState({ ...loadTemplates(), ...data.templates });
        if (Array.isArray(data.world_book)) setWorldBook(data.world_book);
        if (data.language) setWorldSettings(s => ({ ...s, language: data.language }));
        if (typeof data.world_rules === "string") setWorldSettings(s => ({ ...s, world_rules: data.world_rules }));
        setSelectedId(null);
        setWindows([]);
        resetRun();
        // Bundled demo files carry rules alongside entities — load both.
        // v6/v7: also accept the new components[] palette. Known kinds
        // desugar back into rules[]; first-class types (drive observer,
        // middleware, custom action, raw_code) go to customComponents[].
        if (Array.isArray(data.rules)) setRules(data.rules);
        else if (Array.isArray(data.components)) setRules(componentsToRules(data.components));
        if (Array.isArray(data.components)) {
          setCustomComponents(data.components.filter(c => isFirstClassComponent(c)));
        } else {
          setCustomComponents([]);
        }
      } catch (err) {
        alert("Invalid studio JSON: " + err.message);
      }
    };
    r.readAsText(file);
  };

  // ── TRACE REPLAY (v5) ─────────────────────────────────────────────────
  // habitat-engine emits a tick-by-tick trace; loading one switches Studio
  // into replay mode where simTick advances by dispatching log[] entries
  // instead of running the mock simulator.
  const [replayState, setReplayState] = useState(null); // { trace, tickIdx } | null
  const replayRef = useRef(null);
  useEffect(() => { replayRef.current = replayState; }, [replayState]);

  // ── PROJECTS (one project = one world) ───────────────────────────────
  const [projects, setProjectsState] = useState(() => loadProjects());
  const [currentProjectId, setCurrentProjectIdState] = useState(() => {
    try { return localStorage.getItem(CURRENT_PROJECT_KEY) || null; } catch (e) { return null; }
  });
  const setProjects = (next) => {
    setProjectsState(prev => {
      const v = typeof next === "function" ? next(prev) : next;
      saveProjects(v);
      return v;
    });
  };
  const projectsRef = useRef(projects);
  useEffect(() => { projectsRef.current = projects; }, [projects]);
  const currentProjectIdRef = useRef(currentProjectId);
  useEffect(() => { currentProjectIdRef.current = currentProjectId; }, [currentProjectId]);
  const setCurrentProjectId = (id) => {
    setCurrentProjectIdState(id);
    try { id ? localStorage.setItem(CURRENT_PROJECT_KEY, id) : localStorage.removeItem(CURRENT_PROJECT_KEY); } catch (e) {}
  };
  const currentProject = currentProjectId ? projects[currentProjectId] : null;
  const projectName = currentProject ? currentProject.name : "Untitled project";
  // ── VSCode-style file lifecycle: dirty flag + debounced autosave-to-workspace ──
  // hydratingRef suppresses autosave while we LOAD a project/demo (a swap isn't an
  // edit). runTouchedRef marks that a run mutated `entities`, so autosave commits
  // config but NOT the run-gunk entities (the world keeps its authored objects).
  const [dirty, setDirty] = useState(false);
  const hydratingRef = useRef(true);
  const runTouchedRef = useRef(false);
  const autosaveTimer = useRef(null);
  // Gather the current authoring world into a project snapshot.
  const gatherProjectData = () => makeProjectData({
    entities: entitiesRef.current, templates, worldBook,
    rules: rulesRef.current, components: customComponents, worldSettings, snapshots,
  });
  // Write the working world into the ACTIVE workspace (projects[cid].data) — the one
  // source of per-project persistence. Both the debounced autosave and explicit Save
  // funnel through here.
  const commitWorkspace = (opts = {}) => {
    const cid = currentProjectIdRef.current; if (!cid) return;
    const includeEntities = opts.includeEntities !== false;
    setProjects(prev => {                       // functional form so back-to-back commits compose
      const proj = prev[cid]; if (!proj) return prev;
      const prevData = proj.data || {};
      const data = makeProjectData({
        entities: includeEntities ? entitiesRef.current : (prevData.entities || []),
        templates, worldBook, rules: rulesRef.current,
        components: customComponents, worldSettings, snapshots,
      });
      return { ...prev, [cid]: { ...proj, updatedAt: Date.now(), data } };
    });
  };
  // Replace ALL authoring state from a project snapshot (project-scoped swap).
  const applyProjectData = (data) => {
    const d = data || {};
    clearTimeout(autosaveTimer.current);   // cancel any pending autosave from the outgoing world
    hydratingRef.current = true;       // a load is not an edit — suppress autosave
    runTouchedRef.current = false;     // fresh authoring session
    const ents = (d.entities || []).map(normalizeEntity);
    setEntities(ents); bumpIdCounter(ents);
    // Working template set = global Library ∪ this project's EMBEDDED copies (embedded
    // wins). setTemplatesState (NOT setTemplates) so opening a project does not write
    // the global Library — that conflation was the stale-template ("cafe"/untitled) cruft.
    setTemplatesState({ ...loadTemplates(), ...(d.templates || {}) });
    setWorldBook(Array.isArray(d.worldBook) ? d.worldBook : []);
    setRules(Array.isArray(d.rules) ? d.rules : []);
    setCustomComponents(Array.isArray(d.components) ? d.components : []);
    setWorldSettings(d.worldSettings && typeof d.worldSettings === "object"
      ? d.worldSettings : { language: "English" });
    setSnapshotsState(Array.isArray(d.snapshots) ? d.snapshots : []);
    setSelectedId(null); setWindows([]); resetRun(); setReplayState(null);
    setDirty(false);
    setTimeout(() => { hydratingRef.current = false; }, 0);   // re-arm autosave after the swap settles
  };
  const saveAsProject = (name) => {
    const id = newProjectId();
    setProjects({ ...projects, [id]: { id, name: name || `${projectName} copy`,
      createdAt: Date.now(), updatedAt: Date.now(), data: gatherProjectData() } });
    setCurrentProjectId(id);
    setDirty(false);
    return id;
  };
  const saveCurrentProject = () => {
    if (!currentProjectId) { saveAsProject(projectName); return; }
    clearTimeout(autosaveTimer.current);
    setProjects({ ...projects, [currentProjectId]: {
      ...projects[currentProjectId], updatedAt: Date.now(), data: gatherProjectData() } });
    setDirty(false);
  };
  saveRef.current = saveCurrentProject;   // keep the Ctrl/Cmd+S handler pointed at the latest
  // Before leaving the current workspace, flush its working state so edits made in
  // the last (sub-800ms) autosave window aren't lost on a switch.
  const flushCurrentWorkspace = () => {
    clearTimeout(autosaveTimer.current);
    if (currentProjectIdRef.current) commitWorkspace({ includeEntities: !runTouchedRef.current });
  };
  const newProject = (name, seedData) => {
    flushCurrentWorkspace();
    const id = newProjectId();
    const seed = seedData ? { name: name || "Untitled project", data: seedData } : seedProjectData(name);
    setProjects(prev => ({ ...prev, [id]: { id, name: seed.name, createdAt: Date.now(),
      updatedAt: Date.now(), data: seed.data } }));
    setCurrentProjectId(id);
    applyProjectData(seed.data);
    return id;
  };
  const openProject = (id) => {
    const p = projects[id]; if (!p) return;
    if (currentProjectIdRef.current !== id) flushCurrentWorkspace();
    setCurrentProjectId(id);
    applyProjectData(p.data);
  };
  const renameProject = (name) => {
    if (!currentProjectId || !name) return;
    setProjects({ ...projects, [currentProjectId]: { ...projects[currentProjectId], name, updatedAt: Date.now() } });
  };
  const deleteProject = (id) => {
    const all = projectsRef.current || {};
    if (!all[id]) return;
    const { [id]: _drop, ...rest } = all;
    setProjects(rest);
    if (currentProjectIdRef.current === id) {
      // don't leave the app pointing at a deleted workspace — open the next most-recent,
      // or seed a fresh one if that was the last project.
      const remaining = Object.values(rest).sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
      if (remaining.length) { setCurrentProjectId(remaining[0].id); applyProjectData(remaining[0].data); }
      else newProject("My first project");
    }
  };
  const renameProjectById = (id, name) => {
    if (!id || !name) return;
    setProjects(prev => prev[id] ? { ...prev, [id]: { ...prev[id], name, updatedAt: Date.now() } } : prev);
  };
  const duplicateProject = (id) => {
    const p = projects[id]; if (!p) return null;
    const nid = newProjectId();
    setProjects({ ...projects, [nid]: { ...p, id: nid, name: `${p.name} copy`,
      createdAt: Date.now(), updatedAt: Date.now() } });
    return nid;
  };
  // On mount: first run adopts the current working state as "My first project";
  // a returning visit restores the last opened project (project-scoped session).
  useEffect(() => {
    const oldSnaps = loadSnapshots();   // legacy GLOBAL snapshots → migrate into a workspace
    const all = loadProjects();
    if (Object.keys(all).length === 0) {
      const id = newProjectId();
      const data = makeProjectData({ entities: entitiesRef.current, templates, worldBook,
        rules: rulesRef.current, components: customComponents, worldSettings, snapshots: oldSnaps });
      setProjects({ [id]: { id, name: "My first project", createdAt: Date.now(),
        updatedAt: Date.now(), data } });
      setCurrentProjectId(id);
      if (Array.isArray(oldSnaps) && oldSnaps.length) setSnapshotsState(oldSnaps);
    } else {
      let cid = null;
      try { cid = localStorage.getItem(CURRENT_PROJECT_KEY); } catch (e) {}
      if (!cid || !all[cid]) cid = Object.keys(all)[0];
      if (all[cid]) {
        setCurrentProjectId(cid); applyProjectData(all[cid].data);
        // migrate legacy global snapshots into this workspace if it has none yet
        if (!(all[cid].data && all[cid].data.snapshots && all[cid].data.snapshots.length)
            && Array.isArray(oldSnaps) && oldSnaps.length) setSnapshotsState(oldSnaps);
      }
    }
    try { localStorage.removeItem("studio-snapshots"); } catch (e) {}   // legacy global key retired
    setTimeout(() => { hydratingRef.current = false; }, 0);   // arm autosave once mount settles
    // eslint-disable-next-line
  }, []);
  // Debounced autosave: persist the working world into the active workspace ~0.8s
  // after the last edit. Skips while loading (hydrating) and during a run
  // (playing/replay); excludes run-mutated entities so a play-through never
  // overwrites the authored objects. Explicit Save flushes and clears the dot.
  useEffect(() => {
    if (hydratingRef.current) return;
    if (!currentProjectIdRef.current) return;
    if (playing || replayState) return;
    setDirty(true);
    clearTimeout(autosaveTimer.current);
    autosaveTimer.current = setTimeout(() => {
      commitWorkspace({ includeEntities: !runTouchedRef.current });
      setDirty(false);
    }, 800);
    return () => clearTimeout(autosaveTimer.current);
    // eslint-disable-next-line
  }, [entities, rules, customComponents, worldBook, worldSettings, templates, snapshots, playing, replayState]);
  const markWelcomeSeen = () => { try { localStorage.setItem("habitat_welcome_seen", "1"); } catch (e) {} };
  const startWalkthrough = (id) => {
    markWelcomeSeen(); setWelcomeOpen(false);
    setActiveWalkthrough(WALKTHROUGHS[id] ? id : "milgram");   // pick the Learn walkthrough's step set
    // Lessons build from a blank canvas; now that edits autosave, run each in its OWN
    // scratch project so a lesson never overwrites the user's real work.
    newProject(`Tutorial · ${id}`);
    startTour({ force: true });
  };
  const openStarter = (id) => {
    markWelcomeSeen(); setWelcomeOpen(false);
    const inline = STARTER_PROJECTS[id];
    if (inline && inline.data) { newProject(inline.name || id, inline.data); return; }
    // Starters ship as prebuilt worlds under starters/<id>.json (fetched on demand,
    // like demos) and open as a fresh project to explore.
    fetch(`starters/${id}.json`)
      .then(r => r.json())
      .then(s => { if (s && s.data) newProject(s.name || id, s.data); })
      .catch(() => window.alert("Couldn't load this starter project."));
  };
  // Per-project Clear canvas: empty the CURRENT project back to fresh starter
  // templates (keeps the project + its name). Distinct from the global Reset.
  const clearCanvas = () => {
    if (!window.confirm("Clear this project's canvas? Removes all objects and custom templates and resets World Rules / lore (keeps the project).")) return;
    const seed = seedProjectData(projectName).data;
    applyProjectData(seed);
    // persist the clear straight into the workspace (don't wait on the debounce)
    const cid = currentProjectIdRef.current, all = projectsRef.current || {};
    if (cid && all[cid]) setProjects({ ...all, [cid]: { ...all[cid], updatedAt: Date.now(), data: seed } });
  };
  // GLOBAL reset: wipe ALL projects, the Library, and settings from this browser.
  const resetHabitat = () => {
    if (!window.confirm("Reset Habitat? This permanently deletes ALL projects, templates, and saved data in this browser, then reloads. (Your engine and any exported .habitat files are untouched.)")) return;
    try { localStorage.clear(); } catch (e) {}
    window.location.reload();
  };
  const loadTrace = (trace, opts = {}) => {
    if (!trace.schema_version || !String(trace.schema_version).startsWith("engine")) {
      alert(`Trace schema_version "${trace.schema_version}" — Studio expects engine-0.6+`);
      return;
    }
    // Loading a recorded trace is a REPLAY, not an authoring edit — never autosave
    // its world into the current project.
    hydratingRef.current = true; runTouchedRef.current = true;
    // Refresh the WORLD banner from this trace's metadata so it never lingers
    // on the previously loaded world. Bundled demos (loadBundledDemo) pass
    // keepTheme so their rich WORLD_CARDS styling is preserved.
    if (!opts.keepTheme) setWorldTheme(bannerThemeFromImport(trace, opts.fileName));
    // High-fidelity time: the engine ships meta.start_epoch_sec. Set it so
    // every time chip and event-log row reads as real wall-clock time.
    setSimStartEpochSec(trace.meta?.start_epoch_sec || 0);
    // User-designed glyph art for action verbs — read by EntityNode at render.
    ACTION_GLYPHS = trace.action_glyphs || null;
    const ents = seedFromTrace(trace);
    // Hard reset everything else so no phantom default seed (Living Room,
    // Untitled Scene, leftover rules/components) leaks past a demo load.
    setEntities(ents);
    setRules([]);
    // Surface the World Book + engine mechanisms the trace carries, so a REPLAY
    // shows the redesigned rail (Knowledge vs Machinery) read-only, not empty.
    setWorldBook(Array.isArray(trace.world_book) ? trace.world_book : []);
    setCustomComponents(Array.isArray(trace.components) ? trace.components : []);
    bumpIdCounter(ents);
    setSelectedId(null);
    setWindows([]);
    setEvents([]);
    setTickSec(0);
    tickRef.current = 0;
    setPlaying(false);
    setFx({});
    setReplayState({ trace, tickIdx: 0 });
    // Wait for the next paint so canvasRef has its new size, then frame it.
    requestAnimationFrame(() => fitToContent(ents));
    setTimeout(() => { hydratingRef.current = false; }, 0);   // re-arm (replayState now guards autosave)
  };
  const exitReplay = () => {
    setReplayState(null);
    setPlaying(false);
    setSimStartEpochSec(0);
    ACTION_GLYPHS = null;
  };
  // Click-to-seek from the timeline. Replay accumulates incrementally
  // (applyTraceTick appends to each entity's log/driveHistory), so a backward
  // seek can't just bump tickIdx — rendered state would be stale. We rebuild:
  // re-seed from the trace and re-apply ticks 0..target, reusing the same
  // apply path simTick uses, then leave tickIdx == target so the next Step
  // continues forward unchanged. target = number of ticks applied.
  const seekReplay = (target) => {
    const r = replayRef.current;
    if (!r) return;
    const ticks = r.trace.ticks || [];
    const tgt = Math.max(0, Math.min(target, ticks.length));
    let ents = seedFromTrace(r.trace);
    let rows = [];
    let lastT = 0;
    for (let i = 0; i < tgt; i++) {
      const tick = ticks[i];
      lastT = tick.tickSec ?? tick.now ?? lastT;
      ents = applyTraceTick(ents, tick);
      const r2 = summarizeTickLog(tick, ents).map(rr => ({ ...rr, t: fmtT(rr.t) }));
      if (r2.length > 0) rows = rows.concat(r2);
    }
    setPlaying(false);
    setEntities(ents);
    setEvents(rows.slice(-2000));
    tickRef.current = lastT;
    setTickSec(lastT);
    setReplayState(s => s ? { ...s, tickIdx: tgt } : s);
  };
  // Keyboard frame-stepping — Left/Right arrows step the replay one tick
  // back/forward via the same seek path the scrubber uses, so the playhead +
  // event log stay in sync. Guarded: never hijack typing in an input/textarea
  // /contentEditable, and only active while a replay is loaded (live frames
  // stream forward and can't re-seed backward). Stepping pauses playback,
  // which seekReplay already does.
  useEffect(() => {
    const onArrow = (e) => {
      if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
      const t = e.target;
      const tag = t?.tagName;
      const editing = tag === "INPUT" || tag === "TEXTAREA"
        || tag === "SELECT" || t?.isContentEditable;
      if (editing) return;
      const r = replayRef.current;
      if (!r) return;
      const total = (r.trace.ticks || []).length;
      const cur = r.tickIdx || 0;
      const next = e.key === "ArrowLeft"
        ? Math.max(0, cur - 1)
        : Math.min(total, cur + 1);
      if (next === cur) return;
      e.preventDefault();
      seekReplay(next);
    };
    window.addEventListener("keydown", onArrow);
    return () => window.removeEventListener("keydown", onArrow);
  }, []); // refs make this stable; no deps needed
  const loadBundledDemo = async (slug) => {
    const worldCard = (typeof WORLD_CARDS !== "undefined"
      ? WORLD_CARDS.find(c => c.slug === slug) : null);
    setWorldTheme(worldCard || null);
    try {
      const resp = await fetch(`demos/${slug}.json`, { cache: "no-cache" });
      if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
      const data = await resp.json();
      // Engine traces (have ticks[] + cast[]) route to the replay loader so
      // bundled fixtures like the Reunion render via real engine output.
      if (data && Array.isArray(data.ticks) && Array.isArray(data.cast)) {
        // keepTheme: this demo already set its rich WORLD_CARDS banner above.
        loadTrace(data, { keepTheme: true });
        if (data.meta?.language) setWorldSettings(s => ({ ...s, language: data.meta.language }));
        return;
      }
      if (data.language) setWorldSettings(s => ({ ...s, language: data.language }));
      if (Array.isArray(data.world_book)) setWorldBook(data.world_book);
      const raw = data.entities;
      if (!Array.isArray(raw)) throw new Error("malformed demo file");
      const ents = raw.map(normalizeEntity);
      setEntities(ents);
      bumpIdCounter(ents);
      if (Array.isArray(data.rules)) setRules(data.rules);
      else if (Array.isArray(data.components)) setRules(componentsToRules(data.components));
      if (Array.isArray(data.components)) {
        setCustomComponents(data.components.filter(c => isFirstClassComponent(c)));
      } else {
        setCustomComponents([]);
      }
      setSelectedId(null);
      setWindows([]);
      resetRun();
      setReplayState(null);
      requestAnimationFrame(() => fitToContent(ents));
    } catch (e) {
      alert(`Could not load demo "${slug}": ${e.message}`);
    }
  };
  const resetToDemo = () => {
    if (!window.confirm("Reset to demo scene? Unsaved changes will be lost.")) return;
    setSelectedId(null);
    setWindows([]);
    setEntities(seedDemo());
    setWorldTheme(null);
    resetRun();
  };

  // ── LIVE engine (WebSocket round-3) ───────────────────────────────────
  // CONTRACT.md §3. Studio opens ws://localhost:8765, sends a studio-1.1
  // scenario via {op:"load"}, then steers with play/pause/step. The engine
  // streams `tick` frames in the same per-tick shape as our trace files, so
  // applyTraceTick reduces both file replay and live ticks unchanged.
  const [liveState, setLiveState] = useState(null); // { ws, status, tickIdx, supports, manifest, llm, model }
  const liveRef = useRef(null);
  useEffect(() => { liveRef.current = liveState; }, [liveState]);
  // OpenRouter key — session-only (kept in a ref, never persisted to disk /
  // localStorage and never logged). On connect we hand it to the engine, which
  // (re)builds a REAL OpenRouterLLM; with no key the engine stays mock.
  const apiKeyRef = useRef("");
  const [apiKeyField, setApiKeyField] = useState("");
  useEffect(() => { apiKeyRef.current = apiKeyField; }, [apiKeyField]);
  // engine-published authoring manifest (Layer 1 anti-drift; AUTHORING-MODEL.md)
  const [engineManifest, setEngineManifest] = useState(null);
  // F7 — play mode: the character the user is playing/directing + the env's
  // suggested next actions (engine 0.7.0 B7).
  const [playAs, setPlayAs] = useState(null);
  const playAsRef = useRef(null);
  useEffect(() => { playAsRef.current = playAs; }, [playAs]);
  const [suggestions, setSuggestions] = useState([]);
  // "Recruit a person" (flesh_out op): which agent is currently awaiting a
  // `fleshed` reply (drives the button's "Recruiting…" state) + a ref pinning
  // WHICH entity the in-flight request targets, so the incoming frame fills the
  // right agent even if the inspection changed. Cleared on reply / error.
  const [recruiting, setRecruiting] = useState(null);
  const pendingRecruitRef = useRef(null);
  // ephemeral user-level toast queue (from hello.warnings level:"user")
  const [toasts, setToasts] = useState([]);
  useEffect(() => {
    if (toasts.length === 0) return;
    const t = setTimeout(() => setToasts(arr => arr.slice(1)), 6000);
    return () => clearTimeout(t);
  }, [toasts]);

  const buildScenarioFromCurrent = () => {
    // Strip canvas-only fields (x/y/w/h/log/status/driveHistory/busy/activities)
    // so the engine ingests just the authored shape. Components: legacy rules
    // desugared to components[] plus first-class customComponents.
    // Stamp every agent with brain:"llm" — Studio's model is that all agents
    // are LLM-brained; the rule-vs-llm distinction belongs to the world-level
    // adjudicator component, not the agent. (The engine still accepts the field
    // per their contract — we just always send "llm".)
    // Engine defaults brain to "llm" now (per ENGINE_RESPONSE_3 round-4), so
    // we no longer stamp it. Adjudicator component decides world-level rule
    // vs llm; agent open_vocab still authors per-agent improvisation.
    const cleanEnts = entitiesRef.current.map(e => {
      // Strip canvas-only runtime fields AND the action editor's transient
      // JSON parse buffers (_target_effect / _target_add / _spawn_status) so an
      // action entity exports just its authored shape, mirroring cleanComps().
      const { log, status, driveHistory, busy, activities,
              _target_effect, _target_add, _spawn_status, ...rest } = e;
      // Don't persist a spawn with an empty name (matches the component path).
      if (rest.spawn && !(rest.spawn.name || "").trim()) delete rest.spawn;
      return rest;
    });
    // Desugar per-agent rel_drift flags into relationship middleware components.
    const relDriftComponents = cleanEnts
      .filter(e => e.kind === "agent" && e.rel_drift)
      .map(e => ({ id: `cmp_reldrift_${e.id}`, type: "middleware",
                   preset: "relationship", entity: e.id }));
    // Drop UI-only transient fields (e.g. attribute manifestation mode hint)
    // from authored components so the engine ingests just the real shape.
    const cleanComps = (cs) => cs.map(c => {
      const { _manifMode, _target_effect, _target_add, _spawn_status, ...rest } = c;
      // Don't persist spawn when its name is empty.
      if (rest.spawn && !(rest.spawn.name || "").trim()) delete rest.spawn;
      return rest;
    });
    return {
      schema_version: "studio-1.1",
      language: worldSettings.language || "English",
      world_rules: (worldSettings.world_rules || "").trim(),   // free-NL world prompt → minds + adjudicator
      entities: cleanEnts,
      rules,
      components: [...rulesToComponents(rules), ...cleanComps(customComponents), ...relDriftComponents],
      world_book: worldBook,
      meta: { day_start_sec: 0, source: "studio" },
    };
  };

  const connectLive = () => {
    if (liveRef.current?.ws && liveRef.current.ws.readyState === 1) return;
    let ws;
    const url = engineWsUrl();
    try { ws = new WebSocket(url); }
    catch (err) {
      alert(`Cannot reach engine ${url} — for local dev start it with:\n\n  python -m habitat.studio.serve --port 8765\n\n(${err.message})`);
      return;
    }
    setLiveState({ ws, status: "connecting", tickIdx: 0, supports: null,
      llm: null, model: null });
    ws.onopen = () => {
      // Hand the engine the OpenRouter key first, so it (re)builds a REAL LLM
      // before we load the scenario. Empty key ⇒ engine replies mock status and
      // stays mock. Either way the engine reports its mode via a status frame.
      const key = (apiKeyRef.current || "").trim();
      const model = (defaultModel || "").trim();   // global Settings default model (engine honors it)
      ws.send(JSON.stringify({ op: "connect", api_key: key, ...(model ? { model } : {}) }));
      const scenario = buildScenarioFromCurrent();
      ws.send(JSON.stringify({ op: "load", scenario }));
    };
    ws.onmessage = (ev) => {
      let frame;
      try { frame = JSON.parse(ev.data); } catch (err) { return; }
      if (frame.type === "hello") {
        setLiveState(s => s ? {
          ...s, status: "open",
          supports: frame.supports || [],
          manifest: frame.manifest || null,
          cost: frame.cost || frame.meta?.cost || s.cost,
        } : s);
        if (frame.manifest) setEngineManifest(frame.manifest);
        // Structured warnings: toast user-level, console info-level.
        for (const w of (frame.warnings || [])) {
          const obj = typeof w === "string" ? { message: w, level: "info" } : w;
          if (obj.level === "user") {
            setToasts(prev => [...prev, { id: Date.now() + Math.random(), text: obj.message }]);
          } else {
            console.info("[engine]", obj.message);
          }
        }
        return;
      }
      if (frame.type === "status") {
        // Engine reports which LLM backend it (re)built: "openrouter" (REAL) or
        // "mock", plus model + cumulative cost. Drives the REAL/MOCK indicator.
        setLiveState(s => s ? {
          ...s,
          llm: frame.llm ?? s.llm,
          model: frame.model ?? s.model,
          cost: frame.cost != null ? frame.cost : s.cost,
        } : s);
        return;
      }
      if (frame.type === "tick") {
        runTouchedRef.current = true;   // live engine tick mutates entities (run-gunk, not authoring)
        const t = frame.tickSec ?? 0;
        tickRef.current = t;
        setTickSec(t);
        setEntities(p => applyTraceTick(p, frame));
        // Stamp each live beat with REAL/MOCK + model AT CREATION (from the frame, else the
        // session state), so the badge stays accurate even after the socket pauses/closes.
        const isReal = (frame.llm ?? liveRef.current?.llm) === "openrouter";
        const mdl = frame.model ?? liveRef.current?.model ?? "";
        const rows = summarizeTickLog(frame, entitiesRef.current)
          .map(r => ({ ...r, t: fmtT(r.t), _real: isReal, _model: mdl }));
        if (rows.length > 0) setEvents(prev => [...prev, ...rows].slice(-2000));
        // 0.8.0 cost meter: frames carry cumulative cost {llm_calls, usd?}.
        const fcost = frame.cost || frame.meta?.cost;
        setLiveState(s => s ? { ...s, tickIdx: s.tickIdx + 1,
          cost: fcost != null ? fcost : s.cost } : s);
        // F7 — refresh suggested next actions for the played character.
        if (playAsRef.current && ws.readyState === 1) {
          ws.send(JSON.stringify({ op: "suggest", entity: playAsRef.current, n: 3 }));
        }
        return;
      }
      if (frame.type === "suggestions") {
        setSuggestions(Array.isArray(frame.suggestions) ? frame.suggestions : []);
        return;
      }
      if (frame.type === "fleshed") {
        // Reply to a {op:"flesh_out"} — fill the agent that requested it with
        // the returned individual. Only non-empty fields overwrite (so a mock
        // engine's empty strings don't blank the user's authored values). The
        // result stays fully editable. background → entity.background.
        const targetId = pendingRecruitRef.current;
        pendingRecruitRef.current = null;
        setRecruiting(null);
        if (targetId != null) {
          const patch = {};
          if (frame.name && String(frame.name).trim()) patch.name = frame.name;
          if (frame.persona && String(frame.persona).trim()) patch.persona = frame.persona;
          if (frame.background && String(frame.background).trim()) patch.background = frame.background;
          if (frame.goal && String(frame.goal).trim()) patch.goal = frame.goal;
          if (Object.keys(patch).length > 0) updateEntity(targetId, patch);
        }
        return;
      }
      if (frame.type === "error") {
        console.warn("[engine] error:", frame.message);
        appendEntityLog && console.warn(frame);
        // A flesh_out that failed (e.g. no LLM) must not leave the button stuck
        // in "Recruiting…". Clear the in-flight recruit state.
        if (pendingRecruitRef.current != null) {
          pendingRecruitRef.current = null;
          setRecruiting(null);
        }
        // Engine-side budget stop (spend cap or call cap): surface a toast, stop the
        // local play state, and refresh the cost meter from the frame's cost.
        if (frame.message) {
          setToasts(prev => [...prev, { id: Date.now() + Math.random(), text: `⏸ ${frame.message}` }]);
        }
        if (frame.cost) setLiveState(s => s ? { ...s, cost: frame.cost } : s);
        if (/cap|budget/i.test(frame.message || "")) setPlaying(false);
        return;
      }
      if (frame.type === "bye") {
        ws.close();
      }
    };
    ws.onerror = () => {
      setLiveState(s => s ? { ...s, status: "error" } : s);
    };
    ws.onclose = () => {
      setLiveState(null);
      setPlaying(false);
    };
  };

  const disconnectLive = () => {
    const s = liveRef.current;
    if (s?.ws) try { s.ws.close(); } catch (err) {}
    setLiveState(null);
    setPlaying(false);
  };

  const liveSend = (msg) => {
    const s = liveRef.current;
    if (!s?.ws || s.ws.readyState !== 1) return false;
    s.ws.send(JSON.stringify(msg));
    return true;
  };

  // "Recruit a person" — send {op:"flesh_out", sketch, role} over the live WS
  // (reusing the same socket as load/play/suggest). `role` is the agent's
  // current name (a role hint). Pins the target entity so the `fleshed` reply
  // fills the right agent, and flips it into the "Recruiting…" state.
  const recruitPerson = (ent, sketch) => {
    const s = liveRef.current;
    if (!s || s.status !== "open") return;
    const text = (sketch || "").trim();
    if (!text) return;
    pendingRecruitRef.current = ent.id;
    setRecruiting(ent.id);
    const ok = liveSend({ op: "flesh_out", sketch: text, role: (ent.name || "").trim() || "person" });
    if (!ok) { pendingRecruitRef.current = null; setRecruiting(null); }
  };

  // ── render order (scenes drawn first so children sit on top) ──
  const renderOrder = useMemo(() => {
    // Actions live in the library/dock and inspector — never as a floating canvas widget.
    const order = { scene: 0, object: 1, agent: 2 };
    return entities
      .filter(e => e.kind !== "action")
      .sort((a, b) => order[a.kind] - order[b.kind]);
  }, [entities]);

  const agentIndex = useMemo(() => {
    const m = new Map();
    agents.forEach((a, i) => m.set(a.id, i + 1));
    return m;
  }, [agents]);

  const dockEffectiveH = dockMin ? 32 : dockH;

  return (
    <div style={{
      display: "flex", width: "100%", height: "100%",
      background: T.paper, color: T.ink,
    }}>
      <input ref={fileInputRef} type="file" accept="application/json"
        style={{ display: "none" }}
        onChange={(e) => {
          const f = e.target.files?.[0];
          if (f) importFile(f);
          e.target.value = "";
        }}/>
      <Rail
        width={railW}
        scenes={scenes} unplacedObjects={unplacedObjects}
        unplacedAgents={unplacedAgents} actions={actions}
        childrenOf={childrenOf}
        selectedId={selectedId} setSelectedId={setSelectedId}
        addEntity={(k, pt, count) => addEntity(k, pt, count)}
        openWindow={inspectEntity} removeEntity={removeEntity}
        agentIndex={agentIndex}
        templates={templates}
        onOpenTemplates={() => setTemplateEditorOpen(true)}
        rules={rules}
        components={customComponents}
        onOpenRules={() => setRulesEditorOpen(true)}
        isReplay={!!replayState}
        worldBookCount={worldBook.length}
        onOpenWorldBook={() => setWorldBookOpen(true)}
      />
      <div onMouseDown={onRailResizeDown}
        title="Drag to resize sidebar"
        style={{
          width: 4, cursor: "ew-resize", background: T.rule, flexShrink: 0,
        }}/>
      <div style={{ flex: 1, position: "relative", display: "flex", flexDirection: "column", minWidth: 0 }}>
        <TopBar
          counts={{ scene: scenes.length, object: objects.length,
                    agent: agents.length, action: actions.length }}
          zoom={zoom} setZoom={setZoom} setPan={setPan}
          playing={playing}
          setPlaying={(v) => {
            if (liveRef.current?.status === "open") {
              liveSend({ op: v ? "play" : "pause" });
            }
            setPlaying(v);
          }}
          onStep={() => {
            if (liveRef.current?.status === "open") liveSend({ op: "step", n: 1 });
            else stepOnce();
          }}
          onReset={() => {
            if (liveRef.current?.status === "open") liveSend({ op: "reset" });
            resetRun();
          }}
          speed={speed}
          setSpeed={(v) => {
            if (liveRef.current?.status === "open") liveSend({ op: "set_speed", speed: v });
            setSpeed(v);
          }}
          onSkip={() => {
            if (liveRef.current?.status === "open") liveSend({ op: "skip_to_next_event" });
            else skipToNext();
          }}
          hasAgents={agents.length > 0}
          tickSec={tickSec}
          pendingConn={pendingConn} onCancelConn={cancelConn}
          onExport={() => setExportOpen(true)}
          onImport={() => fileInputRef.current?.click()}
          onLoadDemo={(slug) => loadBundledDemo(slug)}
          onOpenWorld={() => setWorldPanelOpen(true)}
          onOpenWelcome={() => setWelcomeOpen(true)}
          projectName={projectName}
          onNewProject={() => {
            const base = "Untitled project";
            const names = new Set(Object.values(projectsRef.current || {}).map(p => p.name));
            let nm = base, k = 1; while (names.has(nm)) { k += 1; nm = `${base} ${k}`; }
            newProject(nm);
          }}
          onSaveProject={saveCurrentProject}
          onSaveProjectAs={() => { const n = window.prompt("Save as a new project named:", `${projectName} copy`); if (n) saveAsProject(n); }}
          onClearCanvas={clearCanvas}
          dirty={dirty}
          onOpenSettings={() => setSettingsOpen(true)}
          recentProjects={Object.values(projects)
            .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
            .map(p => ({ id: p.id, name: p.name }))}
          currentProjectId={currentProjectId}
          onOpenProject={openProject}
          replay={replayState ? {
            tickIdx: replayState.tickIdx,
            total: replayState.trace.ticks.length,
            schemaVersion: replayState.trace.schema_version,
            llmCalls: replayState.trace.meta?.llm_calls,
            onExit: exitReplay,
          } : null}
          live={liveState}
          onConnectLive={connectLive}
          onDisconnectLive={disconnectLive}
          apiKey={apiKeyField}
          setApiKey={setApiKeyField}
          onDemo={resetToDemo}
          themeId={themeId} setTheme={setTheme}
          onOpenTour={startTour}
        />
        <div
          ref={canvasRef}
          data-tour="stage"
          onMouseDown={onCanvasMouseDown}
          onContextMenu={onCanvasContextMenu}
          style={{
            flex: 1, position: "relative", overflow: "hidden",
            cursor: panning ? "grabbing" : (pendingConn ? "crosshair" : "default"),
            backgroundColor: worldTheme ? worldTheme.ground : T.paper,
            backgroundImage: worldTheme ? worldTheme.grain : hatchBG(T.paper, T.paperEdge),
            color: worldTheme ? worldTheme.inkOn : T.ink,
            transition: "background-color 0.4s ease",
          }}
        >
          {worldTheme && (
            <div style={{
              position: "absolute", top: 12, left: 16, zIndex: 5,
              display: "inline-flex", alignItems: "center", gap: 8,
              padding: "5px 10px",
              background: `${worldTheme.ground}cc`,
              border: `1px solid ${worldTheme.accent}`,
              color: worldTheme.inkOn,
              fontFamily: worldTheme.serif ? SCREENPLAY_SERIF : "inherit",
              pointerEvents: "none",
            }}>
              <span style={{
                width: 8, height: 8, borderRadius: 8,
                background: worldTheme.accent,
              }}/>
              <span style={{
                fontSize: 10, fontWeight: 700, color: worldTheme.accent,
                letterSpacing: "0.15em", textTransform: "uppercase",
                fontFamily: "ui-monospace, monospace",
              }}>world</span>
              <span style={{ fontSize: 14, fontWeight: 700 }}>{worldTheme.title}</span>
              {worldSettings.language && (
                <span style={{
                  fontSize: 9, fontWeight: 700, color: worldTheme.accent2,
                  letterSpacing: "0.1em", textTransform: "uppercase",
                  fontFamily: "ui-monospace, monospace", marginLeft: 4,
                }}>· {worldSettings.language}</span>
              )}
            </div>
          )}
          <div style={{
            position: "absolute", left: 0, top: 0,
            transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
            transformOrigin: "0 0",
          }}>
            <ConnectionLayer scenes={scenes} pendingConn={pendingConn}/>
            {renderOrder.map(e => (
              <EntityNode
                key={e.id}
                entity={e}
                selected={selectedId === e.id}
                agentIndex={agentIndex}
                tickNumber={tickSec}
                templates={templates}
                fx={e.kind === "agent" ? (playing ? fx[e.id] : null) : null}
                connectMode={!!pendingConn}
                isConnSource={pendingConn?.from === e.id}
                highlightDrop={
                  (dropHover?.kind === "scene" && e.kind === "scene" && dropHover.id === e.id) ||
                  (dropHover?.kind === "agent" && e.kind === "agent" && dropHover.id === e.id)
                }
                onSelect={() => {
                  if (pendingConn) {
                    if (e.kind === "scene") toggleConnect(pendingConn.from, e.id);
                    cancelConn();
                  } else {
                    setSelectedId(e.id);
                  }
                }}
                onOpen={() => inspectEntity(e.id)}
                onStartConn={(sx, sy) => startConn(e.id, sx, sy)}
                onMove={(x, y) => moveEntity(e.id, x, y)}
                onResize={(w, h) => resizeEntity(e.id, w, h)}
                onDrag={(cx, cy) => {
                  if (e.kind === "agent" || e.kind === "object") {
                    const sc = sceneAt(cx, cy);
                    setDropHover(sc ? { kind: "scene", id: sc.id } : null);
                  } else if (e.kind === "action") {
                    const ag = agentAt(cx, cy);
                    setDropHover(ag ? { kind: "agent", id: ag.id } : null);
                  }
                }}
                onDragEnd={(cx, cy) => {
                  setDropHover(null);
                  placeAt(e.id, cx, cy);
                }}
                onContextMenu={(ev) => {
                  ev.preventDefault();
                  ev.stopPropagation();
                  setSelectedId(e.id);
                  setCtxMenu({ kind: "entity", entityId: e.id, x: ev.clientX, y: ev.clientY });
                }}
                zoom={zoom}
              />
            ))}
          </div>

          {/* inspector windows */}
          {windows.map(w => {
            const ent = entities.find(en => en.id === w.entityId);
            if (!ent) return null;
            return (
              <InspectorRouter
                key={w.id} win={w} entity={ent}
                allEntities={entities}
                actions={actions} scenes={scenes}
                events={events} agentIndex={agentIndex}
                playing={playing}
                templates={templates}
                components={customComponents}
                setComponents={setCustomComponents}
                liveConnected={liveState?.status === "open"}
                recruiting={recruiting}
                onRecruit={recruitPerson}
                onClose={() => closeWindow(w.id)}
                onFocus={() => focusWindow(w.id)}
                onMove={(x, y) => moveWindow(w.id, x, y)}
                onUpdate={(patch) => updateEntity(ent.id, patch)}
                onDelete={() => removeEntity(ent.id)}
                onRemoveAction={(id) => removeEntity(id)}
                onCreateAction={() => {
                  const c = canvasCenter();
                  const a = makeEntity("action", c.x - 80, c.y - 50,
                    { name: "new-custom-action" });
                  setEntities(p => [...p, a]);
                  if (ent.kind === "agent") {
                    updateEntity(ent.id, {
                      pickedActions: [...(ent.pickedActions || []), a.id],
                    });
                  }
                  inspectEntity(a.id);
                  return a.id;
                }}
              />
            );
          })}

          {/* Tidy layout — re-spread any pile of stacked entities into a clean
              non-overlapping grid (per scene for placed, across canvas for
              unplaced). Sits just above the zoom HUD. */}
          <button
            data-tour="tidy-layout"
            onClick={tidyLayout}
            title="Auto-arrange: re-spread stacked entities into a clean grid"
            style={{
              position: "absolute", right: 12, bottom: 52,
              display: "inline-flex", alignItems: "center", gap: 6,
              padding: "5px 10px",
              background: T.paperSoft, color: T.inkMuted,
              border: `1px solid ${T.rule}`, borderRadius: 4,
              fontSize: 11, fontWeight: 600, cursor: "pointer",
            }}>
            <svg width="13" height="13" viewBox="0 0 16 16" aria-hidden="true">
              <rect x="1" y="1" width="5" height="5" fill="none" stroke="currentColor" strokeWidth="1.4"/>
              <rect x="10" y="1" width="5" height="5" fill="none" stroke="currentColor" strokeWidth="1.4"/>
              <rect x="1" y="10" width="5" height="5" fill="none" stroke="currentColor" strokeWidth="1.4"/>
              <rect x="10" y="10" width="5" height="5" fill="none" stroke="currentColor" strokeWidth="1.4"/>
            </svg>
            Tidy layout
          </button>

          {/* zoom HUD — clickable +/- with reset */}
          <div style={{
            position: "absolute", right: 12, bottom: 12,
            display: "flex", alignItems: "stretch",
            background: T.paperSoft,
            border: `1px solid ${T.rule}`, color: T.inkMuted,
            fontSize: 12, borderRadius: 4, overflow: "hidden",
          }}>
            {(() => {
              const stepZoom = (factor) => {
                if (!canvasRef.current) return;
                const rect = canvasRef.current.getBoundingClientRect();
                const cx = rect.width / 2, cy = rect.height / 2;
                const next = Math.max(0.3, Math.min(2.5, zoom * factor));
                const nx = cx - (cx - pan.x) * (next / zoom);
                const ny = cy - (cy - pan.y) * (next / zoom);
                setZoom(next); setPan({ x: nx, y: ny });
              };
              const btn = {
                background: "transparent", border: "none",
                color: T.inkMuted, padding: "4px 10px",
                cursor: "pointer", fontSize: 14, lineHeight: 1,
              };
              return (
                <>
                  <button onClick={() => stepZoom(1 / 1.25)} title="Zoom out" style={btn}>−</button>
                  <button onClick={() => { setZoom(1); setPan({ x: 0, y: 0 }); }}
                    title="Reset zoom + pan"
                    style={{ ...btn, fontVariantNumeric: "tabular-nums",
                      borderLeft: `1px solid ${T.rule}`, borderRight: `1px solid ${T.rule}`,
                      minWidth: 52, fontSize: 11 }}>
                    {Math.round(zoom * 100)}%
                  </button>
                  <button onClick={() => stepZoom(1.25)} title="Zoom in" style={btn}>+</button>
                </>
              );
            })()}
          </div>

          {/* run status footer — tick · scale · seed · ● live */}
          <div style={{
            position: "absolute", left: 12, bottom: 12,
            padding: "4px 10px", background: T.paperSoft,
            border: `1px solid ${T.rule}`, borderRadius: 4,
            fontFamily: "'JetBrains Mono', ui-monospace, monospace",
            fontSize: 10, color: T.inkMuted,
            display: "flex", alignItems: "center", gap: 10,
          }}>
            <span>tick {tickSec}</span>
            <span>·</span>
            <span>scale {zoom.toFixed(2).replace(/\.?0+$/, "")}×</span>
            <span>·</span>
            <span>seed 7B-22</span>
            <span style={{
              color: playing ? T.accent : T.inkFaint,
              marginLeft: 4,
            }}>● {playing ? "live" : "paused"}</span>
          </div>
        </div>

        {/* dock splitter */}
        <div onMouseDown={onDockResizeDown}
          style={{
            height: 4, background: T.rule, cursor: "ns-resize",
            opacity: dockMin ? 0 : 1,
          }}/>
        {/* F7 — play bar: pick a character, type what they do/say, click a
            suggestion. Live engine only (act+suggest are WS ops). */}
        {liveState?.status === "open" && (
          <PlayBar
            agents={agents}
            playAs={playAs}
            setPlayAs={(id) => { setPlayAs(id); setSuggestions([]);
              if (id) liveSend({ op: "suggest", entity: id, n: 3 }); }}
            suggestions={suggestions}
            onAct={(text) => {
              if (!playAsRef.current || !text.trim()) return;
              liveSend({ op: "act", entity: playAsRef.current,
                         verb: "speak", payload: { content: text.trim() } });
              liveSend({ op: "step", n: 1 });
              setSuggestions([]);
            }}
            onChip={(text) => {
              if (!playAsRef.current) return;
              liveSend({ op: "act", entity: playAsRef.current, verb: text });
              liveSend({ op: "step", n: 1 });
              setSuggestions([]);
            }}
          />
        )}
        <BottomDock
          height={dockEffectiveH}
          minimized={dockMin} setMinimized={setDockMin}
          tab={dockTab} setTab={setDockTab}
          events={events} agents={agents} scenes={scenes}
          agentIndex={agentIndex}
          onPlaceAgent={(id, sceneId) => updateEntity(id, { placedIn: sceneId || null })}
          replay={replayState}
          live={liveState}
          onSeek={seekReplay}
          ticking={(liveState?.status === "open" && (liveState?.tickIdx || 0) > 0)
            || (!!replayState && (replayState?.tickIdx || 0) > 0)}
        />

        {/* Engine user-warnings (toast). `info` warnings go to console. */}
        {toasts.length > 0 && (
          <div style={{
            position: "absolute", top: 60, right: 16, zIndex: 200,
            display: "flex", flexDirection: "column", gap: 6, maxWidth: 360,
            pointerEvents: "none",
          }}>
            {toasts.map(t => (
              <div key={t.id} style={{
                background: "#1c1a17", color: "#f6f3ec",
                padding: "8px 12px", fontSize: 12, lineHeight: 1.45,
                borderLeft: `3px solid ${T.accent}`,
                boxShadow: "0 4px 14px rgba(0,0,0,0.2)",
                pointerEvents: "auto",
              }}>{t.text}</div>
            ))}
          </div>
        )}

        {templateEditorOpen && (
          <TemplateEditorModal
            templates={templates}
            setTemplates={setTemplates}
            onClose={() => setTemplateEditorOpen(false)}
          />
        )}

        {exportOpen && (
          <ExportModal
            entities={entities}
            events={events}
            rules={rules}
            components={customComponents}
            templates={templates}
            worldBook={worldBook}
            worldSettings={worldSettings}
            snapshots={snapshots}
            tickSec={tickSec}
            onClose={() => setExportOpen(false)}
          />
        )}

        {snapshotsOpen && (
          <VersionsModal
            snapshots={snapshots}
            currentSnapshotId={currentSnapshotId}
            onLoad={(id) => { loadVersion(id); setSnapshotsOpen(false); }}
            onRemove={removeVersion}
            onSave={() => saveVersion()}
            onClose={() => setSnapshotsOpen(false)}
          />
        )}

        {settingsOpen && (
          <SettingsModal
            themeId={themeId} setTheme={setTheme}
            defaultModel={defaultModel} setDefaultModel={setDefaultModel}
            engineUrl={engineUrl} setEngineUrl={setEngineUrl}
            templates={templates} setTemplates={setTemplates}
            liveModel={liveState?.model}
            onClearCanvas={clearCanvas}
            onResetHabitat={resetHabitat}
            onClose={() => setSettingsOpen(false)}
          />
        )}

        {rulesEditorOpen && (
          engineManifest && Array.isArray(engineManifest.slots) ? (
            <ManifestPaletteModal
              manifest={engineManifest}
              components={customComponents}
              setComponents={setCustomComponents}
              agents={agents}
              worldSettings={worldSettings}
              setWorldSettings={setWorldSettings}
              onClose={() => setRulesEditorOpen(false)}
            />
          ) : (
            <RulesEditorModal
              rules={rules}
              setRules={setRules}
              components={customComponents}
              setComponents={setCustomComponents}
              scenes={scenes}
              agents={agents}
              worldSettings={worldSettings}
              setWorldSettings={setWorldSettings}
              worldBookCount={worldBook.length}
              onOpenWorldBook={() => { setRulesEditorOpen(false); setWorldBookOpen(true); }}
              onClose={() => setRulesEditorOpen(false)}
            />
          )
        )}

        {worldBookOpen && (
          <WorldBookModal
            entries={worldBook}
            setEntries={setWorldBook}
            scenes={scenes}
            agents={agents}
            onClose={() => setWorldBookOpen(false)}
          />
        )}

        {worldPanelOpen && (
          <WorldPanelModal
            entities={entities}
            worldBook={worldBook}
            components={customComponents}
            worldSettings={worldSettings}
            onInspect={(id) => { setSelectedId(id); setRightPanelOpen(true); setWorldPanelOpen(false); }}
            onClose={() => setWorldPanelOpen(false)}
          />
        )}

        {welcomeOpen && (
          <WelcomeModal
            projects={projects}
            learn={LEARN_TUTORIALS}
            onOpenProject={(id) => { markWelcomeSeen(); setWelcomeOpen(false); openProject(id); }}
            onNewProject={() => { markWelcomeSeen(); setWelcomeOpen(false);
              const base = "Untitled project";
              const names = new Set(Object.values(projects).map(p => p.name));
              let nm = base, k = 1; while (names.has(nm)) { k += 1; nm = `${base} ${k}`; }
              newProject(nm); }}
            onOpenFile={() => { markWelcomeSeen(); setWelcomeOpen(false); fileInputRef.current?.click(); }}
            onStartWalkthrough={(id) => startWalkthrough(id)}
            onOpenStarter={(id) => openStarter(id)}
            onPickWorld={(slug) => { markWelcomeSeen(); setWelcomeOpen(false); loadBundledDemo(slug); }}
            onDeleteProject={deleteProject}
            onRenameProject={renameProjectById}
            onReset={resetHabitat}
            onClose={() => { markWelcomeSeen(); setWelcomeOpen(false); }}
          />
        )}

        {tourOpen && (
          <GuidedTour
            onClose={closeTour}
            steps={WALKTHROUGHS[activeWalkthrough] || TOUR_STEPS}
            entities={entities}
            customComponents={customComponents}
            rules={rules}
            worldBook={worldBook}
            templates={templates}
            liveConnected={liveState?.status === "open"}
            running={playing || replayState != null}
            templateEditorOpen={templateEditorOpen}
            worldSettings={worldSettings}
            tourApi={{
              updateEntity,
              setWorldBook,
              setTemplates,
              // Create a real entity on the canvas and return it, so a tour
              // helper operates on the ACTUAL just-created entity (never by
              // name-matching). addEntity selects it as a side effect.
              addEntity,
              // "Show me" helper for the scene-template steps (2-3): create-or-fill
              // the user's custom scene template atomically. setTemplates takes a
              // concrete object (no functional updater), so merge from `templates`.
              seedScene: (patch) => {
                const cur = Object.values(templates || {}).find(
                  t => t && !t.builtin && t.kindHint === "scene");
                if (cur) { setTemplates({ ...templates, [cur.id]: { ...cur, ...patch } }); return; }
                const id = `tpl_${Date.now().toString(36).slice(-5)}`;
                setTemplates({ ...templates, [id]: {
                  id, kindHint: "scene", builtin: false,
                  fields: [{ key: "rules", type: "text", default: "" }],
                  statuses: [], durations: {}, hasMemory: false, hasBelief: false, ...patch } });
              },
              // Create-or-get a NON-builtin template of a given kind, return its id —
              // so the tour can author a CLASS (e.g. "Participant"/"Room") and then
              // stamp instances of it (template→instance teaching).
              seedTemplate: (kindHint, patch) => {
                const cur = Object.values(templates || {}).find(
                  t => t && !t.builtin && t.kindHint === kindHint && (t.label || "") === (patch.label || ""));
                if (cur) { setTemplates({ ...templates, [cur.id]: { ...cur, ...patch } }); return cur.id; }
                const id = `tpl_${kindHint}_${Date.now().toString(36).slice(-4)}`;
                setTemplates({ ...templates, [id]: {
                  id, kindHint, builtin: false,
                  fields: [{ key: "rules", type: "text", default: "" }],
                  statuses: [], durations: {}, hasMemory: false, hasBelief: false, ...patch } });
                return id;
              },
              // Stamp an INSTANCE of a template: create the entity, link it to the
              // class via `template`, and fill its per-instance keys. Returns it.
              stampInstance: (kind, tplId, patch) => {
                const e = addEntity(kind);
                if (e) updateEntity(e.id, { template: tplId || null, ...patch });
                return e;
              },
              // World Rules — the free-NL world prompt (→ minds + adjudicator).
              setWorldRules: (text) => setWorldSettings(s => ({ ...s, world_rules: text })),
              // Add a lore entry: shared (scope 'world') or PRIVATE to one character
              // (scope 'entity', where=id) — the per-character world book / asymmetry.
              addLore: (entry) => setWorldBook([
                ...(worldBook || []),
                { constant: !(entry.keys && entry.keys.length), scope: "world", ...entry }]),
              // Author an attribute (a stat + how it shows) on an object.
              addAttribute: (entityId, attr) => setCustomComponents([
                ...customComponents,
                { id: `cmp_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`,
                  type: "attribute", entity: entityId, ...attr }]),
              // Set the world adjudicator to the LLM engine (disposes the staged scene).
              setAdjudicatorLLM: () => setCustomComponents([
                ...customComponents.filter(c => c.type !== "adjudicator"),
                { id: `cmp_adj_${Date.now().toString(36).slice(-4)}`, type: "adjudicator",
                  kind: "llm", preset: "llm", name: "LLM adjudicator" }]),
              // Open the real modals the tour walks the user through.
              openTemplates: () => setTemplateEditorOpen(true),
              closeTemplates: () => setTemplateEditorOpen(false),
              openRules: () => setRulesEditorOpen(true),
              closeRules: () => setRulesEditorOpen(false),
              openWorldBook: () => setWorldBookOpen(true),
              connectLive: () => { try { connectLive(); } catch (e) {} },
              // Drop a world-scoped or object-scoped component into the store.
              addComponent: (comp) => setCustomComponents([
                ...customComponents,
                { id: `cmp_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`, ...comp },
              ]),
              // Patch the first component matching a predicate (rarely needed).
              updateComponent: (id, patch) => setCustomComponents(
                customComponents.map(c => c.id === id ? { ...c, ...patch } : c)),
              // Case-insensitive name lookup (kept for back-compat; the new
              // tour does NOT rely on it — helpers act on returned entities).
              findEntityByName: (name) => entities.find(
                e => (e.name || "").trim().toLowerCase() === String(name).trim().toLowerCase()),
              // Resolve a custom template's id by its label (for stampInstance).
              tplId: (label) => (Object.values(templates || {}).find(
                t => t && !t.builtin && (t.label || "") === label) || {}).id || null,
              // Resolve an entity's id by name (to place / cross-reference instances).
              entityId: (name) => (entitiesRef.current.find(
                e => (e.name || "") === name) || {}).id || null,
            }}
          />
        )}

        {ctxMenu && (
          <ContextMenu
            ctx={ctxMenu}
            entity={ctxMenu.entityId ? entities.find(e => e.id === ctxMenu.entityId) : null}
            onClose={() => setCtxMenu(null)}
            onAdd={(kind) => { addEntity(kind, ctxMenu.canvasPt); setCtxMenu(null); }}
            onInspect={() => { inspectEntity(ctxMenu.entityId); setCtxMenu(null); }}
            onDuplicate={() => { duplicateEntity(ctxMenu.entityId); setCtxMenu(null); }}
            onDelete={() => { removeEntity(ctxMenu.entityId); setCtxMenu(null); }}
            onUnplace={() => { updateEntity(ctxMenu.entityId, { placedIn: null }); setCtxMenu(null); }}
            onStartConn={() => { startConn(ctxMenu.entityId, ctxMenu.x, ctxMenu.y); setCtxMenu(null); }}
          />
        )}
      </div>
      {rightPanelOpen && (() => {
        const sel = entities.find(e => e.id === selectedId);
        if (!sel) {
          return (
            <div style={{
              width: 320, flexShrink: 0, height: "100%",
              borderLeft: `1px solid ${T.rule}`, background: T.paperSoft,
              color: T.inkFaint, fontSize: 12, padding: 20,
              display: "flex", alignItems: "center", justifyContent: "center",
              textAlign: "center",
            }}>
              Select any entity on the canvas to inspect it here.
            </div>
          );
        }
        return (
          <RightPanel
            entity={sel}
            templates={templates}
            actions={actions}
            scenes={scenes}
            components={customComponents}
            setComponents={setCustomComponents}
            worldBook={worldBook}
            setWorldBook={setWorldBook}
            liveConnected={liveState?.status === "open"}
            recruiting={recruiting}
            onRecruit={recruitPerson}
            onUpdate={(patch) => updateEntity(sel.id, patch)}
            onPopOut={() => { openWindow(sel.id); setRightPanelOpen(false); }}
            onClose={() => setRightPanelOpen(false)}
            onRemoveAction={(id) => removeEntity(id)}
            onCreateAction={() => {
              const c = canvasCenter();
              const a = makeEntity("action", c.x - 80, c.y - 50,
                { name: "new-custom-action" });
              setEntities(p => [...p, a]);
              if (sel.kind === "agent") {
                updateEntity(sel.id, {
                  pickedActions: [...(sel.pickedActions || []), a.id],
                });
              }
              inspectEntity(a.id);
              return a.id;
            }}
          />
        );
      })()}
      {!rightPanelOpen && (
        <button onClick={() => setRightPanelOpen(true)}
          title="Show details panel"
          style={{
            position: "fixed", right: 0, top: "50%",
            transform: "translateY(-50%)",
            padding: "12px 4px", fontSize: 12,
            background: T.chrome, color: T.chromeInk,
            border: "none", cursor: "pointer",
            writingMode: "vertical-rl", letterSpacing: "0.1em",
            borderRadius: "4px 0 0 4px", zIndex: 50,
          }}>◀ details</button>
      )}
    </div>
  );
}

// ─── RIGHT PANEL (T3 + T5) ───────────────────────────────────────────
// Tabbed side panel for the currently selected entity. Tabs: Profile,
// Status, Log. Pop-out button opens the entity in a floating window.
function RightPanel({ entity, templates, actions, scenes, onUpdate, onPopOut, onClose,
                      onCreateAction, onRemoveAction, components = [], setComponents,
                      worldBook = [], setWorldBook, liveConnected = false,
                      recruiting = null, onRecruit }) {
  const [tab, setTab] = useState("profile");
  const [editing, setEditing] = useState(false);
  const tabBtn = (id, label) => (
    <button onClick={() => setTab(id)} style={{
      flex: 1, padding: "6px 0", fontSize: 11,
      background: tab === id ? T.paperSoft : "transparent",
      color: tab === id ? T.ink : T.inkMuted,
      border: "none", borderBottom: tab === id ? `2px solid ${T.accent}` : `2px solid transparent`,
      cursor: "pointer", fontWeight: 600, letterSpacing: "0.04em",
      textTransform: "uppercase",
    }}>{label}</button>
  );
  return (
    <div style={{
      width: 320, flexShrink: 0, height: "100%",
      borderLeft: `1px solid ${T.rule}`, background: T.paperWarm,
      display: "flex", flexDirection: "column", overflow: "hidden",
      color: T.ink,
    }}>
      <div style={{
        height: 36, display: "flex", alignItems: "center", gap: 6,
        padding: "0 10px",
        background: T.chrome, color: T.chromeInk,
        flexShrink: 0,
      }}>
        <KindGlyph kind={entity.kind} small/>
        <span style={{ flex: 1, fontSize: 12, fontWeight: 600,
          overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
          {entity.name || "(unnamed)"}
        </span>
        <button onClick={() => setEditing(e => !e)}
          title={editing ? "Lock fields" : "Edit fields"}
          style={{ ...chromeBtnStyle(), color: editing ? T.accent : T.chromeDim,
            fontSize: 11, padding: "2px 6px" }}>
          {editing ? "● edit" : "edit"}
        </button>
        <button onClick={onPopOut} title="Pop out as floating window"
          style={chromeBtnStyle()}>↗</button>
        <button onClick={onClose} title="Hide panel"
          style={chromeBtnStyle()}>×</button>
      </div>
      <div style={{ display: "flex", flexShrink: 0,
        borderBottom: `1px solid ${T.rule}`, background: T.paperWarm }}>
        {tabBtn("profile", "Profile")}
        {tabBtn("status", "Status")}
        {tabBtn("log", "Log")}
      </div>
      <div style={{ flex: 1, overflow: "auto", padding: 12,
        background: T.paperSoft, color: T.ink, fontSize: 12 }}>
        {tab === "profile" && (
          <RightPanelProfile entity={entity} scenes={scenes}
            actions={actions} templates={templates}
            components={components} setComponents={setComponents}
            worldBook={worldBook} setWorldBook={setWorldBook}
            editing={editing} onUpdate={onUpdate}
            liveConnected={liveConnected} recruiting={recruiting} onRecruit={onRecruit}
            onCreateAction={onCreateAction} onRemoveAction={onRemoveAction}/>
        )}
        {tab === "status" && (
          <StatusLogPanel entity={entity} templates={templates}
            onUpdate={onUpdate} editing={editing}/>
        )}
        {tab === "log" && (
          <RightPanelLog entity={entity}/>
        )}
      </div>
    </div>
  );
}
// ─── RECRUIT A PERSON ────────────────────────────────────────────────
// Shared "Recruit a person" control mounted in BOTH the docked
// RightPanelProfile and the floating AgentCastView, so the two stay in
// sync. Turns a one-line SKETCH into a filled-out individual: sends
// {op:"flesh_out", sketch, role} over the live WS; the top-level
// `fleshed` frame handler fills this agent's name/persona/background/goal
// (editable afterward). Live engine only — disabled (with a connect hint)
// when no socket is open. English + SVG-only, no emoji.
function RecruitPerson({ entity, liveConnected, recruiting, onRecruit }) {
  const [sketch, setSketch] = useState("");
  const busy = recruiting === entity.id;
  const canRecruit = liveConnected && !busy && sketch.trim().length > 0;
  const hint = liveConnected
    ? "Turn a one-line sketch into a named individual (you can edit after)."
    : "Connect the engine (with an OpenRouter key) to recruit a person.";
  return (
    <div style={{
      marginBottom: 12, padding: "8px 9px",
      background: T.paperWarm, border: `1px solid ${T.ruleSoft}`, borderRadius: 4,
    }}>
      <div style={{ fontSize: 10, fontWeight: 700, color: T.inkMuted,
        letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 5,
        display: "flex", alignItems: "center", gap: 5 }}>
        <Ico path={ICO_SPARKLE} size={12} color={T.accent}/> Recruit a person
      </div>
      <input
        value={sketch}
        onChange={(e) => setSketch(e.target.value)}
        onKeyDown={(e) => { if (e.key === "Enter" && canRecruit) onRecruit(entity, sketch.trim()); }}
        placeholder="e.g. a tired night-shift nurse who…"
        title={hint}
        data-qa="recruit-sketch"
        style={{
          width: "100%", padding: "5px 7px", fontSize: 12,
          background: T.paper, color: T.ink, fontFamily: "inherit",
          border: `1px solid ${T.rule}`, boxSizing: "border-box", marginBottom: 6,
        }}/>
      <button
        type="button"
        disabled={!canRecruit}
        title={liveConnected ? hint : "Connect the engine (with an OpenRouter key) to recruit a person."}
        onClick={() => { if (canRecruit) onRecruit(entity, sketch.trim()); }}
        data-qa="recruit-flesh-out"
        style={{
          display: "inline-flex", alignItems: "center", gap: 5,
          padding: "5px 10px", fontSize: 11.5, fontWeight: 600,
          fontFamily: "inherit",
          background: canRecruit ? T.accent : T.paperDeep,
          color: canRecruit ? "#fff" : T.inkFaint,
          border: `1px solid ${canRecruit ? T.accent : T.rule}`,
          borderRadius: 3,
          cursor: canRecruit ? "pointer" : "not-allowed",
        }}>
        <Ico path={ICO_SPARKLE} size={12} color={canRecruit ? "#fff" : T.inkFaint}/>
        {busy ? "Recruiting…" : "Flesh out"}
      </button>
      {!liveConnected && (
        <div data-qa="recruit-hint" style={{ fontSize: 10, color: T.inkFaint, marginTop: 5, lineHeight: 1.4 }}>
          Connect the engine (with an OpenRouter key) to recruit a person.
        </div>
      )}
    </div>
  );
}

function RightPanelProfile({ entity, scenes, actions, templates = {}, editing, onUpdate,
                            onCreateAction, onRemoveAction, components = [], setComponents,
                            worldBook = [], setWorldBook,
                            liveConnected = false, recruiting = null, onRecruit }) {
  // Subscribe to the tour so ghost placeholders (tourGhost) update live as the
  // tour advances between the person step and the item step.
  useTour();
  const ro = !editing;
  // `ghost` (optional) is a GHOST PLACEHOLDER suggestion — the active tour drops
  // sample text into the REAL input via tourGhost(); it is never a committed
  // value and never appears as text in the tour card.
  const fld = (label, value, onChange, multiline = false, ghost) => (
    <div style={{ marginBottom: 10 }}>
      <div style={{ fontSize: 10, fontWeight: 700, color: T.inkMuted,
        letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 3 }}>{label}</div>
      {multiline ? (
        <textarea value={value || ""} readOnly={ro} placeholder={ghost}
          onChange={(e) => onChange(e.target.value)}
          rows={3} style={{
            width: "100%", padding: ro ? "2px 0" : "5px 7px",
            background: ro ? "transparent" : T.paperWarm,
            color: T.ink, fontFamily: "inherit", fontSize: 12,
            border: ro ? "none" : `1px solid ${T.rule}`,
            borderBottom: ro ? `1px dashed ${T.ruleSoft}` : `1px solid ${T.rule}`,
            resize: "vertical", boxSizing: "border-box",
          }}/>
      ) : (
        <input value={value || ""} readOnly={ro} placeholder={ghost}
          onChange={(e) => onChange(e.target.value)}
          style={{
            width: "100%", padding: ro ? "2px 0" : "5px 7px",
            background: ro ? "transparent" : T.paperWarm,
            color: T.ink, fontFamily: "inherit", fontSize: 12,
            border: ro ? "none" : `1px solid ${T.rule}`,
            borderBottom: ro ? `1px dashed ${T.ruleSoft}` : `1px solid ${T.rule}`,
            boxSizing: "border-box",
          }}/>
      )}
    </div>
  );
  // Action entities get the FULL shared behavior editor (the same body the
  // floating ActionInspector mounts) — module / describe / effects + the
  // spawn-despawn lifecycle — so the docked panel is no longer name-only.
  if (entity.kind === "action") {
    return (
      <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
        <ActionEditorBody entity={entity} onUpdate={onUpdate} editing={editing}/>
      </div>
    );
  }
  // Picked actions for the AgentActions attach UI (full list lives in `actions`).
  const picked = entity.pickedActions || [];
  return (
    <div>
      {fld("Name", entity.name, (v) => onUpdate({ name: v }), false,
        entity.kind === "agent" ? tourGhost("personName")
          : entity.kind === "object" ? tourGhost("itemName") : undefined)}
      {entity.kind === "agent" && fld("Persona", entity.persona, (v) => onUpdate({ persona: v }), true, tourGhost("personPersona"))}
      {entity.kind === "agent" && fld("Goal", entity.goal, (v) => onUpdate({ goal: v }), false, tourGhost("personGoal"))}
      {entity.kind === "agent" && fld("Background", entity.background, (v) => onUpdate({ background: v }), true)}
      {entity.kind === "agent" && onRecruit && (
        <RecruitPerson entity={entity} liveConnected={liveConnected}
          recruiting={recruiting} onRecruit={onRecruit}/>
      )}
      {entity.kind === "object" && fld("Description", entity.note, (v) => onUpdate({ note: v }), true, tourGhost("itemLook"))}
      {/* Attach/remove actions inline — the SAME "+ Add action" UI the floating
          inspector uses (AgentActions), so the docked panel no longer requires
          drag-from-canvas to give a person an action. */}
      {entity.kind === "agent" && (
        <AgentActions picked={picked} actions={actions || []}
          onUpdate={onUpdate} onCreateAction={onCreateAction}/>
      )}
      {entity.kind === "agent" && (
        <OpenVocabRow entity={entity} editing={editing} onUpdate={onUpdate}/>
      )}
      {(entity.kind === "agent" || entity.kind === "object") && (
        <BrainSelector entity={entity} editing={editing} onUpdate={onUpdate}/>
      )}
      {(entity.kind === "agent" || entity.kind === "object") && (
        <PerceptionSelector entity={entity} editing={editing} onUpdate={onUpdate}/>
      )}
      {(entity.kind === "agent" || entity.kind === "object") && (
        <CadenceField entity={entity} editing={editing} onUpdate={onUpdate}/>
      )}
      {/* Attribute / manifestation / middleware editor — the SAME
          ObjectComponentsSection the floating ObjectInspector (~6629) and
          AgentCastView (~6948) mount. Threaded the identical
          components/setComponents/templates props so a hidden attribute and
          its manifestation rule can be authored from the docked panel, not
          only from a pop-out. Guarded on setComponents like the pop-outs. */}
      {(entity.kind === "agent" || entity.kind === "object") && setComponents && (
        <ObjectComponentsSection entity={entity} templates={templates}
          components={components} setComponents={setComponents}
          onUpdate={onUpdate} editing={editing}/>
      )}
      {/* Object appearance — Override / inherited preview + the shared
          AppearanceEditor (Import / Library / Draw + overlay rules), inline.
          Reuses ObjectAppearanceSection, the exact body the floating
          ObjectInspector mounts. */}
      {entity.kind === "object" && (
        <ObjectAppearanceSection entity={entity} templates={templates}
          onUpdate={onUpdate} editing={editing}/>
      )}
      {entity.kind === "agent" && (
        <PrivateLorePanel entity={entity} worldBook={worldBook}
          setWorldBook={setWorldBook} editing={editing}/>
      )}
      {entity.kind === "agent" && (
        <MemoryListPanel entity={entity}/>
      )}
    </div>
  );
}
function RightPanelLog({ entity }) {
  const log = (entity.log || []).slice().reverse();
  if (log.length === 0) {
    return <div style={{ color: T.inkFaint, fontSize: 11 }}>No log entries yet.</div>;
  }
  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
      {log.map((l, i) => (
        <div key={i} style={{
          padding: "3px 0", fontSize: 11, color: T.ink,
          borderBottom: `1px dashed ${T.ruleSoft}`,
          background: l.kind === "action" && l.state === "end" ? `${outcomeColor(l.outcome)}11` : "transparent",
        }}>
          <span style={{ color: T.inkFaint, fontFamily: "ui-monospace, monospace" }}>t={l.t}</span>{" "}
          <span style={{
            color: l.kind === "action" && l.state === "end" ? outcomeColor(l.outcome) : logColor(l.kind),
            fontWeight: 600, marginRight: 4,
          }}>
            {l.kind}{l.state ? `:${l.state}` : ""}{l.outcome ? `·${l.outcome}` : ""}
          </span>
          {l.channel && (
            <span style={{
              marginRight: 4, fontSize: 9, padding: "0 4px", borderRadius: 6,
              background: channelColor(l.channel), color: "#fff",
              letterSpacing: "0.04em", textTransform: "uppercase", fontWeight: 700,
            }}>{l.channel}</span>
          )}
          <span>{l.text}</span>
        </div>
      ))}
    </div>
  );
}
// v7: action lifecycle outcome color + channel color.
function outcomeColor(outcome) {
  if (outcome === "completed") return "#3a8c4c";
  if (outcome === "rejected")  return "#c14545";
  if (outcome === "interrupted") return "#c97a3a";
  return T.action || "#7d4a9e";
}
function channelColor(channel) {
  if (channel === "phone") return "#c14545";
  if (channel === "audio") return "#3a5fbf";
  if (channel === "self")  return "#8e8e8e";
  return "#7d4a9e";
}

// ─── CONNECTION LAYER ────────────────────────────────────────────────
function ConnectionLayer({ scenes, pendingConn }) {
  const seen = new Set();
  const edges = [];
  for (const s of scenes) {
    for (const cid of (s.connects || [])) {
      const k = [s.id, cid].sort().join("|");
      if (seen.has(k)) continue;
      seen.add(k);
      const other = scenes.find(o => o.id === cid);
      if (other) edges.push([s, other]);
    }
  }
  return (
    <svg style={{
      position: "absolute", left: 0, top: 0,
      width: 1, height: 1, overflow: "visible", pointerEvents: "none",
    }}>
      {edges.map(([a, b], i) => {
        const ax = a.x + a.w / 2, ay = a.y + a.h / 2;
        const bx = b.x + b.w / 2, by = b.y + b.h / 2;
        return (
          <line key={i} x1={ax} y1={ay} x2={bx} y2={by}
            stroke={T.ink} strokeWidth={1.4}
            strokeDasharray="6 5" strokeOpacity={0.45}/>
        );
      })}
      {pendingConn && (() => {
        const from = scenes.find(s => s.id === pendingConn.from);
        if (!from) return null;
        const fx = from.x + from.w / 2, fy = from.y + from.h / 2;
        return (
          <line x1={fx} y1={fy} x2={pendingConn.mouse.x} y2={pendingConn.mouse.y}
            stroke={T.accent} strokeWidth={1.6} strokeDasharray="6 4" strokeOpacity={0.9}/>
        );
      })()}
    </svg>
  );
}

// ─── TOP BAR ─────────────────────────────────────────────────────────
function TopBar({ counts, zoom, setZoom, setPan, playing, setPlaying,
                  onStep, onReset, onSkip, speed = 1, setSpeed,
                  hasAgents, tickSec, pendingConn, onCancelConn,
                  onExport, onImport, onLoadDemo, onOpenWorld,
                  onOpenWelcome, projectName, onNewProject, onSaveProject, onSaveProjectAs, onClearCanvas,
                  onDemo, themeId, setTheme,
                  replay = null,
                  live = null, onConnectLive, onDisconnectLive,
                  apiKey = "", setApiKey,
                  dirty = false, onOpenSettings, recentProjects = [], currentProjectId = null, onOpenProject,
                  onOpenTour }) {
  const liveOpen = live?.status === "open";
  const liveConnecting = live?.status === "connecting";
  const liveReal = live?.llm === "openrouter";
  const driveable = !!replay || liveOpen;
  const [fileMenuOpen, setFileMenuOpen] = React.useState(false);
  return (
    <div style={{
      minHeight: 44, display: "flex", alignItems: "center",
      padding: "6px 14px", borderBottom: `1px solid ${T.rule}`,
      background: T.chrome, color: T.chromeInk,
      columnGap: 10, rowGap: 6, flexShrink: 0,
      flexWrap: "wrap", whiteSpace: "nowrap",
      minWidth: 0,
    }}>
      <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
        <span style={{
          width: 22, height: 22, background: T.accent,
          display: "grid", placeItems: "center",
          color: T.paper, fontWeight: 800, fontSize: 12,
          letterSpacing: "-0.02em",
        }}>H</span>
        <span style={{
          fontStyle: "italic", fontSize: 17, fontWeight: 500,
          letterSpacing: "0.005em",
        }}>Habitat</span>
      </div>
      <span title={`${counts.scene} scenes · ${counts.object} objects · ${counts.agent} agents · ${counts.action} actions`}
        style={{
          fontSize: 11, color: T.chromeDim, fontVariantNumeric: "tabular-nums",
          marginLeft: 6,
        }}>
        {counts.scene}·{counts.object}·{counts.agent}·{counts.action}
      </span>
      {pendingConn && (
        <span style={{
          fontSize: 11, padding: "3px 10px",
          color: T.accent, border: `1px solid ${T.accent}`, borderRadius: 3,
          marginLeft: 4, display: "flex", alignItems: "center", gap: 6,
        }}>
          Connecting scenes — click another scene (Esc to cancel)
          <button onClick={onCancelConn} style={{
            background: "none", border: "none",
            color: T.accent, cursor: "pointer", fontSize: 12, padding: 0,
          }}>×</button>
        </span>
      )}
      <div style={{ flex: 1 }} />

      {/* iOS-style day/night toggle */}
      {(() => {
        const isNight = themeId === "night";
        return (
          <button
            onClick={() => setTheme(isNight ? "paper" : "night")}
            title={isNight ? "Night — click for Day" : "Day — click for Night"}
            style={{
              position: "relative",
              width: 46, height: 24, padding: 0, flexShrink: 0,
              border: `1px solid ${T.chromeDim}`,
              background: isNight ? "#2d3144" : "#dccfa9",
              borderRadius: 999,
              cursor: "pointer",
              marginRight: 6, transition: "background 220ms ease",
              display: "flex", alignItems: "center",
              boxSizing: "border-box",
            }}>
            <span style={{
              position: "absolute",
              left: isNight ? 24 : 2,
              top: 1,
              width: 20, height: 20, borderRadius: "50%",
              background: isNight ? "#efe8d6" : "#fafaf2",
              boxShadow: "0 1px 3px rgba(0,0,0,0.35)",
              transition: "left 220ms cubic-bezier(.2,.8,.2,1)",
              display: "grid", placeItems: "center",
              fontSize: 11, lineHeight: 1, color: isNight ? "#1d2238" : "#c97a3a",
            }}><Ico path={isNight ? ICO_MOON : ICO_SUN} size={13}
                    color={isNight ? "#1d2238" : "#c97a3a"}/></span>
          </button>
        );
      })()}
      {onOpenWelcome && (
        <button onClick={onOpenWelcome} title="Home / Start screen — projects & tutorials"
          style={topBtn("ghost", false)}>
          <span style={{ marginRight: 6, fontSize: 13, lineHeight: 1 }}>⌂</span>Home
        </button>
      )}
      {projectName && (
        <span title={dirty ? "Current project — unsaved edits (autosaving…)" : "Current project — all changes saved"}
          style={{ fontSize: 11, color: T.inkMuted, padding: "0 8px",
          maxWidth: 170, whiteSpace: "nowrap", fontFamily: "ui-monospace, monospace",
          display: "inline-flex", alignItems: "center", gap: 6 }}>
          <span style={{ overflow: "hidden", textOverflow: "ellipsis", maxWidth: 130 }}>{projectName}</span>
          <span title={dirty ? "unsaved edits" : "saved"} style={{
            width: 7, height: 7, borderRadius: "50%", flexShrink: 0,
            background: dirty ? (T.accent || "#c97a3a") : "transparent",
            border: dirty ? "none" : `1px solid ${T.chromeDim}` }}/>
        </span>
      )}
      {recentProjects.length > 1 && onOpenProject && (
        <select value={currentProjectId || ""} title="Open a recent project (workspace switcher)"
          onChange={e => { if (e.target.value && e.target.value !== currentProjectId) onOpenProject(e.target.value); }}
          style={{ ...topBtn("ghost", false), padding: "4px 6px", maxWidth: 150 }}>
          {recentProjects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
        </select>
      )}
      {/* File ▾ — one VSCode-style menu consolidating New / Open / Save / Save As / Export
          (Versions removed). Save/Save As live alongside disk Open/Export. */}
      {(() => {
        const dot = dirty ? " ●" : "";
        const mi = { display: "flex", alignItems: "center", gap: 8, width: "100%",
          textAlign: "left", padding: "7px 12px", background: "transparent", border: "none",
          cursor: "pointer", color: T.ink, fontSize: 12.5, font: "inherit", whiteSpace: "nowrap" };
        const key = { marginLeft: "auto", fontSize: 10, color: T.inkFaint, fontFamily: "ui-monospace, monospace" };
        const close = () => setFileMenuOpen(false);
        const row = (label, onClick, opts = {}) => (
          <button style={mi} onMouseDown={(e) => e.preventDefault()}
            onClick={() => { close(); onClick && onClick(); }}>
            <span>{label}</span>{opts.kbd ? <span style={key}>{opts.kbd}</span> : null}
          </button>
        );
        return (
          <div style={{ position: "relative" }}>
            <button onClick={() => setFileMenuOpen(o => !o)} title="File — New, Open, Save, Save As, Export"
              style={{ ...topBtn(fileMenuOpen ? "primary" : "ghost", false) }}>
              <span style={{ marginRight: 6, fontSize: 13, lineHeight: 1 }}>⌅</span>File{dot} ▾
            </button>
            {fileMenuOpen && (
              <>
                <div onClick={close} style={{ position: "fixed", inset: 0, zIndex: 59 }}/>
                <div style={{ position: "absolute", top: "calc(100% + 4px)", left: 0, zIndex: 60,
                  minWidth: 230, background: T.paper, color: T.ink, border: `1px solid ${T.rule}`,
                  borderRadius: 6, boxShadow: "0 14px 36px rgba(0,0,0,0.32)", padding: "4px 0" }}>
                  {onNewProject && row("＋ New project", onNewProject)}
                  {onImport && row("↑ Open file…  (.habitat)", onImport)}
                  <div style={{ height: 1, background: T.ruleSoft, margin: "4px 0" }}/>
                  {onSaveProject && row(`⌅ Save${dot}`, onSaveProject, { kbd: "⌘S" })}
                  {onSaveProjectAs && row("Save As…  (fork)", onSaveProjectAs)}
                  {onExport && row("↥ Export to disk…", onExport)}
                  {onClearCanvas && <>
                    <div style={{ height: 1, background: T.ruleSoft, margin: "4px 0" }}/>
                    <button style={{ ...mi, color: T.danger || "#c14545" }} onMouseDown={(e) => e.preventDefault()}
                      onClick={() => { close(); onClearCanvas(); }}>⌫ Clear canvas</button>
                  </>}
                </div>
              </>
            )}
          </div>
        );
      })()}
      {onOpenSettings && (
        <button onClick={onOpenSettings} title="Settings — theme, default model, the shared template Library"
          style={topBtn("ghost", false)}>⚙ Settings</button>
      )}
      {onOpenWorld && (
        <button onClick={onOpenWorld} title="See how the world is built (read-only)"
          style={topBtn("ghost", false)}>
          <span style={{ marginRight: 6, fontSize: 13, lineHeight: 1 }}>◷</span>World
        </button>
      )}

      <span style={{ width: 1, height: 22, background: T.chromeDim, opacity: 0.4, margin: "0 4px" }}/>

      <span style={{
        fontSize: 11, color: T.chromeDim, fontVariantNumeric: "tabular-nums",
      }}
        title={getSimStartEpochSec() ? `Simulator wall-clock (UTC) · start: ${fmtSimDate(0)}` : "Simulated seconds since start"}>
        {getSimStartEpochSec() ? fmtSimDate(tickSec) : `t=${fmtT(tickSec)}`}
      </span>

      {replay && (
        <span title={`Replaying ${replay.schemaVersion}`} style={{
          display: "inline-flex", alignItems: "center", gap: 6,
          fontSize: 10, padding: "3px 8px",
          background: T.accent, color: "#fff",
          letterSpacing: "0.06em", textTransform: "uppercase", fontWeight: 700,
          borderRadius: 2,
        }}>
          replay · {replay.tickIdx}/{replay.total}
          <button onClick={replay.onExit} title="Exit replay" style={{
            background: "transparent", border: "none", color: "#fff",
            cursor: "pointer", padding: 0, fontSize: 13, lineHeight: 1,
          }}>×</button>
        </span>
      )}

      {/* 0.8.0 cost meter. Replay shows the trace's recorded llm_calls; a live
          session shows the cumulative cost the engine streams. */}
      {(() => {
        const liveCost = live?.cost;
        // engine streams the cost object as {calls, cost_usd, nl_triggers}; older
        // traces used {llm_calls, usd} — accept both so the live meter always renders.
        const calls = liveCost?.calls ?? liveCost?.llm_calls ?? (live ? undefined : replay?.llmCalls);
        const usd = liveCost?.cost_usd ?? liveCost?.usd;
        const nlT = liveCost?.nl_triggers;  // D1 guardrail signal
        const budgetUsd = liveCost?.budget_usd;   // engine's per-session $ cap (MAX_COST_USD)
        if (calls == null && usd == null) return null;
        // Spend-cap state: warn near the cap, alarm at/over it.
        const frac = (budgetUsd && usd != null) ? usd / budgetUsd : 0;
        const near = budgetUsd && frac >= 0.8 && frac < 1;
        const over = budgetUsd && frac >= 1;
        const borderC = over ? "#c14545" : near ? "#c97a3a" : (nlT > 8 ? "#c14545" : T.ruleSoft);
        return (
          <span title={`LLM calls and cost this session — engine cost meter.${budgetUsd ? `\nPer-session spend cap: $${Number(budgetUsd).toFixed(2)} (run pauses when reached).` : ""}${nlT ? `\n${nlT} NL trigger(s) — each costs ~1 call/tick.` : ""}`}
            style={{
              display: "inline-flex", alignItems: "center", gap: 5,
              fontSize: 10, padding: "3px 8px",
              border: `1px solid ${borderC}`,
              color: over ? "#c14545" : T.inkMuted,
              background: over ? "#c1454514" : near ? "#c97a3a14" : "transparent",
              fontFamily: "ui-monospace, monospace", borderRadius: 2,
            }}>
            <span style={{ opacity: 0.7 }}><Ico path={ICO_FUEL} size={12}/></span>
            {usd != null
              ? <b style={{ fontWeight: 700 }}>${Number(usd).toFixed(usd < 0.01 ? 4 : 2)}{budgetUsd ? ` / $${Number(budgetUsd).toFixed(2)}` : ""}</b>
              : null}
            {calls != null ? <span style={{ opacity: 0.7 }}> · {calls} call{calls === 1 ? "" : "s"}</span> : ""}
            {over ? <span style={{ color: "#c14545", fontWeight: 700 }}> · CAP</span> : null}
            {nlT ? <span style={{ color: nlT > 8 ? "#c14545" : T.inkFaint }}> · {nlT}NL</span> : null}
          </span>
        );
      })()}

      {/* OpenRouter key — session-only. Empty ⇒ engine runs mock. Handed to the
          engine on Connect via {op:"connect", api_key}. Never persisted/logged. */}
      {!replay && !liveOpen && (
        <input
          type="password"
          data-tour="api-key"
          value={apiKey}
          onChange={(e) => setApiKey && setApiKey(e.target.value)}
          placeholder="OpenRouter key (optional)"
          autoComplete="off"
          spellCheck={false}
          title="OpenRouter API key — used for this session only, never saved to disk. Leave empty to run the engine in mock mode."
          onKeyDown={(e) => { if (e.key === "Enter" && onConnectLive) onConnectLive(); }}
          style={{
            width: 168, height: 26, fontSize: 11, padding: "0 8px",
            border: `1px solid ${T.chromeDim}`, borderRadius: 3,
            background: T.paper, color: T.ink,
            fontFamily: "ui-monospace, monospace", boxSizing: "border-box",
          }}/>
      )}

      {/* REAL/MOCK indicator — once connected, the engine's status frame tells us
          which LLM backend it built. */}
      {liveOpen && (
        <span
          title={liveReal
            ? `Live engine running REAL LLM (OpenRouter)${live.model ? ` · ${live.model}` : ""}`
            : "Live engine running in MOCK mode (no API key, or no key accepted)"}
          style={{
            display: "inline-flex", alignItems: "center", gap: 5,
            fontSize: 10, padding: "3px 8px",
            border: `1px solid ${liveReal ? "#2f7a3f" : T.ruleSoft}`,
            color: liveReal ? "#2f7a3f" : T.inkMuted,
            fontFamily: "ui-monospace, monospace", borderRadius: 2,
            letterSpacing: "0.06em", textTransform: "uppercase", fontWeight: 700,
          }}>
          <span style={{
            width: 7, height: 7, borderRadius: 7,
            background: liveReal ? "#2f7a3f" : "#bbb",
          }}/>
          {liveReal ? "real" : "mock"}
          {liveReal && live.model
            ? <span style={{ textTransform: "none", fontWeight: 500, opacity: 0.8 }}>· {live.model}</span>
            : null}
        </span>
      )}

      {/* Live engine pill — green when connected, dim when not. Clicking opens
          (or closes) ws://localhost:8765 and ingests the current authored
          scenario, so ▶ Play actually drives the real engine. */}
      {!replay && (
        <button
          data-tour="connect-engine"
          onClick={liveOpen ? onDisconnectLive : onConnectLive}
          title={liveOpen
            ? `Connected to engine (${live.tickIdx} ticks streamed). Click to disconnect.`
            : (liveConnecting ? "Connecting…" :
               "Connect to live engine at ws://localhost:8765 (run `python -m habitat.studio.serve` in the engine repo)")}
          style={{
            ...topBtn(liveOpen ? "primary" : "ghost", false),
            background: liveOpen ? "#2f7a3f" : undefined,
            borderColor: liveOpen ? "#2f7a3f" : undefined,
            color: liveOpen ? "#fff" : undefined,
          }}>
          <span style={{ display: "inline-block", width: 8, height: 8, borderRadius: 8,
            background: liveOpen ? "#9bf5a9" : (liveConnecting ? "#e6c14b" : "#bbb"),
            marginRight: 6 }}/>
          {liveOpen ? `live · ${live.tickIdx}` : (liveConnecting ? "connecting…" : "Connect engine")}
        </button>
      )}

      {/* ▶ Play / Step / Skip drive whichever source is active: live engine
          (preferred), otherwise replay. With neither, the buttons disable so
          the local stub never runs in a demo. */}
      <button onClick={() => setPlaying(!playing)} disabled={!hasAgents || !driveable}
        data-tour="run"
        title={!driveable
          ? "Connect to the live engine or load an Engine Replay first."
          : (playing ? "Pause" : "Play")}
        style={topBtn(playing ? "ghost" : "primary", !hasAgents || !driveable)}>
        {playing ? "❚❚ Pause" : "▶ Play"}
      </button>
      {/* "Thinking…" — while the live engine is running it is calling the LLM between
          ticks; a real model takes seconds per tick, so show activity so the user
          knows it's working (not frozen). Replay advances instantly, so only for live. */}
      {liveOpen && playing && (
        <span title="The engine is running — agents are reasoning via the LLM."
          style={{ display: "inline-flex", alignItems: "center", gap: 6, fontSize: 11,
            color: "#2f7a3f", fontFamily: "ui-monospace, monospace", whiteSpace: "nowrap",
            padding: "0 4px" }}>
          <span style={{ display: "inline-flex", gap: 2 }}>
            {[0, 1, 2].map(i => (
              <span key={i} style={{ width: 4, height: 4, borderRadius: 4, background: "#2f7a3f",
                animation: "habThink 1s ease-in-out infinite", animationDelay: `${i * 0.16}s` }}/>
            ))}
          </span>
          thinking…
          <style>{"@keyframes habThink{0%,80%,100%{opacity:.25}40%{opacity:1}}"}</style>
        </span>
      )}
      <button onClick={onStep} disabled={!hasAgents || !driveable}
        data-tour="step"
        title={!driveable ? "Connect engine or load replay to step" : "Advance one tick"}
        style={topBtn("ghost", !hasAgents || !driveable)}>Step</button>
      <button onClick={onSkip} disabled={!hasAgents || !driveable}
        title={!driveable ? "Connect engine or load replay to skip" : "Fast-forward until next action end"}
        style={topBtn("ghost", !hasAgents || !driveable)}>⇥ Skip</button>
      <button onClick={() => setSpeed(speed === 1 ? 2 : speed === 2 ? 4 : 1)}
        title="Click to cycle speed (1× → 2× → 4×)"
        style={topBtn(speed === 1 ? "ghost" : "primary", false)}>
        {speed}×
      </button>
      <button onClick={onReset} style={topBtn("ghost", false)}>Reset</button>

      {/* 新手指引 — reopen the guided tour anytime (#24). */}
      {onOpenTour && (
        <button onClick={onOpenTour}
          title="Take the guided tour"
          style={{
            width: 24, height: 24, flexShrink: 0, padding: 0,
            display: "grid", placeItems: "center",
            background: "transparent", color: T.chromeDim,
            border: `1px solid ${T.chromeDim}`, borderRadius: "50%",
            cursor: "pointer", fontSize: 13, fontWeight: 700, lineHeight: 1,
          }}>?</button>
      )}

    </div>
  );
}
function topBtn(variant, disabled) {
  if (variant === "primary") {
    return {
      padding: "5px 12px", fontSize: 12, fontWeight: 700,
      background: T.accent, color: T.paper, border: "none",
      borderRadius: 4, cursor: disabled ? "not-allowed" : "pointer",
      opacity: disabled ? 0.4 : 1,
    };
  }
  return {
    padding: "4px 10px", fontSize: 11, fontWeight: 500,
    background: "transparent", color: T.chromeDim,
    border: `1px solid ${T.chromeDim}`, borderRadius: 4,
    cursor: disabled ? "not-allowed" : "pointer",
    opacity: disabled ? 0.4 : 1,
  };
}

// ─── LEFT RAIL ───────────────────────────────────────────────────────
function Rail({ width = 280, scenes, unplacedObjects, unplacedAgents, actions,
                childrenOf, selectedId, setSelectedId,
                addEntity, openWindow, removeEntity, agentIndex,
                templates = {}, onOpenTemplates,
                rules = [], components = [], onOpenRules, isReplay = false,
                worldBookCount = 0, onOpenWorldBook }) {
  // Map a template id to the legacy kind we still pass into addEntity
  // (so the existing factory + sim paths keep working). Custom-forked
  // user templates inherit their parent's kindHint.
  // HANDS-ON TOUR — the template area starts BLANK. The built-in default
  // templates still EXIST, but they are HIDDEN from the rail until the tour's
  // "reveal" step (step 4). useTour() re-renders the rail when that flips.
  const tour = useTour();
  const hideTemplates = tour.active && !tour.revealed;
  // Action is NOT a stampable object — it's a behaviour attached to objects. It
  // is excluded from the object-template cards here and lives in its own
  // "Actions" library group below (+ inline authoring in the object inspector).
  const tplOrder = ["room", "item", "human", "animal"];
  const templatesList = hideTemplates ? [] : tplOrder
    .filter(id => templates[id])
    .map(id => templates[id])
    .concat(
      Object.values(templates).filter(t => !tplOrder.includes(t.id) && !t.builtin)
    );
  // Multi-add: clicking a template button adds `addCount` of that kind in one
  // go (scattered so they don't stack), so building 10 people isn't 10 clicks.
  const [addCount, setAddCount] = useState(1);
  const handleAdd = (tpl) => {
    const kind = tpl.kindHint || "object";
    addEntity(kind, null, addCount);
  };
  return (
    <div style={{
      width, flexShrink: 0,
      background: T.paperSoft, color: T.ink,
      borderRight: `1px solid ${T.rule}`,
      display: "flex", flexDirection: "column",
    }}>
      <div style={{
        padding: "12px 16px", borderBottom: `1px solid ${T.rule}`,
        background: T.paperDeep,
      }}>
        <div style={{
          display: "flex", alignItems: "center", justifyContent: "space-between",
          marginBottom: 8,
        }}>
          <span style={{ fontSize: 11, color: T.inkMuted,
            letterSpacing: "0.06em", textTransform: "uppercase", fontWeight: 700 }}>
            Add from template
          </span>
          <button onClick={onOpenTemplates}
            data-tour="tpl-edit"
            title="Edit templates (object classes you stamp instances from)"
            style={{
              background: "transparent", border: `1px solid ${T.rule}`,
              color: T.inkMuted, cursor: "pointer", padding: "1px 7px",
              fontSize: 10, borderRadius: 3, letterSpacing: "0.04em",
            }}>edit templates ›</button>
        </div>
        {/* Multi-add count — each template button adds this many at once
            (scattered so they don't pile up). Quick chips + a free field. */}
        <div data-tour="add-count" style={{
          display: "flex", alignItems: "center", gap: 6, marginBottom: 8,
        }}>
          <span style={{ fontSize: 10, color: T.inkFaint, fontWeight: 600 }}>Add</span>
          {[1, 5, 10].map(n => (
            <button key={n}
              data-qa={`add-count-${n}`}
              onClick={() => setAddCount(n)}
              title={`Add ${n} at a time`}
              style={{
                padding: "2px 8px", fontSize: 11, fontWeight: 700,
                cursor: "pointer", borderRadius: 3,
                border: `1px solid ${addCount === n ? T.accent : T.rule}`,
                background: addCount === n ? T.accent : "transparent",
                color: addCount === n ? T.paper : T.inkMuted,
              }}>{n}</button>
          ))}
          <input type="number" min={1} max={50}
            data-qa="add-count-field"
            value={addCount}
            onChange={(e) => {
              const v = Math.max(1, Math.min(50, parseInt(e.target.value, 10) || 1));
              setAddCount(v);
            }}
            title="How many to add per click (1–50)"
            style={{
              width: 44, height: 22, fontSize: 11, padding: "0 6px",
              border: `1px solid ${T.rule}`, borderRadius: 3,
              background: T.paper, color: T.ink, boxSizing: "border-box",
            }}/>
          <span style={{ fontSize: 10, color: T.inkFaint }}>at a time</span>
        </div>
        <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 6 }}>
          {templatesList.map(tpl => (
            <AddBtn key={tpl.id}
              dataTour={`add-${tpl.id}`}
              label={tpl.label}
              hint={templateHint(tpl)}
              tint={tintForTemplate(tpl)}
              onClick={() => handleAdd(tpl)}/>
          ))}
        </div>
      </div>

      {/* Environment engine — everything the engine owns, grouped by the five
          categories. World Book (lore) now lives INSIDE here, under
          "World & Lore" — no longer a separate top-level rail section. */}
      <RulesRailSection rules={rules} components={components}
        onOpenRules={onOpenRules} isReplay={isReplay}
        worldBookCount={worldBookCount} onOpenWorldBook={onOpenWorldBook}/>

      <div style={{ flex: 1, overflow: "auto", padding: "8px 0" }}>
        <Group title={`Scenes (${scenes.length})`} tint={T.scene}>
          {scenes.length === 0 && <Empty>No scenes yet.</Empty>}
          {scenes.map(s => (
            <SceneTree key={s.id} scene={s}
              children={childrenOf(s.id)}
              selectedId={selectedId} setSelectedId={setSelectedId}
              openWindow={openWindow} removeEntity={removeEntity}
              agentIndex={agentIndex}/>
          ))}
        </Group>
        <Group title={`Unplaced (${unplacedObjects.length + unplacedAgents.length})`}
          tint={T.inkFaint}>
          {(unplacedObjects.length + unplacedAgents.length) === 0 && <Empty>—</Empty>}
          {unplacedAgents.map(e => (
            <RailRow key={e.id} ent={e} selected={selectedId === e.id}
              setSelected={setSelectedId} openWindow={openWindow}
              removeEntity={removeEntity}
              badge={agentIndex.get(e.id)}/>
          ))}
          {unplacedObjects.map(e => (
            <RailRow key={e.id} ent={e} selected={selectedId === e.id}
              setSelected={setSelectedId} openWindow={openWindow}
              removeEntity={removeEntity}/>
          ))}
        </Group>
        <Group title={`Actions (${actions.length})`} tint={T.action}>
          {/* Actions library — a behaviour you author once and attach to objects
              (drag an action onto an agent, or use the object inspector's
              Actions section). NOT a stampable object card. */}
          <button data-tour="new-action" onClick={() => addEntity("action", null, 1)}
            title="Create a new action (a behaviour to attach to objects)"
            style={{
              display: "block", width: "calc(100% - 24px)", margin: "2px 12px 6px",
              padding: "4px 8px", fontSize: 11, fontWeight: 600, cursor: "pointer",
              background: "transparent", color: T.action,
              border: `1px dashed ${T.action}`, borderRadius: 3, textAlign: "left",
            }}>+ new action</button>
          {actions.length === 0 && <Empty>No actions yet — author one, then attach it to objects.</Empty>}
          {actions.map(e => (
            <RailRow key={e.id} ent={e} selected={selectedId === e.id}
              setSelected={setSelectedId} openWindow={openWindow}
              removeEntity={removeEntity}/>
          ))}
        </Group>
      </div>

      <div style={{
        padding: "10px 14px", borderTop: `1px solid ${T.rule}`,
        fontSize: 11, color: T.inkFaint, lineHeight: 1.6,
        background: T.paperDeep,
      }}>
        Wheel = zoom · Shift+wheel = pan<br/>
        Right-click = menu · Del = remove<br/>
        Drag action → agent to attach
      </div>
    </div>
  );
}

function templateHint(tpl) {
  if (tpl.id === "human") return "agent · person";
  if (tpl.id === "animal") return "agent · animal";
  if (tpl.id === "item") return "physical object";
  if (tpl.id === "room") return "bounded space";
  if (tpl.id === "action") return "behavior unit";
  return tpl.kindHint || "entity";
}
function tintForTemplate(tpl) {
  const k = tpl.kindHint;
  if (k === "scene") return T.scene;
  if (k === "object") return T.object;
  if (k === "agent") return T.agent;
  if (k === "action") return T.action;
  return T.inkFaint;
}

// ── ENGINE CATEGORIES (anti-drift contract) ────────────────────────────
// The engine groups everything it owns into FIVE categories. World Book
// (lore) now lives INSIDE the engine under "World & Lore" — it is no longer
// a separate top-level rail section. Both the rail and the add-component
// palette group by these same five.
// MIRRORS component_manifest()'s `categories` in the engine — keep in sync.
// The engine rail buckets ONLY world-scoped components. Object-scoped
// components (middleware / attribute / action) now live ON Objects (see
// the Object inspector's "Object components" section), so they no longer
// appear here. "Norm" is dropped as a concept. The "Action" category is
// removed (actions are object-scoped).
// The Env Engine is exactly THREE mechanisms (Adjudication · Perception · World
// Rules, the free-NL box above) + the Knowledge layer (World Book). Everything
// else is a property of an OBJECT: a drive is an object Attribute with a rate; a
// timed process is a process-agent object (cadence + behaviour). The old
// "World Dynamics" presets remain under "Advanced" so existing worlds still load
// and edit — but the model is: author objects, not engine knobs.
// Order: the MECHANISMS first (how the world runs), then the Knowledge layer,
// then Advanced. World Rules is the free-NL prompt (edited in the Env-Engine
// editor); Adjudication + Perception are the two seams; World Book is knowledge,
// not a mechanism, so it sits below them.
const ENGINE_CATEGORIES = [
  { id: "adjudication",   label: "Adjudication",  caption: "how the world resolves actions (rule | LLM)" },
  { id: "perception",     label: "Perception",    caption: "who senses what" },
  { id: "world-lore",     label: "Knowledge (World Book)", caption: "what the characters know" },
  { id: "world-dynamics", label: "Advanced",      caption: "drives → object attributes · events → process agents · triggers → World Rules" },
];
// COMPONENT TYPE → CATEGORY — world-scoped types only. Object-scoped
// types (middleware/attribute/action) and `norm` are intentionally absent
// so they're never bucketed into the engine rail.
const ENGINE_TYPE_CATEGORY = {
  world_book:       "world-lore",
  perception_rule:  "perception",
  perception:       "perception",
  adjudicator:      "adjudication",
  trigger:          "world-dynamics",
  event:            "world-dynamics",
  observer:         "world-dynamics",
};
// Bucket a world-scoped mechanism into an engine category. First-class
// components carry a `type`; legacy rules[] carry a `kind`. Object-scoped
// components, `norm`, and legacy text/duration rules return null and are
// filtered out of the engine list entirely.
function engineGroupOf(m) {
  const byType = ENGINE_TYPE_CATEGORY[m.type];
  if (byType) return byType;
  const k = m.kind;
  if (k === "event") return "world-dynamics";    // legacy scheduled event
  return null;                                    // not world-scoped → drop
}
const ENGINE_GROUPS = [...ENGINE_CATEGORIES];

// Clearer rail labels for mechanisms (the bare component name was cryptic — e.g.
// "·LLM adjudicator"). Adjudicator says what it does; others fall back to name.
function engineItemLabel(m) {
  if (m.type === "adjudicator") {
    return m.kind === "llm"
      ? "Adjudicator: LLM — resolves actions & writes each object's perception"
      : "Adjudicator: Rule — deterministic outcomes";
  }
  return m.name || m.kind || m.type || "rule";
}

function RulesRailSection({ rules = [], components = [], onOpenRules, isReplay = false,
                            worldBookCount = 0, onOpenWorldBook }) {
  const [open, setOpen] = useState(true);
  // The engine is the sum of legacy rules + first-class components, PLUS the
  // World Book lore entries — but ONLY the world-scoped ones (object-scoped
  // components and norms are dropped: engineGroupOf returns null for them).
  // World & Lore is special: it surfaces both world_book-type components and
  // the standalone worldBook lore entries (reachable via onOpenWorldBook).
  const allMechanisms = [...(rules || []), ...(components || [])];
  const mechanisms = allMechanisms.filter(m => engineGroupOf(m) !== null);
  const buckets = {};
  for (const m of mechanisms) {
    const g = engineGroupOf(m);
    (buckets[g] || (buckets[g] = [])).push(m);
  }
  // World Book lore entries count toward the engine even when no world_book
  // component exists, so World & Lore can show with lore-only content.
  const count = mechanisms.length + (worldBookCount || 0);

  // Per-category collapse state. Default: a category is collapsed when it
  // carries more than 5 items, else expanded — so the short groups (World &
  // Lore, Perception, Adjudication) start open and the big World Dynamics
  // group (triggers/observers/events) starts collapsed.
  // Lazy initializer so it's computed once from the initial props.
  const [groupOpen, setGroupOpen] = useState(() => {
    const ms = [...(rules || []), ...(components || [])];
    const counts = {};
    for (const m of ms) {
      const g = engineGroupOf(m);
      if (g === null) continue;
      counts[g] = (counts[g] || 0) + 1;
    }
    counts["world-lore"] = (counts["world-lore"] || 0) + (worldBookCount || 0);
    const map = {};
    for (const g of ENGINE_GROUPS) {
      map[g.id] = (counts[g.id] || 0) <= 5; // open when small, collapsed when big
    }
    return map;
  });
  const toggleGroup = (id) => setGroupOpen(m => ({ ...m, [id]: !m[id] }));
  return (
    <div data-tour="rail-engine" style={{
      padding: "10px 16px",
      borderBottom: `1px solid ${T.rule}`, background: T.paperDeep,
    }}>
      <div style={{
        display: "flex", alignItems: "center", gap: 6,
        cursor: "pointer",
      }} onClick={() => setOpen(o => !o)}>
        <span style={{
          width: 10, color: T.inkMuted, fontSize: 9, lineHeight: 1,
        }}>{open ? "▾" : "▸"}</span>
        <span style={{ fontSize: 12 }}><Ico path={ICO_GEAR} size={12}/></span>
        <span style={{
          flex: 1, fontSize: 11, color: T.inkMuted,
          letterSpacing: "0.06em", textTransform: "uppercase", fontWeight: 700,
        }}>Environment engine{count > 0 ? ` · ${count}` : ""}</span>
        {!isReplay && (
          <button onClick={(e) => { e.stopPropagation(); onOpenRules(); }}
            data-tour="engine-edit"
            title="Edit the Environment Engine: adjudication, perception, World Rules"
            style={{
              background: "transparent", border: `1px solid ${T.rule}`,
              color: T.inkMuted, cursor: "pointer", padding: "1px 7px",
              fontSize: 10, borderRadius: 3, letterSpacing: "0.04em",
            }}>edit engine ›</button>
        )}
      </div>
      {open && (
        <div style={{ fontSize: 10.5, color: T.inkFaint, marginTop: 3, paddingLeft: 26 }}>
          how the world runs (mechanisms)
        </div>
      )}
      {open && isReplay && count === 0 && (
        // Replays don't carry their components; the engine adjudicated this
        // trace already. Don't say "no rules yet" — that undersells what
        // the engine did. UX-REVIEW #5.
        <div style={{ fontSize: 11, color: T.inkFaint, padding: "6px 0 0 16px",
          fontStyle: "italic" }}>
          engine ran this world · adjudicator + observers baked in
        </div>
      )}
      {open && !isReplay && count === 0 && (
        <div style={{ fontSize: 11, color: T.inkFaint, padding: "6px 0 0 16px" }}>
          (no rules yet)
        </div>
      )}
      {open && count > 0 && (
        <div style={{ marginTop: 6 }}>
          {ENGINE_GROUPS.filter(g => {
            // World & Lore shows whenever it has world_book components OR
            // standalone lore entries; every other category needs ≥1 item.
            if (g.id === "world-lore") {
              return (buckets[g.id] || []).length > 0 || (worldBookCount || 0) > 0;
            }
            return (buckets[g.id] || []).length > 0;
          }).map(g => {
            const isLore = g.id === "world-lore";
            const items = buckets[g.id] || [];
            // Header item count includes the World Book lore line for the
            // World & Lore group (it renders as one extra row below).
            const itemCount = items.length + (isLore ? (worldBookCount || 0 ? 1 : 0) : 0);
            const gOpen = groupOpen[g.id] !== false;
            return (
              <div key={g.id} style={{ marginBottom: 6 }}>
                {/* Collapsible category header: caret + label + count. */}
                <div style={{
                  display: "flex", alignItems: "center", gap: 6, paddingLeft: 16,
                  cursor: "pointer", userSelect: "none",
                }} onClick={() => toggleGroup(g.id)}>
                  <span style={{
                    width: 9, color: T.inkMuted, fontSize: 9, lineHeight: 1,
                  }}>{gOpen ? "▾" : "▸"}</span>
                  <span style={{
                    flex: 1, fontSize: 10, color: T.inkMuted,
                    letterSpacing: "0.06em", textTransform: "uppercase", fontWeight: 700,
                  }}>{g.label}{itemCount > 0 ? ` · ${itemCount}` : ""}</span>
                  {/* World & Lore opens the dedicated World Book editor. */}
                  {isLore && !isReplay && onOpenWorldBook && (
                    <button onClick={(e) => { e.stopPropagation(); onOpenWorldBook(); }}
                      title="Edit the shared World Book (era / lore the characters know)"
                      style={{
                        background: "transparent", border: `1px solid ${T.rule}`,
                        color: T.inkMuted, cursor: "pointer", padding: "0px 6px",
                        fontSize: 9, borderRadius: 3, letterSpacing: "0.04em",
                        marginRight: 6,
                      }}>edit lore ›</button>
                  )}
                </div>
                {gOpen && g.caption && (
                  <div style={{ fontSize: 10, color: T.inkFaint, paddingLeft: 31 }}>
                    {g.caption}
                  </div>
                )}
                {gOpen && items.map((m, i) => (
                  <div key={m.id || `${g.id}-${i}`} style={{
                    padding: "3px 0 3px 31px", fontSize: 11.5, color: T.ink,
                  }}>
                    <span style={{
                      fontFamily: "ui-monospace, monospace", color: T.inkFaint, marginRight: 6,
                    }}>·</span>
                    {engineItemLabel(m)}
                  </div>
                ))}
                {/* World Book lore entries as a single line under World & Lore. */}
                {gOpen && isLore && (worldBookCount || 0) > 0 && (
                  <div style={{
                    padding: "3px 0 3px 31px", fontSize: 11.5, color: T.ink,
                  }}>
                    <span style={{ fontSize: 11 }}><Ico path={ICO_BOOK} size={11}/></span>
                    <span style={{ marginLeft: 6 }}>World Book · {worldBookCount}</span>
                  </div>
                )}
              </div>
            );
          })}
        </div>
      )}
    </div>
  );
}

function SceneTree({ scene, children, selectedId, setSelectedId,
                     openWindow, removeEntity, agentIndex }) {
  const [open, setOpen] = useState(true);
  const sel = selectedId === scene.id;
  return (
    <div>
      <div style={{
        display: "flex", alignItems: "center", gap: 4,
        padding: "5px 4px 5px 6px", borderRadius: 4, cursor: "pointer",
        background: sel ? T.paperWarm : "transparent",
        border: `1px solid ${sel ? T.rule : "transparent"}`,
      }}
        onClick={() => setSelectedId(scene.id)}
        onDoubleClick={() => openWindow(scene.id)}>
        <button onClick={(e) => { e.stopPropagation(); setOpen(!open); }}
          style={{
            background: "none", border: "none", padding: "0 2px",
            color: T.inkMuted, cursor: "pointer", fontSize: 10, width: 14,
          }}>
          {children.length > 0 ? (open ? "▾" : "▸") : "·"}
        </button>
        <KindGlyph kind="scene"/>
        <span style={{
          flex: 1, fontSize: 13, fontWeight: 600,
          overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
        }}>{scene.name}</span>
        <span style={{
          fontSize: 10, color: T.inkFaint, padding: "0 4px",
        }}>{children.length}</span>
        <IconBtn title="Inspect" onClick={(ev) => { ev.stopPropagation(); openWindow(scene.id); }}>⊕</IconBtn>
        <IconBtn title="Delete" onClick={(ev) => { ev.stopPropagation(); removeEntity(scene.id); }}>×</IconBtn>
      </div>
      {open && children.length > 0 && (
        <div style={{ paddingLeft: 22, display: "flex", flexDirection: "column", gap: 2 }}>
          {children.map(c => (
            <RailRow key={c.id} ent={c} selected={selectedId === c.id}
              setSelected={setSelectedId} openWindow={openWindow}
              removeEntity={removeEntity}
              badge={c.kind === "agent" ? agentIndex.get(c.id) : null}
              indent/>
          ))}
        </div>
      )}
    </div>
  );
}

function Group({ title, tint, children }) {
  const [open, setOpen] = useState(true);
  return (
    <div style={{ padding: "8px 14px 6px" }}>
      <div
        onClick={() => setOpen(o => !o)}
        style={{
          display: "flex", alignItems: "center", gap: 6, marginBottom: 6,
          cursor: "pointer", userSelect: "none",
        }}>
        <span style={{
          width: 10, color: T.inkMuted, fontSize: 9, lineHeight: 1,
        }}>{open ? "▾" : "▸"}</span>
        <span style={{ width: 8, height: 8, borderRadius: 2, background: tint }}/>
        <span style={{
          fontSize: 10, fontWeight: 700, color: T.inkMuted,
          letterSpacing: "0.08em", textTransform: "uppercase",
        }}>{title}</span>
      </div>
      {open && (
        <div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
          {children}
        </div>
      )}
    </div>
  );
}
function Empty({ children }) {
  return <div style={{ color: T.inkFaint, fontSize: 11, padding: "2px 4px" }}>{children}</div>;
}

function RailRow({ ent, selected, setSelected, openWindow, removeEntity, badge, indent }) {
  return (
    <div
      onClick={() => setSelected(ent.id)}
      onDoubleClick={() => openWindow(ent.id)}
      style={{
        display: "flex", alignItems: "center", gap: 8,
        padding: "4px 6px", borderRadius: 4, cursor: "pointer",
        background: selected ? T.paperWarm : "transparent",
        border: `1px solid ${selected ? T.rule : "transparent"}`,
      }}
    >
      {badge != null && (
        <span style={{
          width: 16, height: 16, borderRadius: "50%",
          background: T.agent, color: "#fff",
          fontSize: 9, display: "grid", placeItems: "center",
          flexShrink: 0, fontWeight: 600,
        }}>{badge}</span>
      )}
      {ent.kind === "agent" ? (
        <span style={{
          width: 20, height: 20, background: T.paperWarm,
          border: `1px solid ${T.ruleSoft}`, padding: 1,
          display: "inline-flex", flexShrink: 0,
        }}>
          <MiniAvatar agent={ent} size={16}/>
        </span>
      ) : ent.kind === "action" ? (
        <span title={ent.module} style={{
          width: 20, height: 20, background: T.actionSoft,
          border: `1px solid ${T.action}`, borderRadius: 4,
          display: "grid", placeItems: "center", flexShrink: 0,
          color: T.action, fontSize: 13, lineHeight: 1, fontWeight: 700,
        }}>{ent.icon || "✦"}</span>
      ) : ent.kind === "object" ? (
        <span style={{
          width: 20, height: 20, background: T.objectSoft,
          border: `1px solid ${T.object}`, borderRadius: 4,
          display: "grid", placeItems: "center", flexShrink: 0,
          padding: 1, overflow: "hidden",
        }}>
          <ObjectSprite entity={ent} scale={1}/>
        </span>
      ) : (
        <KindGlyph kind={ent.kind} small/>
      )}
      <div style={{
        flex: 1, minWidth: 0, fontSize: 12,
        overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
      }}>{ent.name || "(unnamed)"}</div>
      <IconBtn title="Inspect" onClick={(ev) => { ev.stopPropagation(); openWindow(ent.id); }}>⊕</IconBtn>
      <IconBtn title="Delete" onClick={(ev) => { ev.stopPropagation(); removeEntity(ent.id); }}>×</IconBtn>
    </div>
  );
}

function KindGlyph({ kind, small }) {
  const map = {
    scene: { c: T.scene, ch: "▢" },
    object: { c: T.object, ch: "◇" },
    agent: { c: T.agent, ch: "♟" },
    action: { c: T.action, ch: "✦" },
  };
  const m = map[kind] || { c: T.inkFaint, ch: "·" };
  const sz = small ? 16 : 18;
  if (kind === "scene") {
    return (
      <span style={{
        width: sz, height: sz, display: "grid", placeItems: "center",
        borderRadius: 3, background: T.paperWarm,
        border: `1px solid ${m.c}`, flexShrink: 0, padding: 1,
      }}>
        <SceneGlyph size={sz - 4}/>
      </span>
    );
  }
  return (
    <span style={{
      width: sz, height: sz, display: "grid", placeItems: "center",
      borderRadius: 3, background: T.paperWarm,
      color: m.c, border: `1px solid ${m.c}`,
      fontSize: small ? 10 : 12, flexShrink: 0,
    }}>{m.ch}</span>
  );
}

// Tiny top-down room: wall ring + floor, all theme-colored.
function SceneGlyph({ size = 14 }) {
  return (
    <svg width={size} height={size} viewBox="0 0 16 16" shapeRendering="crispEdges"
         style={{ display: "block" }}>
      <rect x="1" y="1" width="14" height="14" fill={T.sceneWall}/>
      <rect x="3" y="3" width="10" height="10" fill={T.sceneFloorA}/>
      <rect x="4"  y="4"  width="3" height="3" fill={T.sceneFloorB}/>
      <rect x="9"  y="4"  width="3" height="3" fill={T.sceneFloorB}/>
      <rect x="4"  y="9"  width="3" height="3" fill={T.sceneFloorB}/>
      <rect x="9"  y="9"  width="3" height="3" fill={T.sceneFloorB}/>
      <rect x="7" y="13" width="2" height="2" fill={T.sceneFloorA}/>
    </svg>
  );
}

function AddBtn({ label, hint, tint, onClick, dataTour }) {
  const [hover, setHover] = useState(false);
  return (
    <button
      data-tour={dataTour}
      onClick={onClick}
      onMouseEnter={() => setHover(true)}
      onMouseLeave={() => setHover(false)}
      style={{
        display: "flex", flexDirection: "column", alignItems: "flex-start",
        gap: 2, padding: "8px 10px",
        background: hover ? T.paperWarm : T.paperSoft,
        color: T.ink,
        border: `1px solid ${hover ? tint : T.rule}`,
        borderRadius: 5, cursor: "pointer", textAlign: "left",
      }}
    >
      <span style={{ display: "flex", alignItems: "center", gap: 6 }}>
        <span style={{ width: 8, height: 8, borderRadius: 2, background: tint }}/>
        <span style={{ fontWeight: 700, fontSize: 13 }}>{label}</span>
        <span style={{ marginLeft: 4, color: T.inkFaint, fontSize: 13 }}>+</span>
      </span>
      <span style={{ color: T.inkFaint, fontSize: 11 }}>{hint}</span>
    </button>
  );
}

function IconBtn({ children, onClick, title }) {
  const [hover, setHover] = useState(false);
  return (
    <button
      onClick={onClick} title={title}
      onMouseEnter={() => setHover(true)}
      onMouseLeave={() => setHover(false)}
      style={{
        background: hover ? T.paperDeep : "transparent",
        border: "none", color: hover ? T.ink : T.inkMuted,
        cursor: "pointer", padding: "2px 5px", borderRadius: 3,
        fontSize: 12, lineHeight: 1,
      }}
    >{children}</button>
  );
}

// ─── ACTION GLYPHS ───────────────────────────────────────────────────
// Tiny SVG marks that float above a figure when it acts this tick. The
// glyphKind is derived from the figure's action-intend log beat for the
// current tick (verb → speech / footsteps / spark).
const GLYPH_SPEECH = "M3 4h14v9H8l-4 4v-4H3z";                       // chat bubble w/ tail
const GLYPH_FOOTSTEPS = "M6 3c1.4 0 2 1.6 2 3.4 0 1.8-.7 3.1-2 3.1S4 8.2 4 6.4 4.6 3 6 3zm0 8c1 0 1.6.6 1.6 1.5S6.9 14 6 14s-1.5-.6-1.5-1.5S5 11 6 11zm8-6c1.4 0 2 1.6 2 3.4 0 1.8-.7 3.1-2 3.1s-2-1.3-2-3.1S12.6 5 14 5zm0 8c1 0 1.6.6 1.6 1.5S14.9 16 14 16s-1.5-.6-1.5-1.5S13 13 14 13z";
const GLYPH_SPARK = "M10 2l1.6 5.1L17 8l-4.6 2.4L13 16l-3-3.4L7 16l.6-5.6L3 8l5.4-.9z"; // action burst
const GLYPH_STOP = "M6.6 2h6.8l4.6 4.6v6.8L13.4 18H6.6L2 13.4V6.6z";                   // octagon (refuse / stop)
// Map a verb to one of the three glyph paths.
function glyphForVerb(verb) {
  const v = String(verb || "").toLowerCase();
  if (["speak", "whisper", "say", "toast"].includes(v)) return "speech";
  if (["move", "walk", "go"].includes(v)) return "footsteps";
  return "spark";
}
const GLYPH_PATHS = { speech: GLYPH_SPEECH, footsteps: GLYPH_FOOTSTEPS, spark: GLYPH_SPARK, stop: GLYPH_STOP };
// Color-code the badge by what the figure is doing, so it reads at a glance.
const GLYPH_COLOR = { speech: "#3b6ea5", footsteps: "#2e8b57", spark: "#d4453a" };

// ─── INLINE ICONS ────────────────────────────────────────────────────
// The product owner dislikes emoji; every UI emoji is rendered as one of
// these simple 20x20-viewBox paths instead (same convention as GLYPH_PATHS).
const ICO_BOOK   = "M4 3h7v14H6a2 2 0 0 0-2 1V3zm12 0v15a2 2 0 0 0-2-1h-1V3h3z";          // open book (World Book)
const ICO_GEAR   = "M10 7a3 3 0 1 0 0 6 3 3 0 0 0 0-6zm6.5 3l1.3-1-1.3-2.3-1.6.5a5.6 5.6 0 0 0-1.4-.8L12.7 4H10l-.3 1.6a5.6 5.6 0 0 0-1.4.8L6.7 5.9 5.4 8.2l1.3 1a5.7 5.7 0 0 0 0 1.6l-1.3 1 1.3 2.3 1.6-.5c.4.3.9.6 1.4.8L10 16h2.7l.3-1.6c.5-.2 1-.5 1.4-.8l1.6.5 1.3-2.3-1.3-1a5.7 5.7 0 0 0 0-1.6z"; // gear (Environment Engine)
const ICO_PENCIL = "M14.7 2.6l2.7 2.7-1.6 1.6-2.7-2.7zM12 5.3l2.7 2.7L6.4 16.3 3 17l.7-3.4z";  // pencil (author / describe)
const ICO_PLAY   = "M5 3l12 7-12 7z";                                                       // play triangle (replay)
const ICO_FUEL   = "M4 3h7v14H4zM11 6l3 3v6a1.5 1.5 0 0 0 3 0V8l-2-2 1-1 1 1v9a3 3 0 0 1-6 0V6z"; // fuel pump (budget meter)
const ICO_GLOBE  = "M10 2a8 8 0 1 0 0 16 8 8 0 0 0 0-16zm0 1.6c1.4 0 2.7 2.6 2.9 5.6H7.1c.2-3 1.5-5.6 2.9-5.6zM4 10h2.5c.1 1.8.5 3.4 1.1 4.6A6.4 6.4 0 0 1 4 10zm8.4 4.6c.6-1.2 1-2.8 1.1-4.6H16a6.4 6.4 0 0 1-3.6 4.6z"; // globe (public)
const ICO_EYE    = "M10 4C5 4 2 10 2 10s3 6 8 6 8-6 8-6-3-6-8-6zm0 3a3 3 0 1 1 0 6 3 3 0 0 1 0-6z";  // eye (self / visible)
const ICO_LOCK   = "M6 9V6a4 4 0 0 1 8 0v3h1v8H5V9h1zm2 0h4V6a2 2 0 0 0-4 0v3z";              // padlock (hidden truth)
const ICO_BOLT   = "M11 2L4 11h4l-1 7 7-9h-4z";                                              // lightning bolt (trigger / act)
const ICO_MOON   = "M12 3a7 7 0 1 0 4.5 12.4A5.6 5.6 0 0 1 12 3z";                            // crescent (night)
const ICO_SUN    = "M10 7a3 3 0 1 0 0 6 3 3 0 0 0 0-6zM10 1l1.2 2.6H8.8zM10 19l1.2-2.6H8.8zM1 10l2.6 1.2V8.8zM19 10l-2.6 1.2V8.8zM3.5 3.5l2.4 1.2-1.2 1.2zM16.5 16.5l-2.4-1.2 1.2-1.2zM16.5 3.5l-1.2 2.4-1.2-1.2zM3.5 16.5l1.2-2.4 1.2 1.2z"; // sun (day)
const ICO_CHECK  = "M8 13.2L4.8 10l-1.3 1.3L8 15.8 16.5 7.3 15.2 6z";                        // checkmark (step done)
const ICO_SPARKLE = "M10 1l1.9 5.6L17.5 8.5l-5.6 1.9L10 16l-1.9-5.6L2.5 8.5l5.6-1.9zM15.5 12l.8 2.4 2.4.8-2.4.8-.8 2.4-.8-2.4-2.4-.8 2.4-.8z"; // sparkle (flesh out / recruit)
// Tiny inline-icon component used everywhere an emoji used to sit.
function Ico({ path, size = 14, color, title }) {
  return (
    <svg width={size} height={size} viewBox="0 0 20 20" style={{ verticalAlign: "-2px" }}>
      {title ? <title>{title}</title> : null}
      <path d={path} fill={color || "currentColor"}/>
    </svg>
  );
}
// Stage caption (placed agent/object name under the sprite). At density,
// transparent 9px labels of neighbouring occupants ran together into
// unreadable strings (e.g. "Persd5erSders0"). A small semi-opaque plate +
// ellipsis truncation keeps each label legible; the SELECTED node drops the
// truncation (shows the full name) and floats above its neighbours.
function captionStyle(selected = false) {
  return {
    position: "absolute", left: "50%", top: TILE,
    transform: "translateX(-50%)",
    fontSize: 9, fontWeight: 700, color: T.ink,
    padding: "0 4px", borderRadius: 3,
    background: `${T.paper}e6`,
    border: `1px solid ${T.ruleSoft}`,
    boxShadow: `0 1px 1px ${T.paperEdge}66`,
    maxWidth: selected ? "none" : TILE * 2.6,
    overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
    pointerEvents: "none",
    zIndex: selected ? 6 : 4,
  };
}
// Derive this tick's glyph for an entity from its merged action-intend beats.
function tickGlyphKind(entity, tickNumber) {
  const log = entity && entity.log;
  if (!Array.isArray(log)) return null;
  for (let i = log.length - 1; i >= 0; i--) {
    const b = log[i];
    if (b && b.kind === "action" && b.state === "intend" && b.t === tickNumber) {
      return glyphForVerb(b.verb);
    }
  }
  return null;
}
// Raw verb of this tick's action-intend beat (for action_glyphs lookup).
function tickActionVerb(entity, tickNumber) {
  const log = entity && entity.log;
  if (!Array.isArray(log)) return null;
  for (let i = log.length - 1; i >= 0; i--) {
    const b = log[i];
    if (b && b.kind === "action" && b.state === "intend" && b.t === tickNumber) {
      return b.verb || null;
    }
  }
  return null;
}
// Loaded trace's `action_glyphs` map ({verb:{icon?,color?}}), threaded here from
// loadTrace so the plain-function EntityNode can read user-designed glyph art at
// render with no extra prop plumbing. Cleared on exit so a stale demo's art never
// leaks into a fresh scene.
let ACTION_GLYPHS = null;
// Resolve a builtin shape name OR a "svg:M…" raw path to a <path d> string.
// Returns null if neither (caller falls back to by-kind behavior).
function resolveGlyphIconPath(icon) {
  if (!icon || typeof icon !== "string") return null;
  if (icon.startsWith("svg:")) return icon.slice(4);
  return GLYPH_PATHS[icon] || null;
}

// ─── ENTITY NODE ─────────────────────────────────────────────────────
function EntityNode({ entity, selected, connectMode, isConnSource, highlightDrop, fx,
                      agentIndex, tickNumber, templates,
                      onSelect, onOpen, onStartConn, onMove, onResize,
                      onDrag, onDragEnd,
                      onContextMenu, zoom }) {
  const [dragging, setDragging] = useState(false);
  const [resizing, setResizing] = useState(false);
  const drag = useRef(null);
  const [hover, setHover] = useState(false);

  const onMouseDown = (e) => {
    if (e.button !== 0) return;
    e.stopPropagation();
    if (connectMode) { onSelect(); return; }
    onSelect();
    setDragging(true);
    drag.current = { mx: e.clientX, my: e.clientY, ex: entity.x, ey: entity.y };
  };

  useEffect(() => {
    if (!dragging) return;
    const mv = (e) => {
      const d = drag.current;
      const nx = d.ex + (e.clientX - d.mx) / zoom;
      const ny = d.ey + (e.clientY - d.my) / zoom;
      onMove(nx, ny);
      onDrag && onDrag(nx + entity.w / 2, ny + entity.h / 2);
    };
    const up = (e) => {
      const d = drag.current;
      const nx = d.ex + (e.clientX - d.mx) / zoom;
      const ny = d.ey + (e.clientY - d.my) / zoom;
      setDragging(false);
      onDragEnd && onDragEnd(nx + entity.w / 2, ny + entity.h / 2);
    };
    window.addEventListener("mousemove", mv);
    window.addEventListener("mouseup", up);
    return () => {
      window.removeEventListener("mousemove", mv);
      window.removeEventListener("mouseup", up);
    };
  }, [dragging, zoom, onMove, onDrag, onDragEnd, entity.w, entity.h]);

  useEffect(() => {
    if (!resizing) return;
    const min = MIN_SIZE[entity.kind] || { w: 60, h: 60 };
    const snapToTile = entity.kind === "scene";
    const mv = (e) => {
      const d = drag.current;
      let w = Math.max(min.w, d.w0 + (e.clientX - d.mx) / zoom);
      let h = Math.max(min.h, d.h0 + (e.clientY - d.my) / zoom);
      if (snapToTile) {
        w = Math.round(w / TILE) * TILE;
        h = Math.round(h / TILE) * TILE;
      }
      onResize(w, h);
    };
    const up = () => setResizing(false);
    window.addEventListener("mousemove", mv);
    window.addEventListener("mouseup", up);
    return () => {
      window.removeEventListener("mousemove", mv);
      window.removeEventListener("mouseup", up);
    };
  }, [resizing, zoom, onResize, entity.kind]);

  const onDoubleClick = (e) => { e.stopPropagation(); onOpen(); };
  const onHandleDown = (e) => {
    if (e.button !== 0) return;
    e.stopPropagation();
    onStartConn(e.clientX, e.clientY);
  };
  const onResizeDown = (e) => {
    if (e.button !== 0) return;
    e.stopPropagation();
    setResizing(true);
    drag.current = { mx: e.clientX, my: e.clientY, w0: entity.w, h0: entity.h };
  };

  const baseOutline = highlightDrop
    ? `3px dashed ${T.accent}`
    : (selected || isConnSource) ? `2px solid ${T.accent}` : "none";

  const base = {
    position: "absolute", left: entity.x, top: entity.y,
    cursor: dragging ? "grabbing" : (connectMode ? "crosshair" : "grab"),
    userSelect: "none",
    outline: baseOutline, outlineOffset: 1, borderRadius: 4,
  };
  const common = {
    onMouseDown, onDoubleClick, onContextMenu,
    onMouseEnter: () => setHover(true),
    onMouseLeave: () => setHover(false),
  };

  const resizeHandle = (
    <ResizeHandle onMouseDown={onResizeDown}
      visible={hover || selected} color={KIND_COLOR[entity.kind]}/>
  );

  if (entity.kind === "scene") {
    const connectHandle = (
      <div onMouseDown={onHandleDown} title="Drag/click to connect scenes"
        style={{
          // INSIDE the box (the box is overflow:hidden, so an overhang at -9 was
          // clipped to a tiny unclickable sliver — the connect affordance was dead).
          position: "absolute", right: 6, top: 6,
          width: 20, height: 20, borderRadius: "50%",
          background: T.accent, border: `2px solid ${T.paper}`,
          color: "#fff", fontSize: 11,
          display: (hover || selected) ? "grid" : "none",
          placeItems: "center", cursor: "crosshair", zIndex: 4,
        }}>◉</div>
    );
    return (
      <div {...common} style={{
        ...base, width: entity.w, height: entity.h,
        background: T.canvas,
        boxShadow: highlightDrop
          ? `0 0 0 3px ${T.accent}, 6px 6px 0 ${T.paperEdge}`
          : `6px 6px 0 ${T.paperEdge}`,
        overflow: "hidden",
      }}>
        <SceneTiles w={entity.w} h={entity.h}/>
        {/* room label chip — sits on floor, just inside the wall ring */}
        <div style={{
          position: "absolute", left: TILE + 4, top: TILE + 4, zIndex: 1,
          padding: "2px 6px",
          background: T.ink, color: T.paper,
          fontSize: 10, fontWeight: 600,
          letterSpacing: "0.04em", textTransform: "uppercase",
          border: `1px solid ${T.ink}`,
        }}>{entity.name}</div>
        {connectHandle}
        {resizeHandle}
      </div>
    );
  }
  if (entity.kind === "object") {
    const iconSize = Math.min(entity.w * 0.5, entity.h * 0.55, 40);
    if (entity.placedIn) {
      // bare object on the floor — one tile, hand-pixeled SVG sprite
      return (
        <div {...common} style={{
          ...base, width: TILE, height: TILE,
          background: "transparent", border: "none",
          display: "block", padding: 0, zIndex: 2,
          transition: dragging
            ? "none"
            : "left 720ms cubic-bezier(.34,.1,.4,1.0), top 720ms cubic-bezier(.34,.1,.4,1.0)",
        }}>
          <div style={{
            width: TILE, height: TILE,
            animation: "spriteBob 4.2s ease-in-out infinite",
            transformOrigin: "center bottom",
          }}>
            <ObjectSprite entity={entity} scale={1.5} templates={templates}/>
          </div>
          {/* name caption — matches the placed-agent caption treatment so a
              non-coder can tell what an on-floor object is at default zoom.
              Semi-opaque plate + ellipsis truncation keeps it legible when
              several occupants crowd one scene; the selected node shows the
              full name and floats above its neighbours. */}
          <span style={captionStyle(selected)}>{entity.name}</span>
          {isCrowdEntity(entity) && (
            <span style={{
              position: "absolute", left: "50%", bottom: -8,
              transform: "translateX(-50%)",
              fontSize: 9, fontWeight: 700,
              padding: "0 4px", background: "rgba(0,0,0,0.55)", color: "#fff",
              borderRadius: 8, whiteSpace: "nowrap", pointerEvents: "none",
            }}>×{entity.status?.count ?? 8}</span>
          )}
        </div>
      );
    }
    return (
      <div {...common} style={{
        ...base, width: entity.w, height: entity.h,
        background: T.paperWarm, border: `1.5px solid ${T.object}`,
        display: "flex", flexDirection: "column",
        alignItems: "center", justifyContent: "center",
        zIndex: 2,
      }}>
        <ObjectSprite entity={entity} scale={Math.max(1.2, Math.min(2.5, entity.h / 32))} templates={templates}/>
        <div style={{
          fontSize: 10, color: T.ink, marginTop: 2, padding: "0 4px",
          maxWidth: "100%", overflow: "hidden", textOverflow: "ellipsis",
          whiteSpace: "nowrap", fontWeight: 600,
        }}>{entity.name}</div>
        {resizeHandle}
      </div>
    );
  }
  if (entity.kind === "agent") {
    const num = agentIndex.get(entity.id);
    const attached = (entity.pickedActions || []).length;
    const spriteScale = Math.max(1.5, Math.min(4, entity.h / 28));
    // Slice 2b: an agent may carry a status-driven vector appearance (class or
    // instance). When present it REPLACES the default Sprite body; the glyph
    // badge, wobble and movement glide overlays below still apply on top. With
    // no visual, agentVisual is null and behavior is unchanged.
    const agentVisual = resolveVisual(entity, templates);
    const hasAgentVisual = !!(agentVisual && agentVisual.baseSvg);
    // Action glyph for THIS tick (speech / footsteps / spark). Re-keyed per
    // tick so the float-up animation replays each time the figure acts.
    const glyphKind = tickGlyphKind(entity, tickNumber);
    // #18 (render): if the loaded trace ships user-designed art for this tick's
    // verb (action_glyphs[verb]), honor its color + icon (a builtin shape name OR
    // a "svg:M…" raw path). Else fall back to the by-kind color/path.
    const actionVerb = tickActionVerb(entity, tickNumber);
    const customGlyph = (ACTION_GLYPHS && actionVerb && ACTION_GLYPHS[actionVerb]) || null;
    const customPath = customGlyph ? resolveGlyphIconPath(customGlyph.icon) : null;
    const glyphColor =
      (customGlyph && customGlyph.color) || GLYPH_COLOR[glyphKind] || T.accent || "#3b6ea5";
    const glyphPath = customPath || GLYPH_PATHS[glyphKind];
    // A bold, color-coded badge (blue=speak · green=move · red=act) on a filled chip so it's
    // unmistakable above the figure, and it stays put while you're on the tick (ends opaque).
    const glyphBadge = (glyphKind || customPath) ? (
      <svg
        key={`glyph-${entity.id}-${tickNumber}`}
        width="30" height="30" viewBox="0 0 20 20"
        style={{
          position: "absolute", top: -36, left: "50%",
          transform: "translateX(-50%)",
          animation: "glyphFloat 620ms cubic-bezier(.22,1.25,.36,1) both",
          pointerEvents: "none", zIndex: 30,
          filter: "drop-shadow(0 2px 3px rgba(0,0,0,0.4))",
        }}>
        <circle cx="10" cy="10" r="9.2" fill={glyphColor} stroke="#fff" strokeWidth="1.3"/>
        <g transform="translate(4 4) scale(0.6)">
          <path d={glyphPath} fill="#fff"/>
        </g>
      </svg>
    ) : null;
    // #19 roly-poly (不倒翁) sprite wobble when the figure acts this tick. Speech
    // → gentle tilt; any other action → sharper bob/squash. Re-keyed per tick so
    // it replays each acting tick. Applied to an inner wrapper so it never fights
    // the position-glide transform transition on the outer node.
    const wobbleAnim = glyphKind
      ? (glyphKind === "speech"
          ? "figWobbleSpeak 560ms ease-in-out both"
          : "figWobbleAct 480ms cubic-bezier(.3,1.4,.5,1) both")
      : null;
    const wobbleStyle = wobbleAnim
      ? { animation: wobbleAnim, transformOrigin: "center bottom" }
      : null;
    const badge = (
      <>
        <span style={{
          position: "absolute", left: -6, top: -4,
          width: 15, height: 15, borderRadius: "50%",
          background: T.agent, color: "#fff",
          fontSize: 9, display: "grid", placeItems: "center",
          fontWeight: 700, border: `1.5px solid ${T.paper}`,
        }}>{num}</span>
        {attached > 0 && (
          <span title={`${attached} action(s) attached`} style={{
            position: "absolute", right: -7, top: -4,
            padding: "1px 4px", borderRadius: 8,
            background: T.action, color: "#fff",
            fontSize: 8, fontWeight: 700,
            border: `1.5px solid ${T.paper}`,
            whiteSpace: "nowrap",
          }}>✦{attached}</span>
        )}
      </>
    );

    if (entity.placedIn) {
      // Bare sprite on the floor — exactly one tile (paper.jsx tile-world style).
      // No upper-left number / upper-right action-count badges — keep the tile clean.
      const anim =
        fx === "move" ? "spriteWalk 1.1s ease-in-out infinite" :
        fx === "talk" ? "spriteTalk 0.95s ease-in-out infinite" :
        "spriteBob 2.6s ease-in-out infinite";
      return (
        <div {...common} style={{
          ...base, width: TILE, height: TILE,
          background: "transparent", border: "none",
          display: "block", padding: 0, zIndex: 3,
          // Tile-glide only when the sim moves the agent — never while the user is dragging.
          transition: dragging
            ? "none"
            : "left 720ms cubic-bezier(.34,.1,.4,1.0), top 720ms cubic-bezier(.34,.1,.4,1.0)",
        }}>
          {glyphBadge}
          <div style={{
            width: TILE, height: TILE,
            animation: anim,
            transformOrigin: "center bottom",
          }}>
            {/* #19 wobble lives on its own wrapper so it doesn't override the
                idle/walk `anim` (both drive `animation`/`transform`). */}
            <div key={`wob-${entity.id}-${tickNumber}`} style={wobbleStyle || undefined}>
              {hasAgentVisual
                ? <VisualSprite visual={agentVisual} status={entity.status} scale={1.5}/>
                : <Sprite agent={entity} scale={1.5} mood={fx === "talk" ? "talk" : null}/>}
            </div>
          </div>
          <span style={captionStyle(selected)}>{entity.name}</span>
        </div>
      );
    }

    return (
      <div {...common} style={{
        ...base, width: entity.w, height: entity.h,
        background: T.agentSoft, border: `1.5px solid ${T.agent}`,
        display: "flex", flexDirection: "column", alignItems: "center", padding: 4,
        zIndex: 3,
        // Glide to a new position when the sim relocates the figure.
        transition: dragging
          ? "none"
          : "left 600ms ease, top 600ms ease, transform 600ms ease",
      }}>
        <div style={{ position: "relative" }}>
          {glyphBadge}
          {/* #19 wobble on an inner wrapper so it never fights the outer node's
              position-glide transform transition. */}
          <div key={`wob-${entity.id}-${tickNumber}`} style={wobbleStyle || undefined}>
            {hasAgentVisual
              ? <VisualSprite visual={agentVisual} status={entity.status}
                  scale={Math.max(1.5, Math.min(3, entity.h / 32))}/>
              : <Sprite agent={entity} scale={Math.max(1.5, Math.min(3, entity.h / 32))}/>}
          </div>
          {badge}
        </div>
        <div style={{
          fontSize: 10, fontWeight: 700, color: T.ink, marginTop: 1,
          maxWidth: "100%", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
        }}>{entity.name}</div>
        {entity.goal && (
          <div style={{
            fontSize: 8, color: T.inkMuted, marginTop: 0, fontStyle: "italic",
            maxWidth: "100%", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
          }}>{entity.goal}</div>
        )}
        {resizeHandle}
      </div>
    );
  }
  if (entity.kind === "action") {
    return (
      <div {...common} style={{
        ...base, width: entity.w, height: entity.h,
        background: T.actionSoft, border: `1.5px solid ${T.action}`,
        padding: 10, display: "flex", flexDirection: "column",
        zIndex: 5,
      }}>
        <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
          {entity.spritePixels
            ? <PixelSprite pixels={entity.spritePixels} scale={1}/>
            : (typeof entity.icon === "string" && entity.icon.startsWith("svg:"))
              ? <svg width="20" height="20" viewBox="0 0 20 20" style={{ overflow: "visible" }}>
                  <path d={entity.icon.slice(4)} fill={entity.color || T.action}/>
                </svg>
              : <span style={{ fontSize: 14, color: T.action }}>{entity.icon}</span>}
          <span style={{ fontSize: 13, fontWeight: 700, color: T.ink,
            overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
          }}>{entity.name}</span>
        </div>
        <div style={{ fontSize: 9, color: T.action, marginTop: 4,
          letterSpacing: "0.04em", textTransform: "uppercase", fontWeight: 700,
        }}>{ACTION_MODULES.find(m => m.id === entity.module)?.label || entity.module}</div>
        {entity.describe && (
          <div style={{
            fontSize: 10, color: T.inkMuted, marginTop: 6, lineHeight: 1.4,
            display: "-webkit-box", WebkitLineClamp: 3, WebkitBoxOrient: "vertical",
            overflow: "hidden",
          }}>{entity.describe}</div>
        )}
        {resizeHandle}
      </div>
    );
  }
  return null;
}

function ResizeHandle({ onMouseDown, visible, color }) {
  return (
    <div onMouseDown={onMouseDown} title="Drag to resize"
      style={{
        position: "absolute", right: -2, bottom: -2,
        width: 16, height: 16, cursor: "nwse-resize",
        background: "transparent", zIndex: 4,
        display: visible ? "block" : "none",
      }}>
      <svg width="16" height="16" viewBox="0 0 16 16">
        <path d="M 3 13 L 13 13 L 13 3" fill="none"
          stroke={color} strokeWidth="2" strokeLinecap="square"/>
        <circle cx="13" cy="13" r="2.4" fill={T.accent}/>
      </svg>
    </div>
  );
}

// ─── FLOATING WINDOW ─────────────────────────────────────────────────
function FloatingWindow({ win, title, glyph, badge, onClose, onFocus,
                          onMove, onDelete, children, aside, width = 420,
                          editing = false, onToggleEdit }) {
  const [dragging, setDragging] = useState(false);
  const drag = useRef(null);
  const onHeaderDown = (e) => {
    if (e.button !== 0) return;
    e.stopPropagation();
    onFocus && onFocus();
    setDragging(true);
    drag.current = { mx: e.clientX, my: e.clientY, wx: win.x, wy: win.y };
  };
  // Drag from body too, but skip when the user is interacting with a form
  // field or button — those need their own click semantics. closest() handles
  // clicks on icons or spans inside buttons.
  const onBodyDown = (e) => {
    if (e.button !== 0) return;
    const interactive = e.target?.closest?.(
      "input, textarea, select, button, a, label, [contenteditable=true]"
    );
    if (interactive) return;
    onFocus && onFocus();
    setDragging(true);
    drag.current = { mx: e.clientX, my: e.clientY, wx: win.x, wy: win.y };
  };
  useEffect(() => {
    if (!dragging) return;
    const mv = (e) => {
      const d = drag.current;
      onMove(d.wx + (e.clientX - d.mx), d.wy + (e.clientY - d.my));
    };
    const up = () => setDragging(false);
    window.addEventListener("mousemove", mv);
    window.addEventListener("mouseup", up);
    return () => {
      window.removeEventListener("mousemove", mv);
      window.removeEventListener("mouseup", up);
    };
  }, [dragging, onMove]);
  const totalW = aside ? width + 240 : width;
  return (
    <div
      onMouseDown={(e) => { e.stopPropagation(); onFocus && onFocus(); onBodyDown(e); }}
      onWheel={(e) => e.stopPropagation()}
      onContextMenu={(e) => e.stopPropagation()}
      style={{
        position: "absolute", left: win.x, top: win.y, width: totalW,
        background: T.paperWarm, border: `1px solid ${T.rule}`,
        borderRadius: 4, boxShadow: "0 8px 30px rgba(20,18,12,0.18)",
        zIndex: win.z, display: "flex", flexDirection: "column",
        overflow: "hidden",
        maxHeight: `calc(100vh - ${Math.max(0, win.y + 20)}px)`,
      }}
    >
      <div onMouseDown={onHeaderDown}
        style={{
          height: 34, display: "flex", alignItems: "center",
          padding: "0 10px", gap: 8,
          background: T.chrome, color: T.chromeInk,
          cursor: dragging ? "grabbing" : "grab",
        }}>
        {glyph}
        <div style={{
          flex: 1, fontSize: 12, fontWeight: 600,
          overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
        }}>{title}</div>
        {badge && <span style={{
          fontSize: 10, color: T.chromeDim, letterSpacing: "0.08em",
          textTransform: "uppercase", marginRight: 4,
        }}>{badge}</span>}
        {onToggleEdit && (
          <button onClick={(e) => { e.stopPropagation(); onToggleEdit(); }}
            title={editing ? "Lock fields" : "Edit fields"}
            style={{
              ...chromeBtnStyle(),
              color: editing ? T.accent : T.chromeDim,
              fontSize: 11, fontWeight: 600, letterSpacing: "0.04em",
              padding: "2px 8px", fontFamily: "ui-monospace, monospace",
            }}>{editing ? "● edit" : "edit ›"}</button>
        )}
        {onDelete && (
          <button onClick={(e) => { e.stopPropagation(); onDelete(); }}
            title="Delete" style={chromeBtnStyle()}>⌫</button>
        )}
        <button onClick={(e) => { e.stopPropagation(); onClose(); }}
          title="Close" style={chromeBtnStyle()}>×</button>
      </div>
      {aside ? (
        <div style={{ display: "flex", minHeight: 0, alignItems: "stretch", flex: 1, overflow: "hidden" }}>
          <div style={{
            width, flexShrink: 0,
            borderRight: `1px solid ${T.ruleSoft}`,
            background: T.paperSoft,
            display: "flex", flexDirection: "column",
            minHeight: 0, overflow: "hidden",
          }}>
            {children}
          </div>
          <div style={{
            width: 240, flexShrink: 0,
            display: "flex", flexDirection: "column",
            background: T.paperSoft,
            minHeight: 0, overflow: "auto",
          }}>
            {aside}
          </div>
        </div>
      ) : (
        <div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column", overflow: "hidden" }}>
          {children}
        </div>
      )}
    </div>
  );
}
const chromeBtnStyle = () => ({
  background: "none", border: "none", color: T.chromeDim,
  cursor: "pointer", padding: "2px 6px", fontSize: 14, lineHeight: 1,
});

// ─── STATUS + LOG PANEL (Phase 3) ────────────────────────────────────
// Right-side panel on every inspector window: editable status (per the
// entity's template schema) on top, scrolling log below.
// ─── ACTIVITIES (v5 — engine concurrency model) ───────────────────────
// Renders activities[] as one badge per concurrent track + a per-track
// horizontal bar showing remaining duration vs. start.
const TRACK_COLOR = {
  move: "#3a8c4c", voice: "#3a5fbf", phone: "#c14545",
  manipulate: "#a87248", attend: "#8e8e8e",
};
// v7: mood sparkline + label. Reads status.mood_valence (-1..1) over time
// from entity.driveHistory.mood_valence + current status.mood label.
function MoodWidget({ entity }) {
  const status = entity.status || {};
  const series = entity.driveHistory?.mood_valence || [];
  const cur = typeof status.mood_valence === "number" ? status.mood_valence : null;
  const label = status.mood || (cur != null ? (cur > 0.1 ? "warm" : cur < -0.1 ? "cool" : "neutral") : null);
  if (cur == null && series.length === 0 && !label) return null;
  const W = 140, H = 28, pad = 2;
  const pts = series.map((p, i) => {
    const x = pad + (i / Math.max(1, series.length - 1)) * (W - pad * 2);
    const y = H / 2 - (p.v) * (H / 2 - pad);
    return `${x.toFixed(1)},${y.toFixed(1)}`;
  }).join(" ");
  const lastV = series.length ? series[series.length - 1].v : (cur ?? 0);
  const tint = moodTint(lastV);
  return (
    <div style={{ marginTop: 10, paddingTop: 8, borderTop: `1px dashed ${T.ruleSoft}` }}>
      <div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 4 }}>
        <span style={{ fontSize: 10, fontWeight: 700, color: T.inkMuted,
          letterSpacing: "0.08em", textTransform: "uppercase" }}>Mood</span>
        {label && (
          <span style={{
            fontSize: 10, padding: "1px 6px", borderRadius: 8,
            background: tint, color: "#fff", fontWeight: 600,
          }}>{label}</span>
        )}
        <span style={{ marginLeft: "auto", fontSize: 10, color: T.inkFaint,
          fontFamily: "ui-monospace, monospace" }}>
          {cur != null ? `valence ${cur.toFixed(2)}` : ""}
        </span>
      </div>
      {series.length >= 2 && (
        <svg width={W} height={H} style={{ display: "block",
          background: T.paperWarm, border: `1px solid ${T.ruleSoft}` }}>
          <line x1={0} y1={H/2} x2={W} y2={H/2} stroke={T.ruleSoft} strokeWidth={1}/>
          <polyline points={pts} fill="none" stroke={tint} strokeWidth={1.6}/>
        </svg>
      )}
    </div>
  );
}
function moodTint(v) {
  if (v == null || isNaN(v)) return "#8e8e8e";
  // Map -1..1 to a warm-cool gradient.
  const r = Math.round(v > 0 ? 200 + v * 30 : 90 - v * 30);
  const g = Math.round(120 + (1 - Math.abs(v)) * 60);
  const b = Math.round(v < 0 ? 200 + Math.abs(v) * 30 : 90 + v * 30);
  return `rgb(${r},${g},${b})`;
}

// v7: numeric drive bars. Any status key whose history shows numeric
// values in a bounded range renders as a horizontal bar with a track tint.
const DRIVE_COLOR = {
  tipsy: "#c14545", fatigue: "#7a4a26",
  hunger: "#e89060", stamina: "#3a8c4c",
  paid: "#3a5fbf",
};
function DriveBars({ entity }) {
  const hist = entity.driveHistory || {};
  const status = entity.status || {};
  const keys = ["tipsy","fatigue","hunger","stamina","paid"]
    .filter(k => typeof status[k] === "number" || (hist[k] && hist[k].length));
  if (keys.length === 0) return null;
  return (
    <div style={{ marginTop: 10, paddingTop: 8, borderTop: `1px dashed ${T.ruleSoft}` }}>
      <div style={{ fontSize: 10, fontWeight: 700, color: T.inkMuted,
        letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 6 }}>Drives</div>
      <div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
        {keys.map(k => {
          const v = status[k];
          const series = hist[k] || [];
          const max = Math.max(1, ...series.map(p => p.v), v || 0, k === "paid" ? 1 : 100);
          const pct = Math.max(0, Math.min(100, ((v || 0) / max) * 100));
          const c = DRIVE_COLOR[k] || T.accent;
          return (
            <div key={k} style={{ display: "flex", alignItems: "center", gap: 6 }}>
              <span style={{ width: 60, fontSize: 10, color: T.inkMuted, textAlign: "right" }}>{k}</span>
              <div style={{ flex: 1, height: 8, background: T.paperWarm,
                border: `1px solid ${T.ruleSoft}`, position: "relative" }}>
                <div style={{ width: `${pct}%`, height: "100%", background: c, opacity: 0.85 }}/>
              </div>
              <span style={{ width: 42, fontSize: 10, color: T.inkFaint,
                fontFamily: "ui-monospace, monospace", textAlign: "right" }}>{v}</span>
            </div>
          );
        })}
      </div>
    </div>
  );
}

// memory panel — engine-driven status.memory[] = {text, t, kind, origin} (Tier-2
// episodic memory). Shows newest-first with a kind chip (saw/heard/did) and the
// virtual-time stamp, so a researcher can SEE what an object remembers over a long
// run. Back-compat: old {text, recalled} rows still render.
// Per-character WORLD BOOK — authored PRIVATE knowledge for this character
// (entity-scoped lore: scope 'entity', where=id). The static "given-knowledge"
// twin of the agent's dynamic Memory; injected ONLY into this character's mind
// (and the adjudicator when they act) — never shared. This is where belief
// asymmetry lives (e.g. only the experimenter knows the study is staged).
function PrivateLorePanel({ entity, worldBook = [], setWorldBook, editing }) {
  const mine = (worldBook || []).filter(e => e && e.scope === "entity" && e.where === entity.id);
  const [draft, setDraft] = useState("");
  if (mine.length === 0 && !editing) return null;
  const add = () => {
    const c = draft.trim();
    if (!c || !setWorldBook) return;
    setWorldBook([...(worldBook || []), { scope: "entity", where: entity.id, constant: true, content: c }]);
    setDraft("");
  };
  const del = (target) => { if (setWorldBook) setWorldBook((worldBook || []).filter(e => e !== target)); };
  return (
    <div style={{ marginTop: 10 }}>
      <div style={{ fontSize: 10, fontWeight: 700, color: T.inkMuted,
        letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 4 }}>
        Private world book · {mine.length}
      </div>
      <div style={{ fontSize: 10.5, color: T.inkFaint, marginBottom: 6 }}>
        what only this character knows (injected only into their mind) — the given-knowledge twin of Memory
      </div>
      {mine.map((e, i) => (
        <div key={i} style={{ display: "flex", gap: 6, alignItems: "flex-start",
          padding: "4px 8px", fontSize: 11, color: T.ink, lineHeight: 1.4,
          border: `1px solid ${T.ruleSoft}`, background: T.paperSoft, marginBottom: 4 }}>
          <span style={{ fontSize: 8.5, fontWeight: 700, color: "#fff", background: "#8a6d3b",
            borderRadius: 3, padding: "1px 4px", flexShrink: 0,
            textTransform: "uppercase", letterSpacing: "0.04em" }}>
            {(e.keys && e.keys.length) ? "keyed" : "always"}</span>
          <span style={{ flex: 1 }}>{e.content}</span>
          {editing && setWorldBook && (
            <button onClick={() => del(e)} title="remove" style={{ background: "transparent",
              border: "none", color: T.inkFaint, cursor: "pointer", fontSize: 12, padding: 0 }}>×</button>
          )}
        </div>
      ))}
      {editing && setWorldBook && (
        <div style={{ display: "flex", gap: 6, marginTop: 4 }}>
          <textarea value={draft} onChange={(ev) => setDraft(ev.target.value)} rows={2}
            placeholder="e.g. PRIVATE: this study is staged; the learner is an actor."
            style={{ ...inputStyle(), flex: 1, fontSize: 11, resize: "vertical" }}/>
          <button onClick={add} style={{ alignSelf: "flex-start", padding: "4px 10px",
            fontSize: 11, fontWeight: 700, background: T.accent, color: T.paper,
            border: "none", borderRadius: 3, cursor: "pointer" }}>add</button>
        </div>
      )}
    </div>
  );
}

const MEM_KIND = {
  saw: { label: "saw", color: "#3a6ea5" },
  heard: { label: "heard", color: "#2f7a3f" },
  did: { label: "did", color: T.accent },
  seed: { label: "seed", color: "#8a6d3b" },
};
function _memClock(t) {
  if (typeof t !== "number" || !isFinite(t)) return "";
  const s = Math.max(0, Math.floor(t));
  const hh = Math.floor(s / 3600), mm = Math.floor((s % 3600) / 60), ss = s % 60;
  const p = (n) => String(n).padStart(2, "0");
  return hh ? `${hh}:${p(mm)}:${p(ss)}` : `${mm}:${p(ss)}`;
}
function MemoryListPanel({ entity }) {
  const items = Array.isArray(entity.status?.memory) ? entity.status.memory : [];
  if (items.length === 0) return null;
  const rows = items.slice().reverse();   // newest first
  return (
    <div style={{ marginTop: 10 }}>
      <div style={{ fontSize: 10, fontWeight: 700, color: T.inkMuted,
        letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 4 }}>
        Memory · {items.length}
      </div>
      <div style={{ maxHeight: 200, overflow: "auto",
        border: `1px solid ${T.ruleSoft}`, background: T.paperSoft }}>
        {rows.map((m, i) => {
          const k = MEM_KIND[m.kind];
          return (
            <div key={i} style={{
              padding: "4px 8px", fontSize: 11, color: T.ink,
              display: "flex", alignItems: "baseline", gap: 6,
              background: m.recalled ? `${T.accent}22` : "transparent",
              borderLeft: m.recalled ? `3px solid ${T.accent}` : "3px solid transparent",
              borderBottom: i < rows.length - 1 ? `1px dashed ${T.ruleSoft}` : "none",
              lineHeight: 1.4,
            }}>
              {k && (
                <span style={{ fontSize: 8.5, fontWeight: 700, color: "#fff",
                  background: k.color, borderRadius: 3, padding: "1px 4px",
                  letterSpacing: "0.04em", textTransform: "uppercase", flexShrink: 0 }}>
                  {k.label}</span>
              )}
              {typeof m.t === "number" && (
                <span style={{ fontSize: 9, color: T.inkFaint,
                  fontFamily: "ui-monospace, monospace", flexShrink: 0 }}>{_memClock(m.t)}</span>
              )}
              <span style={{ fontStyle: m.recalled ? "italic" : "normal" }}>
                {m.recalled && (
                  <span style={{ marginRight: 6, fontSize: 9, color: T.accent,
                    fontWeight: 700, letterSpacing: "0.06em" }}>RECALLED</span>
                )}
                {m.text}
              </span>
            </div>
          );
        })}
      </div>
    </div>
  );
}

function ActivitiesBlock({ activities, now }) {
  if (!activities || activities.length === 0) return null;
  const earliestStart = Math.min(...activities.map(a => a.start || 0));
  const latestEnd = Math.max(...activities.map(a => a.until || 0));
  const span = Math.max(1, latestEnd - earliestStart);
  return (
    <div style={{ marginTop: 10, paddingTop: 8, borderTop: `1px dashed ${T.ruleSoft}` }}>
      <div style={{ fontSize: 10, fontWeight: 700, color: T.inkMuted,
        letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 6 }}>
        Activities · {activities.length} track{activities.length > 1 ? "s" : ""}
      </div>
      <div style={{ display: "flex", flexWrap: "wrap", gap: 4, marginBottom: 8 }}>
        {activities.map((a, i) => {
          const c = TRACK_COLOR[a.track] || T.accent;
          return (
            <span key={i} title={`${a.what} (track: ${a.track})`} style={{
              fontSize: 10, padding: "2px 6px",
              background: c, color: "#fff", borderRadius: 8,
              fontWeight: 600, letterSpacing: "0.02em",
            }}>{a.track}: {a.what}</span>
          );
        })}
      </div>
      <div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
        {activities.map((a, i) => {
          const c = TRACK_COLOR[a.track] || T.accent;
          const startPct = ((a.start - earliestStart) / span) * 100;
          const widthPct = Math.max(4, ((a.until - a.start) / span) * 100);
          return (
            <div key={i} style={{ display: "flex", alignItems: "center", gap: 6 }}>
              <span style={{ width: 60, fontSize: 10, color: T.inkMuted,
                textAlign: "right", fontFamily: "ui-monospace, monospace" }}>{a.track}</span>
              <div style={{ flex: 1, height: 8, background: T.paperWarm,
                border: `1px solid ${T.ruleSoft}`, position: "relative" }}>
                <div style={{
                  position: "absolute", left: `${startPct}%`, top: 0,
                  width: `${widthPct}%`, height: "100%",
                  background: c, opacity: 0.85,
                }}/>
              </div>
              <span style={{ width: 56, fontSize: 10, color: T.inkFaint,
                fontFamily: "ui-monospace, monospace" }}>
                {a.start}→{a.until}
              </span>
            </div>
          );
        })}
      </div>
    </div>
  );
}

function StatusLogPanel({ entity, templates, onUpdate, editing = false }) {
  const tplDef = (templates && templates[entity.template]) || DEFAULT_TEMPLATES[entity.template] || null;
  const statuses = (tplDef?.statuses) || [];
  const status = entity.status || {};
  const log = entity.log || [];
  const activities = entity.activities || status.activities || [];
  const [filter, setFilter] = useState("all");

  const setStatusVal = (key, v) => {
    onUpdate({ status: { ...status, [key]: v } });
  };
  const filterOpts = ["all", "reasoning", "action", "interaction", "user", "rule"];
  const filtered = filter === "all" ? log : log.filter(l => (l.kind || "").startsWith(filter));

  return (
    <>
      <div style={{
        padding: "10px 12px 10px",
        borderBottom: `1px solid ${T.ruleSoft}`,
        background: T.paperWarm,
      }}>
        <div style={{
          fontSize: 10, fontWeight: 700, color: T.inkMuted,
          letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 8,
        }}>Status</div>
        {statuses.length === 0 && Object.keys(status).length === 0 && (
          <div style={{ fontSize: 11, color: T.inkFaint }}>No statuses yet.</div>
        )}
        {/* Fix #20: only render a template-declared status row when the entity
            actually carries that key. Loaded demo/trace entities seed status
            from world_0 (not from template defaults), so showing every template
            field would inject phantom defaults like energy:80 the scenario never
            defined. Keys present on status but absent from the template still
            render via the dynamic-extras block below, so nothing is lost. */}
        {statuses.filter((f) => Object.prototype.hasOwnProperty.call(status, f.key)).map((f) => (
          <div key={f.key} style={{ display: "flex", alignItems: "center", gap: 6 }}>
            <div style={{ flex: 1, minWidth: 0 }}>
              <StatusRow field={f} value={status[f.key]} editing={editing}
                onChange={(v) => setStatusVal(f.key, v)}/>
            </div>
            <VisibilityChip entity={entity} fieldKey={f.key} editing={editing} onUpdate={onUpdate}/>
          </div>
        ))}
        {/* v6: dynamic status keys — fields the engine added that the
            template didn't declare (hunger, mood, asset, etc.). Hide
            internal scaffolding (activities, location duplicated above).
            F6: each carries a visibility chip (public 🌐 / self 👁 / hidden 🔒)
            that round-trips via entity.visibility. */}
        {(() => {
          const known = new Set([
            ...statuses.map(s => s.key),
            "activities","busy","busy_until","doing","location","relationships",
            // BUGFIX (STATUS "[object Object]") — these are structured
            // array/object blobs the engine writes onto status. String()-coercing
            // them into a generic row renders "[object Object],[object Object]".
            // `memory` has its own MemoryPanel; the others render via their own
            // dedicated blocks (relationships above, persona vector, etc.) or are
            // internal scaffolding. Exclude them from the generic-row fallthrough.
            "memory","worldview","soul","perceptions","inbox","relationship",
          ]);
          // Defensive: also skip any remaining value that is a structured
          // array/object — a generic StatusRow can only render scalars; anything
          // else would String()-coerce to "[object Object]".
          const extras = Object.keys(status).filter(k => {
            if (known.has(k)) return false;
            const v = status[k];
            if (v !== null && typeof v === "object") return false;
            return true;
          });
          return extras.map((k) => (
            <div key={`dyn_${k}`} style={{ display: "flex", alignItems: "center", gap: 6 }}>
              <div style={{ flex: 1, minWidth: 0 }}>
                <StatusRow
                  field={{ key: k, type: typeof status[k] === "number" ? "int" :
                                           typeof status[k] === "boolean" ? "bool" : "text" }}
                  value={status[k]} editing={editing}
                  onChange={(v) => setStatusVal(k, v)}/>
              </div>
              <VisibilityChip entity={entity} fieldKey={k} editing={editing} onUpdate={onUpdate}/>
            </div>
          ));
        })()}
        {/* F2/F6 — live relationships from the engine (status.relationships).
            Writers watch resentment soften in real time. */}
        {status.relationships && typeof status.relationships === "object" && (
          <div style={{ marginTop: 8 }}>
            <div style={{
              fontSize: 10, fontWeight: 700, color: T.inkMuted,
              letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 4,
            }}>Relationships <span style={{ fontWeight: 400 }}><Ico path={ICO_EYE} size={11}/> self</span></div>
            {Object.entries(status.relationships).map(([target, r]) => (
              <div key={target} style={{
                display: "flex", alignItems: "center", gap: 8,
                fontSize: 11.5, padding: "2px 0", color: T.ink,
              }}>
                <b style={{ width: 80, overflow: "hidden", textOverflow: "ellipsis" }}>{target}</b>
                <span style={{ color: T.inkMuted, fontStyle: "italic", flex: 1 }}>{r?.stance || ""}</span>
                <span style={{
                  fontVariantNumeric: "tabular-nums",
                  fontFamily: "ui-monospace, monospace",
                  color: (r?.score ?? 0) < 0 ? "#c14545" : (r?.score ?? 0) > 30 ? "#2f7a3f" : T.inkMuted,
                }}>{r?.score ?? 0}</span>
              </div>
            ))}
          </div>
        )}
        {/* Persona vector merged into Status — appears as additional bar
            rows on humans, alongside the template's own statuses.
            Fix #20: only render when the entity actually defines a persona
            vector (seed entities carry one; loaded demo/trace agents do not).
            Otherwise the five traits would all show a phantom 0.50 default the
            scenario never defined. */}
        {entity.template === "human" && (hasPersonaVector(entity) || editing) && (
          <PersonaVectorRows entity={entity} editing={editing} onUpdate={onUpdate}/>
        )}
        {activities.length > 0 && (
          <ActivitiesBlock activities={activities} now={status.busy_until || 0}/>
        )}
        <MoodWidget entity={entity}/>
        <DriveBars entity={entity}/>
        {/* BUGFIX (STATUS "[object Object]") — status.memory is now excluded
            from the generic-row fallthrough (it's an array of objects). Render
            it here via its own MemoryListPanel so it stays visible instead of
            being lost. The panel returns null when there is no memory. */}
        <MemoryListPanel entity={entity}/>
      </div>
      {entity.template === "human" && (
        <SoulBlock entity={entity} editing={editing} onUpdate={onUpdate}/>
      )}
      {entity.template === "human" && (
        <ValidationBlock entity={entity}/>
      )}
      <div style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}>
        <div style={{
          padding: "8px 12px 4px",
          borderBottom: `1px solid ${T.ruleSoft}`,
          display: "flex", alignItems: "center", gap: 6,
        }}>
          <span style={{
            fontSize: 10, fontWeight: 700, color: T.inkMuted,
            letterSpacing: "0.08em", textTransform: "uppercase",
          }}>Log</span>
          <select value={filter} onChange={(e) => setFilter(e.target.value)}
            style={{ ...tplInputStyle(false), fontSize: 10, padding: "1px 4px", marginLeft: "auto" }}>
            {filterOpts.map(o => <option key={o} value={o}>{o}</option>)}
          </select>
        </div>
        <div style={{ flex: 1, overflow: "auto", padding: "4px 10px 10px", fontSize: 11, lineHeight: 1.5 }}>
          {filtered.length === 0 && (
            <div style={{ color: T.inkFaint, padding: "6px 0" }}>(no entries)</div>
          )}
          {filtered.slice().reverse().map((l, i) => (
            <div key={i} style={{
              padding: "4px 6px", marginBottom: 3,
              borderLeft: `2px solid ${logColor(l.kind)}`,
              background: T.paperWarm,
            }}>
              <div style={{ fontSize: 9, color: T.inkFaint, fontFamily: "ui-monospace, monospace" }}>
                t={l.t ?? "?"} · {l.kind || "log"}
              </div>
              <div style={{ color: T.ink }}>{l.text || l.msg || JSON.stringify(l.payload || {})}</div>
            </div>
          ))}
        </div>
      </div>
    </>
  );
}
// Status row — always shown the same way:
// • int with min/max → thin horizontal bar (rule track, accent fill, mono value)
// • int without min/max → editable number input
// • bool → checkbox styled as a small accent dot pill
// • enum → inline select that looks like a label
// • text → minimal input that reads like a value
// No edit-mode toggle: rows behave the same whether or not the user is "editing".
// F6 — per-field visibility (engine B2). public = everyone perceives it,
// self = only the owner's reasoning sees it, hidden = only the world/
// adjudicator knows. Unlisted fields default to self.
const VIS_ORDER = ["public", "self", "hidden"];
const VIS_GLYPH = { public: ICO_GLOBE, self: ICO_EYE, hidden: ICO_LOCK };
function VisibilityChip({ entity, fieldKey, editing, onUpdate }) {
  const vis = (entity.visibility || {})[fieldKey] || "self";
  if (!editing) {
    if (vis === "self") return null; // default — don't clutter
    return <span title={`visibility: ${vis}`} style={{ fontSize: 11, flexShrink: 0 }}><Ico path={VIS_GLYPH[vis]} size={11}/></span>;
  }
  return (
    <button
      title={`visibility: ${vis} — click to cycle (public → self → hidden)`}
      onClick={() => {
        const next = VIS_ORDER[(VIS_ORDER.indexOf(vis) + 1) % VIS_ORDER.length];
        onUpdate({ visibility: { ...(entity.visibility || {}), [fieldKey]: next } });
      }}
      style={{
        flexShrink: 0, background: "transparent",
        border: `1px solid ${T.ruleSoft}`, borderRadius: 3,
        padding: "1px 6px", fontSize: 10, cursor: "pointer",
        color: vis === "hidden" ? "#7a4a26" : T.inkMuted,
      }}>
      <Ico path={VIS_GLYPH[vis]} size={11}/> {vis}
    </button>
  );
}

function StatusRow({ field, value, editing = false, onChange }) {
  const f = field;
  const v = value ?? f.default;
  const ro = !editing;
  const key = (
    <span style={{
      width: 78, fontSize: 10.5, color: T.inkMuted,
      fontFamily: "ui-monospace, monospace", letterSpacing: "0.02em",
    }}>{f.key}</span>
  );

  if (f.type === "int" && Number.isFinite(f.min) && Number.isFinite(f.max)) {
    const span = (f.max - f.min) || 1;
    const pct = Math.max(0, Math.min(1, (Number(v) - f.min) / span));
    return (
      <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 6 }}>
        {key}
        <div
          onClick={(e) => {
            if (ro) return;
            const r = e.currentTarget.getBoundingClientRect();
            const p = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
            onChange(Math.round(f.min + p * span));
          }}
          style={{
            flex: 1, height: 6, background: T.rule, position: "relative",
            cursor: ro ? "default" : "pointer",
          }}>
          <div style={{
            position: "absolute", left: 0, top: 0, bottom: 0,
            width: `${pct * 100}%`, background: T.accent,
          }}/>
        </div>
        <span style={{
          width: 30, fontSize: 10.5, textAlign: "right",
          fontFamily: "ui-monospace, monospace", color: T.ink,
        }}>{v}</span>
      </div>
    );
  }
  if (f.type === "bool") {
    return (
      <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 6 }}>
        {key}
        <button onClick={() => !ro && onChange(!v)} disabled={ro} style={{
          marginLeft: "auto", padding: "1px 8px", fontSize: 11,
          background: "transparent", color: v ? T.accent : T.inkFaint,
          border: "none", cursor: ro ? "default" : "pointer",
          fontFamily: "ui-monospace, monospace",
        }}>{v ? "● yes" : "○ no"}</button>
      </div>
    );
  }
  if (f.type === "enum") {
    return (
      <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 6 }}>
        {key}
        <select value={v ?? ""} disabled={ro}
          onChange={(e) => onChange(e.target.value)}
          style={{
            marginLeft: "auto", textAlign: "right", appearance: "none",
            background: "transparent", border: "none",
            fontSize: 11.5, color: T.ink, cursor: ro ? "default" : "pointer",
            padding: 0, fontFamily: "inherit", outline: "none",
          }}>
          {(f.values || []).map(o => <option key={o} value={o}>{o}</option>)}
        </select>
      </div>
    );
  }
  if (f.type === "int") {
    return (
      <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 6 }}>
        {key}
        <input type="number" value={v ?? 0} readOnly={ro}
          onChange={(e) => onChange(Number(e.target.value))}
          style={{
            marginLeft: "auto", width: 60, textAlign: "right",
            background: "transparent", border: "none", outline: "none",
            fontSize: 11.5, fontFamily: "ui-monospace, monospace", color: T.ink,
            cursor: ro ? "default" : "text",
          }}/>
      </div>
    );
  }
  // text fallback
  return (
    <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 6 }}>
      {key}
      <input value={v ?? ""} readOnly={ro}
        onChange={(e) => onChange(e.target.value)} placeholder="—"
        style={{
          marginLeft: "auto", textAlign: "right", flex: 1,
          background: "transparent", border: "none", outline: "none",
          fontSize: 11.5, color: T.ink,
          cursor: ro ? "default" : "text",
        }}/>
    </div>
  );
}

// Persona Vector — five 0..1 traits drawn as bar meters (paper Stage-02 style).
// Lives at entity.personaVector. Seed entities are pre-populated; others
// default to 0.5 across the board.
const PERSONA_VECTOR_KEYS = ["warmth", "assertive", "openness", "risk-taking", "honesty"];
// Fix #20: an entity "has" a persona vector only if it actually defines one —
// either a non-empty personaVector object, or one of the trait keys living in
// its status. Loaded demo/trace agents have neither, so the section stays hidden
// rather than fabricating 0.50 defaults.
function hasPersonaVector(entity) {
  const pv = entity && entity.personaVector;
  if (pv && typeof pv === "object" && PERSONA_VECTOR_KEYS.some(k => k in pv)) return true;
  const st = entity && entity.status;
  if (st && typeof st === "object" && PERSONA_VECTOR_KEYS.some(k => k in st)) return true;
  return false;
}
function PersonaVectorRows({ entity, editing, onUpdate }) {
  const ro = !editing;
  const pv = entity.personaVector || {};
  const setVal = (k, val) => {
    const next = { ...pv, [k]: Math.max(0, Math.min(1, val)) };
    onUpdate({ personaVector: next });
  };
  return (
    <>
      {PERSONA_VECTOR_KEYS.map(k => {
        const v = typeof pv[k] === "number" ? pv[k] : 0.5;
        return (
          <div key={k} style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 6 }}>
            <span style={{
              width: 78, fontSize: 10.5, color: T.inkMuted,
              fontFamily: "ui-monospace, monospace",
            }}>{k}</span>
            <div
              onClick={(e) => {
                if (ro) return;
                const r = e.currentTarget.getBoundingClientRect();
                setVal(k, (e.clientX - r.left) / r.width);
              }}
              style={{
                flex: 1, height: 6, background: T.rule, position: "relative",
                cursor: ro ? "default" : "pointer",
              }}>
              <div style={{
                position: "absolute", left: 0, top: 0, bottom: 0,
                width: `${v * 100}%`, background: T.accent,
              }}/>
            </div>
            <span style={{
              width: 30, fontSize: 10.5, textAlign: "right",
              fontFamily: "ui-monospace, monospace", color: T.ink,
            }}>{v.toFixed(2)}</span>
          </div>
        );
      })}
    </>
  );
}

// Soul — two parts:
// • Episodic memory: short rolling list of observed events (driven by sim)
// • Worldview prose: a long descriptive paragraph — how this agent sees
//   themselves, others, and the world. Free-form text. Auto-extended by
//   the periodic refresh and freely editable by the user.
function SoulBlock({ entity, editing, onUpdate }) {
  const ro = !editing;
  const soul = entity.soul || "";
  return (
    <div style={{ padding: "10px 12px", borderBottom: `1px solid ${T.ruleSoft}`, background: T.paperSoft }}>
      <div style={{
        fontSize: 10, fontWeight: 700, color: T.inkMuted,
        letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 6,
      }}>Belief</div>
      {ro ? (
        <div style={{
          fontSize: 12, lineHeight: 1.55, color: T.ink,
          fontStyle: "italic", whiteSpace: "pre-wrap",
          padding: "4px 0", borderBottom: `1px dashed ${T.ruleSoft}`,
          minHeight: 24,
        }}>
          {soul ? soul : (
            <span style={{ color: T.inkFaint, fontStyle: "normal" }}>
              (no belief written yet)
            </span>
          )}
        </div>
      ) : (
        <textarea value={soul}
          onChange={(e) => onUpdate({ soul: e.target.value })}
          placeholder={'How they see themselves, others, the world. e.g. "I am quieter than I look. Oren is loud but means well. The kitchen is the only honest room in this house."'}
          rows={6}
          style={{
            width: "100%", padding: "6px 8px", fontSize: 12, lineHeight: 1.55,
            background: T.paperWarm, color: T.ink,
            border: `1px solid ${T.rule}`, outline: "none",
            fontFamily: "inherit", resize: "vertical", boxSizing: "border-box",
          }}/>
      )}
    </div>
  );
}

// Validation — derives checks from entity fields. Greens for satisfied,
// accent ! for soft warnings. Matches paper.jsx Validation block.
function ValidationBlock({ entity }) {
  const checks = [];
  const warn = [];
  // BUGFIX (stale VALIDATION warnings) — point the persona/goal check at the
  // SAME fields the PROFILE tab reads (persona + goal; `profile` is an optional
  // secondary field there, not required to be populated). Previously this
  // required `profile && persona && goal`, so an agent whose PROFILE shows a
  // full persona/goal but no `profile` string still warned "persona missing".
  if (entity.persona && entity.goal) {
    checks.push("Persona + goal set");
  } else {
    warn.push("Persona / goal missing");
  }
  if (!entity.placedIn) {
    warn.push("No spawn room assigned");
  }
  if (!entity.background) {
    warn.push("Consider adding a background");
  }
  // BUGFIX (stale VALIDATION warnings) — count attached actions AND actions the
  // agent is actually using in the replay trace. An agent that is actively
  // acting (e.g. Milgram Teacher running administer_shock) has its action verbs
  // recorded in entity.log even when pickedActions is empty (loaded traces seed
  // status/log, not pickedActions). open_vocab agents may improvise too.
  const attached = (entity.pickedActions || []).length;
  const usedInTrace = Array.isArray(entity.log) && entity.log.some(
    l => l && (l.kind === "action" || (l.verb && l.kind !== "memory")));
  if (attached === 0 && !usedInTrace && !entity.open_vocab) {
    warn.push("No actions enabled");
  } else if (usedInTrace && attached === 0) {
    checks.push("Actions in use (from trace)");
  }
  if (warn.length === 0 && entity.goal && !entity.background) {
    warn.push("Consider adding a fallback goal");
  }
  return (
    <div style={{ padding: "10px 12px", background: T.paperSoft }}>
      <div style={{
        fontSize: 10, fontWeight: 700, color: T.inkMuted,
        letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 6,
      }}>Validation</div>
      <div style={{ fontSize: 12, lineHeight: 1.55 }}>
        {checks.map((c, i) => (
          <div key={i} style={{ color: "#3a7a3a" }}>✓ {c}</div>
        ))}
        {warn.map((c, i) => (
          <div key={i} style={{ color: T.accent }}>! {c}</div>
        ))}
      </div>
    </div>
  );
}

function logColor(kind) {
  if (!kind) return T.inkFaint;
  if (kind.startsWith("reasoning")) return T.agent;
  if (kind.startsWith("action"))    return T.action;
  if (kind.startsWith("interaction")) return T.scene;
  if (kind.startsWith("user"))      return T.accent;
  if (kind.startsWith("rule"))      return T.danger;
  if (kind.startsWith("soul"))      return T.object;
  return T.inkFaint;
}

// ─── OOP: object-scoped components on an instance ────────────────────
// Components are either OBJECT-SCOPED (live on an Object/class) or
// WORLD-SCOPED (live on the Environment engine). The inspector below
// shows an instance's object-scoped components: those INHERITED from its
// template (class) chain plus the ones it OWNS / overrides / removes.
const OBJECT_SCOPED_TYPES = ["middleware", "attribute", "action"];

// Frontend mirror of the backend: root→…→tname (cycle-safe).
function templateChain(tname, templates) {
  const chain = [];
  const seen = new Set();
  let cur = tname;
  let guard = 0;
  while (cur && templates && templates[cur] && !seen.has(cur) && guard++ < 64) {
    seen.add(cur);
    chain.unshift(cur);
    cur = templates[cur].extends;
  }
  return chain;
}
// Default id for a class component, mirroring the backend:
//   `${type}:${name||preset||verb||t}`
function classComponentId(comp) {
  if (comp.id) return comp.id;
  const t = comp.type || "component";
  const tail = comp.name || comp.preset || comp.verb || t;
  return `${t}:${tail}`;
}
// Ordered map id→comp across a template's whole chain; a child class
// overrides a parent's component when they share an id.
function classComponents(tname, templates) {
  const out = new Map();
  for (const tn of templateChain(tname, templates)) {
    const tpl = (templates && templates[tn]) || null;
    if (!tpl) continue;
    for (const c of (tpl.components || [])) {
      out.set(classComponentId(c), { ...c, _class: tn });
    }
  }
  return out;
}

// "Object components" — inherited (class) + own, with add/override/remove.
function ObjectComponentsSection({ entity, templates = {}, components = [],
                                   setComponents, onUpdate, editing = false }) {
  const hasTemplates = templates && Object.keys(templates).length > 0;
  const removed = entity.remove_components || [];
  // Own = this instance's object-scoped components.
  const own = (components || []).filter(c =>
    c && c.entity === entity.id && OBJECT_SCOPED_TYPES.includes(c.type));
  const ownIds = new Set(own.map(c => classComponentId(c)));
  // Inherited = class-chain components minus what the instance overrides or removes.
  const inheritedAll = entity.template ? classComponents(entity.template, templates) : new Map();
  const inherited = [];
  for (const [id, c] of inheritedAll) {
    if (!OBJECT_SCOPED_TYPES.includes(c.type)) continue;
    if (ownIds.has(id)) continue;        // overridden by an own component
    if (removed.includes(id)) continue;  // removed on this instance
    inherited.push([id, c]);
  }

  const cid = () => `cmp_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
  const updateComp = (id, patch) =>
    setComponents((components || []).map(c => c.id === id ? { ...c, ...patch } : c));
  const removeOwn = (id) =>
    setComponents((components || []).filter(c => c.id !== id));
  const overrideInherited = (id, c) => {
    // Copy the inherited component into an OWN component with the same id so
    // the user can edit it. `entity` pins it to this instance.
    const { _class, ...rest } = c;
    setComponents([...(components || []), { ...rest, id, entity: entity.id }]);
  };
  const removeInherited = (id) =>
    onUpdate({ remove_components: [...removed, id] });
  const restoreRemoved = (id) =>
    onUpdate({ remove_components: removed.filter(x => x !== id) });
  const addOwn = (type) => {
    const base =
      type === "middleware" ? { type: "middleware", preset: "mood", name: "mood" }
      : type === "attribute" ? { type: "attribute", name: "trait", range: [0, 100], init: 50, visibility: "public" }
      : { type: "action", name: "custom action", verb: "do_something", duration: 3, effect: {} };
    setComponents([...(components || []), { ...base, id: cid(), entity: entity.id }]);
  };

  const labelFont = {
    fontSize: 10, fontWeight: 700, color: T.inkMuted,
    letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 6,
  };
  const badge = (txt, tint) => (
    <span style={{
      fontSize: 9, fontWeight: 700, letterSpacing: "0.04em",
      color: tint || T.inkMuted, border: `1px solid ${T.ruleSoft}`,
      borderRadius: 3, padding: "0 5px", whiteSpace: "nowrap",
    }}>{txt}</span>
  );
  const smallBtn = (onClick, txt, key, dataTour) => (
    <button key={key} onClick={onClick} data-tour={dataTour} style={{
      background: "transparent", border: `1px solid ${T.rule}`,
      color: T.inkMuted, cursor: "pointer", padding: "1px 7px",
      fontSize: 10, borderRadius: 3, letterSpacing: "0.04em",
    }}>{txt}</button>
  );

  return (
    <div style={{ borderTop: `1px dashed ${T.rule}`, paddingTop: 10, marginTop: 4 }}>
      <div style={labelFont}>Object components</div>
      {/* INHERITED — read-only, with override / remove. */}
      {hasTemplates && inherited.length > 0 && (
        <div style={{ marginBottom: 8 }}>
          {inherited.map(([id, c]) => (
            <div key={id} style={{
              display: "flex", alignItems: "center", gap: 8,
              padding: "5px 8px", marginBottom: 4, fontSize: 12,
              background: T.paperWarm, border: `1px solid ${T.ruleSoft}`, borderRadius: 3,
            }}>
              <span style={{
                fontFamily: "ui-monospace, monospace", fontSize: 10, color: T.inkFaint,
                width: 78, textTransform: "uppercase",
              }}>{c.type}{c.preset ? `·${c.preset}` : ""}</span>
              <span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
                {c.name || c.verb || c.preset || id}
              </span>
              {badge(`inherited · ${templates[c._class]?.label || c._class}`)}
              {editing && smallBtn(() => overrideInherited(id, c), "Override")}
              {editing && smallBtn(() => removeInherited(id), "Remove")}
            </div>
          ))}
        </div>
      )}
      {/* REMOVED — let the user restore a removed inherited component. */}
      {hasTemplates && editing && removed.length > 0 && (
        <div style={{ marginBottom: 8 }}>
          {removed.map(id => (
            <div key={id} style={{
              display: "flex", alignItems: "center", gap: 8,
              padding: "4px 8px", marginBottom: 4, fontSize: 11.5,
              color: T.inkFaint, textDecoration: "line-through",
            }}>
              <span style={{ flex: 1, minWidth: 0 }}>{id}</span>
              {badge("removed")}
              {smallBtn(() => restoreRemoved(id), "Restore")}
            </div>
          ))}
        </div>
      )}
      {/* OWN — editable component rows. */}
      {own.length > 0 && (
        <div style={{ marginBottom: 6 }}>
          {own.map(c => (
            <ComponentRow key={c.id} comp={c} agents={[]} scenes={[]}
              onChange={(patch) => updateComp(c.id, patch)}
              onRemove={() => removeOwn(c.id)}
              classMode/>
          ))}
        </div>
      )}
      {own.length === 0 && inherited.length === 0 && (
        <div style={{ fontSize: 11, color: T.inkFaint, marginBottom: 6 }}>
          no object components yet
        </div>
      )}
      {editing && (
        <div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
          {smallBtn(() => addOwn("middleware"), "+ middleware", "obj-mw", "obj-add-middleware")}
          {smallBtn(() => addOwn("attribute"), "+ attribute", "obj-attr", "obj-add-attribute")}
          {smallBtn(() => addOwn("action"), "+ action", "obj-act")}
        </div>
      )}
    </div>
  );
}

// ─── INSPECTORS ──────────────────────────────────────────────────────
function InspectorRouter(props) {
  const { entity, templates, onUpdate } = props;
  // Edit toggles whether inputs accept changes. The LAYOUT never changes.
  const [editing, setEditing] = useState(false);
  const onToggleEdit = () => setEditing(e => !e);
  const aside = entity.kind === "action" ? null : (
    <StatusLogPanel entity={entity} templates={templates}
      onUpdate={onUpdate} editing={editing}/>
  );
  const withAside = { ...props, aside, editing, onToggleEdit };
  if (entity.kind === "scene")  return <SceneInspector {...withAside}/>;
  if (entity.kind === "object") return <ObjectInspector {...withAside}/>;
  if (entity.kind === "agent")  return <AgentInspector {...withAside}/>;
  if (entity.kind === "action") return <ActionInspector {...withAside}/>;
  return null;
}

function InspectorBody({ children }) {
  return (
    <div style={{
      padding: 12, display: "flex", flexDirection: "column", gap: 10,
      flex: 1, minHeight: 0, overflow: "auto",
      background: T.paperSoft, color: T.ink,
    }}>{children}</div>
  );
}

// Shared "Advanced" collapsible block — perception fields (vision/hearing
// range, walls-block, interruptible_by). Defaults come from the kind's
// DEFAULT_PERCEPTION map; per-entity overrides are stored on entity.perception.
function AdvancedBlock({ entity, editing, onUpdate }) {
  const [open, setOpen] = useState(false);
  const p = entity.perception || DEFAULT_PERCEPTION[entity.kind] || DEFAULT_PERCEPTION.object;
  const set = (patch) => onUpdate({ perception: { ...p, ...patch } });
  const reset = () => onUpdate({ perception: { ...(DEFAULT_PERCEPTION[entity.kind] || DEFAULT_PERCEPTION.object) } });
  const ro = !editing;
  const numStyle = { width: 80, fontSize: 12, padding: ro ? "2px 0" : "4px 6px",
    border: ro ? "none" : `1px solid ${T.rule}`,
    borderBottom: ro ? `1px dashed ${T.ruleSoft}` : `1px solid ${T.rule}`,
    background: ro ? "transparent" : T.paperWarm, color: T.ink,
    fontFamily: "inherit" };
  const txtStyle = { ...numStyle, width: "100%" };
  return (
    <div style={{
      borderTop: `1px solid ${T.ruleSoft}`, marginTop: 6, paddingTop: 6,
    }}>
      <button onClick={() => setOpen(!open)} style={{
        background: "transparent", border: "none", padding: 0,
        fontSize: 10, fontWeight: 700, letterSpacing: "0.08em",
        textTransform: "uppercase", color: T.inkMuted, cursor: "pointer",
        display: "flex", alignItems: "center", gap: 6,
      }}>{open ? "▾" : "▸"} Advanced · perception</button>
      {open && (
        <div style={{ display: "flex", flexDirection: "column", gap: 6, marginTop: 8 }}>
          <div style={{ display: "flex", gap: 10, alignItems: "center" }}>
            <span style={{ width: 130, fontSize: 11, color: T.inkMuted }}>Vision range (tiles)</span>
            <input type="number" min={0} value={p.vision_range}
              disabled={ro}
              onChange={(e) => set({ vision_range: Math.max(0, +e.target.value || 0) })}
              style={numStyle}/>
          </div>
          <div style={{ display: "flex", gap: 10, alignItems: "center" }}>
            <span style={{ width: 130, fontSize: 11, color: T.inkMuted }}>Hearing range (tiles)</span>
            <input type="number" min={0} value={p.hearing_range}
              disabled={ro}
              onChange={(e) => set({ hearing_range: Math.max(0, +e.target.value || 0) })}
              style={numStyle}/>
          </div>
          <div style={{ display: "flex", gap: 10, alignItems: "center" }}>
            <span style={{ width: 130, fontSize: 11, color: T.inkMuted }}>Walls block vision</span>
            <input type="checkbox" checked={!!p.vision_walls_block}
              disabled={ro}
              onChange={(e) => set({ vision_walls_block: e.target.checked })}/>
          </div>
          <div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
            <span style={{ fontSize: 11, color: T.inkMuted }}>Interruptible by (free-text rule)</span>
            <textarea value={p.interruptible_by || ""}
              disabled={ro}
              onChange={(e) => set({ interruptible_by: e.target.value })}
              placeholder="e.g. loud noise within hearing range, or any touch"
              rows={2} style={txtStyle}/>
          </div>
          {editing && (
            <button onClick={reset} style={{
              alignSelf: "flex-start", marginTop: 4,
              background: "transparent", border: `1px solid ${T.rule}`,
              padding: "2px 8px", fontSize: 11, color: T.inkMuted, cursor: "pointer",
            }}>Reset to default</button>
          )}
        </div>
      )}
    </div>
  );
}

function SceneInspector({ win, entity, allEntities, onClose, onFocus, onMove, onUpdate, onDelete, aside, editing = false, onToggleEdit }) {
  const otherScenes = allEntities.filter(e => e.kind === "scene" && e.id !== entity.id);
  const contained = allEntities.filter(e => (e.kind === "agent" || e.kind === "object") && e.placedIn === entity.id);
  return (
    <FloatingWindow win={win}
      title={entity.name || "(unnamed scene)"}
      glyph={<KindGlyph kind="scene"/>} badge="scene"
      aside={aside} editing={editing} onToggleEdit={onToggleEdit}
      onClose={onClose} onFocus={onFocus} onMove={onMove} onDelete={onDelete}>
      <InspectorBody>
        <Field label="Name">
          <input value={entity.name} onChange={e => onUpdate({ name: e.target.value })}
            style={inputStyle()}/>
        </Field>
        <Field label="Size (w × h)">
          <div style={{ display: "flex", gap: 6 }}>
            <input type="number" min={140} value={entity.w}
              onChange={e => onUpdate({ w: Math.max(140, +e.target.value || 140) })}
              style={inputStyle()}/>
            <input type="number" min={110} value={entity.h}
              onChange={e => onUpdate({ h: Math.max(110, +e.target.value || 110) })}
              style={inputStyle()}/>
          </div>
        </Field>
        {/* F4 — scenes carry layered descriptions too (engine B2). */}
        <Field label="What it looks like · appearance">
          <textarea value={entity.appearance || ""} rows={2}
            placeholder="red-wood furniture under the late father's portrait"
            onChange={e => onUpdate({ appearance: e.target.value })}
            style={inputStyle()}/>
        </Field>
        <Field label={<><Ico path={ICO_LOCK} size={11}/> Hidden truth · only the world knows</>}>
          <textarea value={entity.hidden || ""} rows={2}
            placeholder="the safe code is the mother's death-day; the real will is inside"
            onChange={e => onUpdate({ hidden: e.target.value })}
            style={{ ...inputStyle(), background: "#f3ead9", borderColor: "#c9b27a" }}/>
        </Field>
        <Field label="Connects to (other scenes)">
          {otherScenes.length === 0 && (
            <div style={{ fontSize: 11, color: T.inkFaint }}>No other scenes yet.</div>
          )}
          <div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
            {otherScenes.map(s => {
              const checked = (entity.connects || []).includes(s.id);
              return (
                <label key={s.id} style={checkLabelStyle()}>
                  <input type="checkbox" checked={checked}
                    onChange={() => {
                      const list = entity.connects || [];
                      onUpdate({
                        connects: checked ? list.filter(x => x !== s.id) : [...list, s.id],
                      });
                    }}/>
                  <KindGlyph kind="scene" small/>
                  <span style={{ fontSize: 12 }}>{s.name}</span>
                </label>
              );
            })}
          </div>
        </Field>
        <Field label="Rules (LLM context)">
          <textarea value={entity.rules}
            onChange={e => onUpdate({ rules: e.target.value })}
            rows={3} style={inputStyle()}
            placeholder='e.g. "Quiet voices only. Door stays cracked."'/>
        </Field>
        <Field label={`Contains · ${contained.length}`}>
          {contained.length === 0 && (
            <div style={{ fontSize: 11, color: T.inkFaint }}>
              Drag agents/objects into this scene.
            </div>
          )}
          <div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
            {contained.map(c => (
              <div key={c.id} style={{
                display: "flex", alignItems: "center", gap: 8,
                padding: "3px 6px", fontSize: 12,
                background: T.paperWarm, border: `1px solid ${T.ruleSoft}`, borderRadius: 3,
              }}>
                <span style={{
                  width: 20, height: 20, display: "inline-flex",
                  alignItems: "center", justifyContent: "center",
                  background: T.paperWarm, border: `1px solid ${T.ruleSoft}`, padding: 1,
                }}>
                  {c.kind === "agent"
                    ? <MiniAvatar agent={c} size={16}/>
                    : <ObjectSprite entity={c} scale={1}/>}
                </span>
                <span style={{ flex: 1 }}>{c.name}</span>
              </div>
            ))}
          </div>
        </Field>
        <MetaRow entity={entity}/>
        <AdvancedBlock entity={entity} editing={editing} onUpdate={onUpdate}/>
      </InspectorBody>
    </FloatingWindow>
  );
}

function ObjectInspector({ win, entity, scenes, onClose, onFocus, onMove, onUpdate, onDelete, aside, editing = false, onToggleEdit, templates = {}, components = [], setComponents }) {
  const ro = !editing;
  const [spriteEditor, setSpriteEditor] = useState(false);
  return (
    <FloatingWindow win={win}
      title={entity.name || "(unnamed object)"}
      glyph={<KindGlyph kind="object"/>} badge="object"
      aside={aside} editing={editing} onToggleEdit={onToggleEdit}
      onClose={onClose} onFocus={onFocus} onMove={onMove} onDelete={onDelete}>
      {spriteEditor && (
        <SpriteEditorModal
          initialPixels={entity.spritePixels}
          initialW={16} initialH={16}
          title={`Sprite · ${entity.name || "object"}`}
          onSave={({ pixels }) => { onUpdate({ spritePixels: pixels }); setSpriteEditor(false); }}
          onClose={() => setSpriteEditor(false)}/>
      )}
      <InspectorBody>
        <div style={{
          display: "flex", gap: 12, alignItems: "center",
          padding: "4px 0 8px",
        }}>
          <div style={{
            width: 72, height: 72, flexShrink: 0,
            background: T.paperWarm, border: `1px solid ${T.rule}`,
            display: "grid", placeItems: "center", position: "relative",
          }}>
            <ObjectSprite entity={entity} scale={3} templates={templates}/>
            {editing && (
              <button onClick={() => setSpriteEditor(true)}
                title="Edit sprite"
                style={{
                  position: "absolute", right: -6, bottom: -6,
                  width: 22, height: 22, borderRadius: "50%",
                  background: T.accent, color: "#fff",
                  border: `2px solid ${T.paper}`, cursor: "pointer",
                  fontSize: 11, padding: 0,
                }}><Ico path={ICO_PENCIL} size={11} color="#fff"/></button>
            )}
          </div>
          <div style={{ flex: 1, minWidth: 0 }}>
            <input value={entity.name || ""}
              placeholder="(unnamed)" readOnly={ro}
              onChange={e => onUpdate({ name: e.target.value })}
              style={{
                width: "100%", fontStyle: "italic", fontSize: 17, fontWeight: 500,
                background: "transparent", border: "none", outline: "none",
                color: T.ink, padding: 0,
                cursor: ro ? "default" : "text",
              }}/>
            <div style={{ fontSize: 11, color: T.inkMuted, marginTop: 2 }}>
              {entity.template || "item"} sprite — derived from name
            </div>
            {/* Object tint — `color` already drives the rendered object glyph. */}
            <label style={{
              display: "flex", alignItems: "center", gap: 6, marginTop: 6,
              fontSize: 11, color: T.inkMuted,
            }}>
              <span>color</span>
              <input type="color"
                value={entity.color || T.object || "#c9a23a"}
                disabled={ro}
                onChange={e => onUpdate({ color: e.target.value })}
                style={{
                  width: 32, height: 22, padding: 0,
                  cursor: ro ? "default" : "pointer",
                  background: "transparent", border: `1px solid ${T.rule}`,
                }}/>
              {entity.color && !ro && (
                <button onClick={() => onUpdate({ color: null })}
                  title="Reset to default object color"
                  style={{
                    fontSize: 10, padding: "2px 6px",
                    background: "transparent", color: T.inkMuted,
                    border: `1px solid ${T.rule}`, cursor: "pointer",
                  }}>reset</button>
              )}
            </label>
          </div>
        </div>
        <Field label="Placed in scene">
          <select value={entity.placedIn || ""} disabled={ro}
            onChange={e => onUpdate({ placedIn: e.target.value || null })}
            style={inputStyle()}>
            <option value="">— unplaced —</option>
            {scenes.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
          </select>
        </Field>
        <Field label="Description">
          <textarea value={entity.note} readOnly={ro}
            onChange={e => onUpdate({ note: e.target.value })}
            rows={4} style={inputStyle()}
            placeholder="What this object is and how agents can use it."/>
        </Field>
        <BrainSelector entity={entity} editing={editing} onUpdate={onUpdate}/>
        <PerceptionSelector entity={entity} editing={editing} onUpdate={onUpdate}/>
        <CadenceField entity={entity} editing={editing} onUpdate={onUpdate}/>
        {setComponents && (
          <ObjectComponentsSection entity={entity} templates={templates}
            components={components} setComponents={setComponents}
            onUpdate={onUpdate} editing={editing}/>
        )}
        <ObjectAppearanceSection entity={entity} templates={templates}
          onUpdate={onUpdate} editing={editing}/>
        <MetaRow entity={entity}/>
        <AdvancedBlock entity={entity} editing={editing} onUpdate={onUpdate}/>
      </InspectorBody>
    </FloatingWindow>
  );
}

// ─── INSTANCE APPEARANCE OVERRIDE (slice 2a) ─────────────────────────────
// An instance inherits its template's visual (resolveVisual reads instance
// `visual` first, then walks the class chain). This section surfaces that:
// it shows whether the appearance is INHERITED FROM TEMPLATE or OVERRIDDEN ON
// THIS INSTANCE, lets the user start an override (seeded from the inherited
// visual so they tweak rather than start blank), edits it via the shared
// AppearanceEditor, and reverts to inherited by clearing the instance `visual`.
function ObjectAppearanceSection({ entity, templates = {}, onUpdate, editing = false }) {
  // Inherited = the template-chain visual only (ignore the instance value).
  const inherited = React.useMemo(
    () => resolveVisual({ template: entity.template }, templates),
    [entity.template, templates]);
  // An instance is "overridden" the moment it carries its OWN visual object —
  // even one with an empty base SVG (e.g. a deliberately blank override, or a
  // rules-only tweak in progress). Reverting clears entity.visual entirely.
  const overridden = !!(entity.visual && typeof entity.visual === "object");
  const ro = !editing;

  const startOverride = () => {
    // Seed from the inherited visual so the user edits a copy, not a blank.
    const seed = inherited
      ? { baseSvg: inherited.baseSvg,
          rules: Array.isArray(inherited.rules) ? inherited.rules.map(r => ({ ...r })) : [] }
      : { baseSvg: "", rules: [] };
    onUpdate({ visual: seed });
  };
  const revert = () => onUpdate({ visual: null });

  const badge = (text, tint) => (
    <span style={{
      fontSize: 9.5, fontWeight: 700, letterSpacing: "0.06em",
      textTransform: "uppercase", padding: "2px 6px", borderRadius: 8,
      background: tint.bg, color: tint.fg, border: `1px solid ${tint.bd}`,
    }}>{text}</span>
  );

  return (
    <div>
      <SectionHdr>Appearance</SectionHdr>
      <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 8 }}>
        {overridden
          ? badge("Overridden on this instance",
              { bg: T.paperDeep, fg: T.accent, bd: T.accent })
          : badge(inherited ? "Inherited from template" : "No appearance",
              { bg: T.paperWarm, fg: T.inkMuted, bd: T.ruleSoft })}
        {overridden && !ro && (
          <button onClick={revert} title="Drop the instance override and use the template's appearance"
            style={topBtn("ghost", false)}>Revert to inherited</button>
        )}
        {!overridden && !ro && (
          <button onClick={startOverride}
            title="Give this one instance its own appearance, copied from the template"
            style={topBtn("ghost", false)}>Override on this instance</button>
        )}
      </div>

      {!overridden && (
        <div style={{ fontSize: 11, color: T.inkFaint, lineHeight: 1.5, marginBottom: 4 }}>
          {inherited
            ? "This object renders the appearance defined on its template "
              + `(${entity.template || "item"}). Override it here to give just this `
              + "instance a different base SVG or overlay rules."
            : "Neither this instance nor its template defines a vector appearance. "
              + "Edit the template to add one, or override it here on this instance."}
          {!ro && !inherited && (
            <span> Use <b>Override on this instance</b> above to start.</span>
          )}
        </div>
      )}

      {!overridden && inherited && (
        // Read-only preview of the inherited visual at the instance's status.
        <div style={{
          display: "flex", alignItems: "center", gap: 10, margin: "4px 0 2px",
          padding: 8, background: T.paperSoft, border: `1px solid ${T.ruleSoft}`,
          borderRadius: 4,
        }}>
          <VisualSprite visual={inherited} status={entity.status} scale={3}/>
          <span style={{ fontSize: 11, color: T.inkMuted }}>
            Live preview at this instance&apos;s current status.
          </span>
        </div>
      )}

      {overridden && (
        ro ? (
          <div style={{ fontSize: 11, color: T.inkFaint, lineHeight: 1.5 }}>
            This instance has its own appearance. Switch to edit mode to change it.
          </div>
        ) : (
          <div>
            <div style={{ fontSize: 11, color: T.inkFaint, marginBottom: 8, lineHeight: 1.5 }}>
              Editing this instance only — the template and other instances are
              unaffected. Revert above to drop the override and inherit again.
            </div>
            <AppearanceEditor
              visual={entity.visual}
              onChange={(visual) => onUpdate({ visual })}/>
          </div>
        )
      )}
    </div>
  );
}

function AgentInspector({ win, entity, actions, scenes, events, agentIndex, playing,
                          onClose, onFocus, onMove, onUpdate, onDelete, onCreateAction, onRemoveAction,
                          aside, editing = false, onToggleEdit,
                          templates = {}, components = [], setComponents,
                          liveConnected = false, recruiting = null, onRecruit }) {
  const picked = entity.pickedActions || [];
  const myIdx = agentIndex?.get(entity.id);
  const myEvents = (events || []).filter(ev => ev.actorIdx === myIdx);
  const lastEvent = myEvents[myEvents.length - 1];
  const scene = entity.placedIn ? scenes.find(s => s.id === entity.placedIn) : null;
  return (
    <FloatingWindow win={win}
      title={entity.name || "(unnamed agent)"}
      glyph={<KindGlyph kind="agent"/>} badge="agent" width={540}
      aside={aside} editing={editing} onToggleEdit={onToggleEdit}
      onClose={onClose} onFocus={onFocus} onMove={onMove} onDelete={onDelete}>
      <AgentCastView
        entity={entity} scene={scene} scenes={scenes}
        actions={actions} picked={picked} editing={editing}
        onUpdate={onUpdate} onCreateAction={onCreateAction}
        onRemoveAction={onRemoveAction}
        liveConnected={liveConnected} recruiting={recruiting} onRecruit={onRecruit}
        templates={templates} components={components} setComponents={setComponents}/>
    </FloatingWindow>
  );
}

// Paper Stage-02 "Cast" layout, condensed to fit the floating window.
// Same view whether or not the user is editing: all inputs are live.
function AgentCastView({ entity, scene, scenes, actions, picked, editing = false, onUpdate, onCreateAction, onRemoveAction,
                         templates = {}, components = [], setComponents,
                         liveConnected = false, recruiting = null, onRecruit }) {
  const ro = !editing;
  // In edit mode inputs look like editable fields (white card, border).
  // In read-only mode they collapse into static text with a faint dashed
  // underline so the layout stays the same but doesn't read as a form.
  const labelFontDef = {
    fontSize: 10, fontWeight: 700, color: T.inkMuted,
    letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 6,
  };
  const fieldStyle = ro ? {
    width: "100%", padding: "4px 0", fontSize: 12.5,
    background: "transparent", color: T.ink,
    border: "none", borderBottom: `1px dashed ${T.ruleSoft}`,
    borderRadius: 0, outline: "none",
    fontFamily: "inherit", boxSizing: "border-box",
    cursor: "default",
  } : {
    width: "100%", padding: "8px 10px", fontSize: 12.5,
    background: T.paperWarm, color: T.ink,
    border: `1px solid ${T.rule}`, borderRadius: 0, outline: "none",
    fontFamily: "inherit", boxSizing: "border-box",
    cursor: "text",
  };
  const labelFont = labelFontDef;
  const fieldInput = fieldStyle;
  return (
    <div style={{
      padding: 14, display: "flex", gap: 14, alignItems: "flex-start",
      flex: 1, overflow: "auto", background: T.paperSoft, color: T.ink,
    }}>
      {/* LEFT: sprite card */}
      <div style={{
        width: 156, flexShrink: 0,
        background: T.paperWarm, color: T.ink,
        border: `1px solid ${T.rule}`,
        padding: "12px 10px 12px", textAlign: "center",
      }}>
        <div style={{
          height: 88, display: "grid", placeItems: "center",
          marginBottom: 10,
        }}>
          <Sprite agent={entity} scale={3}/>
        </div>
        <input
          value={entity.name || ""} readOnly={ro}
          onChange={(e) => onUpdate({ name: e.target.value })}
          placeholder="(unnamed)"
          style={{
            width: "100%", textAlign: "center",
            fontStyle: "italic", fontSize: 17, fontWeight: 500,
            background: "transparent", border: "none", outline: "none",
            color: T.ink, padding: 0, marginBottom: 4,
            cursor: ro ? "default" : "text",
          }}/>
        <input
          value={entity.profile || ""} readOnly={ro}
          onChange={(e) => onUpdate({ profile: e.target.value })}
          placeholder="profile"
          style={{
            width: "100%", textAlign: "center",
            fontSize: 11.5, color: T.inkMuted,
            background: "transparent", border: "none", outline: "none",
            padding: 0,
            cursor: ro ? "default" : "text",
          }}/>

        <div style={{ borderTop: `1px dashed ${T.rule}`, margin: "12px 0 10px" }}/>

        <div style={{ ...labelFont, marginBottom: 6 }}>Cognitive bias</div>
        <div style={{ display: "flex", justifyContent: "center", gap: 4, alignItems: "center" }}>
          {[0, 1, 2, 3, 4].map(i => (
            <button key={i}
              title={ro ? `bias ${i + 1}` : `Set bias to ${i + 1}`}
              disabled={ro}
              onClick={() => onUpdate({ bias: i + 1 })}
              style={{
                width: 16, height: 16,
                background: (entity.bias ?? 0) > i ? T.accent : "transparent",
                border: `1px solid ${(entity.bias ?? 0) > i ? T.accent : T.rule}`,
                padding: 0, cursor: ro ? "default" : "pointer",
              }}/>
          ))}
          <span style={{
            marginLeft: 6, fontSize: 11, color: T.inkMuted,
            fontFamily: "ui-monospace, monospace",
          }}>{entity.bias ?? 0}/5</span>
        </div>

        <div style={{ marginTop: 14, ...labelFont, marginBottom: 6 }}>Appearance</div>
        <div style={{ display: "flex", flexWrap: "wrap", gap: 4, justifyContent: "center" }}>
          {AGENT_PALETTE.map((p, i) => {
            const sel = p.skin === entity.skin && p.shirt === entity.shirt;
            return (
              <button key={i} onClick={() => onUpdate(p)} disabled={ro}
                style={{
                  width: 20, height: 20, background: p.shirt,
                  border: `2px solid ${sel ? T.accent : "transparent"}`,
                  padding: 0, cursor: ro ? "default" : "pointer",
                }} title={ro ? "" : "Swap palette"}/>
            );
          })}
        </div>
      </div>

      {/* RIGHT: labelled inputs + actions */}
      <div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", gap: 12 }}>
        <div>
          <div style={labelFont}>Persona</div>
          <input value={entity.persona || ""} readOnly={ro}
            placeholder="Warm, observant, slightly anxious in groups"
            onChange={(e) => onUpdate({ persona: e.target.value })}
            style={fieldInput}/>
        </div>
        {/* Fix #21: GOAL + BACKGROUND blocks, alongside persona/appearance/
            hidden. Shown when the entity defines them (non-empty) — and still
            shown while editing so a brand-new agent can author them. */}
        {(editing || entity.goal) && (
          <div>
            <div style={labelFont}>Goal</div>
            <input value={entity.goal || ""} readOnly={ro}
              placeholder="What this agent wants"
              onChange={(e) => onUpdate({ goal: e.target.value })}
              style={fieldInput}/>
          </div>
        )}
        {(editing || entity.background) && (
          <div>
            <div style={labelFont}>Background</div>
            <input value={entity.background || ""} readOnly={ro}
              placeholder="Grew up in a museum-archive family"
              onChange={(e) => onUpdate({ background: e.target.value })}
              style={fieldInput}/>
          </div>
        )}
        {onRecruit && (
          <RecruitPerson entity={entity} liveConnected={liveConnected}
            recruiting={recruiting} onRecruit={onRecruit}/>
        )}
        {/* F2 — layered descriptions (engine B2). persona above = the INTERNAL
            layer; these two complete the triple. */}
        <div>
          <div style={labelFont}>How others see them <span style={{
            fontWeight: 400, textTransform: "none", letterSpacing: 0,
          }}>· appearance (external)</span></div>
          <input value={entity.appearance || ""} readOnly={ro}
            placeholder="a calm traveller in a grey coat"
            onChange={(e) => onUpdate({ appearance: e.target.value })}
            style={fieldInput}/>
        </div>
        <div>
          <div style={{ ...labelFont, color: "#7a4a26" }}><Ico path={ICO_LOCK} size={11}/> Hidden truth <span style={{
            fontWeight: 400, textTransform: "none", letterSpacing: 0,
          }}>· only the world/adjudicator knows</span></div>
          <input value={entity.hidden || ""} readOnly={ro}
            placeholder="her left arm is a concealed weapon"
            onChange={(e) => onUpdate({ hidden: e.target.value })}
            style={{ ...fieldInput, background: "#f3ead9", borderColor: "#c9b27a" }}/>
        </div>
        <RelationshipRows entity={entity} editing={editing} onUpdate={onUpdate}/>
        <div>
          <div style={labelFont}>Available actions
            {entity.open_vocab && (
              <span style={{
                marginLeft: 8, fontSize: 9, color: T.accent,
                fontWeight: 700, letterSpacing: "0.06em", textTransform: "uppercase",
              }}>preferred — agent may improvise others</span>
            )}
          </div>
          <ActionChipRow picked={picked} actions={actions} editing={editing}
            onUpdate={onUpdate} onCreateAction={onCreateAction}
            onRemoveAction={onRemoveAction}/>
        </div>
        <OpenVocabRow entity={entity} editing={editing} onUpdate={onUpdate}/>
        <BrainSelector entity={entity} editing={editing} onUpdate={onUpdate}/>
        <PerceptionSelector entity={entity} editing={editing} onUpdate={onUpdate}/>
        <CadenceField entity={entity} editing={editing} onUpdate={onUpdate}/>
        {setComponents && (
          <ObjectComponentsSection entity={entity} templates={templates}
            components={components} setComponents={setComponents}
            onUpdate={onUpdate} editing={editing}/>
        )}
        {/* Slice 2b: vector appearance for agents — same inherit/override model
            objects use. The drawn SVG renders as the agent body on stage with
            the glyph badge + movement still overlaid. */}
        <ObjectAppearanceSection entity={entity} templates={templates}
          onUpdate={onUpdate} editing={editing}/>
      </div>
    </div>
  );
}

// F2 — first-class relationships (engine 0.7.0 B4). Rows of target/stance/
// score. Seeded into status.relationships by the engine (visibility=self) and
// injected into the owner's reasoning prompt. The "drifts with events" toggle
// stores entity.rel_drift; buildScenarioFromCurrent desugars it into a
// {type:"middleware", preset:"relationship"} component.
function RelationshipRows({ entity, editing, onUpdate }) {
  const ro = !editing;
  const rels = entity.relationships || {};
  const entries = Object.entries(rels);
  const setRel = (target, patch) => {
    onUpdate({ relationships: { ...rels, [target]: { ...(rels[target] || {}), ...patch } } });
  };
  const renameRel = (oldT, newT) => {
    if (!newT || newT === oldT) return;
    const next = { ...rels };
    next[newT] = next[oldT];
    delete next[oldT];
    onUpdate({ relationships: next });
  };
  const removeRel = (target) => {
    const next = { ...rels };
    delete next[target];
    onUpdate({ relationships: next });
  };
  if (ro && entries.length === 0) return null;
  return (
    <div>
      <div style={{
        fontSize: 10, fontWeight: 700, color: T.inkMuted,
        letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 6,
      }}>Relationships</div>
      {entries.map(([target, r]) => (
        <div key={target} style={{
          display: "flex", alignItems: "center", gap: 6, marginBottom: 6,
        }}>
          <input key={target} defaultValue={target} readOnly={ro} placeholder="who"
            onBlur={(e) => !ro && renameRel(target, e.target.value.trim())}
            style={{ ...inputStyle(), width: 110, fontSize: 12 }}/>
          <input value={r.stance || ""} readOnly={ro} placeholder="resentful / trusting…"
            onChange={(e) => setRel(target, { stance: e.target.value })}
            style={{ ...inputStyle(), flex: 1, fontSize: 12 }}/>
          <input type="range" min={-100} max={100} value={r.score ?? 0} disabled={ro}
            onChange={(e) => setRel(target, { score: Number(e.target.value) })}
            style={{ width: 90 }}/>
          <span style={{
            width: 34, fontSize: 11, color: T.inkMuted, textAlign: "right",
            fontVariantNumeric: "tabular-nums", fontFamily: "ui-monospace, monospace",
          }}>{r.score ?? 0}</span>
          {!ro && (
            <button onClick={() => removeRel(target)} style={{
              background: "transparent", border: "none", color: T.inkFaint,
              cursor: "pointer", fontSize: 14, padding: 0,
            }}>×</button>
          )}
        </div>
      ))}
      {!ro && (
        <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
          <button onClick={() => setRel(`target_${entries.length + 1}`, { stance: "", score: 0 })}
            style={{
              padding: "3px 10px", fontSize: 11,
              background: "transparent", color: T.inkMuted,
              border: `1px dashed ${T.rule}`, cursor: "pointer",
            }}>+ relationship</button>
          <label style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 11, color: T.ink, cursor: "pointer" }}>
            <input type="checkbox" checked={!!entity.rel_drift}
              onChange={(e) => onUpdate({ rel_drift: e.target.checked })}/>
            drifts with events
            <span style={{
              fontSize: 9, color: T.accent, fontWeight: 700,
              letterSpacing: "0.06em", fontFamily: "ui-monospace, monospace",
            }}>LLM</span>
          </label>
        </div>
      )}
    </div>
  );
}

// Every agent runs an LLM brain. The rule-vs-LLM distinction lives on the
// world-level adjudicator (Components → Adjudicator), not per-agent. The only
// per-agent knob is whether the agent may improvise verbs outside its picked
// set — the adjudicator then judges the novel proposals.
function OpenVocabRow({ entity, onUpdate }) {
  return (
    <div style={{
      marginTop: 10, paddingTop: 10, borderTop: `1px dashed ${T.ruleSoft}`,
      display: "flex", flexDirection: "column", gap: 6,
    }}>
      <div style={{
        fontSize: 10, fontWeight: 700, color: T.inkMuted,
        letterSpacing: "0.08em", textTransform: "uppercase",
      }}>Improvisation</div>
      <label style={{
        display: "flex", alignItems: "center", gap: 8, fontSize: 11,
        color: T.ink, cursor: "pointer",
      }}>
        {/* Fix #23: open_vocab is a per-agent behavioural knob — keep it live
            (don't gate on edit mode). Previously `disabled={ro}` left the
            checkbox inert unless the inspector was in edit mode, so a plain
            click never toggled it. */}
        <input type="checkbox" checked={!!entity.open_vocab}
          onChange={(e) => onUpdate({ open_vocab: e.target.checked })}/>
        Open vocabulary — may propose verbs outside the picked set
      </label>
      <span style={{ fontSize: 11, color: T.inkFaint }}>
        Adjudicator (world-level) decides if a novel verb resolves; rule-adjudicator rejects, LLM-adjudicator can synthesize an outcome.
      </span>
    </div>
  );
}

// Brain selector (engine entity knob): how an entity decides what to do.
//   llm   — reasons in natural language (the default)
//   rule  — deterministic, scripted decisions (no LLM)
//   human — the playable character; you drive it (engine B6)
//   none  — a Prop: no brain, only status; never proposes, actuated by others
// Persisted as entity.brain. Absent ⇒ engine default (llm for agents). Applies
// to both agents and objects (an object set to `none` is an inert prop).
const BRAIN_OPTS = [
  { v: "llm",   label: "LLM",   hint: "reasons in natural language (default)" },
  { v: "rule",  label: "Rule",  hint: "deterministic, scripted — no LLM" },
  { v: "human", label: "Human", hint: "you play this character" },
  { v: "none",  label: "None (prop)", hint: "no brain, only status; actuated by others" },
];
function BrainSelector({ entity, onUpdate, editing = true }) {
  const ro = !editing;
  const cur = entity.brain || (entity.kind === "agent" ? "llm" : "none");
  const sel = BRAIN_OPTS.find(o => o.v === cur) || BRAIN_OPTS[0];
  return (
    <div data-qa="brain-selector" style={{
      marginTop: 10, paddingTop: 10, borderTop: `1px dashed ${T.ruleSoft}`,
      display: "flex", flexDirection: "column", gap: 6,
    }}>
      <div style={{
        fontSize: 10, fontWeight: 700, color: T.inkMuted,
        letterSpacing: "0.08em", textTransform: "uppercase",
      }}>Brain</div>
      <div style={{ display: "flex", border: `1px solid ${T.ruleSoft}`, borderRadius: 3, overflow: "hidden", flexWrap: "wrap" }}>
        {BRAIN_OPTS.map(o => (
          <button key={o.v} data-qa={`brain-opt-${o.v}`} disabled={ro}
            title={o.v === "none"
              ? "None (prop — no brain, only status; actuated by others)"
              : o.hint}
            onClick={() => onUpdate({ brain: o.v })}
            style={{
              padding: "3px 9px", fontSize: 10, border: "none",
              cursor: ro ? "default" : "pointer",
              background: cur === o.v ? T.accent : "transparent",
              color: cur === o.v ? "#fff" : T.inkMuted, fontWeight: 700,
            }}>{o.label}</button>
        ))}
      </div>
      <span style={{ fontSize: 11, color: T.inkFaint }}>
        {cur === "none"
          ? "Prop — no brain, only status; never proposes, actuated by others."
          : sel.hint}
      </span>
    </div>
  );
}

// Perception selector (engine entity knob): how the Environment composes THIS
// object's perception each round. Distinct from the brain (how it decides) and
// from realization / level-of-detail focus.
//   rule — cheap rule-based projector (the default; pay nothing extra)
//   llm  — placed in the LLMCompose focus set: the env reconciles a richer,
//          per-recipient view for this object (pay-per-LLM)
// Persisted as entity.perception_mode = "rule" | "llm". Absent ⇒ "rule".
// (Note: entity.perception is a separate object blob — the vision/hearing
// override surfaced in the Advanced tab — so this knob uses its own key.)
const PERCEPTION_OPTS = [
  { v: "rule", label: "Rule (cheap)", hint: "cheap rule-based projector (default)" },
  { v: "llm",  label: "LLM (detailed)", hint: "LLM — detailed reconciled perception (pay-per-LLM)" },
];
function PerceptionSelector({ entity, onUpdate, editing = true }) {
  const ro = !editing;
  const cur = entity.perception_mode === "llm" ? "llm" : "rule";
  const sel = PERCEPTION_OPTS.find(o => o.v === cur) || PERCEPTION_OPTS[0];
  return (
    <div data-qa="perception-selector" style={{
      marginTop: 10, paddingTop: 10, borderTop: `1px dashed ${T.ruleSoft}`,
      display: "flex", flexDirection: "column", gap: 6,
    }}>
      <div style={{
        fontSize: 10, fontWeight: 700, color: T.inkMuted,
        letterSpacing: "0.08em", textTransform: "uppercase",
      }}>Perception</div>
      <div style={{ display: "flex", border: `1px solid ${T.ruleSoft}`, borderRadius: 3, overflow: "hidden", flexWrap: "wrap" }}>
        {PERCEPTION_OPTS.map(o => (
          <button key={o.v} data-qa={`perception-opt-${o.v}`} disabled={ro}
            title={o.hint}
            onClick={() => onUpdate({ perception_mode: o.v })}
            style={{
              padding: "3px 9px", fontSize: 10, border: "none",
              cursor: ro ? "default" : "pointer",
              background: cur === o.v ? T.accent : "transparent",
              color: cur === o.v ? "#fff" : T.inkMuted, fontWeight: 700,
            }}>{o.label}</button>
        ))}
      </div>
      <span style={{ fontSize: 11, color: T.inkFaint }}>{sel.hint}</span>
    </div>
  );
}

// Cadence — an entity knob (engine): integer VIRTUAL SECONDS between
// invocations (a throttle). 0/empty ⇒ the object runs every clock-stop
// (default). Throttled objects are skipped entirely between due times; the
// event-driven clock jumps the gaps. This is how "process agents" (weather,
// metabolism, slow processes) are authored — just an object with a cadence.
// Persisted as entity.cadence (non-negative integer). Mirrors the
// Brain/Perception control style.
function CadenceField({ entity, onUpdate, editing = true }) {
  const ro = !editing;
  const cur = Number.isFinite(entity.cadence) && entity.cadence > 0 ? entity.cadence : 0;
  return (
    <div data-qa="cadence-field" style={{
      marginTop: 10, paddingTop: 10, borderTop: `1px dashed ${T.ruleSoft}`,
      display: "flex", flexDirection: "column", gap: 6,
    }}>
      <div style={{
        fontSize: 10, fontWeight: 700, color: T.inkMuted,
        letterSpacing: "0.08em", textTransform: "uppercase",
      }}>Cadence (s)</div>
      <input
        data-qa="cadence-input" type="number" min={0} step={1} readOnly={ro}
        value={cur === 0 ? "" : cur} placeholder="0"
        onChange={e => {
          const raw = e.target.value;
          if (raw === "") { onUpdate({ cadence: 0 }); return; }
          const n = Math.floor(Number(raw));
          onUpdate({ cadence: Number.isFinite(n) && n > 0 ? n : 0 });
        }}
        style={{ ...inputStyle(), width: 90 }}/>
      <span style={{ fontSize: 11, color: T.inkFaint }}>
        invoke every N virtual seconds; 0 = every step (throttle weather/slow processes)
      </span>
    </div>
  );
}

// Action chips below "Available actions" — paper-style: icon + name, with
// disabled ones drawn struck-through. Click to toggle on/off.
function ActionChipRow({ picked, actions, editing = false, onUpdate, onCreateAction, onRemoveAction }) {
  const ro = !editing;
  const toggle = (id) => {
    if (ro) return;
    const on = picked.includes(id);
    onUpdate({ pickedActions: on ? picked.filter(x => x !== id) : [...picked, id] });
  };
  return (
    <div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
      {actions.map(a => {
        const on = picked.includes(a.id);
        return (
          <span key={a.id} style={{
            display: "inline-flex", alignItems: "center", gap: 4,
            background: T.paperWarm, border: `1px solid ${T.rule}`,
          }}>
            <button onClick={() => toggle(a.id)} title={on ? "Click to disable" : "Click to enable"}
              style={{
                display: "inline-flex", alignItems: "center", gap: 6,
                padding: "5px 10px", fontSize: 12,
                background: "transparent", color: on ? T.ink : T.inkFaint,
                border: "none",
                cursor: "pointer",
                textDecoration: on ? "none" : "line-through",
                opacity: on ? 1 : 0.7,
              }}>
              {a.spritePixels
                ? <PixelSprite pixels={a.spritePixels} scale={0.85}/>
                : <span style={{ color: on ? T.action : T.inkFaint, fontSize: 13, lineHeight: 1 }}>{a.icon || "✦"}</span>}
              <span>{a.name}</span>
            </button>
            {editing && onRemoveAction && (
              <button title={`Delete action "${a.name}" from the world`}
                onClick={() => {
                  if (confirm(`Delete action "${a.name}"? This removes it for every agent.`)) {
                    onRemoveAction(a.id);
                  }
                }}
                style={{
                  padding: "0 6px 0 0", fontSize: 13, lineHeight: 1,
                  background: "transparent", color: T.inkFaint,
                  border: "none", cursor: "pointer",
                }}>×</button>
            )}
          </span>
        );
      })}
      <button onClick={onCreateAction}
        style={{
          padding: "5px 10px", fontSize: 12,
          background: "transparent", color: T.inkMuted,
          border: `1px dashed ${T.rule}`, cursor: "pointer",
        }}>+ new</button>
    </div>
  );
}

const THOUGHTS = [
  "He re-plated the dish. Without making it a thing.",
  "I should say something — but not too much.",
  "I was just thinking about you. Strange timing.",
  "Strange to be in this room again.",
  "I wonder if anyone noticed I left.",
  "There's an itch I can't quite name.",
  "Could I leave without anyone catching me?",
  "Maybe later. Not now.",
];
function hashStr(s) {
  let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
  return h;
}
function deriveStatus(verb) {
  if (!verb) return "idle";
  if (/talk|compliment|hug|whisper/i.test(verb))   return "talking";
  if (/move/i.test(verb))                           return "moving";
  if (/observe|reflect|notice|approach/i.test(verb)) return "thinking";
  return "active";
}
function deriveIntent(ev) {
  if (!ev) return "idle(1s)";
  if (ev.target) return `wait_for_response(${ev.target}, 4s)`;
  return "scan_environment(2s)";
}

function RuntimeBlock({ entity, scene, event, playing, myIdx }) {
  const status = deriveStatus(event.verb);
  const statusColor = status === "talking" ? T.accent
    : status === "moving" ? T.accent2 : T.inkMuted;
  const moodSeed = hashStr(entity.id + event.t);
  const moodWord = ["warm","calm","wired","wary","keen"][moodSeed % 5];
  const moodVal = (0.4 + ((moodSeed % 60) / 100)).toFixed(2);
  const thought = THOUGHTS[(moodSeed) % THOUGHTS.length];
  const intent = deriveIntent(event);
  return (
    <div style={{
      background: T.paperSoft, border: `1px solid ${T.rule}`,
      borderRadius: 4, padding: 10,
      display: "flex", flexDirection: "column", gap: 8,
    }}>
      <div style={{
        display: "flex", alignItems: "center", justifyContent: "space-between",
        fontSize: 10, color: T.inkMuted, letterSpacing: "0.08em",
        textTransform: "uppercase", fontWeight: 700,
      }}>
        <span style={{ display: "flex", alignItems: "center", gap: 6 }}>
          <span style={{
            width: 7, height: 7, borderRadius: "50%",
            background: playing ? T.scene : T.inkFaint,
          }}/>
          Runtime
        </span>
        <span style={{ color: T.inkFaint }}>
          {scene ? `in ${scene.name} · ` : ""}{event.t}
        </span>
      </div>
      <div style={{ display: "grid", gridTemplateColumns: "70px 1fr", rowGap: 4, columnGap: 8, fontSize: 12 }}>
        <span style={metaLabel()}>Status</span>
        <span style={{ color: statusColor, fontWeight: 600 }}>● {status}</span>
        <span style={metaLabel()}>Target</span>
        <span>{event.target ? <>Agent {myIdx} → <b>{event.target}</b></> : "—"}</span>
        <span style={metaLabel()}>Action</span>
        <span>{event.verb}</span>
        <span style={metaLabel()}>Mood</span>
        <span>{moodWord} · {moodVal}</span>
      </div>
      <div>
        <div style={metaLabel()}>Recent thoughts</div>
        <div style={{
          marginTop: 4, fontSize: 12.5, fontStyle: "italic",
          lineHeight: 1.5, color: T.ink,
        }}>"{thought}"</div>
      </div>
      <div>
        <div style={metaLabel()}>Next intent</div>
        <div style={{
          marginTop: 4, padding: "5px 8px",
          background: T.paperWarm, border: `1px dashed ${T.accent}`, borderRadius: 3,
          fontSize: 12, color: T.ink, fontVariantNumeric: "tabular-nums",
          letterSpacing: "-0.01em",
        }}>→ {intent}</div>
      </div>
    </div>
  );
}
const metaLabel = () => ({
  fontSize: 10, color: T.inkMuted, fontWeight: 700,
  letterSpacing: "0.06em", textTransform: "uppercase",
});

function AgentActions({ picked, actions, onUpdate, onCreateAction }) {
  const [pickerOpen, setPickerOpen] = useState(false);
  const unpicked = actions.filter(a => !picked.includes(a.id));
  const pickedActions = picked
    .map(id => actions.find(a => a.id === id)).filter(Boolean);

  const attach = (id) => onUpdate({ pickedActions: [...picked, id] });
  const detach = (id) => onUpdate({ pickedActions: picked.filter(x => x !== id) });

  return (
    <div style={{ borderTop: `1px solid ${T.ruleSoft}`, paddingTop: 10 }}>
      <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 6 }}>
        <span style={{
          flex: 1, fontSize: 10, fontWeight: 700, color: T.inkMuted,
          letterSpacing: "0.08em", textTransform: "uppercase",
        }}>Picked Actions · {picked.length}</span>
        <button onClick={() => setPickerOpen(v => !v)}
          title="Add action"
          style={{
            width: 22, height: 22, lineHeight: 1, padding: 0,
            background: pickerOpen ? T.action : T.actionSoft,
            color: pickerOpen ? "#fff" : T.action,
            border: `1px solid ${T.action}`,
            borderRadius: 11, cursor: "pointer",
            fontSize: 14, fontWeight: 700,
          }}>+</button>
      </div>

      <div style={{
        fontSize: 11, color: T.inkFaint, marginBottom: 6, fontStyle: "italic",
      }}>Drag an Action widget onto this agent on the canvas, or use + above.</div>

      <div style={{ display: "flex", flexWrap: "wrap", gap: 5 }}>
        {pickedActions.length === 0 && !pickerOpen && (
          <div style={{ fontSize: 11, color: T.inkFaint, padding: "4px 0" }}>
            No actions picked yet.
          </div>
        )}
        {pickedActions.map(a => (
          <span key={a.id} title={a.module}
            style={{
              display: "inline-flex", alignItems: "center", gap: 6,
              padding: "3px 4px 3px 8px",
              background: T.actionSoft,
              border: `1px solid ${T.action}`, borderRadius: 12,
              fontSize: 12,
            }}>
            {a.spritePixels
              ? <PixelSprite pixels={a.spritePixels} scale={0.85}/>
              : <span style={{ color: T.action, fontSize: 13, lineHeight: 1 }}>{a.icon}</span>}
            <span>{a.name}</span>
            <button onClick={() => detach(a.id)}
              title="Remove"
              style={{
                width: 16, height: 16, lineHeight: 1, padding: 0,
                background: "transparent", color: T.action,
                border: "none", cursor: "pointer", fontSize: 14,
                opacity: 0.7,
              }}>×</button>
          </span>
        ))}
      </div>

      {pickerOpen && (
        <div style={{
          marginTop: 8, padding: 8,
          background: T.paperWarm, border: `1px solid ${T.rule}`, borderRadius: 4,
        }}>
          <button onClick={() => { onCreateAction(); setPickerOpen(false); }}
            style={{
              display: "flex", alignItems: "center", gap: 6,
              width: "100%", padding: "5px 8px",
              background: T.action, color: "#fff",
              border: "none", borderRadius: 3, fontSize: 12, fontWeight: 600,
              cursor: "pointer", textAlign: "left", marginBottom: 6,
            }}>
            <span>✦</span>
            <span>Create new custom action</span>
          </button>
          {unpicked.length === 0 ? (
            <div style={{ fontSize: 11, color: T.inkFaint, padding: "4px 2px" }}>
              All existing actions already picked.
            </div>
          ) : (
            <>
              <div style={{
                fontSize: 10, fontWeight: 700, color: T.inkFaint,
                letterSpacing: "0.08em", textTransform: "uppercase",
                padding: "4px 2px",
              }}>Attach existing · {unpicked.length}</div>
              <div style={{
                display: "flex", flexDirection: "column", gap: 2,
                maxHeight: 160, overflow: "auto",
              }}>
                {unpicked.map(a => (
                  <button key={a.id} onClick={() => attach(a.id)}
                    style={{
                      display: "flex", alignItems: "center", gap: 6,
                      padding: "4px 6px", background: "transparent",
                      border: `1px solid ${T.ruleSoft}`, borderRadius: 3,
                      cursor: "pointer", textAlign: "left", fontSize: 12,
                    }}
                    onMouseEnter={(e) => e.currentTarget.style.background = T.actionSoft}
                    onMouseLeave={(e) => e.currentTarget.style.background = "transparent"}>
                    {a.spritePixels
                      ? <PixelSprite pixels={a.spritePixels} scale={0.75}/>
                      : <span style={{ color: T.action }}>{a.icon}</span>}
                    <span style={{ flex: 1 }}>{a.name}</span>
                    <span style={{ color: T.inkFaint, fontSize: 9 }}>{a.module}</span>
                  </button>
                ))}
              </div>
            </>
          )}
        </div>
      )}
    </div>
  );
}

function ActionInspector({ win, entity, onClose, onFocus, onMove, onUpdate, onDelete, editing = false, onToggleEdit }) {
  return (
    <FloatingWindow win={win}
      title={entity.name || "(unnamed action)"}
      glyph={<KindGlyph kind="action"/>} badge="action" width={460}
      editing={editing} onToggleEdit={onToggleEdit}
      onClose={onClose} onFocus={onFocus} onMove={onMove} onDelete={onDelete}>
      <InspectorBody>
        <ActionEditorBody entity={entity} onUpdate={onUpdate} editing={editing}/>
      </InspectorBody>
    </FloatingWindow>
  );
}

// Shared action-behavior editor body — the SAME editor mounted by both the
// floating ActionInspector and the docked RightPanelProfile, so the two stay
// in sync and there is no duplicated logic. Covers identity (name/icon/badge),
// module/describe, strict/soft norms, the effect surface (target_effect /
// target_add / announces / reveals) and the spawn-despawn LIFECYCLE — the
// latter previously authorable only on `action` COMPONENTS in the Templates
// editor's ComponentRow.
function ActionEditorBody({ entity, onUpdate, editing = false }) {
  const [spriteEditor, setSpriteEditor] = useState(false);
  return (
    <>
      {spriteEditor && (
        <SpriteEditorModal
          initialPixels={entity.spritePixels}
          initialW={16} initialH={16}
          title={`Action icon · ${entity.name || "action"}`}
          onSave={({ pixels }) => { onUpdate({ spritePixels: pixels }); setSpriteEditor(false); }}
          onClose={() => setSpriteEditor(false)}/>
      )}
        {/* Magazine title */}
        <div>
          <div style={{
            fontSize: 10, color: T.inkMuted, fontWeight: 700,
            letterSpacing: "0.12em", textTransform: "uppercase",
          }}>ACTION NAME</div>
          <div style={{
            display: "flex", alignItems: "baseline", gap: 10,
            borderBottom: `2px solid ${T.ink}`, paddingBottom: 4, marginTop: 2,
          }}>
            <input value={entity.name}
              onChange={e => onUpdate({ name: e.target.value })}
              placeholder="(unnamed)"
              style={{
                flex: 1, fontSize: 26, fontStyle: "italic", fontWeight: 500,
                background: "transparent", border: "none", outline: "none",
                color: T.ink, padding: 0, letterSpacing: "-0.01em",
              }}/>
            <span style={{
              fontSize: 11, color: T.inkMuted, letterSpacing: "0.04em",
            }}>— uniquely named</span>
          </div>
          <div style={{
            display: "flex", alignItems: "center", gap: 8, marginTop: 6,
            fontSize: 11, color: T.inkMuted,
          }}>
            <span>icon</span>
            <div style={{
              width: 36, height: 36, background: T.paperWarm,
              border: `1px solid ${T.rule}`, display: "grid", placeItems: "center",
            }}>
              {entity.spritePixels
                ? <PixelSprite pixels={entity.spritePixels} scale={1.5}/>
                : <span style={{ fontSize: 18, color: T.action }}>{entity.icon}</span>}
            </div>
            <button onClick={() => setSpriteEditor(true)}
              style={{
                fontSize: 11, padding: "4px 8px",
                background: "transparent", color: T.accent,
                border: `1px solid ${T.accent}`, cursor: "pointer",
              }}>{entity.spritePixels ? "Edit sprite" : "Draw sprite"}</button>
            {entity.spritePixels && (
              <button onClick={() => onUpdate({ spritePixels: null })}
                title="Remove pixel sprite (use character glyph)"
                style={{
                  fontSize: 11, padding: "4px 8px",
                  background: "transparent", color: T.inkMuted,
                  border: `1px solid ${T.rule}`, cursor: "pointer",
                }}>Use glyph</button>
            )}
          </div>
        </div>

        {/* Action badge designer — writes `icon` (builtin shape name OR "svg:M…")
            and `color` (hex) onto this action. These flow to the floating badge
            via the trace's action_glyphs map (resolveGlyphIconPath / GLYPH_COLOR). */}
        <Field label="Action badge — shape & color">
          <div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
            <div style={{ display: "flex", gap: 6 }}>
              {Object.keys(GLYPH_PATHS).map(name => {
                const sel = entity.icon === name;
                const swatchColor = entity.color || GLYPH_COLOR[name] || T.accent;
                return (
                  <button key={name} onClick={() => onUpdate({ icon: name })}
                    title={name}
                    style={{
                      width: 32, height: 32, padding: 0, cursor: "pointer",
                      background: sel ? "#fff" : T.paperWarm,
                      border: sel ? `2px solid ${T.ink}` : `1px solid ${T.rule}`,
                      display: "grid", placeItems: "center", borderRadius: 0,
                    }}>
                    <svg width={20} height={20} viewBox="0 0 20 20">
                      <path d={GLYPH_PATHS[name]} fill={swatchColor}/>
                    </svg>
                  </button>
                );
              })}
            </div>
            <label style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 11, color: T.inkMuted }}>
              <span>color</span>
              <input type="color"
                value={entity.color || GLYPH_COLOR[entity.icon] || T.accent}
                onChange={e => onUpdate({ color: e.target.value })}
                style={{
                  width: 32, height: 24, padding: 0, cursor: "pointer",
                  background: "transparent", border: `1px solid ${T.rule}`,
                }}/>
            </label>
            {/* live preview of the chosen badge */}
            <div style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 11, color: T.inkMuted }}>
              <span>preview</span>
              <div style={{
                width: 36, height: 36, background: T.paperWarm,
                border: `1px solid ${T.rule}`, display: "grid", placeItems: "center",
              }}>
                {(() => {
                  const path = resolveGlyphIconPath(entity.icon);
                  const fill = entity.color || GLYPH_COLOR[entity.icon] || T.accent;
                  return path
                    ? <svg width={22} height={22} viewBox="0 0 20 20"><path d={path} fill={fill}/></svg>
                    : <span style={{ fontSize: 10, color: T.inkFaint }}>none</span>;
                })()}
              </div>
            </div>
            {entity.icon && (
              <button onClick={() => onUpdate({ icon: null })}
                title="Clear badge shape (fall back to by-verb default)"
                style={{
                  fontSize: 11, padding: "4px 8px",
                  background: "transparent", color: T.inkMuted,
                  border: `1px solid ${T.rule}`, cursor: "pointer",
                }}>Clear</button>
            )}
          </div>
        </Field>

        <Field label="Module — how does this action travel?">
          <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
            {ACTION_MODULES.map(m => {
              const sel = entity.module === m.id;
              const [cat, kind] = m.label.split(" → ");
              const verbal = kind === "verbal";
              return (
                <button key={m.id} onClick={() => onUpdate({ module: m.id })}
                  style={{
                    textAlign: "left",
                    border: sel ? `2px solid ${T.ink}` : `1px solid ${T.rule}`,
                    padding: sel ? "8px 10px" : "9px 11px",
                    background: sel ? "#fff" : "transparent",
                    cursor: "pointer", borderRadius: 0,
                  }}>
                  <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
                    <span style={{
                      width: 12, height: 12, borderRadius: "50%",
                      border: `1.5px solid ${sel ? T.accent : T.inkMuted}`,
                      display: "grid", placeItems: "center",
                    }}>
                      {sel && <span style={{
                        width: 5, height: 5, borderRadius: "50%",
                        background: T.accent, display: "block",
                      }}/>}
                    </span>
                    <span style={{ fontSize: 13, fontStyle: "italic", fontWeight: 500 }}>
                      {cat}
                    </span>
                    <span style={{
                      fontSize: 9, padding: "1px 6px",
                      background: verbal ? `${T.agent}22` : `${T.accent}22`,
                      color: verbal ? T.agent : T.accent,
                      textTransform: "uppercase", letterSpacing: "0.06em",
                    }}>{kind}</span>
                  </div>
                </button>
              );
            })}
          </div>
        </Field>

        <Field label="Describe the action">
          <textarea value={entity.describe}
            onChange={e => onUpdate({ describe: e.target.value })}
            rows={3} style={{ ...inputStyle(), fontSize: 12.5, lineHeight: 1.55 }}
            placeholder="One sentence describing what this action does and when."/>
        </Field>
        <Field label="Strict rules & preconditions">
          <textarea value={entity.strict}
            onChange={e => onUpdate({ strict: e.target.value })}
            rows={5} style={{ ...inputStyle(), lineHeight: 1.55 }}
            placeholder={"• Both agents must be in the same scene\n• Cognitive bias > 1 required"}/>
        </Field>
        <Field label="Soft norms">
          <textarea value={entity.soft}
            onChange={e => onUpdate({ soft: e.target.value })}
            rows={4} style={{ ...inputStyle(), lineHeight: 1.55 }}
            placeholder={"• Avoid back-to-back uses\n• Honesty beats effusiveness"}/>
        </Field>

        {/* Effect surface (engine 0.7.0) — deterministic guaranteed effects an
            authored action applies to its target. Mirrors the `action`
            ComponentRow branch so the same authoring lives wherever an action
            is edited. JSON parse buffers (`_target_effect` / `_target_add`)
            keep mid-type invalid text without clobbering the parsed value. */}
        <Field label="Effects on target (JSON)">
          <input value={entity._target_effect ?? JSON.stringify(entity.target_effect || {})}
            placeholder='{"cursed": true}'
            onChange={e => {
              const raw = e.target.value;
              try { onUpdate({ target_effect: JSON.parse(raw), _target_effect: undefined }); }
              catch (err) { onUpdate({ _target_effect: raw }); }
            }}
            style={{ ...inputStyle(), fontFamily: "ui-monospace, monospace", fontSize: 12 }}/>
        </Field>
        <Field label="Target += (numeric JSON)">
          <input value={entity._target_add ?? JSON.stringify(entity.target_add || {})}
            placeholder='{"hp": -3}'
            onChange={e => {
              const raw = e.target.value;
              try { onUpdate({ target_add: JSON.parse(raw), _target_add: undefined }); }
              catch (err) { onUpdate({ _target_add: raw }); }
            }}
            style={{ ...inputStyle(), fontFamily: "ui-monospace, monospace", fontSize: 12 }}/>
        </Field>
        <Field label="Announces">
          <input value={entity.emit_summary || ""}
            placeholder="A cold wind sweeps the room."
            onChange={e => onUpdate({ emit_summary: e.target.value })}
            style={inputStyle()}/>
        </Field>
        <Field label="Reveals">
          <input value={entity.reveal || ""}
            placeholder="the amulet is a fake"
            onChange={e => onUpdate({ reveal: e.target.value })}
            style={inputStyle()}/>
        </Field>

        {/* Lifecycle (engine 0.8.0): spawn / despawn for eat-kill-reproduce —
            previously authorable only on `action` COMPONENTS in ComponentRow. */}
        <div data-qa="action-lifecycle" style={{
          marginTop: 2, paddingTop: 8, borderTop: `1px dashed ${T.ruleSoft}`,
          display: "flex", flexDirection: "column", gap: 8,
        }}>
          <span style={{
            fontSize: 10, fontWeight: 700, color: T.inkMuted,
            letterSpacing: "0.08em", textTransform: "uppercase",
          }}>Lifecycle</span>
          <label style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 12, color: T.ink, cursor: "pointer" }}>
            <input type="checkbox" data-qa="action-despawn" checked={!!entity.despawn}
              onChange={e => onUpdate({ despawn: e.target.checked })}/>
            despawn target
          </label>
          <span style={{ fontSize: 11, color: T.inkFaint, fontStyle: "italic", paddingLeft: 20 }}>
            removes the target from the world (e.g. eat/kill); add a self gain to absorb it
          </span>
          <label style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 12, color: T.ink, cursor: "pointer" }}>
            <input type="checkbox" data-qa="action-spawn-toggle" checked={!!entity.spawn}
              onChange={e => onUpdate(e.target.checked
                ? { spawn: { name: entity.spawn?.name || "", status: entity.spawn?.status || {} } }
                : { spawn: null, _spawn_status: undefined })}/>
            spawn (reproduce)
          </label>
          {entity.spawn && (
            <div style={{ display: "flex", flexDirection: "column", gap: 6, paddingLeft: 20 }}>
              <Field label="Spawn name">
                <input data-qa="action-spawn-name" value={entity.spawn.name || ""}
                  placeholder="e.g. offspring"
                  onChange={e => onUpdate({ spawn: { ...entity.spawn, name: e.target.value } })}
                  style={inputStyle()}/>
              </Field>
              <Field label="Spawn status (JSON)">
                <input data-qa="action-spawn-status"
                  value={entity._spawn_status ?? JSON.stringify(entity.spawn.status || {})}
                  placeholder='{"energy": 5}'
                  onChange={e => {
                    const raw = e.target.value;
                    try { onUpdate({ spawn: { ...entity.spawn, status: JSON.parse(raw) }, _spawn_status: undefined }); }
                    catch (err) { onUpdate({ _spawn_status: raw }); }
                  }}
                  style={{ ...inputStyle(), fontFamily: "ui-monospace, monospace", fontSize: 12 }}/>
              </Field>
              <span style={{ fontSize: 11, color: T.inkFaint, fontStyle: "italic" }}>
                creates a new object at the actor&apos;s location
              </span>
            </div>
          )}
        </div>

        <MetaRow entity={entity}/>
        <AdvancedBlock entity={entity} editing={editing} onUpdate={onUpdate}/>
    </>
  );
}

function MetaRow({ entity }) {
  return (
    <div style={{
      fontSize: 10, color: T.inkFaint, paddingTop: 8,
      borderTop: `1px solid ${T.ruleSoft}`, letterSpacing: "0.04em",
    }}>
      id: {entity.id} · ({Math.round(entity.x)}, {Math.round(entity.y)})
    </div>
  );
}

// ─── CONTEXT MENU ────────────────────────────────────────────────────
function ContextMenu({ ctx, entity, onClose, onAdd, onInspect, onDuplicate, onDelete,
                       onUnplace, onStartConn }) {
  const ref = useRef(null);
  useEffect(() => {
    const h = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose(); };
    const t = setTimeout(() => window.addEventListener("mousedown", h), 0);
    return () => { clearTimeout(t); window.removeEventListener("mousedown", h); };
  }, []);
  const item = (label, fn, opts = {}) => (
    <button
      onClick={(e) => { e.stopPropagation(); fn(); }}
      disabled={opts.disabled}
      onMouseEnter={(e) => { if (!opts.disabled) e.currentTarget.style.background = T.paperDeep; }}
      onMouseLeave={(e) => { e.currentTarget.style.background = "transparent"; }}
      style={{
        all: "unset", display: "block", width: "100%",
        padding: "6px 12px", fontSize: 12,
        cursor: opts.disabled ? "not-allowed" : "pointer",
        color: opts.danger ? T.danger : T.ink,
        opacity: opts.disabled ? 0.4 : 1,
      }}
    >{label}</button>
  );
  const sep = <div style={{ height: 1, background: T.ruleSoft, margin: "4px 0" }}/>;
  return (
    <div
      ref={ref}
      onMouseDown={(e) => e.stopPropagation()}
      onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); }}
      style={{
        position: "fixed", left: ctx.x, top: ctx.y,
        minWidth: 180, background: T.paperWarm, border: `1px solid ${T.rule}`,
        borderRadius: 4, boxShadow: "0 6px 20px rgba(20,18,12,0.2)",
        padding: "4px 0", zIndex: 10000,
      }}>
      {ctx.kind === "bg" && (
        <>
          <div style={menuHeader()}>Add here</div>
          {item("Scene",  () => onAdd("scene"))}
          {item("Object", () => onAdd("object"))}
          {item("Agent",  () => onAdd("agent"))}
          {item("Action", () => onAdd("action"))}
        </>
      )}
      {ctx.kind === "entity" && entity && (
        <>
          <div style={menuHeader()}>{entity.name || "(unnamed)"}</div>
          {item("Inspect", onInspect)}
          {item("Duplicate", onDuplicate)}
          {entity.kind === "scene" && sep}
          {entity.kind === "scene" && item("Connect to…", onStartConn)}
          {(entity.kind === "agent" || entity.kind === "object") && entity.placedIn && sep}
          {(entity.kind === "agent" || entity.kind === "object") && entity.placedIn &&
            item("Remove from scene", onUnplace)}
          {sep}
          {item("Delete", onDelete, { danger: true })}
        </>
      )}
    </div>
  );
}
const menuHeader = () => ({
  padding: "4px 12px 6px", fontSize: 10, color: T.inkFaint,
  letterSpacing: "0.08em", textTransform: "uppercase", fontWeight: 700,
});

// ─── SPRITE EDITOR MODAL (P5) ─────────────────────────────────────────
// Grid-based pixel editor. Resolution: 16/24/32/48/64. Palette swatches +
// transparent (eraser). Click/drag to paint. Save returns { pixels, w, h }
// where pixels is a 2D array (h × w) of color strings (null = transparent).
const SPRITE_PALETTE = [
  null,        // transparent / eraser
  "#000000", "#ffffff",
  "#7a4a26", "#a87248", "#c98a55",  // wood/skin
  "#3a8c4c", "#7cba62",              // green
  "#3a5fbf", "#7baef5",              // blue
  "#c14545", "#e89060",              // red/orange
  "#f0c860", "#eedf9c",              // yellow/cream
  "#3a3030", "#8e8e8e",              // greys
];
function emptyPixelGrid(w, h) {
  return Array.from({ length: h }, () => Array(w).fill(null));
}
function resizePixelGrid(pixels, newW, newH) {
  const out = emptyPixelGrid(newW, newH);
  if (!pixels) return out;
  const oldH = pixels.length;
  const oldW = pixels[0]?.length || 0;
  for (let y = 0; y < Math.min(oldH, newH); y++) {
    for (let x = 0; x < Math.min(oldW, newW); x++) {
      out[y][x] = pixels[y][x];
    }
  }
  return out;
}
function SpriteEditorModal({ initialPixels, initialW = 16, initialH = 16, onSave, onClose, title = "Sprite editor" }) {
  const [w, setW] = useState(initialPixels?.[0]?.length || initialW);
  const [h, setH] = useState(initialPixels?.length || initialH);
  const [pixels, setPixels] = useState(() => initialPixels || emptyPixelGrid(w, h));
  const [color, setColor] = useState("#000000");
  const [painting, setPaint] = useState(false);
  const paint = (x, y) => {
    setPixels(p => {
      const next = p.map(row => row.slice());
      next[y][x] = color;
      return next;
    });
  };
  const changeRes = (n) => {
    setPixels(resizePixelGrid(pixels, n, n));
    setW(n); setH(n);
  };
  const cellSize = Math.max(6, Math.min(24, Math.floor(420 / w)));
  return (
    <div style={{
      position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)",
      display: "grid", placeItems: "center", zIndex: 200,
    }} onClick={onClose}>
      <div onClick={(e) => e.stopPropagation()} style={{
        background: T.paper, color: T.ink,
        border: `1px solid ${T.rule}`,
        boxShadow: `12px 12px 0 ${T.paperEdge}`,
        width: 720, maxHeight: "90vh", display: "flex", flexDirection: "column",
      }}>
        <div style={{
          padding: "10px 14px", borderBottom: `1px solid ${T.rule}`,
          display: "flex", justifyContent: "space-between", alignItems: "center",
        }}>
          <span style={{ fontWeight: 700, fontSize: 14 }}>{title}</span>
          <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
            <label style={{ fontSize: 11, color: T.inkMuted }}>Resolution
              <select value={w} onChange={(e) => changeRes(Number(e.target.value))}
                style={{ marginLeft: 6, fontSize: 12,
                  background: T.paperWarm, color: T.ink,
                  border: `1px solid ${T.rule}`, padding: "2px 4px" }}>
                {[16, 24, 32, 48, 64].map(n => <option key={n} value={n}>{n}×{n}</option>)}
              </select>
            </label>
            <button onClick={onClose} style={{
              background: "transparent", border: "none", color: T.inkMuted,
              fontSize: 18, cursor: "pointer", padding: 0,
            }}>×</button>
          </div>
        </div>
        <div style={{ display: "flex", gap: 12, padding: 14, flex: 1, overflow: "hidden" }}>
          <div style={{ flex: 1, display: "grid", placeItems: "center",
            background: T.paperSoft, border: `1px dashed ${T.ruleSoft}`,
            padding: 8, overflow: "auto",
          }}>
            <div onMouseLeave={() => setPaint(false)}
              style={{
                display: "grid",
                gridTemplateColumns: `repeat(${w}, ${cellSize}px)`,
                gridTemplateRows: `repeat(${h}, ${cellSize}px)`,
                gap: 0, userSelect: "none",
                background: "#777",
              }}>
              {pixels.flatMap((row, y) => row.map((c, x) => (
                <div key={`${x},${y}`}
                  onMouseDown={(e) => { e.preventDefault(); setPaint(true); paint(x, y); }}
                  onMouseEnter={() => painting && paint(x, y)}
                  onMouseUp={() => setPaint(false)}
                  style={{
                    width: cellSize, height: cellSize,
                    background: c || "transparent",
                    boxShadow: c ? "none" : `inset 0 0 0 0.5px rgba(0,0,0,0.08)`,
                    backgroundImage: c ? "none" :
                      `linear-gradient(45deg, #d8d8d8 25%, transparent 25%, transparent 75%, #d8d8d8 75%),
                       linear-gradient(45deg, #d8d8d8 25%, transparent 25%, transparent 75%, #d8d8d8 75%)`,
                    backgroundSize: `${cellSize}px ${cellSize}px`,
                    backgroundPosition: `0 0, ${cellSize / 2}px ${cellSize / 2}px`,
                    cursor: "pointer",
                  }}/>
              )))}
            </div>
          </div>
          <div style={{ width: 180, display: "flex", flexDirection: "column", gap: 12 }}>
            <div>
              <div style={{ fontSize: 10, fontWeight: 700, letterSpacing: "0.08em",
                color: T.inkMuted, textTransform: "uppercase", marginBottom: 6 }}>Palette</div>
              <div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 4 }}>
                {SPRITE_PALETTE.map((c, i) => (
                  <button key={i} onClick={() => setColor(c)} title={c || "transparent"}
                    style={{
                      width: 32, height: 32,
                      background: c || "#fff",
                      backgroundImage: c ? "none" :
                        `linear-gradient(45deg, #d0d0d0 25%, transparent 25%, transparent 75%, #d0d0d0 75%),
                         linear-gradient(45deg, #d0d0d0 25%, transparent 25%, transparent 75%, #d0d0d0 75%)`,
                      backgroundSize: "12px 12px",
                      backgroundPosition: "0 0, 6px 6px",
                      border: color === c ? `2px solid ${T.accent}` : `1px solid ${T.rule}`,
                      cursor: "pointer", padding: 0,
                    }}/>
                ))}
              </div>
            </div>
            <div>
              <div style={{ fontSize: 10, fontWeight: 700, letterSpacing: "0.08em",
                color: T.inkMuted, textTransform: "uppercase", marginBottom: 6 }}>Tools</div>
              <button onClick={() => setPixels(emptyPixelGrid(w, h))}
                style={{ width: "100%", fontSize: 12, padding: "6px 8px",
                  background: T.paperWarm, color: T.ink,
                  border: `1px solid ${T.rule}`, cursor: "pointer",
                  marginBottom: 4 }}>Clear</button>
              <button onClick={() => onSave({ pixels: emptyPixelGrid(w, h) })}
                style={{ width: "100%", fontSize: 11, padding: "4px 8px",
                  background: "transparent", color: T.inkMuted,
                  border: `1px dashed ${T.rule}`, cursor: "pointer" }}>
                Save as blank
              </button>
            </div>
            <div style={{ marginTop: "auto", display: "flex", flexDirection: "column", gap: 4 }}>
              <button onClick={() => onSave({ pixels })}
                style={{ fontSize: 13, padding: "8px 10px", fontWeight: 700,
                  background: T.accent, color: "#fff",
                  border: "none", cursor: "pointer" }}>Save</button>
              <button onClick={onClose}
                style={{ fontSize: 12, padding: "6px 10px",
                  background: "transparent", color: T.inkMuted,
                  border: `1px solid ${T.rule}`, cursor: "pointer" }}>Cancel</button>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

// ─── TEMPLATE EDITOR MODAL ───────────────────────────────────────────
function TemplateEditorModal({ templates, setTemplates, onClose }) {
  // HANDS-ON TOUR — the library is BLANK until the built-ins are revealed.
  // The default templates still EXIST; we just don't LIST them here while the
  // tour wants the user to author their own from scratch. After the reveal
  // step they show normally. tourTemplatesHidden() reads the live gate.
  const tour = useTour();
  const hideBuiltins = tour.active && !tour.revealed;
  const visibleIds = Object.keys(templates).filter(
    id => !(hideBuiltins && templates[id]?.builtin));
  const ids = Object.keys(templates);
  const [selectedId, setSelectedId] = useState(visibleIds[0] || null);
  const tpl = selectedId ? templates[selectedId] : null;

  const updateTpl = (patch) => {
    if (!selectedId) return;
    setTemplates({ ...templates, [selectedId]: { ...templates[selectedId], ...patch } });
  };
  // Create a brand-new BLANK template (the hands-on tour's "create + name"
  // step) — a fresh scene class the user owns. Selects it so the name input
  // (with a ghost placeholder) is ready to type into.
  const createTpl = () => {
    const newId = `tpl_${Date.now().toString(36).slice(-5)}`;
    setTemplates({
      ...templates,
      [newId]: {
        id: newId, label: "New scene", kindHint: "scene", builtin: false,
        fields: [{ key: "rules", type: "text", default: "" }],
        statuses: [], durations: {}, hasMemory: false, hasBelief: false,
      },
    });
    setSelectedId(newId);
  };
  const forkTpl = () => {
    if (!selectedId) return;
    const newId = `${selectedId}_${Date.now().toString(36).slice(-4)}`;
    setTemplates({
      ...templates,
      [newId]: { ...tpl, id: newId, label: `${tpl.label} (copy)`, builtin: false },
    });
    setSelectedId(newId);
  };
  const deleteTpl = () => {
    if (!tpl) return;
    if (tpl.builtin) { alert("Built-in templates can't be deleted."); return; }
    const { [selectedId]: _, ...rest } = templates;
    setTemplates(rest);
    setSelectedId(Object.keys(rest).filter(
      id => !(hideBuiltins && templates[id]?.builtin))[0] || null);
  };
  const renameTpl = (v) => updateTpl({ label: v });

  const updateFields = (i, patch) => {
    const next = (tpl.fields || []).map((f, idx) => idx === i ? { ...f, ...patch } : f);
    updateTpl({ fields: next });
  };
  const addField = () => updateTpl({ fields: [...(tpl.fields || []), { key: "newField", type: "text", default: "" }] });
  const removeField = (i) => updateTpl({ fields: (tpl.fields || []).filter((_, idx) => idx !== i) });

  const updateStatus = (i, patch) => {
    const next = (tpl.statuses || []).map((f, idx) => idx === i ? { ...f, ...patch } : f);
    updateTpl({ statuses: next });
  };
  const addStatus = () => updateTpl({ statuses: [...(tpl.statuses || []), { key: "newStatus", type: "text", default: "" }] });
  const removeStatus = (i) => updateTpl({ statuses: (tpl.statuses || []).filter((_, idx) => idx !== i) });

  const updateDuration = (verb, secs) => {
    const next = { ...(tpl.durations || {}) };
    if (secs === null || secs === undefined || secs === "") delete next[verb];
    else next[verb] = Number(secs);
    updateTpl({ durations: next });
  };
  const [newVerb, setNewVerb] = useState("");
  const [newVerbSecs, setNewVerbSecs] = useState(30);

  // ── OOP: parent (`extends`) + shared object-scoped components ──
  // A template is a CLASS. `extends` is a single-parent chain; `components`
  // are shared, object-scoped pieces every instance inherits.
  // Cycle guard: a candidate parent is offered only if it does NOT already
  // (transitively) extend the template being edited.
  const extendsChainContains = (startId, targetId) => {
    let cur = startId, guard = 0;
    while (cur && guard++ < 64) {
      if (cur === targetId) return true;
      cur = templates[cur]?.extends;
    }
    return false;
  };
  const parentOptions = ids.filter(
    (id) => id !== selectedId && !extendsChainContains(id, selectedId)
  );

  const comps = (tpl && tpl.components) || [];
  const updateComp = (i, patch) =>
    updateTpl({ components: comps.map((c, idx) => idx === i ? { ...c, ...patch } : c) });
  const removeComp = (i) =>
    updateTpl({ components: comps.filter((_, idx) => idx !== i) });
  const addComp = (type) => {
    const id = `${type}-${Date.now().toString(36)}`;
    const base =
      type === "middleware" ? { type: "middleware", preset: "mood" }
      : type === "attribute" ? { type: "attribute", name: "trait", range: [0, 100], init: 50, visibility: "public" }
      : { type: "action", verb: "do_something", effect: { doing: "…" } };
    updateTpl({ components: [...comps, { ...base, id }] });
  };

  return (
    <div onClick={onClose} style={{
      position: "fixed", inset: 0, zIndex: 1000,
      background: "rgba(10,12,20,0.55)",
      display: "grid", placeItems: "center",
    }}>
      <div onClick={(e) => e.stopPropagation()} style={{
        width: "min(880px, 92vw)", height: "min(640px, 86vh)",
        background: T.paper, color: T.ink,
        border: `1px solid ${T.rule}`, borderRadius: 6,
        display: "flex", flexDirection: "column", overflow: "hidden",
        boxShadow: "0 24px 60px rgba(0,0,0,0.4)",
      }}>
        <div style={{
          height: 44, display: "flex", alignItems: "center",
          padding: "0 14px", borderBottom: `1px solid ${T.rule}`,
          background: T.paperDeep,
        }}>
          <span style={{ fontWeight: 700, fontSize: 14, letterSpacing: "0.02em" }}>Templates</span>
          <div style={{ flex: 1 }}/>
          <button data-tour="tpl-close" onClick={onClose}
            style={topBtn("ghost", false)}>Close</button>
        </div>
        <div style={{ flex: 1, display: "flex", minHeight: 0 }}>
          <div style={{
            width: 200, borderRight: `1px solid ${T.rule}`,
            background: T.paperSoft, display: "flex", flexDirection: "column",
          }}>
            <div style={{ flex: 1, overflow: "auto", padding: "8px 0" }} data-tour="tpl-list">
              {visibleIds.length === 0 && (
                <div style={{
                  padding: "14px 16px", fontSize: 12, color: T.inkFaint,
                  lineHeight: 1.5,
                }}>
                  No templates yet. Click <b>+ New template</b> below to author
                  one.
                </div>
              )}
              {visibleIds.map(id => {
                const t = templates[id];
                const sel = id === selectedId;
                return (
                  <div key={id} data-tour={`tpl-row-${id}`}
                    onClick={() => setSelectedId(id)} style={{
                    padding: "8px 14px", cursor: "pointer",
                    background: sel ? T.paperWarm : "transparent",
                    borderLeft: `3px solid ${sel ? T.accent : "transparent"}`,
                    fontSize: 13,
                  }}>
                    <div style={{ fontWeight: 600 }}>{t.label || "(unnamed)"}</div>
                    <div style={{ fontSize: 10, color: T.inkFaint, marginTop: 2 }}>
                      {t.kindHint}{t.builtin ? " · built-in" : ""}
                    </div>
                  </div>
                );
              })}
            </div>
            <div style={{ padding: 10, borderTop: `1px solid ${T.rule}`,
              display: "flex", flexDirection: "column", gap: 6 }}>
              <button data-tour="tpl-new" onClick={createTpl}
                style={{ ...topBtn("ghost", false), fontWeight: 700 }}>+ New template</button>
              <div style={{ display: "flex", gap: 6 }}>
                <button onClick={forkTpl} disabled={!tpl}
                  style={topBtn("ghost", !tpl)}>Fork</button>
                <button onClick={deleteTpl} disabled={!tpl || tpl?.builtin}
                  style={topBtn("ghost", !tpl || !!tpl?.builtin)}>Delete</button>
              </div>
            </div>
          </div>
          {!tpl && (
            <div style={{
              flex: 1, display: "flex", alignItems: "center",
              justifyContent: "center", padding: 24, textAlign: "center",
              color: T.inkFaint, fontSize: 13, lineHeight: 1.6,
            }}>
              Author a template: click <b>&nbsp;+ New template&nbsp;</b> on the left,
              then give it a name and its descriptions.
            </div>
          )}
          {tpl && (
            <div style={{ flex: 1, overflow: "auto", padding: "14px 18px" }}>
              <Row label="Name">
                <input data-tour="tpl-name"
                  value={tpl.label}
                  placeholder={tplGhost(tour, "name")}
                  onChange={(e) => renameTpl(e.target.value)}
                  style={tplInputStyle(false)}/>
              </Row>
              {tpl.kindHint === "scene" && (
                <>
                  <SectionHdr>Scene descriptions</SectionHdr>
                  <Row label="External — how others see it">
                    <input data-tour="tpl-desc-external"
                      value={tpl.descExternal || ""}
                      placeholder={tplGhost(tour, "external")}
                      onChange={(e) => updateTpl({ descExternal: e.target.value })}
                      style={tplInputStyle(false)}/>
                  </Row>
                  <Row label="Internal — its own state">
                    <input data-tour="tpl-desc-internal"
                      value={tpl.descInternal || ""}
                      placeholder={tplGhost(tour, "internal")}
                      onChange={(e) => updateTpl({ descInternal: e.target.value })}
                      style={tplInputStyle(false)}/>
                  </Row>
                  <Row label="Starting status">
                    <input data-tour="tpl-desc-status"
                      value={tpl.descStatus || ""}
                      placeholder={tplGhost(tour, "status")}
                      onChange={(e) => updateTpl({ descStatus: e.target.value })}
                      style={tplInputStyle(false)}/>
                  </Row>
                </>
              )}
              <Row label="Maps to kind">
                <span style={{ fontSize: 12, color: T.inkMuted }}>{tpl.kindHint}</span>
              </Row>
              <Row label="Extends">
                <select value={tpl.extends || ""}
                  onChange={(e) => updateTpl({ extends: e.target.value || undefined })}
                  style={{ ...tplInputStyle(false), width: 200 }}>
                  <option value="">— none —</option>
                  {parentOptions.map(id => (
                    <option key={id} value={id}>{templates[id].label}</option>
                  ))}
                </select>
              </Row>
              <Row label="Memory">
                <input type="checkbox" checked={!!tpl.hasMemory}
                  onChange={(e) => updateTpl({ hasMemory: e.target.checked })}/>
              </Row>
              <Row label="Belief">
                <input type="checkbox" checked={!!tpl.hasBelief}
                  onChange={(e) => updateTpl({ hasBelief: e.target.checked })}/>
              </Row>

              <SectionHdr>Default fields</SectionHdr>
              {(tpl.fields || []).map((f, i) => (
                <div key={i} style={fieldRowStyle()}>
                  <input value={f.key} onChange={(e) => updateFields(i, { key: e.target.value })}
                    style={{ ...tplInputStyle(false), width: 140 }}/>
                  <select value={f.type} onChange={(e) => updateFields(i, { type: e.target.value })}
                    style={{ ...tplInputStyle(false), width: 90 }}>
                    <option value="text">text</option>
                    <option value="int">int</option>
                    <option value="bool">bool</option>
                    <option value="enum">enum</option>
                  </select>
                  <input value={f.default ?? ""} placeholder="default"
                    onChange={(e) => updateFields(i, { default: e.target.value })}
                    style={{ ...tplInputStyle(false), flex: 1 }}/>
                  {(
                    <button onClick={() => removeField(i)} style={topBtn("ghost", false)}>×</button>
                  )}
                </div>
              ))}
              {(
                <button onClick={addField} style={topBtn("ghost", false)}>+ field</button>
              )}

              <SectionHdr>Default statuses</SectionHdr>
              {(tpl.statuses || []).map((f, i) => (
                <div key={i} style={fieldRowStyle()}>
                  <input value={f.key} onChange={(e) => updateStatus(i, { key: e.target.value })}
                    style={{ ...tplInputStyle(false), width: 140 }}/>
                  <select value={f.type} onChange={(e) => updateStatus(i, { type: e.target.value })}
                    style={{ ...tplInputStyle(false), width: 90 }}>
                    <option value="text">text</option>
                    <option value="int">int</option>
                    <option value="bool">bool</option>
                    <option value="enum">enum</option>
                  </select>
                  <input value={String(f.default ?? "")} placeholder="default"
                    onChange={(e) => updateStatus(i, { default: e.target.value })}
                    style={{ ...tplInputStyle(false), flex: 1 }}/>
                  {(
                    <button onClick={() => removeStatus(i)} style={topBtn("ghost", false)}>×</button>
                  )}
                </div>
              ))}
              {(
                <button onClick={addStatus} style={topBtn("ghost", false)}>+ status</button>
              )}

              <SectionHdr>Action durations (sec)</SectionHdr>
              {Object.entries(tpl.durations || {}).map(([verb, secs]) => (
                <div key={verb} style={fieldRowStyle()}>
                  <span style={{ width: 140, fontSize: 12 }}>{verb}</span>
                  <input type="number" value={secs}
                    onChange={(e) => updateDuration(verb, e.target.value)}
                    style={{ ...tplInputStyle(false), width: 100 }}/>
                  {(
                    <button onClick={() => updateDuration(verb, null)} style={topBtn("ghost", false)}>×</button>
                  )}
                </div>
              ))}
              {(
                <div style={fieldRowStyle()}>
                  <input value={newVerb} placeholder="verb (e.g. dance)"
                    onChange={(e) => setNewVerb(e.target.value)}
                    style={{ ...tplInputStyle(false), width: 140 }}/>
                  <input type="number" value={newVerbSecs}
                    onChange={(e) => setNewVerbSecs(Number(e.target.value))}
                    style={{ ...tplInputStyle(false), width: 100 }}/>
                  <button style={topBtn("ghost", false)}
                    onClick={() => {
                      if (!newVerb.trim()) return;
                      updateDuration(newVerb.trim(), newVerbSecs);
                      setNewVerb("");
                    }}>+ duration</button>
                </div>
              )}

              <SectionHdr>Shared components (inherited by all instances)</SectionHdr>
              <div style={{ fontSize: 11, color: T.inkFaint, marginBottom: 8, lineHeight: 1.5 }}>
                Every instance of this class inherits these. An instance can
                override one by id or remove it.
              </div>
              {comps.map((c, i) => (
                <ComponentRow key={c.id ?? i} comp={c}
                  agents={[]} scenes={[]} classMode
                  onChange={(patch) => updateComp(i, patch)}
                  onRemove={() => removeComp(i)}/>
              ))}
              <div style={{ display: "flex", gap: 6, marginTop: 2 }}>
                <button onClick={() => addComp("middleware")} style={topBtn("ghost", false)}>+ middleware</button>
                <button onClick={() => addComp("attribute")} style={topBtn("ghost", false)}>+ attribute</button>
                <button onClick={() => addComp("action")} style={topBtn("ghost", false)}>+ action</button>
              </div>

              <SectionHdr>Appearance</SectionHdr>
              <div style={{ fontSize: 11, color: T.inkFaint, marginBottom: 8, lineHeight: 1.5 }}>
                A base SVG drawing plus overlay rules that re-style it as this
                object&apos;s status changes (e.g. open vs closed). Separate from
                the NL appearance text the engine reads. Instances inherit it.
              </div>
              <AppearanceEditor
                visual={tpl.visual}
                onChange={(visual) => updateTpl({ visual })}/>

              {tpl.builtin && (
                <div style={{
                  marginTop: 18, display: "flex", alignItems: "center", gap: 10,
                }}>
                  <button onClick={() => {
                    const fresh = resetTemplateToDefault(tpl.id);
                    if (!fresh) return;
                    setTemplates({ ...templates, [tpl.id]: fresh });
                  }} style={topBtn("ghost", false)}>Reset to default</button>
                  <span style={{ fontSize: 11, color: T.inkFaint }}>
                    Built-in — edits persist; reset restores the shipped defaults.
                  </span>
                </div>
              )}
            </div>
          )}
        </div>
      </div>
    </div>
  );
}
// ─── DRAW COMPOSER (slice 2b) ────────────────────────────────────────────
// A minimal in-app vector composer for non-coders: add rect / circle / line /
// text, place + size + color them, and crucially give each an editable NAME
// (the SVG id) so overlay rules can target `#id`. It keeps its own element-list
// state and serializes to a clean SVG string on every change (drawComposerToSvg),
// written straight into visual.baseSvg — the same shape the rest of the system
// consumes, so extractSvgIds + overlay rules pick up the drawn ids. On open it
// re-parses an existing composer-authored SVG (svgToDrawComposer) so editing a
// drawing round-trips; raw imported SVG that it can't parse starts a fresh page
// (the Import tab remains the path for hand-written SVG).
const DRAW_VIEWBOX = 100;
function drawElDefaults(type) {
  if (type === "rect")   return { type, id: "", x: 30, y: 30, w: 40, h: 40, fill: "#7a9cc6" };
  if (type === "circle") return { type, id: "", cx: 50, cy: 50, r: 22, fill: "#c67a7a" };
  if (type === "line")   return { type, id: "", x1: 20, y1: 20, x2: 80, y2: 80, stroke: "#444", sw: 4 };
  if (type === "text")   return { type, id: "", x: 50, y: 54, text: "label", fill: "#222", size: 16 };
  return { type, id: "" };
}
// Serialize the composer's element list to a clean, sanitized SVG string.
function drawComposerToSvg(els) {
  if (!Array.isArray(els) || els.length === 0) return "";
  const esc = (s) => String(s == null ? "" : s)
    .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
  const idAttr = (e) => e.id ? ` id='${esc(e.id)}'` : "";
  const body = els.map((e) => {
    if (e.type === "rect")
      return `<rect${idAttr(e)} x='${e.x}' y='${e.y}' width='${e.w}' height='${e.h}' fill='${esc(e.fill)}'/>`;
    if (e.type === "circle")
      return `<circle${idAttr(e)} cx='${e.cx}' cy='${e.cy}' r='${e.r}' fill='${esc(e.fill)}'/>`;
    if (e.type === "line")
      return `<line${idAttr(e)} x1='${e.x1}' y1='${e.y1}' x2='${e.x2}' y2='${e.y2}' stroke='${esc(e.stroke)}' stroke-width='${e.sw}'/>`;
    if (e.type === "text")
      return `<text${idAttr(e)} x='${e.x}' y='${e.y}' fill='${esc(e.fill)}' font-size='${e.size}' text-anchor='middle' font-family='sans-serif'>${esc(e.text)}</text>`;
    return "";
  }).filter(Boolean).join("");
  return `<svg viewBox='0 0 ${DRAW_VIEWBOX} ${DRAW_VIEWBOX}' xmlns='http://www.w3.org/2000/svg'>${body}</svg>`;
}
// Best-effort parse of a (composer-authored) SVG string back into the element
// list. Recognizes the four primitives the composer emits. Returns null if the
// SVG contains tags it can't represent (so the caller keeps it opaque).
function svgToDrawComposer(svg) {
  if (typeof svg !== "string" || !svg.trim()) return [];
  let doc;
  try { doc = new DOMParser().parseFromString(svg, "image/svg+xml"); } catch { return null; }
  const root = doc && doc.querySelector("svg");
  if (!root) return null;
  if (doc.querySelector("parsererror")) return null;
  const els = [];
  const known = new Set(["rect", "circle", "line", "text"]);
  for (const node of Array.from(root.children)) {
    const tag = node.tagName.toLowerCase();
    if (!known.has(tag)) return null; // group/path/etc → not composer-editable
    const num = (a, d) => { const n = Number(node.getAttribute(a)); return Number.isNaN(n) ? d : n; };
    const id = node.getAttribute("id") || "";
    if (tag === "rect")
      els.push({ type: "rect", id, x: num("x", 0), y: num("y", 0), w: num("width", 10), h: num("height", 10), fill: node.getAttribute("fill") || "#7a9cc6" });
    else if (tag === "circle")
      els.push({ type: "circle", id, cx: num("cx", 50), cy: num("cy", 50), r: num("r", 10), fill: node.getAttribute("fill") || "#c67a7a" });
    else if (tag === "line")
      els.push({ type: "line", id, x1: num("x1", 0), y1: num("y1", 0), x2: num("x2", 100), y2: num("y2", 100), stroke: node.getAttribute("stroke") || "#444", sw: num("stroke-width", 4) });
    else if (tag === "text")
      els.push({ type: "text", id, x: num("x", 50), y: num("y", 50), text: node.textContent || "", fill: node.getAttribute("fill") || "#222", size: num("font-size", 16) });
  }
  return els;
}
function DrawComposer({ baseSvg, onCommit }) {
  // Seed from an existing composer-authored SVG; if unparseable, start blank but
  // warn that committing will replace the current (imported) drawing.
  const seeded = React.useMemo(() => svgToDrawComposer(baseSvg), [baseSvg]);
  // `freshDraw` lets the user opt out of the read-only (Import/Library) SVG and
  // start a blank composer drawing. It resets whenever a new external SVG loads.
  const [freshDraw, setFreshDraw] = useState(false);
  const opaque = seeded === null && !freshDraw; // existing SVG isn't composer-editable
  const [els, setEls] = useState(() => seeded || []);
  const [sel, setSel] = useState(null);
  const [counter, setCounter] = useState(1);
  // The SVG this composer last emitted upward. We use it to distinguish an
  // EXTERNAL baseSvg change (tab switch / load / Import → must re-seed and clear
  // selection) from our OWN commit echoing back through the parent (must NOT
  // re-seed, or it would wipe the just-set selection mid-edit).
  const lastEmitted = React.useRef(baseSvg);
  useEffect(() => {
    if (baseSvg === lastEmitted.current) return; // our own echo — ignore
    lastEmitted.current = baseSvg;
    setEls(seeded || []);
    setSel(null);
    setFreshDraw(false); // a fresh external SVG re-locks the read-only state
  }, [baseSvg]);

  // Discard the read-only Import/Library SVG and begin a blank composer drawing.
  const startFreshDraw = () => {
    setEls([]);
    setSel(null);
    setFreshDraw(true);
    lastEmitted.current = "";
    onCommit("");
  };

  const commit = (next) => {
    setEls(next);
    const svg = drawComposerToSvg(next);
    lastEmitted.current = svg;
    onCommit(svg);
  };
  const addEl = (type) => {
    const id = `${type}${counter}`;
    setCounter(counter + 1);
    const el = { ...drawElDefaults(type), id };
    const next = [...els, el];
    setSel(next.length - 1);
    commit(next);
  };
  const patchEl = (i, patch) => commit(els.map((e, j) => j === i ? { ...e, ...patch } : e));
  const removeEl = (i) => { commit(els.filter((_, j) => j !== i)); setSel(null); };
  const moveZ = (i, dir) => {
    const j = i + dir;
    if (j < 0 || j >= els.length) return;
    const next = els.slice();
    [next[i], next[j]] = [next[j], next[i]];
    commit(next);
    setSel(j);
  };

  const previewSvg = useMemo(() => sanitizeSvg(drawComposerToSvg(els)), [els]);
  const cur = sel != null ? els[sel] : null;
  const numField = (label, key, w = 56) => (
    <label style={{ display: "inline-flex", alignItems: "center", gap: 3, fontSize: 10, color: T.inkMuted }}>
      {label}
      <input type="number" value={cur[key]} onChange={(e) => patchEl(sel, { [key]: Number(e.target.value) })}
        style={{ ...tplInputStyle(false), width: w }}/>
    </label>
  );

  return (
    <div data-draw-composer>
      {opaque && (
        <div data-draw-readonly style={{
          fontSize: 10.5, color: "#8a5a26", marginBottom: 8, lineHeight: 1.45,
          padding: "7px 9px", border: "1px solid #8a5a2655",
          background: "#8a5a260f", borderRadius: 4,
          display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap",
        }}>
          <span style={{ flex: 1, minWidth: 180 }}>
            Drawing is read-only because this SVG came from Import/Library.
            Clear it to draw from scratch.
          </span>
          <button data-draw-clear onClick={startFreshDraw} style={topBtn("ghost", false)}>
            Clear &amp; start drawing
          </button>
        </div>
      )}
      {/* Toolbar — greyed/disabled while the imported SVG is read-only. */}
      <div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 8 }}>
        {["rect", "circle", "line", "text"].map(t => (
          <button key={t} data-add={t} disabled={opaque}
            title={opaque ? "Read-only — clear the imported SVG to draw" : `Add ${t}`}
            onClick={() => { if (!opaque) addEl(t); }}
            style={{
              ...topBtn("ghost", false),
              opacity: opaque ? 0.4 : 1,
              cursor: opaque ? "not-allowed" : "pointer",
            }}>+ {t}</button>
        ))}
      </div>
      <div style={{ display: "flex", gap: 12, alignItems: "flex-start" }}>
        {/* Canvas */}
        <div style={{ flexShrink: 0 }}>
          <svg width="160" height="160" viewBox={`0 0 ${DRAW_VIEWBOX} ${DRAW_VIEWBOX}`}
            data-draw-canvas
            style={{ border: `1px solid ${T.ruleSoft}`, background: T.paperDeep, borderRadius: 4 }}>
            <rect x="0" y="0" width={DRAW_VIEWBOX} height={DRAW_VIEWBOX} fill="transparent"
              onClick={() => setSel(null)}/>
            {els.map((e, i) => {
              const onPick = (ev) => { ev.stopPropagation(); setSel(i); };
              const selStroke = i === sel ? "#3b6ea5" : "none";
              if (e.type === "rect")
                return <rect key={i} x={e.x} y={e.y} width={e.w} height={e.h} fill={e.fill}
                  stroke={selStroke} strokeWidth={i === sel ? 1.5 : 0} strokeDasharray="3 2"
                  onClick={onPick} style={{ cursor: "pointer" }}/>;
              if (e.type === "circle")
                return <circle key={i} cx={e.cx} cy={e.cy} r={e.r} fill={e.fill}
                  stroke={selStroke} strokeWidth={i === sel ? 1.5 : 0} strokeDasharray="3 2"
                  onClick={onPick} style={{ cursor: "pointer" }}/>;
              if (e.type === "line")
                return <line key={i} x1={e.x1} y1={e.y1} x2={e.x2} y2={e.y2} stroke={e.stroke}
                  strokeWidth={e.sw} onClick={onPick} style={{ cursor: "pointer" }}/>;
              if (e.type === "text")
                return <text key={i} x={e.x} y={e.y} fill={e.fill} fontSize={e.size}
                  textAnchor="middle" fontFamily="sans-serif"
                  onClick={onPick} style={{ cursor: "pointer", outline: i === sel ? "1px dashed #3b6ea5" : "none" }}>{e.text}</text>;
              return null;
            })}
          </svg>
          {els.length === 0 && (
            <div style={{ fontSize: 10, color: T.inkFaint, marginTop: 4, width: 160 }}>
              Empty. Use the toolbar to add a shape.
            </div>
          )}
        </div>
        {/* Element list + editor */}
        <div style={{ flex: 1, minWidth: 0 }}>
          <div style={{ display: "flex", flexDirection: "column", gap: 2, marginBottom: 8 }}>
            {els.map((e, i) => (
              <button key={i} onClick={() => setSel(i)} data-layer={e.id}
                style={{
                  textAlign: "left", fontSize: 11, padding: "3px 6px", cursor: "pointer",
                  border: `1px solid ${i === sel ? T.accent : T.ruleSoft}`,
                  background: i === sel ? T.paperDeep : T.paperWarm, color: T.ink,
                  fontFamily: "ui-monospace, monospace", borderRadius: 3,
                }}>{e.type} · #{e.id || "(no id)"}</button>
            ))}
          </div>
          {cur && (
            <div style={{
              padding: 8, border: `1px solid ${T.ruleSoft}`, borderRadius: 4,
              background: T.paperSoft, display: "flex", flexDirection: "column", gap: 6,
            }}>
              <label style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 10, color: T.inkMuted }}>
                name (id)
                <input value={cur.id} data-id-input placeholder="e.g. body"
                  onChange={(e) => patchEl(sel, { id: e.target.value.replace(/[^A-Za-z0-9_-]/g, "") })}
                  style={{ ...tplInputStyle(false), flex: 1, fontFamily: "ui-monospace, monospace" }}/>
              </label>
              <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
                {cur.type === "rect" && <>{numField("x", "x")}{numField("y", "y")}{numField("w", "w")}{numField("h", "h")}</>}
                {cur.type === "circle" && <>{numField("cx", "cx")}{numField("cy", "cy")}{numField("r", "r")}</>}
                {cur.type === "line" && <>{numField("x1", "x1")}{numField("y1", "y1")}{numField("x2", "x2")}{numField("y2", "y2")}{numField("width", "sw", 44)}</>}
                {cur.type === "text" && <>{numField("x", "x")}{numField("y", "y")}{numField("size", "size", 44)}</>}
              </div>
              {cur.type === "text" && (
                <label style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 10, color: T.inkMuted }}>
                  text
                  <input value={cur.text} onChange={(e) => patchEl(sel, { text: e.target.value })}
                    style={{ ...tplInputStyle(false), flex: 1 }}/>
                </label>
              )}
              <label style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 10, color: T.inkMuted }}>
                color
                <input type="color"
                  value={cur.type === "line" ? cur.stroke : cur.fill}
                  onChange={(e) => patchEl(sel, cur.type === "line" ? { stroke: e.target.value } : { fill: e.target.value })}
                  style={{ width: 36, height: 22, padding: 0, border: `1px solid ${T.rule}`, background: "none", cursor: "pointer" }}/>
              </label>
              <div style={{ display: "flex", gap: 6 }}>
                <button onClick={() => moveZ(sel, -1)} style={topBtn("ghost", false)}>↓ back</button>
                <button onClick={() => moveZ(sel, 1)} style={topBtn("ghost", false)}>↑ front</button>
                <button onClick={() => removeEl(sel)} style={topBtn("ghost", false)}>Delete</button>
              </div>
            </div>
          )}
        </div>
        {/* Live preview */}
        <div style={{
          width: 96, height: 96, flexShrink: 0,
          border: `1px solid ${T.ruleSoft}`, borderRadius: 4,
          background: T.paperDeep, display: "grid", placeItems: "center",
        }}>
          {previewSvg
            ? <span style={{ width: 76, height: 76, lineHeight: 0 }}
                dangerouslySetInnerHTML={{ __html: previewSvg.replace("<svg ", "<svg width='76' height='76' ") }}/>
            : <span style={{ fontSize: 10, color: T.inkFaint }}>empty</span>}
        </div>
      </div>
    </div>
  );
}

// Appearance authoring for a template: base SVG (Import / Library / Draw)
// + a list of status-overlay rules. Persists into `template.visual`.
function AppearanceEditor({ visual, onChange }) {
  const v = visual || {};
  const baseSvg = v.baseSvg || "";
  const rules = Array.isArray(v.rules) ? v.rules : [];
  const [tab, setTab] = useState("import");
  const [draft, setDraft] = useState(baseSvg);
  useEffect(() => { setDraft(baseSvg); }, [baseSvg]);

  const ids = useMemo(() => extractSvgIds(baseSvg), [baseSvg]);
  const safePreview = useMemo(() => sanitizeSvg(baseSvg), [baseSvg]);

  const setBase = (svg) => onChange({ ...v, baseSvg: sanitizeSvg(svg), rules });
  const setRules = (next) => onChange({ ...v, baseSvg, rules: next });
  const updateRule = (i, patch) => setRules(rules.map((r, idx) => idx === i ? { ...r, ...patch } : r));
  const addRule = () => setRules([...rules, { when: "", set: [], show: [], hide: [] }]);
  const removeRule = (i) => setRules(rules.filter((_, idx) => idx !== i));

  const onFile = (e) => {
    const file = e.target.files && e.target.files[0];
    if (!file) return;
    const reader = new FileReader();
    reader.onload = () => {
      const txt = String(reader.result || "");
      setDraft(txt);
      setBase(txt);
    };
    reader.readAsText(file);
    e.target.value = "";
  };

  const tabBtn = (id, label, disabled) => (
    <button
      disabled={disabled}
      onClick={() => !disabled && setTab(id)}
      style={{
        padding: "4px 10px", fontSize: 11, fontWeight: 700,
        border: "none", cursor: disabled ? "not-allowed" : "pointer",
        background: tab === id ? T.accent : "transparent",
        color: disabled ? T.inkFaint : (tab === id ? "#fff" : T.inkMuted),
      }}>{label}{disabled ? " (soon)" : ""}</button>
  );

  // A multi-row set / show / hide effect block inside an overlay rule.
  // `set` is an array of {sel,attr,val}; the editor lets the user add/remove
  // rows. VisualSprite already iterates the whole array and applies each.
  const effectEditor = (rule, i) => {
    const setRows = Array.isArray(rule.set) ? rule.set : [];
    const setSetRow = (k, patch) =>
      updateRule(i, { set: setRows.map((s, j) => j === k ? { ...s, ...patch } : s) });
    const addSetRow = () =>
      updateRule(i, { set: [...setRows, { sel: "", attr: "", val: "" }] });
    const removeSetRow = (k) =>
      updateRule(i, { set: setRows.filter((_, j) => j !== k) });
    const idOpts = ["", ...ids];
    return (
      <div style={{ display: "flex", flexDirection: "column", gap: 4, marginTop: 6 }}>
        {setRows.map((s, k) => (
          <div key={k} style={{ display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" }}>
            <span style={{ fontSize: 10, color: T.inkMuted, width: 38 }}>{k === 0 ? "set" : ""}</span>
            <select value={s.sel || ""} onChange={(e) => setSetRow(k, { sel: e.target.value })}
              style={{ ...tplInputStyle(false), width: 120 }}>
              <option value="">— element —</option>
              {idOpts.filter(Boolean).map(id => <option key={id} value={`#${id}`}>{`#${id}`}</option>)}
            </select>
            <input value={s.attr || ""} placeholder="attr (e.g. fill)"
              onChange={(e) => setSetRow(k, { attr: e.target.value })}
              style={{ ...tplInputStyle(false), width: 110 }}/>
            <input value={s.val || ""} placeholder="value (e.g. #888)"
              onChange={(e) => setSetRow(k, { val: e.target.value })}
              style={{ ...tplInputStyle(false), width: 110 }}/>
            <button title="Remove this set row" onClick={() => removeSetRow(k)}
              style={chipXStyle}>×</button>
          </div>
        ))}
        <div style={{ display: "flex", gap: 6, alignItems: "center" }}>
          <span style={{ fontSize: 10, color: T.inkMuted, width: 38 }}>{setRows.length === 0 ? "set" : ""}</span>
          <button onClick={addSetRow} style={topBtn("ghost", false)}>+ set attribute</button>
        </div>
        <div style={{ display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" }}>
          <span style={{ fontSize: 10, color: T.inkMuted, width: 38 }}>show</span>
          <select value=""
            onChange={(e) => { if (e.target.value) updateRule(i, { show: [...(rule.show || []), e.target.value] }); }}
            style={{ ...tplInputStyle(false), width: 120 }}>
            <option value="">+ layer…</option>
            {idOpts.filter(Boolean).map(id => <option key={id} value={`#${id}`}>{`#${id}`}</option>)}
          </select>
          {(rule.show || []).map((sel, k) => (
            <span key={k} style={chipStyle}>
              {sel}
              <button onClick={() => updateRule(i, { show: rule.show.filter((_, j) => j !== k) })}
                style={chipXStyle}>×</button>
            </span>
          ))}
        </div>
        <div style={{ display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" }}>
          <span style={{ fontSize: 10, color: T.inkMuted, width: 38 }}>hide</span>
          <select value=""
            onChange={(e) => { if (e.target.value) updateRule(i, { hide: [...(rule.hide || []), e.target.value] }); }}
            style={{ ...tplInputStyle(false), width: 120 }}>
            <option value="">+ layer…</option>
            {idOpts.filter(Boolean).map(id => <option key={id} value={`#${id}`}>{`#${id}`}</option>)}
          </select>
          {(rule.hide || []).map((sel, k) => (
            <span key={k} style={chipStyle}>
              {sel}
              <button onClick={() => updateRule(i, { hide: rule.hide.filter((_, j) => j !== k) })}
                style={chipXStyle}>×</button>
            </span>
          ))}
        </div>
      </div>
    );
  };

  return (
    <div>
      {/* Base SVG source */}
      <div style={{ display: "flex", gap: 12, alignItems: "flex-start" }}>
        <div style={{ flex: 1 }}>
          <div style={{ display: "flex", border: `1px solid ${T.ruleSoft}`, borderRadius: 3,
            overflow: "hidden", width: "fit-content", marginBottom: 6 }}>
            {tabBtn("import", "Import", false)}
            {tabBtn("library", "Library", false)}
            {tabBtn("draw", "Draw", false)}
          </div>
          {tab === "import" && (
            <div>
              <textarea
                value={draft}
                placeholder="<svg viewBox='0 0 100 100'> … give elements id='…' so rules can target them … </svg>"
                onChange={(e) => setDraft(e.target.value)}
                onBlur={() => setBase(draft)}
                style={{ ...tplInputStyle(false), width: "100%", height: 96,
                  fontFamily: "ui-monospace, monospace", resize: "vertical" }}/>
              <div style={{ display: "flex", gap: 6, alignItems: "center", marginTop: 4 }}>
                <button onClick={() => setBase(draft)} style={topBtn("ghost", false)}>
                  <Ico path={ICO_CHECK} size={11}/> Apply
                </button>
                <label style={{ ...topBtn("ghost", false), display: "inline-flex",
                  alignItems: "center", gap: 4, cursor: "pointer" }}>
                  <Ico path={ICO_PENCIL} size={11}/> Upload .svg
                  <input type="file" accept=".svg,image/svg+xml" onChange={onFile}
                    style={{ display: "none" }}/>
                </label>
                {baseSvg && (
                  <button onClick={() => onChange({ ...v, baseSvg: "" })}
                    style={topBtn("ghost", false)}>Clear</button>
                )}
              </div>
            </div>
          )}
          {tab === "library" && (
            <div data-library-presets style={{
              display: "flex", gap: 8, flexWrap: "wrap",
              // Wrap to a new row and scroll if the panel is too narrow, so all
              // five presets (incl. "Status panel") stay reachable.
              maxHeight: 220, overflowY: "auto", overflowX: "hidden",
              paddingBottom: 2,
            }}>
              {VISUAL_PRESETS.map(p => (
                <button key={p.id}
                  onClick={() => onChange({ baseSvg: sanitizeSvg(p.baseSvg),
                    rules: p.suggestedRules ? p.suggestedRules.map(r => ({ ...r })) : [] })}
                  style={{
                    display: "flex", flexDirection: "column", alignItems: "center",
                    gap: 2, padding: 6, cursor: "pointer",
                    background: T.paperWarm, border: `1px solid ${T.rule}`, borderRadius: 4,
                  }}>
                  <span style={{ width: 40, height: 40, lineHeight: 0 }}
                    dangerouslySetInnerHTML={{ __html: sanitizeSvg(
                      p.baseSvg.replace("<svg ", "<svg width='40' height='40' ")) }}/>
                  <span style={{ fontSize: 10, color: T.inkMuted }}>{p.label}</span>
                </button>
              ))}
            </div>
          )}
          {tab === "draw" && (
            <DrawComposer baseSvg={baseSvg} onCommit={(svg) => setBase(svg)}/>
          )}
        </div>
        {/* Live preview */}
        <div style={{
          width: 96, height: 96, flexShrink: 0,
          border: `1px solid ${T.ruleSoft}`, borderRadius: 4,
          background: T.paperDeep, display: "grid", placeItems: "center",
        }}>
          {safePreview
            ? <span style={{ width: 76, height: 76, lineHeight: 0 }}
                dangerouslySetInnerHTML={{ __html: safePreview.replace(
                  "<svg ", "<svg width='76' height='76' ") }}/>
            : <span style={{ fontSize: 10, color: T.inkFaint }}>no SVG</span>}
        </div>
      </div>

      {/* Overlay rules */}
      <div style={{ marginTop: 12, fontSize: 11, fontWeight: 700, color: T.inkMuted,
        letterSpacing: "0.06em", textTransform: "uppercase" }}>Overlay rules</div>
      {rules.length === 0 && (
        <div style={{ fontSize: 11, color: T.inkFaint, margin: "6px 0" }}>
          No rules — the base SVG always renders as-is.
        </div>
      )}
      {rules.map((r, i) => (
        <div key={i} style={{
          padding: "8px 10px", marginTop: 8,
          background: T.paperSoft, border: `1px solid ${T.ruleSoft}`, borderRadius: 4,
        }}>
          <div style={{ display: "flex", gap: 6, alignItems: "center" }}>
            <span style={{ fontSize: 10, color: T.inkMuted, width: 38 }}>when</span>
            <input value={r.when || ""} placeholder="open==false"
              onChange={(e) => updateRule(i, { when: e.target.value })}
              style={{ ...tplInputStyle(false), flex: 1,
                fontFamily: "ui-monospace, monospace" }}/>
            <button onClick={() => removeRule(i)} style={topBtn("ghost", false)}>×</button>
          </div>
          {effectEditor(r, i)}
        </div>
      ))}
      <button onClick={addRule} style={{ ...topBtn("ghost", false), marginTop: 8 }}>
        <Ico path={ICO_BOLT} size={11}/> + overlay rule
      </button>
    </div>
  );
}
const chipStyle = {
  display: "inline-flex", alignItems: "center", gap: 3,
  fontSize: 10, padding: "1px 4px", borderRadius: 8,
  background: T.paperWarm, border: `1px solid ${T.rule}`, color: T.ink,
  fontFamily: "ui-monospace, monospace",
};
const chipXStyle = {
  border: "none", background: "transparent", cursor: "pointer",
  color: T.inkMuted, fontSize: 12, lineHeight: 1, padding: 0,
};
function Row({ label, children }) {
  return (
    <div style={{ display: "flex", alignItems: "center", gap: 12, padding: "5px 0" }}>
      <span style={{ width: 110, fontSize: 11, color: T.inkMuted,
        letterSpacing: "0.06em", textTransform: "uppercase", fontWeight: 600 }}>{label}</span>
      <div style={{ flex: 1 }}>{children}</div>
    </div>
  );
}
function SectionHdr({ children }) {
  return (
    <div style={{
      marginTop: 14, marginBottom: 8,
      paddingBottom: 4, borderBottom: `1px solid ${T.ruleSoft}`,
      fontSize: 11, fontWeight: 700, color: T.inkMuted,
      letterSpacing: "0.08em", textTransform: "uppercase",
    }}>{children}</div>
  );
}
function fieldRowStyle() {
  return { display: "flex", alignItems: "center", gap: 6, marginBottom: 5 };
}
// ─── EXPORT MODAL (Phase 8) ──────────────────────────────────────────
// Pick what to include and which format; one click → file download.
function ExportModal({ entities, events, rules, components = [], templates, worldBook = [], worldSettings = {}, snapshots, tickSec, onClose }) {
  const [parts, setParts] = useState({
    events: true,
    perEntityLogs: true,
    reasoning: true,
    soul: true,
    sceneMap: true,
    templates: false,
    rules: true,
    versions: false,
  });
  const [format, setFormat] = useState("json");
  // Brief inline success confirmation after a download; auto-clears so a second
  // export is always possible (the Download button is never left dead).
  const [exported, setExported] = useState(false);
  const exportedTimer = React.useRef(null);
  useEffect(() => () => { if (exportedTimer.current) clearTimeout(exportedTimer.current); }, []);
  const toggle = (k) => setParts(p => ({ ...p, [k]: !p[k] }));
  // Per-entity selection. Default: include everything.
  const [selectedIds, setSelectedIds] = useState(() => new Set(entities.map(e => e.id)));
  const toggleId = (id) => setSelectedIds(prev => {
    const next = new Set(prev);
    if (next.has(id)) next.delete(id); else next.add(id);
    return next;
  });
  const setAll = (val) => setSelectedIds(val ? new Set(entities.map(e => e.id)) : new Set());

  const generate = () => {
    const out = {};
    const inScope = (e) => selectedIds.has(e.id);
    const selected = entities.filter(inScope);
    if (parts.events) {
      const nameSet = new Set(selected.filter(e => e.kind === "agent").map(e => e.name));
      out.eventLog = events.filter(ev => !ev.actorName || nameSet.has(ev.actorName));
    }
    if (parts.perEntityLogs) {
      out.entityLogs = selected
        .filter(e => Array.isArray(e.log) && e.log.length > 0)
        .map(e => ({ id: e.id, name: e.name, kind: e.kind, template: e.template, log: e.log }));
    }
    if (parts.reasoning) {
      out.reasoning = selected.flatMap(e =>
        (e.log || []).filter(l => (l.kind || "").startsWith("reasoning"))
          .map(l => ({ agent: e.name, ...l }))
      );
    }
    if (parts.soul) {
      out.soul = selected
        .filter(e => e.template === "human")
        .map(e => ({
          id: e.id, name: e.name,
          memory: e.memory || [],
          soul: e.soul || "",
          soulUpdates: (e.log || []).filter(l => (l.kind || "").startsWith("soul")),
        }));
    }
    if (parts.sceneMap) {
      const selScenes = selected.filter(e => e.kind === "scene");
      const selPlaced = selected.filter(e => e.placedIn);
      out.sceneMap = {
        scenes: selScenes.map(s => ({
          id: s.id, name: s.name, x: s.x, y: s.y, w: s.w, h: s.h,
          connects: s.connects, rules: s.rules,
        })),
        placedEntities: selPlaced.map(e => ({
          id: e.id, name: e.name, kind: e.kind, template: e.template,
          placedIn: e.placedIn, x: e.x, y: e.y,
        })),
      };
    }
    if (parts.templates) out.templates = templates;
    if (parts.rules) {
      // studio-1.1: emit both legacy rules[] (back-compat) and the
      // first-class components[] union: desugared rule presets + the
      // engine-only types (drives, middleware, custom actions, raw code).
      out.rules = rules;
      out.components = [...rulesToComponents(rules), ...components];
    }
    if (parts.versions) out.versions = snapshots;
    // Always embed the FULL world so the file re-imports — the documented
    // Export → Import backup/restore contract. importFile reads these keys
    // (the analysis sections above are extra). Without this, Export produced an
    // analysis bundle with no entities[] and Import threw "no entities[]".
    out.entities = entities;
    out.world_book = worldBook;
    out.language = worldSettings.language || "English";
    if ((worldSettings.world_rules || "").trim()) out.world_rules = worldSettings.world_rules;
    out.schema_version = "studio-1.1";
    out.meta = {
      exportedAt: new Date().toISOString(), tickSec, version: 2,
      selectedEntities: selected.map(e => e.id),
    };
    return out;
  };

  const download = () => {
    const data = generate();
    let body, mime, ext;
    if (format === "json") {
      body = JSON.stringify(data, null, 2);
      mime = "application/json"; ext = "json";
    } else if (format === "ndjson") {
      const lines = [];
      if (data.eventLog) for (const e of data.eventLog) lines.push(JSON.stringify({ type: "event", ...e }));
      if (data.reasoning) for (const r of data.reasoning) lines.push(JSON.stringify({ type: "reasoning", ...r }));
      if (data.entityLogs) for (const el of data.entityLogs)
        for (const l of el.log) lines.push(JSON.stringify({ type: "log", entity: el.name, ...l }));
      body = lines.join("\n");
      mime = "application/x-ndjson"; ext = "ndjson";
    } else if (format === "markdown") {
      const lines = [`# Habitat export · ${data.meta.exportedAt}`, ""];
      if (data.eventLog) {
        lines.push("## Event log"); lines.push("");
        for (const e of data.eventLog)
          lines.push(`- \`${e.t}\` **${e.actorName}** ${e.verb}${e.target ? " " + e.target : ""}${e.line ? `: "${e.line}"` : ""}`);
        lines.push("");
      }
      if (data.entityLogs) {
        lines.push("## Per-entity logs"); lines.push("");
        for (const el of data.entityLogs) {
          lines.push(`### ${el.name} (${el.kind} · ${el.template})`);
          for (const l of el.log) lines.push(`- \`t=${l.t}\` *${l.kind}* — ${l.text || ""}`);
          lines.push("");
        }
      }
      if (data.soul) {
        lines.push("## Soul"); lines.push("");
        for (const s of data.soul) {
          lines.push(`### ${s.name}`);
          lines.push(`Memory (${s.memory.length}):`);
          for (const m of s.memory) lines.push(`- t=${m.t} · ${m.actorName} ${m.verb}${m.target ? " → " + m.target : ""}`);
          lines.push(""); lines.push("Beliefs:");
          for (const b of s.beliefs) lines.push(`- about ${b.about} — ${b.claim} (${b.confidence.toFixed(2)})`);
          lines.push("");
        }
      }
      if (data.rules) {
        lines.push("## Rules"); lines.push("");
        for (const r of data.rules)
          lines.push(`- **${r.name}** (${r.kind})${r.text ? ` — ${r.text}` : ""}`);
        lines.push("");
      }
      body = lines.join("\n");
      mime = "text/markdown"; ext = "md";
    } else if (format === "csv") {
      // flat per-entity log csv
      const rows = [["entity", "kind", "template", "t", "logKind", "text"]];
      for (const el of (data.entityLogs || [])) {
        for (const l of el.log) rows.push([el.name, el.kind, el.template, l.t, l.kind, (l.text || "").replace(/"/g, '""')]);
      }
      body = rows.map(r => r.map(c => `"${c}"`).join(",")).join("\n");
      mime = "text/csv"; ext = "csv";
    }
    const blob = new Blob([body], { type: mime });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = `habitat-export-${Date.now()}.${ext}`;
    document.body.appendChild(a);
    a.click();
    setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100);
    // Show a short success confirmation, then clear it so re-export is obvious.
    setExported(true);
    if (exportedTimer.current) clearTimeout(exportedTimer.current);
    exportedTimer.current = setTimeout(() => setExported(false), 2600);
  };

  const partItem = (key, label, hint) => (
    <label style={{
      display: "flex", alignItems: "center", gap: 10,
      padding: "6px 10px",
      background: parts[key] ? T.paperWarm : "transparent",
      border: `1px solid ${parts[key] ? T.rule : T.ruleSoft}`,
      borderRadius: 4, cursor: "pointer", marginBottom: 4,
    }}>
      <input type="checkbox" checked={parts[key]} onChange={() => toggle(key)}/>
      <div style={{ flex: 1, minWidth: 0 }}>
        <div style={{ fontSize: 12, fontWeight: 600 }}>{label}</div>
        <div style={{ fontSize: 10.5, color: T.inkFaint }}>{hint}</div>
      </div>
    </label>
  );

  return (
    <div onClick={onClose} style={{
      position: "fixed", inset: 0, zIndex: 1000,
      background: "rgba(10,12,20,0.55)",
      display: "grid", placeItems: "center",
    }}>
      <div onClick={(e) => e.stopPropagation()} style={{
        width: "min(820px, 94vw)", maxHeight: "min(720px, 88vh)",
        background: T.paper, color: T.ink,
        border: `1px solid ${T.rule}`, borderRadius: 6,
        display: "flex", flexDirection: "column", overflow: "hidden",
        boxShadow: "0 24px 60px rgba(0,0,0,0.4)",
      }}>
        <div style={{
          height: 44, display: "flex", alignItems: "center",
          padding: "0 14px", borderBottom: `1px solid ${T.rule}`,
          background: T.paperDeep, gap: 10,
        }}>
          <span style={{ fontWeight: 700, fontSize: 14 }}>Export</span>
          <span style={{ fontSize: 11, color: T.inkMuted }}>
            pick the entities and the parts to include
          </span>
          <div style={{ flex: 1 }}/>
          <button onClick={onClose} style={topBtn("ghost", false)}>Close</button>
        </div>
        <div style={{ flex: 1, display: "flex", minHeight: 0 }}>
          {/* LEFT: per-entity selection */}
          <div style={{
            width: 320, flexShrink: 0,
            borderRight: `1px solid ${T.ruleSoft}`,
            display: "flex", flexDirection: "column",
            background: T.paperSoft,
          }}>
            <div style={{
              display: "flex", alignItems: "center", gap: 6,
              padding: "8px 14px", borderBottom: `1px solid ${T.ruleSoft}`,
            }}>
              <span style={{
                fontSize: 10, fontWeight: 700, color: T.inkMuted,
                letterSpacing: "0.08em", textTransform: "uppercase",
              }}>Entities · {selectedIds.size}/{entities.length}</span>
              <div style={{ flex: 1 }}/>
              <button onClick={() => setAll(true)} style={topBtn("ghost", false)}>all</button>
              <button onClick={() => setAll(false)} style={topBtn("ghost", false)}>none</button>
            </div>
            <div style={{ flex: 1, overflow: "auto", padding: "6px 0" }}>
              {["scene", "agent", "object", "action"].map(k => {
                const group = entities.filter(e => e.kind === k);
                if (group.length === 0) return null;
                return (
                  <div key={k} style={{ marginBottom: 4 }}>
                    <div style={{
                      padding: "4px 14px",
                      fontSize: 10, fontWeight: 700, color: T.inkFaint,
                      letterSpacing: "0.08em", textTransform: "uppercase",
                    }}>{k} · {group.length}</div>
                    {group.map(e => (
                      <label key={e.id} style={{
                        display: "flex", alignItems: "center", gap: 8,
                        padding: "4px 14px", cursor: "pointer", fontSize: 12,
                        background: selectedIds.has(e.id) ? T.paperWarm : "transparent",
                      }}>
                        <input type="checkbox" checked={selectedIds.has(e.id)}
                          onChange={() => toggleId(e.id)}/>
                        <span style={{ flex: 1, minWidth: 0,
                          overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
                        }}>{e.name || "(unnamed)"}</span>
                        <span style={{
                          fontSize: 10, color: T.inkFaint,
                          fontFamily: "ui-monospace, monospace",
                        }}>{e.template || e.kind}</span>
                      </label>
                    ))}
                  </div>
                );
              })}
            </div>
          </div>
          {/* RIGHT: which parts */}
          <div style={{ flex: 1, overflow: "auto", padding: "12px 18px" }}>
            <div style={{
              fontSize: 10, fontWeight: 700, color: T.inkMuted,
              letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 8,
            }}>Parts</div>
            {partItem("events", "Event log", "high-level sim events (actorName, verb, target)")}
            {partItem("perEntityLogs", "Per-entity logs", "every log entry on selected entities")}
            {partItem("reasoning", "Reasoning traces", "first-person justifications per action")}
            {partItem("soul", "Soul (memory + worldview)", "episodic memory + long-form worldview text per human")}
            {partItem("sceneMap", "Scene map", "scenes, connections, placed entity positions")}
            {partItem("rules", "Environment engine", "social norms + drives + adjudicator")}
            {partItem("templates", "Templates", "current template registry (built-ins + user forks)")}
            {partItem("versions", "Version snapshots", "the entire snapshot tree from localStorage")}
          </div>
        </div>
        <div style={{
          display: "flex", gap: 8, padding: "10px 16px",
          borderTop: `1px solid ${T.rule}`, background: T.paperDeep, alignItems: "center",
        }}>
          <span style={{ fontSize: 11, color: T.inkMuted,
            letterSpacing: "0.06em", textTransform: "uppercase", fontWeight: 600 }}>Format</span>
          <select value={format} onChange={(e) => setFormat(e.target.value)}
            style={{ ...tplInputStyle(false), padding: "4px 8px" }}>
            <option value="json">JSON (structured)</option>
            <option value="ndjson">NDJSON (line-delimited)</option>
            <option value="markdown">Markdown</option>
            <option value="csv">CSV (logs only)</option>
          </select>
          <div style={{ flex: 1 }}/>
          {exported && (
            <span data-export-ok style={{
              display: "inline-flex", alignItems: "center", gap: 5,
              fontSize: 12, fontWeight: 700, color: "#2f7a3f",
              padding: "3px 9px", borderRadius: 4,
              border: "1px solid #2f7a3f55", background: "#2f7a3f12",
            }}>
              <Ico path={ICO_CHECK} size={12} color="#2f7a3f"/> Exported
            </span>
          )}
          <button onClick={download} style={topBtn("primary", false)}>
            {exported ? "Export again" : "Download"}
          </button>
        </div>
      </div>
    </div>
  );
}

// ─── VERSIONS MODAL (Phase 7) ─────────────────────────────────────────
function VersionsModal({ snapshots, currentSnapshotId, onLoad, onRemove, onSave, onClose }) {
  // Group children by parentId for a simple indented-tree render.
  const childrenOf = (pid) => snapshots.filter(s => (s.parentId || null) === (pid || null));
  const renderNode = (snap, depth) => (
    <div key={snap.id}>
      <div style={{
        display: "flex", alignItems: "center", gap: 8,
        padding: "6px 10px",
        marginLeft: depth * 16,
        background: currentSnapshotId === snap.id ? T.paperWarm : "transparent",
        borderLeft: `3px solid ${currentSnapshotId === snap.id ? T.accent : "transparent"}`,
        fontSize: 12,
      }}>
        <span style={{
          fontFamily: "ui-monospace, monospace", color: T.inkFaint,
          fontSize: 10, width: 70,
        }}>t={fmtT(snap.t || 0)}</span>
        <div style={{ flex: 1, minWidth: 0 }}>
          <div style={{ fontWeight: 600 }}>{snap.label}</div>
          <div style={{
            fontSize: 10, color: T.inkFaint,
            fontFamily: "ui-monospace, monospace",
          }}>{snap.id} · {snap.createdAt.slice(11, 19)}</div>
        </div>
        <button onClick={() => onLoad(snap.id)}
          style={topBtn("ghost", false)}>Load</button>
        <button onClick={() => { if (window.confirm("Delete this version?")) onRemove(snap.id); }}
          style={{ ...topBtn("ghost", false), color: T.danger }}>×</button>
      </div>
      {childrenOf(snap.id).map(c => renderNode(c, depth + 1))}
    </div>
  );
  return (
    <div onClick={onClose} style={{
      position: "fixed", inset: 0, zIndex: 1000,
      background: "rgba(10,12,20,0.55)",
      display: "grid", placeItems: "center",
    }}>
      <div onClick={(e) => e.stopPropagation()} style={{
        width: "min(720px, 92vw)", maxHeight: "min(640px, 86vh)",
        background: T.paper, color: T.ink,
        border: `1px solid ${T.rule}`, borderRadius: 6,
        display: "flex", flexDirection: "column", overflow: "hidden",
        boxShadow: "0 24px 60px rgba(0,0,0,0.4)",
      }}>
        <div style={{
          height: 44, display: "flex", alignItems: "center",
          padding: "0 14px", borderBottom: `1px solid ${T.rule}`,
          background: T.paperDeep, gap: 10,
        }}>
          <span style={{ fontWeight: 700, fontSize: 14 }}>Versions</span>
          <span style={{ fontSize: 11, color: T.inkMuted }}>
            snapshots of the world — load to revert, save to branch
          </span>
          <div style={{ flex: 1 }}/>
          <button onClick={onSave} style={topBtn("primary", false)}>＋ Snapshot now</button>
          <button onClick={onClose} style={topBtn("ghost", false)}>Close</button>
        </div>
        <div style={{ flex: 1, overflow: "auto", padding: "10px 0" }}>
          {snapshots.length === 0 && (
            <div style={{
              padding: 30, textAlign: "center", color: T.inkFaint,
              fontSize: 12.5, lineHeight: 1.6,
            }}>
              No saved versions yet.<br/>
              Click <b>＋ Snapshot now</b> to capture the world.
            </div>
          )}
          {childrenOf(null).map(s => renderNode(s, 0))}
        </div>
      </div>
    </div>
  );
}

// GLOBAL Settings (the VSCode "user" tier): appearance, default model, the shared
// template Library, and the two reset levels. Project content is NOT edited here.
function SettingsModal({ themeId, setTheme, defaultModel, setDefaultModel,
                         engineUrl = "", setEngineUrl, templates, setTemplates, liveModel,
                         onClearCanvas, onResetHabitat, onClose }) {
  const H = { fontSize: 10, fontWeight: 700, color: T.inkMuted,
    letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 8 };
  const fld = { padding: "6px 9px", fontSize: 12.5, border: `1px solid ${T.rule}`,
    borderRadius: 4, background: T.paperWarm, color: T.ink };
  const THEME_LABELS = { paper: "Paper · light", night: "Night · dark" };
  const customTpls = Object.entries(templates || {}).filter(([, v]) => v && !v.builtin);
  const renameTpl = (id, label) => setTemplates({ ...templates, [id]: { ...templates[id], label } });
  const deleteTpl = (id) => {
    if (!window.confirm(`Remove "${(templates[id] && templates[id].label) || id}" from the shared Library? (Projects that already embed it keep their copy.)`)) return;
    const { [id]: _drop, ...rest } = templates;
    setTemplates(rest);
  };
  const exportLibrary = () => {
    const lib = {}; for (const [k, v] of customTpls) lib[k] = v;
    const blob = new Blob([JSON.stringify({ kind: "habitat-library", templates: lib }, null, 2)], { type: "application/json" });
    const a = document.createElement("a");
    a.href = URL.createObjectURL(blob); a.download = "habitat-library.json"; a.click();
    setTimeout(() => URL.revokeObjectURL(a.href), 1000);
  };
  const importLibrary = () => {
    const inp = document.createElement("input"); inp.type = "file"; inp.accept = "application/json,.json";
    inp.onchange = () => {
      const f = inp.files && inp.files[0]; if (!f) return;
      const rd = new FileReader();
      rd.onload = () => {
        try {
          const d = JSON.parse(rd.result);
          const incoming = (d && d.templates) || d || {};
          const add = {};
          for (const [k, v] of Object.entries(incoming)) if (v && typeof v === "object" && !v.builtin) add[k] = v;
          setTemplates({ ...templates, ...add });
        } catch (e) { window.alert("Couldn't read that Library file."); }
      };
      rd.readAsText(f);
    };
    inp.click();
  };
  return (
    <div onClick={onClose} style={{
      position: "fixed", inset: 0, zIndex: 1000,
      background: "rgba(10,12,20,0.55)", display: "grid", placeItems: "center",
    }}>
      <div onClick={(e) => e.stopPropagation()} style={{
        width: "min(620px, 92vw)", maxHeight: "min(680px, 88vh)",
        background: T.paper, color: T.ink, border: `1px solid ${T.rule}`, borderRadius: 6,
        display: "flex", flexDirection: "column", overflow: "hidden",
        boxShadow: "0 24px 60px rgba(0,0,0,0.4)",
      }}>
        <div style={{
          height: 44, display: "flex", alignItems: "center", padding: "0 14px",
          borderBottom: `1px solid ${T.rule}`, background: T.paperDeep, gap: 10,
        }}>
          <span style={{ fontWeight: 700, fontSize: 14 }}>⚙ Settings</span>
          <span style={{ fontSize: 11, color: T.inkMuted }}>global — shared across all your projects</span>
          <div style={{ flex: 1 }}/>
          <button onClick={onClose} style={topBtn("ghost", false)}>Close</button>
        </div>
        <div style={{ flex: 1, overflow: "auto", padding: "16px 18px",
          display: "flex", flexDirection: "column", gap: 22 }}>
          <div>
            <div style={H}>Appearance</div>
            <div style={{ display: "flex", gap: 8 }}>
              {Object.keys(THEMES).map(id => (
                <button key={id} onClick={() => setTheme(id)}
                  style={topBtn(themeId === id ? "primary" : "ghost", false)}>{THEME_LABELS[id] || id}</button>
              ))}
            </div>
          </div>
          <div>
            <div style={H}>Engine</div>
            <label style={{ fontSize: 12, color: T.inkMuted, display: "block", marginBottom: 6 }}>
              Default LLM model — applied the next time you Connect the engine.
            </label>
            <input value={defaultModel} onChange={e => setDefaultModel(e.target.value)}
              placeholder="deepseek/deepseek-v4-flash (engine default)"
              style={{ ...fld, width: "100%", boxSizing: "border-box", fontFamily: "ui-monospace, monospace" }}/>
            <label style={{ fontSize: 12, color: T.inkMuted, display: "block", margin: "12px 0 6px" }}>
              Engine address — where Connect dials. Leave blank for local dev (ws://localhost:8765).
            </label>
            <input value={engineUrl} onChange={e => setEngineUrl && setEngineUrl(e.target.value.trim())}
              placeholder="ws://localhost:8765  (or wss://your-engine.onrender.com for a deploy)"
              style={{ ...fld, width: "100%", boxSizing: "border-box", fontFamily: "ui-monospace, monospace" }}/>
            <div style={{ fontSize: 11, color: T.inkFaint, marginTop: 6, lineHeight: 1.5 }}>
              The OpenRouter API key is entered at Connect and kept for this session only — never saved here or to a project file.{liveModel ? <> Currently running: <b>{liveModel}</b>.</> : null}
            </div>
          </div>
          <div>
            <div style={H}>Template Library</div>
            <div style={{ fontSize: 11, color: T.inkFaint, marginBottom: 8, lineHeight: 1.5 }}>
              The shared palette of object classes you stamp from. Projects embed their own copies,
              so removing one here won't break worlds that already use it.
            </div>
            {customTpls.length === 0 ? (
              <div style={{ fontSize: 12, color: T.inkFaint, padding: "8px 0" }}>
                No custom templates yet — create one from the rail's Template editor.
              </div>
            ) : customTpls.map(([id, v]) => (
              <div key={id} style={{ display: "flex", alignItems: "center", gap: 8, padding: "4px 0" }}>
                <input value={v.label || ""} onChange={e => renameTpl(id, e.target.value)}
                  style={{ ...fld, flex: 1 }}/>
                <span style={{ fontSize: 10, color: T.inkFaint, fontFamily: "ui-monospace, monospace" }}>{v.kindHint || ""}</span>
                <button onClick={() => deleteTpl(id)} style={{ ...topBtn("ghost", false), color: T.danger }}>Delete</button>
              </div>
            ))}
            <div style={{ display: "flex", gap: 8, marginTop: 10 }}>
              <button onClick={exportLibrary} style={topBtn("ghost", false)}>↥ Export Library</button>
              <button onClick={importLibrary} style={topBtn("ghost", false)}>↧ Import Library</button>
            </div>
          </div>
          <div>
            <div style={{ ...H, color: T.danger }}>Reset</div>
            <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
              <button onClick={() => { onClose(); onClearCanvas && onClearCanvas(); }}
                style={topBtn("ghost", false)}>⌫ Clear this workspace</button>
              <button onClick={() => onResetHabitat && onResetHabitat()}
                style={{ ...topBtn("ghost", false), color: T.danger, borderColor: T.danger }}>⟲ Reset Habitat…</button>
            </div>
            <div style={{ fontSize: 11, color: T.inkFaint, marginTop: 6, lineHeight: 1.5 }}>
              <b>Clear this workspace</b> empties the current project (keeps it). <b>Reset Habitat</b>
              {" "}wipes ALL projects, the Library, and settings from this browser.
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

// ─── RULES EDITOR MODAL (Phase 2) ─────────────────────────────────────
// v6 — Environment Engine modal is now a tabbed component palette.
// Tabs map to the engine's open extension slots: Observer / Action /
// Adjudicator / Middleware / Norm / Event. Studio's existing 4 rule kinds
// surface as presets; the data shape stays as rules[] for back-compat and
// gets desugared into components[] on export.
// Each tab carries the engine `category` it belongs to (see ENGINE_CATEGORIES,
// which mirrors component_manifest()). The palette tab strip is grouped by
// those same five categories, so the palette and the rail read identically.
const COMPONENT_TABS = [
  { id: "worldlore", label: "World Book", category: "world-lore", kinds: [],
    presets: [], componentTypes: [["world_book", null]], worldBook: true,
    hint: "Lore your characters know — the era and its facts, injected into every mind and the adjudicator. Edit it in the World Book editor." },
  { id: "perception", label: "Perception", category: "perception", kinds: [],
    presets: ["percHearing","percSight","percAnon"],
    componentTypes: [["perception_rule", null]],
    hint: "World-level perception rules (engine 0.8.0): hearing range by graph hops, line-of-sight occlusion, and anonymized strangers (you perceive 'a stranger' until introduced)." },
  { id: "adjudicator", label: "Adjudicator", category: "adjudication", kinds: [],
    presets: ["adjRule","adjLLM"],
    componentTypes: [["adjudicator", null]],
    hint: "World-level. Pick the engine that decides outcomes: deterministic rules, or an LLM that can dispose novel verbs. Per-agent open_vocab lets agents improvise verbs the adjudicator then judges." },
  { id: "observer", label: "Drive (legacy)", category: "world-dynamics", kinds: [],
    presets: ["drive"],
    componentTypes: [["observer", "drive"]],
    hint: "A drive makes a status field climb every tick (hunger, fatigue). PREFER an object Attribute with a `rate` instead — a drive is a property of one object, not the world. Kept here so existing worlds still edit." },
  { id: "trigger",  label: "Expression rule (advanced)",  category: "world-dynamics", kinds: [],
    presets: ["triggerExpr","triggerNL"],
    componentTypes: [["trigger", null]],
    hint: "Exact when→then logic (e.g. agent_1.fear > 90). For most rules, write them in plain words in World Rules above; use this only when you need a deterministic expression." },
  { id: "event",    label: "Event (legacy)",    category: "world-dynamics", kinds: ["event"],
    presets: ["event"], componentTypes: [],
    hint: "A timed world change. PREFER a process-agent object — an object with a cadence + a behaviour — since a thing that acts on its own IS an object. Kept here so existing worlds still edit." },
];
const PRESET_LABEL = {
  text: "+ norm", duration: "+ duration", event: "+ event",
  drive: "+ drive (observer)", memoryMW: "+ memory middleware", moodMW: "+ mood middleware",
  customAct: "+ custom action", rawCode: "+ raw code",
  adjRule: "+ rule adjudicator", adjLLM: "+ LLM adjudicator",
  triggerExpr: "+ trigger (expression)", triggerNL: "+ trigger (in words · LLM)",
  attribute: "+ attribute", percHearing: "+ hearing range", percSight: "+ line of sight",
  percAnon: "+ anonymize strangers",
};
// ── MANIFEST-DRIVEN PALETTE (anti-drift contract) ─────────────────────
// Renders the authoring palette from `hello.manifest` so any preset the
// engine adds appears here with no code change. Lead with `describe` (✍️
// write a sentence, LLM-realized); fold deterministic presets under ⚙️.
function makeComponentFromPreset(slot, preset, agents) {
  const id = `cmp_${Date.now().toString(36)}_${Math.random().toString(36).slice(2,6)}`;
  const base = { id, type: slot.slot, preset: preset.preset, name: preset.preset };
  for (const [pname] of Object.entries(preset.params || {})) {
    base[pname] = "";
  }
  if (slot.scope === "entity" && agents[0]) base.entity = agents[0].id;
  return base;
}

// WORLD RULES — the single free natural-language prompt to the engine: several
// sentences / rules, anything true about how THIS world behaves. Always in force;
// injected into every mind AND the adjudicator (supersedes the old preset 'norm').
function WorldRulesBox({ worldSettings = {}, setWorldSettings }) {
  if (!setWorldSettings) return null;
  return (
    <div style={{ padding: "12px 22px", borderBottom: `1px solid ${T.ruleSoft}`,
      background: T.paperSoft }}>
      <div style={{ display: "flex", alignItems: "baseline", gap: 8, marginBottom: 5 }}>
        <span style={{ fontSize: 11, fontWeight: 700, color: T.ink,
          letterSpacing: "0.06em", textTransform: "uppercase",
          fontFamily: "ui-monospace, monospace" }}>World Rules</span>
        <span style={{ fontSize: 11, color: T.inkMuted, fontFamily: "inherit" }}>
          how this world behaves — your own rules, in plain words (always in force;
          every mind + the adjudicator read them)
        </span>
      </div>
      <textarea
        data-tour="world-rules"
        value={worldSettings.world_rules || ""}
        onChange={(e) => setWorldSettings(s => ({ ...s, world_rules: e.target.value }))}
        placeholder={"e.g.\n- The experimenter never raises his voice; he answers every objection with a calm, scripted prod.\n- The shocks are not real and the learner is an actor — but the teacher does not know this.\n- No one may leave the room until the experiment ends."}
        rows={4}
        style={{ ...inputStyle(), width: "100%", boxSizing: "border-box",
          fontFamily: "inherit", fontSize: 13, lineHeight: 1.5, resize: "vertical" }}/>
    </div>
  );
}

function ManifestPaletteModal({ manifest, components, setComponents, agents,
                                worldSettings = {}, setWorldSettings, onClose }) {
  const slots = (manifest.slots || []).filter(s => s.slot !== "entity");
  const [tab, setTab] = useState(slots[0]?.slot || "");
  const slot = slots.find(s => s.slot === tab) || slots[0];
  if (!slot) {
    return (
      <div onClick={onClose} style={{
        position: "fixed", inset: 0, zIndex: 1000,
        background: "rgba(10,12,20,0.55)", display: "grid", placeItems: "center",
      }}>
        <div onClick={(e) => e.stopPropagation()} style={{
          background: T.paper, padding: 24, maxWidth: 500,
        }}>
          <div style={{ fontWeight: 700, marginBottom: 8 }}>Empty manifest</div>
          <div style={{ fontSize: 12, color: T.inkMuted, marginBottom: 12 }}>
            The engine published a manifest but it has no slots. Probably an old build.
          </div>
          <button onClick={onClose} style={topBtn("ghost", false)}>Close</button>
        </div>
      </div>
    );
  }
  const describePreset = (slot.presets || []).find(p => p.preset === "describe");
  const otherPresets = (slot.presets || []).filter(p => p.preset !== "describe");
  const inSlot = components.filter(c => c.type === slot.slot);
  const update = (id, patch) => setComponents(components.map(c => c.id === id ? { ...c, ...patch } : c));
  const remove = (id) => setComponents(components.filter(c => c.id !== id));
  const addPreset = (preset) => setComponents([...components, makeComponentFromPreset(slot, preset, agents)]);

  return (
    <div onClick={onClose} style={{
      position: "fixed", inset: 0, zIndex: 1000,
      background: "rgba(10,12,20,0.55)", backdropFilter: "blur(2px)",
      display: "grid", placeItems: "center",
    }}>
      <div onClick={(e) => e.stopPropagation()} style={{
        width: "min(880px, 94vw)", maxHeight: "min(720px, 90vh)",
        background: T.paper, color: T.ink,
        border: `1px solid ${T.rule}`,
        display: "flex", flexDirection: "column", overflow: "hidden",
        boxShadow: "0 24px 60px rgba(0,0,0,0.4)",
        fontFamily: SCREENPLAY_SERIF,
      }}>
        <div style={{
          padding: "16px 22px",
          borderBottom: `1px solid ${T.ruleSoft}`,
          display: "flex", alignItems: "baseline", justifyContent: "space-between",
        }}>
          <div>
            <div style={{ fontSize: 18, fontWeight: 700 }}>Author this world</div>
            <div style={{ color: T.inkMuted, fontSize: 12, marginTop: 2,
              fontFamily: "inherit" }}>
              Engine manifest v{manifest.version || "?"} · live from the engine
            </div>
          </div>
          {manifest.world_fields?.language && setWorldSettings && (
            <div style={{ display: "flex", alignItems: "center", gap: 8,
              padding: "6px 10px", border: `1px solid ${T.ruleSoft}`,
              background: T.paperSoft,
            }}>
              <span style={{
                fontSize: 10, fontWeight: 700, color: T.inkMuted,
                letterSpacing: "0.08em", textTransform: "uppercase",
                fontFamily: "ui-monospace, monospace",
              }}>language</span>
              <select value={worldSettings.language || "English"}
                onChange={(e) => setWorldSettings(s => ({ ...s, language: e.target.value }))}
                style={{ ...inputStyle(), width: "auto", padding: "3px 6px",
                  fontFamily: "inherit", fontSize: 12 }}>
                {(Array.isArray(manifest.world_fields.language)
                  ? manifest.world_fields.language
                  : ["English","Chinese","Japanese","Spanish","French"]).map(l => (
                  <option key={l} value={l}>{l}</option>
                ))}
              </select>
            </div>
          )}
          <button onClick={onClose} style={{
            background: "transparent", border: "none", fontSize: 20,
            color: T.inkMuted, cursor: "pointer", padding: 0,
          }}>×</button>
        </div>
        <WorldRulesBox worldSettings={worldSettings} setWorldSettings={setWorldSettings}/>
        <div style={{
          display: "flex", borderBottom: `1px solid ${T.ruleSoft}`,
          background: T.paperWarm, flexShrink: 0, overflow: "auto",
        }}>
          {slots.map(s => (
            <button key={s.slot} onClick={() => setTab(s.slot)} style={{
              padding: "10px 16px", fontSize: 11, fontWeight: 700,
              background: tab === s.slot ? T.paper : "transparent",
              color: tab === s.slot ? T.ink : T.inkMuted,
              border: "none",
              borderBottom: tab === s.slot ? `2px solid ${T.accent}` : "2px solid transparent",
              cursor: "pointer", letterSpacing: "0.06em",
              textTransform: "uppercase", whiteSpace: "nowrap",
              fontFamily: "inherit",
            }}>{s.slot}</button>
          ))}
        </div>
        <div style={{ flex: 1, overflow: "auto", padding: "18px 22px" }}>
          {/* Headline: write-a-sentence (describe) preset */}
          {describePreset && (
            <div style={{
              border: `2px solid ${T.accent}`, padding: 16,
              marginBottom: 18, background: T.paperSoft,
            }}>
              <div style={{
                fontSize: 11, fontWeight: 700, color: T.accent,
                letterSpacing: "0.1em", textTransform: "uppercase",
                marginBottom: 6,
                fontFamily: "ui-monospace, monospace",
              }}><Ico path={ICO_PENCIL} size={12}/> write a sentence · LLM</div>
              <div style={{ fontSize: 14, color: T.ink, marginBottom: 10 }}>
                Describe a {slot.slot} in one sentence. The LLM realizes it at runtime.
              </div>
              <button onClick={() => addPreset(describePreset)} style={{
                ...topBtn("primary", false),
                fontFamily: "inherit", fontWeight: 700,
              }}>+ add a sentence</button>
            </div>
          )}
          {/* Deterministic presets folded under ⚙️ */}
          {otherPresets.length > 0 && (
            <div style={{ marginBottom: 16 }}>
              <div style={{
                fontSize: 11, fontWeight: 700, color: T.inkMuted,
                letterSpacing: "0.1em", textTransform: "uppercase",
                marginBottom: 8,
                fontFamily: "ui-monospace, monospace",
              }}><Ico path={ICO_GEAR} size={12}/> deterministic presets</div>
              <div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
                {otherPresets.map(p => (
                  <button key={p.preset} onClick={() => addPreset(p)} style={{
                    ...topBtn("ghost", false),
                    fontFamily: "inherit",
                  }}>
                    + {p.preset}{p.llm ? " · LLM" : ""}
                  </button>
                ))}
              </div>
            </div>
          )}
          {/* Existing components in this slot */}
          {inSlot.length === 0 && (
            <div style={{
              padding: "16px 0", fontSize: 13, color: T.inkFaint,
              fontStyle: "italic", textAlign: "center",
            }}>nothing in this slot yet — add one above.</div>
          )}
          {inSlot.map(c => {
            const preset = (slot.presets || []).find(p => p.preset === c.preset);
            const isDescribe = c.preset === "describe";
            return (
              <div key={c.id} style={{
                border: `1px solid ${T.ruleSoft}`, padding: 12,
                marginBottom: 8, background: T.paper,
              }}>
                <div style={{ display: "flex", justifyContent: "space-between",
                  alignItems: "center", marginBottom: 8 }}>
                  <span style={{ fontWeight: 700, fontSize: 13 }}>
                    <Ico path={isDescribe ? ICO_PENCIL : ICO_GEAR} size={12}/> {c.preset}
                    {preset?.llm && <span style={{
                      marginLeft: 8, fontSize: 9, color: T.accent,
                      fontWeight: 700, letterSpacing: "0.08em",
                      fontFamily: "ui-monospace, monospace",
                    }}>LLM</span>}
                  </span>
                  <button onClick={() => remove(c.id)} style={{
                    background: "transparent", border: "none",
                    color: T.inkFaint, cursor: "pointer", fontSize: 16,
                  }}>×</button>
                </div>
                {Object.entries(preset?.params || {}).map(([pname, ptype]) => {
                  const isLongText = isDescribe && (pname === "description" || pname === "rule" || pname === "text");
                  return (
                    <div key={pname} style={{ marginBottom: 8 }}>
                      <div style={{
                        fontSize: 10, color: T.inkMuted, fontWeight: 700,
                        letterSpacing: "0.06em", textTransform: "uppercase",
                        marginBottom: 3, fontFamily: "ui-monospace, monospace",
                      }}>{pname} <span style={{ opacity: 0.5 }}>· {ptype}</span></div>
                      {isLongText ? (
                        <textarea value={c[pname] || ""} rows={3}
                          placeholder={`e.g. "${slot.slot === "middleware"
                            ? "rises when others are praised and this person is ignored"
                            : slot.slot === "action"
                            ? "hack into the target's cyberlimb interface" : "..."}"`}
                          onChange={(ev) => update(c.id, { [pname]: ev.target.value })}
                          style={{
                            width: "100%", padding: 8, fontSize: 13,
                            fontFamily: SCREENPLAY_SERIF, lineHeight: 1.6,
                            border: `1px solid ${T.ruleSoft}`,
                            background: T.paperWarm, color: T.ink,
                            resize: "vertical",
                          }}/>
                      ) : (
                        <input value={c[pname] ?? ""}
                          onChange={(ev) => update(c.id, { [pname]: ev.target.value })}
                          style={{
                            width: "100%", padding: 6, fontSize: 12,
                            fontFamily: "ui-monospace, monospace",
                            border: `1px solid ${T.ruleSoft}`,
                            background: T.paperWarm, color: T.ink,
                          }}/>
                      )}
                    </div>
                  );
                })}
                {!preset && (
                  <div style={{ fontSize: 11, color: T.inkFaint, fontStyle: "italic" }}>
                    (Unknown preset for this slot — engine may have removed it.)
                  </div>
                )}
              </div>
            );
          })}
          {/* Roadmap pointer */}
          {(manifest.not_yet_exposed || []).length > 0 && tab === slots[0].slot && (
            <div style={{
              marginTop: 24, padding: 12, fontSize: 11,
              color: T.inkFaint, fontStyle: "italic",
              borderLeft: `3px solid ${T.ruleSoft}`,
            }}>
              <div style={{ marginBottom: 4, fontWeight: 700, fontStyle: "normal",
                textTransform: "uppercase", letterSpacing: "0.08em",
                fontFamily: "ui-monospace, monospace", fontSize: 10,
              }}>roadmap</div>
              {(manifest.not_yet_exposed || []).map((r, i) => (
                <div key={i}>· {r}</div>
              ))}
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

function RulesEditorModal({ rules, setRules, components = [], setComponents,
                            scenes, agents = [], engineManifest = null,
                            worldSettings = {}, setWorldSettings,
                            worldBookCount = 0, onOpenWorldBook, onClose }) {
  const [tab, setTab] = useState(COMPONENT_TABS[0].id);
  const cid = () => `cmp_${Date.now().toString(36)}_${Math.random().toString(36).slice(2,6)}`;
  const adders = {
    text: () => setRules(r => [...r, {
      id: `rule_${Date.now().toString(36)}`,
      name: "Social norm", kind: "text", text: "",
    }]),
    duration: () => setRules(r => [...r, {
      id: `rule_${Date.now().toString(36)}`,
      name: "Override duration", kind: "duration",
      verb: "sleep", seconds: 480,
    }]),
    event: () => setRules(r => [...r, {
      id: `rule_${Date.now().toString(36)}`,
      name: "Skip night", kind: "event",
      at: 79200, effect: "skip_to", target: 25200, text: "",
    }]),
    // v7 first-class component presets (not derived from rules)
    drive: () => setComponents([...components, {
      id: cid(), type: "observer", preset: "drive",
      name: "Drive — fatigue", field: "fatigue", rate: 2, max: 100,
    }]),
    memoryMW: () => setComponents([...components, {
      id: cid(), type: "middleware", preset: "memory",
      name: "Memory middleware", entity: agents[0]?.id || null,
    }]),
    moodMW: () => setComponents([...components, {
      id: cid(), type: "middleware", preset: "mood",
      name: "Mood middleware", entity: agents[0]?.id || null,
    }]),
    customAct: () => setComponents([...components, {
      id: cid(), type: "action",
      name: "Custom action", verb: "wave", duration: 3, effect: {},
    }]),
    rawCode: () => setComponents([...components, {
      id: cid(), type: "raw_code",
      name: "Custom code (escape hatch)", language: "python", source: "# write an Observer / Action here\n",
    }]),
    adjRule: () => setComponents([...components, {
      id: cid(), type: "adjudicator", kind: "rule",
      name: "Rule adjudicator",
    }]),
    adjLLM: () => setComponents([...components, {
      id: cid(), type: "adjudicator", kind: "llm",
      name: "LLM adjudicator",
    }]),
    triggerExpr: () => setComponents([...components, {
      id: cid(), type: "trigger", name: "trigger",
      when: "", then: {}, once: true,
    }]),
    triggerNL: () => setComponents([...components, {
      id: cid(), type: "trigger", name: "trigger",
      when_nl: "", then: {}, once: true,
    }]),
    attribute: () => setComponents([...components, {
      id: cid(), type: "attribute", name: "composure",
      entity: agents[0]?.id || null,
      range: [0, 100], init: 80, visibility: "public",
      rate: 0, manifestation: "",
    }]),
    percHearing: () => setComponents([...components, {
      id: cid(), type: "perception_rule", preset: "hearing", name: "hearing", max_hops: 1,
    }]),
    percSight: () => setComponents([...components, {
      id: cid(), type: "perception_rule", preset: "line_of_sight", name: "line of sight",
    }]),
    percAnon: () => setComponents([...components, {
      id: cid(), type: "perception_rule", preset: "anonymize", name: "anonymize strangers",
    }]),
  };
  const update = (id, patch) => setRules(rules.map(r => r.id === id ? { ...r, ...patch } : r));
  const remove = (id) => setRules(rules.filter(r => r.id !== id));
  const updateComp = (id, patch) => setComponents(components.map(c => c.id === id ? { ...c, ...patch } : c));
  const removeComp = (id) => setComponents(components.filter(c => c.id !== id));
  const activeTab = COMPONENT_TABS.find(t => t.id === tab) || COMPONENT_TABS[0];
  const rulesInTab = rules.filter(r => activeTab.kinds.includes(r.kind));
  const compsInTab = components.filter(c =>
    (activeTab.componentTypes || []).some(([typ, preset]) =>
      c.type === typ && (preset == null || c.preset === preset))
  );

  return (
    <div onClick={onClose} style={{
      position: "fixed", inset: 0, zIndex: 1000,
      background: "rgba(10,12,20,0.55)",
      display: "grid", placeItems: "center",
    }}>
      <div onClick={(e) => e.stopPropagation()} style={{
        width: "min(820px, 94vw)", maxHeight: "min(640px, 86vh)",
        background: T.paper, color: T.ink,
        border: `1px solid ${T.rule}`, borderRadius: 6,
        display: "flex", flexDirection: "column", overflow: "hidden",
        boxShadow: "0 24px 60px rgba(0,0,0,0.4)",
      }}>
        <div style={{
          height: 44, display: "flex", alignItems: "center",
          padding: "0 14px", borderBottom: `1px solid ${T.rule}`,
          background: T.paperDeep, gap: 10, flexShrink: 0,
        }}>
          <span style={{ fontWeight: 700, fontSize: 14 }}>Environment engine</span>
          <span style={{ fontSize: 11, color: T.inkMuted }}>
            component palette · studio-1.1
          </span>
          <div style={{ flex: 1 }}/>
          <button onClick={onClose} style={topBtn("ghost", false)}>Close</button>
        </div>
        <WorldRulesBox worldSettings={worldSettings} setWorldSettings={setWorldSettings}/>
        {/* Tab strip grouped by the five engine categories — same structure
            as the rail's RulesRailSection. */}
        <div style={{
          display: "flex", flexShrink: 0, alignItems: "stretch",
          background: T.paperWarm, borderBottom: `1px solid ${T.rule}`,
        }}>
          {ENGINE_CATEGORIES.map(cat => {
            const catTabs = COMPONENT_TABS.filter(t => t.category === cat.id);
            if (catTabs.length === 0) return null;
            return (
              <div key={cat.id} style={{
                display: "flex", flexDirection: "column", flex: catTabs.length,
                borderRight: `1px solid ${T.ruleSoft}`,
              }}>
                <div style={{
                  fontSize: 8.5, fontWeight: 700, color: T.inkFaint,
                  letterSpacing: "0.08em", textTransform: "uppercase",
                  padding: "4px 6px 2px", textAlign: "center",
                }}>{cat.label}</div>
                <div style={{ display: "flex", flex: 1 }}>
                  {catTabs.map(t => (
                    <button key={t.id} onClick={() => setTab(t.id)} data-tour={`tab-${t.id}`} style={{
                      flex: 1, padding: "6px 0 8px", fontSize: 10.5, fontWeight: 600,
                      background: tab === t.id ? T.paper : "transparent",
                      color: tab === t.id ? T.ink : T.inkMuted,
                      border: "none",
                      borderBottom: tab === t.id ? `2px solid ${T.accent}` : `2px solid transparent`,
                      cursor: "pointer", letterSpacing: "0.03em",
                      textTransform: "uppercase",
                    }}>{t.label}{rules.filter(r => t.kinds.includes(r.kind)).length > 0 && (
                      <span style={{ marginLeft: 4, color: T.inkFaint }}>
                        {rules.filter(r => t.kinds.includes(r.kind)).length}
                      </span>
                    )}</button>
                  ))}
                </div>
              </div>
            );
          })}
        </div>
        <div style={{
          padding: "10px 18px 4px", fontSize: 11.5, color: T.inkMuted,
          background: T.paperSoft, lineHeight: 1.5, flexShrink: 0,
        }}>{activeTab.hint}</div>
        <div style={{ flex: 1, overflow: "auto", padding: "10px 18px" }}>
          {/* World & Lore tab: lore lives in the dedicated World Book editor. */}
          {activeTab.worldBook && (
            <div style={{ display: "flex", gap: 6, marginBottom: 12, alignItems: "center", flexWrap: "wrap" }}>
              {onOpenWorldBook && (
                <button onClick={onOpenWorldBook} style={topBtn("ghost", false)}>
                  + add a World Book entry
                </button>
              )}
              <span style={{ fontSize: 11, color: T.inkFaint }}>
                {worldBookCount > 0
                  ? `${worldBookCount} lore entr${worldBookCount === 1 ? "y" : "ies"} — open the World Book to edit.`
                  : "No lore yet — add the era and what your characters know."}
              </span>
            </div>
          )}
          {/* Add-buttons for the presets in this tab */}
          {!activeTab.worldBook && (
          <div style={{ display: "flex", gap: 6, marginBottom: 12, flexWrap: "wrap" }}>
            {(activeTab.presets || []).map(p => (
              <button key={p} onClick={adders[p]} style={topBtn("ghost", false)}>
                {PRESET_LABEL[p] || `+ ${p}`}
              </button>
            ))}
            {(activeTab.presets || []).length === 0 && (
              <span style={{ fontSize: 11, color: T.inkFaint, fontStyle: "italic" }}>
                (no Studio-authorable presets — see hint above)
              </span>
            )}
          </div>
          )}
          {rulesInTab.length === 0 && compsInTab.length === 0 && (activeTab.presets || []).length > 0 && (
            <div style={{ padding: "20px 0", color: T.inkFaint, fontSize: 12 }}>
              No {activeTab.label.toLowerCase()} yet. Use the buttons above to add one.
            </div>
          )}
          {rulesInTab.map(r => (
            <RuleRow key={r.id} rule={r} scenes={scenes}
              onChange={(patch) => update(r.id, patch)}
              onRemove={() => remove(r.id)}/>
          ))}
          {compsInTab.map(c => (
            <ComponentRow key={c.id} comp={c} agents={agents} scenes={scenes}
              onChange={(patch) => updateComp(c.id, patch)}
              onRemove={() => removeComp(c.id)}/>
          ))}
        </div>
      </div>
    </div>
  );
}
// F5 — trigger when→then editor (engine 0.7.0 B3).
// when: expression fast path (free, deterministic) | when_nl: plain words
// (env-LLM judges, ≤1 call per tick). then: five effect checkboxes.
// B5 — one form for an inner attribute (engine 0.7.0): name + entity + range +
// init + visibility + optional auto-rate (drive) + a manifestation sentence
// the LLM appraises each perceive. Replaces drive+describe+visibility.
function AttributeFields({ comp, agents = [], onChange, classMode = false }) {
  const range = Array.isArray(comp.range) ? comp.range : [0, 100];
  const lbl = (s) => <span style={{ fontSize: 10, color: T.inkMuted, marginRight: 4, flexShrink: 0 }}>{s}</span>;
  return (
    <>
      <div style={{ display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" }}>
        {!classMode && (
          <>
            {lbl("on")}
            <select value={comp.entity || ""}
              onChange={(e) => onChange({ entity: e.target.value || null })}
              style={{ ...tplInputStyle(false), width: 130 }}>
              <option value="">(pick agent)</option>
              {agents.map(a => <option key={a.id} value={a.id}>{a.name || a.id}</option>)}
            </select>
          </>
        )}
        {lbl("field")}
        <input value={comp.name || ""} placeholder="suspicion"
          onChange={(e) => onChange({ name: e.target.value })}
          style={{ ...tplInputStyle(false), width: 120 }}/>
        {lbl("range")}
        <input type="number" value={range[0]}
          onChange={(e) => onChange({ range: [Number(e.target.value) || 0, range[1]] })}
          style={{ ...tplInputStyle(false), width: 56 }}/>
        <span style={{ color: T.inkFaint }}>–</span>
        <input type="number" value={range[1]}
          onChange={(e) => onChange({ range: [range[0], Number(e.target.value) || 100] })}
          style={{ ...tplInputStyle(false), width: 56 }}/>
      </div>
      <div style={{ display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" }}>
        {lbl("init")}
        <input type="number" value={comp.init ?? 0}
          onChange={(e) => onChange({ init: Number(e.target.value) || 0 })}
          style={{ ...tplInputStyle(false), width: 64 }}/>
        {lbl("auto +/tick")}
        <input type="number" value={comp.rate ?? 0}
          onChange={(e) => onChange({ rate: Number(e.target.value) || 0 })}
          style={{ ...tplInputStyle(false), width: 64 }}/>
        {lbl("visibility")}
        <div style={{ display: "flex", border: `1px solid ${T.ruleSoft}`, borderRadius: 3, overflow: "hidden" }}>
          {["public","self","hidden"].map(v => (
            <button key={v} onClick={() => onChange({ visibility: v })}
              style={{
                padding: "3px 8px", fontSize: 10, border: "none", cursor: "pointer",
                background: (comp.visibility || "public") === v ? T.accent : "transparent",
                color: (comp.visibility || "public") === v ? "#fff" : T.inkMuted,
                fontWeight: 700,
              }}>{VIS_GLYPH[v]} {v}</button>
          ))}
        </div>
      </div>
      <ManifestationEditor comp={comp} onChange={onChange}/>
    </>
  );
}

// Manifestation authoring — how an attribute "shows" in the world. Two modes
// the author picks between (never both at once, per engine attribute params):
//   • Rules (deterministic, RULE-path, no LLM) → edits `comp.manifest`, an
//     array of {min?, max?, external?, internal?, bias?}; first-match wins.
//     external = NL others perceive; internal = NL the owner's brain reads;
//     bias = a behaviour tendency string.
//   • Free text (LLM-path) → edits `comp.manifestation`, a single sentence.
// Switching mode clears the other field so the persisted shape stays exclusive.
function ManifestationEditor({ comp, onChange }) {
  // Default to the mode already populated; else "rules" (the headline ask).
  const hasText = typeof comp.manifestation === "string" && comp.manifestation.length > 0;
  const mode = comp.manifest !== undefined ? "rules"
    : hasText ? "text"
    : (comp._manifMode || "rules");
  const rows = Array.isArray(comp.manifest) ? comp.manifest : [];
  const toRules = () =>
    onChange({ _manifMode: "rules", manifestation: undefined,
               manifest: Array.isArray(comp.manifest) ? comp.manifest : [] });
  const toText = () =>
    onChange({ _manifMode: "text", manifest: undefined,
               manifestation: comp.manifestation || "" });
  const setRow = (i, patch) => {
    const next = rows.map((r, j) => j === i ? { ...r, ...patch } : r);
    onChange({ manifest: next });
  };
  const addRow = () => onChange({ manifest: [...rows, { min: 0, external: "" }] });
  const removeRow = (i) => onChange({ manifest: rows.filter((_, j) => j !== i) });
  const numOrUndef = (v) => v === "" ? undefined : (Number(v) || 0);
  const tab = (m, on, label) => (
    <button data-qa={`manif-mode-${m}`} onClick={on}
      style={{
        padding: "3px 8px", fontSize: 10, border: "none", cursor: "pointer",
        background: mode === m ? T.accent : "transparent",
        color: mode === m ? "#fff" : T.inkMuted, fontWeight: 700,
      }}>{label}</button>
  );
  const cellLbl = (s) => <span style={{ fontSize: 9, color: T.inkMuted, marginRight: 3, flexShrink: 0 }}>{s}</span>;
  return (
    <div>
      <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }}>
        <div style={{
          fontSize: 9, color: T.accent, fontWeight: 700,
          letterSpacing: "0.06em", fontFamily: "ui-monospace, monospace",
        }}><Ico path={ICO_PENCIL} size={11}/> how it shows · manifestation</div>
        <div style={{ display: "flex", border: `1px solid ${T.ruleSoft}`, borderRadius: 3, overflow: "hidden" }}>
          {tab("rules", toRules, <><Ico path={ICO_GEAR} size={10}/> rules</>)}
          {tab("text", toText, <><Ico path={ICO_PENCIL} size={10}/> free text · LLM</>)}
        </div>
      </div>
      {mode === "text" ? (
        <textarea data-qa="manif-text" value={comp.manifestation || ""} rows={2}
          placeholder="rises when caught with banned words or near a misfire; the Inquisition comes at 80"
          onChange={(e) => onChange({ manifestation: e.target.value, manifest: undefined })}
          style={{ ...tplInputStyle(false), fontFamily: SCREENPLAY_SERIF,
                   fontSize: 13, lineHeight: 1.5, resize: "vertical", width: "100%", boxSizing: "border-box" }}/>
      ) : (
        <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
          {rows.length === 0 && (
            <div style={{ fontSize: 11, color: T.inkFaint, fontStyle: "italic" }}>
              No manifestation rules yet. First matching range wins; deterministic, no LLM.
            </div>
          )}
          {rows.map((r, i) => (
            <div key={i} data-qa="manif-rule" style={{
              display: "flex", flexDirection: "column", gap: 4,
              padding: "8px 10px", background: T.paperSoft,
              border: `1px solid ${T.ruleSoft}`, borderRadius: 4,
            }}>
              <div style={{ display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" }}>
                {cellLbl("min")}
                <input type="number" data-qa="manif-min" value={r.min ?? ""}
                  placeholder="—"
                  onChange={(e) => setRow(i, { min: numOrUndef(e.target.value) })}
                  style={{ ...tplInputStyle(false), width: 64 }}/>
                {cellLbl("max")}
                <input type="number" data-qa="manif-max" value={r.max ?? ""}
                  placeholder="—"
                  onChange={(e) => setRow(i, { max: numOrUndef(e.target.value) })}
                  style={{ ...tplInputStyle(false), width: 64 }}/>
                <button onClick={() => removeRow(i)} style={{
                  marginLeft: "auto", background: "transparent", border: "none",
                  color: T.inkMuted, cursor: "pointer", fontSize: 14, padding: "0 4px",
                }} title="remove rule">×</button>
              </div>
              <div style={{ display: "flex", gap: 6, alignItems: "center" }}>
                {cellLbl("external")}
                <input data-qa="manif-external" value={r.external || ""}
                  placeholder="what others perceive (e.g. fists clenched)"
                  onChange={(e) => setRow(i, { external: e.target.value })}
                  style={{ ...tplInputStyle(false), flex: 1 }}/>
              </div>
              <div style={{ display: "flex", gap: 6, alignItems: "center" }}>
                {cellLbl("internal")}
                <input data-qa="manif-internal" value={r.internal || ""}
                  placeholder="what the owner's brain reads (e.g. I'm furious)"
                  onChange={(e) => setRow(i, { internal: e.target.value })}
                  style={{ ...tplInputStyle(false), flex: 1 }}/>
              </div>
              <div style={{ display: "flex", gap: 6, alignItems: "center" }}>
                {cellLbl("bias")}
                <input data-qa="manif-bias" value={r.bias || ""}
                  placeholder="behaviour tendency (e.g. lash out, refuse)"
                  onChange={(e) => setRow(i, { bias: e.target.value })}
                  style={{ ...tplInputStyle(false), flex: 1 }}/>
              </div>
            </div>
          ))}
          <button data-qa="manif-add-rule" onClick={addRow} style={{
            alignSelf: "flex-start", padding: "3px 10px", fontSize: 11,
            background: "transparent", color: T.inkMuted,
            border: `1px dashed ${T.rule}`, cursor: "pointer", borderRadius: 3,
          }}>+ manifestation rule</button>
        </div>
      )}
    </div>
  );
}

function TriggerFields({ comp, onChange, scenes = [], agents = [] }) {
  const isNL = comp.when_nl !== undefined && comp.when === undefined;
  const then = comp.then || {};
  const setThen = (patch) => onChange({ then: { ...then, ...patch } });
  const lbl = (s) => (
    <span style={{ fontSize: 10, color: T.inkMuted, marginRight: 6, flexShrink: 0 }}>{s}</span>
  );
  const jsonInput = (key, placeholder, width) => (
    <input value={comp[`_${key}`] ?? JSON.stringify(then[key] || {})}
      placeholder={placeholder}
      onChange={(e) => {
        const raw = e.target.value;
        try { setThen({ [key]: JSON.parse(raw) }); onChange({ [`_${key}`]: undefined }); }
        catch (err) { onChange({ [`_${key}`]: raw }); }
      }}
      style={{ ...tplInputStyle(false), width: width || 180, fontFamily: "ui-monospace, monospace" }}/>
  );
  return (
    <>
      <div style={{ display: "flex", gap: 6, alignItems: "center" }}>
        <div style={{ display: "flex", border: `1px solid ${T.ruleSoft}`, borderRadius: 3, overflow: "hidden", flexShrink: 0 }}>
          <button onClick={() => onChange({ when: comp.when_nl || comp.when || "", when_nl: undefined })}
            style={{
              padding: "3px 8px", fontSize: 10, border: "none", cursor: "pointer",
              background: !isNL ? T.accent : "transparent",
              color: !isNL ? "#fff" : T.inkMuted, fontWeight: 700,
            }}><Ico path={ICO_GEAR} size={11}/> expression</button>
          <button onClick={() => onChange({ when_nl: comp.when || comp.when_nl || "", when: undefined })}
            style={{
              padding: "3px 8px", fontSize: 10, border: "none", cursor: "pointer",
              background: isNL ? T.accent : "transparent",
              color: isNL ? "#fff" : T.inkMuted, fontWeight: 700,
            }}><Ico path={ICO_PENCIL} size={11}/> in words · LLM</button>
        </div>
        <input
          value={isNL ? (comp.when_nl || "") : (Array.isArray(comp.when) ? comp.when.join(" AND ") : comp.when || "")}
          placeholder={isNL ? "someone picks up the pistol" : "agent_1.suspicion > 90"}
          onChange={(e) => onChange(isNL ? { when_nl: e.target.value } : { when: e.target.value })}
          style={{ ...tplInputStyle(false), flex: 1,
            fontFamily: isNL ? "inherit" : "ui-monospace, monospace" }}/>
        <label style={{ display: "flex", alignItems: "center", gap: 4, fontSize: 11, flexShrink: 0, cursor: "pointer" }}>
          <input type="checkbox" checked={comp.once !== false}
            onChange={(e) => onChange({ once: e.target.checked })}/>
          once
        </label>
        <ScopeSelector value={comp.scope} where={comp.where}
          scenes={scenes} agents={agents} compact
          onChange={(patch) => onChange(patch)}/>
      </div>
      <div style={{ display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" }}>
        {lbl("set")}
        {jsonInput("set", '{"Bo": {"alerted": true}}')}
        {lbl("add")}
        {jsonInput("add", '{"Ava": {"fear": 10}}', 150)}
        {lbl("wake at t=")}
        <input type="number" value={then.wake ?? ""}
          placeholder="—"
          onChange={(e) => setThen({ wake: e.target.value === "" ? undefined : Number(e.target.value) })}
          style={{ ...tplInputStyle(false), width: 80 }}/>
      </div>
      <div style={{ display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" }}>
        {lbl("emit")}
        <input value={then.emit?.summary || ""} placeholder="Alarm bells ring."
          onChange={(e) => setThen({ emit: e.target.value ? { ...(then.emit || {}), summary: e.target.value, to: then.emit?.to ?? null } : undefined })}
          style={{ ...tplInputStyle(false), flex: 1 }}/>
        {lbl("reveal")}
        <input value={then.reveal || ""} placeholder="the will on the table is forged"
          onChange={(e) => setThen({ reveal: e.target.value || undefined })}
          style={{ ...tplInputStyle(false), flex: 1 }}/>
      </div>
    </>
  );
}

// v7: ComponentRow renders first-class component types (drive observer,
// middleware, custom action, raw code) that have no rule equivalent.
function ComponentRow({ comp, agents, scenes, onChange, onRemove, classMode = false }) {
  const lbl = (s) => (
    <span style={{ fontSize: 10, color: T.inkMuted, marginRight: 6 }}>{s}</span>
  );
  return (
    <div style={{
      display: "flex", gap: 10, alignItems: "flex-start",
      padding: "10px 12px", marginBottom: 8,
      background: T.paperSoft, border: `1px solid ${T.ruleSoft}`, borderRadius: 4,
    }}>
      <div style={{
        width: 90, fontSize: 10, fontWeight: 700, color: T.inkMuted,
        letterSpacing: "0.08em", textTransform: "uppercase", paddingTop: 6,
      }}>{comp.type}{comp.preset ? `·${comp.preset}` : ""}</div>
      <div style={{ flex: 1, display: "flex", flexDirection: "column", gap: 6 }}>
        <input value={comp.name || ""} placeholder="Name"
          onChange={(e) => onChange({ name: e.target.value })}
          style={tplInputStyle(false)}/>
        {comp.type === "observer" && comp.preset === "drive" && (
          <div style={{ display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" }}>
            {lbl("field")}
            <input value={comp.field || ""} placeholder="hunger / fatigue / tipsy"
              onChange={(e) => onChange({ field: e.target.value })}
              style={{ ...tplInputStyle(false), width: 130 }}/>
            {lbl("rate per tick")}
            <input type="number" value={comp.rate ?? 1}
              onChange={(e) => onChange({ rate: Number(e.target.value) || 0 })}
              style={{ ...tplInputStyle(false), width: 70 }}/>
            {lbl("max")}
            <input type="number" value={comp.max ?? 100}
              onChange={(e) => onChange({ max: Number(e.target.value) || 100 })}
              style={{ ...tplInputStyle(false), width: 70 }}/>
          </div>
        )}
        {comp.type === "middleware" && !classMode && (
          <div style={{ display: "flex", gap: 6, alignItems: "center" }}>
            {lbl("attached to")}
            <select value={comp.entity || ""}
              onChange={(e) => onChange({ entity: e.target.value || null })}
              style={{ ...tplInputStyle(false), flex: 1 }}>
              <option value="">(no agent selected)</option>
              {agents.map(a => <option key={a.id} value={a.id}>{a.name || a.id}</option>)}
            </select>
          </div>
        )}
        {comp.type === "action" && (
          <>
            <div style={{ display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" }}>
              {lbl("verb")}
              <input value={comp.verb || ""} placeholder="e.g. dance"
                onChange={(e) => onChange({ verb: e.target.value })}
                style={{ ...tplInputStyle(false), width: 120 }}/>
              {lbl("duration")}
              <input type="number" value={comp.duration ?? 3}
                onChange={(e) => onChange({ duration: Number(e.target.value) || 0 })}
                style={{ ...tplInputStyle(false), width: 70 }}/>
              {lbl("track")}
              <input value={comp.track || ""} placeholder="hands / voice / move"
                onChange={(e) => onChange({ track: e.target.value })}
                style={{ ...tplInputStyle(false), width: 100 }}/>
            </div>
            {/* E6 — the full effect surface for authored actions (engine 0.7.0):
                deterministic spells/mechanisms with guaranteed effects. */}
            <div style={{ display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" }}>
              {lbl("effects on target (JSON)")}
              <input value={comp._target_effect ?? JSON.stringify(comp.target_effect || {})}
                placeholder='{"cursed": true}'
                onChange={(e) => {
                  const raw = e.target.value;
                  try { onChange({ target_effect: JSON.parse(raw), _target_effect: undefined }); }
                  catch (err) { onChange({ _target_effect: raw }); }
                }}
                style={{ ...tplInputStyle(false), flex: 1, fontFamily: "ui-monospace, monospace" }}/>
              {lbl("target += (JSON)")}
              <input value={comp._target_add ?? JSON.stringify(comp.target_add || {})}
                placeholder='{"hp": -3}'
                onChange={(e) => {
                  const raw = e.target.value;
                  try { onChange({ target_add: JSON.parse(raw), _target_add: undefined }); }
                  catch (err) { onChange({ _target_add: raw }); }
                }}
                style={{ ...tplInputStyle(false), width: 130, fontFamily: "ui-monospace, monospace" }}/>
            </div>
            <div style={{ display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" }}>
              {lbl("announces")}
              <input value={comp.emit_summary || ""} placeholder="A cold wind sweeps the room."
                onChange={(e) => onChange({ emit_summary: e.target.value })}
                style={{ ...tplInputStyle(false), flex: 1 }}/>
              {lbl("reveals")}
              <input value={comp.reveal || ""} placeholder="the amulet is a fake"
                onChange={(e) => onChange({ reveal: e.target.value })}
                style={{ ...tplInputStyle(false), flex: 1 }}/>
            </div>
            {/* Lifecycle (engine 0.8.0): spawn / despawn for eat-kill-reproduce. */}
            <div data-qa="action-lifecycle" style={{
              marginTop: 2, paddingTop: 6, borderTop: `1px dashed ${T.ruleSoft}`,
              display: "flex", flexDirection: "column", gap: 6,
            }}>
              <span style={{
                fontSize: 9, fontWeight: 700, color: T.inkMuted,
                letterSpacing: "0.08em", textTransform: "uppercase",
              }}>lifecycle</span>
              <label style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 11, color: T.ink, cursor: "pointer" }}>
                <input type="checkbox" data-qa="action-despawn" checked={!!comp.despawn}
                  onChange={(e) => onChange({ despawn: e.target.checked })}/>
                despawn target
              </label>
              <span style={{ fontSize: 11, color: T.inkFaint, fontStyle: "italic", paddingLeft: 20 }}>
                removes the target from the world (e.g. eat/kill); add a self gain to absorb it
              </span>
              <label style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 11, color: T.ink, cursor: "pointer" }}>
                <input type="checkbox" data-qa="action-spawn-toggle" checked={!!comp.spawn}
                  onChange={(e) => onChange(e.target.checked
                    ? { spawn: { name: comp.spawn?.name || "", status: comp.spawn?.status || {} } }
                    : { spawn: undefined, _spawn_status: undefined })}/>
                spawn (reproduce)
              </label>
              {comp.spawn && (
                <div style={{ display: "flex", flexDirection: "column", gap: 6, paddingLeft: 20 }}>
                  <div style={{ display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" }}>
                    {lbl("name")}
                    <input data-qa="action-spawn-name" value={comp.spawn.name || ""} placeholder="e.g. offspring"
                      onChange={(e) => onChange({ spawn: { ...comp.spawn, name: e.target.value } })}
                      style={{ ...tplInputStyle(false), width: 150 }}/>
                    {lbl("status (JSON)")}
                    <input data-qa="action-spawn-status" value={comp._spawn_status ?? JSON.stringify(comp.spawn.status || {})}
                      placeholder='{"energy": 5}'
                      onChange={(e) => {
                        const raw = e.target.value;
                        try { onChange({ spawn: { ...comp.spawn, status: JSON.parse(raw) }, _spawn_status: undefined }); }
                        catch (err) { onChange({ _spawn_status: raw }); }
                      }}
                      style={{ ...tplInputStyle(false), flex: 1, fontFamily: "ui-monospace, monospace" }}/>
                  </div>
                  <span style={{ fontSize: 11, color: T.inkFaint, fontStyle: "italic" }}>
                    creates a new object at the actor's location
                  </span>
                </div>
              )}
            </div>
          </>
        )}
        {comp.type === "trigger" && (
          <TriggerFields comp={comp} onChange={onChange} scenes={scenes} agents={agents}/>
        )}
        {comp.type === "attribute" && (
          <AttributeFields comp={comp} agents={agents} onChange={onChange} classMode={classMode}/>
        )}
        {comp.type === "perception_rule" && (
          <div style={{ display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" }}>
            <span style={{ fontSize: 10, color: T.inkMuted }}>rule</span>
            <select value={comp.preset || "hearing"}
              onChange={(e) => onChange({ preset: e.target.value })}
              style={{ ...tplInputStyle(false), width: 150 }}>
              <option value="hearing">hearing (by hops)</option>
              <option value="line_of_sight">line of sight</option>
              <option value="anonymize">anonymize strangers</option>
            </select>
            {comp.preset === "hearing" && (
              <>
                <span style={{ fontSize: 10, color: T.inkMuted }}>max hops</span>
                <input type="number" min={0} value={comp.max_hops ?? 1}
                  onChange={(e) => onChange({ max_hops: Number(e.target.value) || 0 })}
                  style={{ ...tplInputStyle(false), width: 70 }}/>
              </>
            )}
            <span style={{ fontSize: 11, color: T.inkFaint, fontStyle: "italic" }}>
              {comp.preset === "line_of_sight" ? "walls/scenes occlude perception"
                : comp.preset === "anonymize" ? "strangers read as 'a stranger' until introduced"
                : "you hear entities within N graph hops"}
            </span>
          </div>
        )}
        {comp.type === "adjudicator" && (
          <div style={{ display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" }}>
            <span style={{ fontSize: 10, color: T.inkMuted }}>kind</span>
            <select value={comp.preset || "llm"}
              onChange={(e) => onChange({ preset: e.target.value })}
              style={{ ...tplInputStyle(false), width: 170 }}>
              <option value="llm">LLM (reasons + narrates)</option>
              <option value="rule">rule (deterministic)</option>
            </select>
            <span style={{ fontSize: 11, color: T.inkFaint, fontStyle: "italic" }}>
              {comp.preset === "rule" ? "fast, deterministic resolution — no LLM"
                : "the env-LLM adjudicates each action and writes each object's perception"}
            </span>
          </div>
        )}
        {comp.type === "raw_code" && (
          <textarea value={comp.source || ""}
            placeholder="# python source for engine-side execution"
            rows={4} onChange={(e) => onChange({ source: e.target.value })}
            style={{ ...tplInputStyle(false), fontFamily: "ui-monospace, monospace",
                     fontSize: 11, resize: "vertical" }}/>
        )}
      </div>
      <button onClick={onRemove} style={{
        background: "transparent", border: "none", color: T.inkMuted,
        cursor: "pointer", padding: "4px 8px", fontSize: 13,
      }}>×</button>
    </div>
  );
}

function RuleRow({ rule, scenes, onChange, onRemove }) {
  return (
    <div style={{
      display: "flex", gap: 10, alignItems: "flex-start",
      padding: "10px 12px", marginBottom: 8,
      background: T.paperSoft, border: `1px solid ${T.ruleSoft}`, borderRadius: 4,
    }}>
      <div style={{
        width: 70, fontSize: 10, fontWeight: 700, color: T.inkMuted,
        letterSpacing: "0.08em", textTransform: "uppercase", paddingTop: 6,
      }}>{rule.kind}</div>
      <div style={{ flex: 1, display: "flex", flexDirection: "column", gap: 6 }}>
        <input value={rule.name || ""} placeholder="Rule name"
          onChange={(e) => onChange({ name: e.target.value })}
          style={tplInputStyle(false)}/>
        {rule.kind === "text" && (
          <textarea value={rule.text || ""}
            placeholder='e.g. "Quiet voices after 10pm. No phones at the table."'
            rows={2}
            onChange={(e) => onChange({ text: e.target.value })}
            style={{ ...tplInputStyle(false), fontFamily: "inherit", resize: "vertical" }}/>
        )}
        {rule.kind === "duration" && (
          <div style={{ display: "flex", gap: 6, alignItems: "center" }}>
            <span style={{ fontSize: 11, color: T.inkMuted }}>verb</span>
            <input value={rule.verb || ""} placeholder="e.g. sleep"
              onChange={(e) => onChange({ verb: e.target.value })}
              style={{ ...tplInputStyle(false), width: 120 }}/>
            <span style={{ fontSize: 11, color: T.inkMuted }}>takes</span>
            <input type="number" min={1} value={rule.seconds ?? 1}
              onChange={(e) => onChange({ seconds: Math.max(1, Number(e.target.value) || 1) })}
              style={{ ...tplInputStyle(false), width: 90 }}/>
            <span style={{ fontSize: 11, color: T.inkMuted }}>sim-seconds</span>
          </div>
        )}
        {rule.kind === "event" && (
          <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
            <div style={{ display: "flex", gap: 6, alignItems: "center" }}>
              <span style={{ fontSize: 11, color: T.inkMuted }}>at</span>
              <input value={secsToHHMM(rule.at)} placeholder="HH:MM"
                onChange={(e) => onChange({ at: hhmmToSecs(e.target.value, rule.at) })}
                style={{ ...tplInputStyle(false), width: 70, fontFamily: "ui-monospace, monospace" }}/>
              <span style={{ fontSize: 11, color: T.inkMuted }}>each day,</span>
              <select value={rule.effect || "skip_to"}
                onChange={(e) => onChange({ effect: e.target.value })}
                style={{ ...tplInputStyle(false), width: 130 }}>
                <option value="skip_to">skip forward to</option>
                <option value="broadcast">broadcast message</option>
              </select>
              {rule.effect === "skip_to" && (
                <input value={secsToHHMM(rule.target)} placeholder="HH:MM"
                  onChange={(e) => onChange({ target: hhmmToSecs(e.target.value, rule.target) })}
                  style={{ ...tplInputStyle(false), width: 70, fontFamily: "ui-monospace, monospace" }}/>
              )}
            </div>
            {(rule.effect === "broadcast" || rule.text) && (
              <input value={rule.text || ""}
                placeholder={rule.effect === "broadcast" ? "e.g. morning bell rings" : "(optional note)"}
                onChange={(e) => onChange({ text: e.target.value })}
                style={tplInputStyle(false)}/>
            )}
          </div>
        )}
      </div>
      <button onClick={onRemove} style={{
        background: "transparent", border: "none", color: T.inkMuted,
        cursor: "pointer", padding: "4px 8px", fontSize: 13,
      }}>×</button>
    </div>
  );
}

function tplInputStyle(disabled) {
  return {
    padding: "4px 8px", fontSize: 12,
    background: disabled ? T.paperDeep : T.paperWarm,
    color: disabled ? T.inkFaint : T.ink,
    border: `1px solid ${T.rule}`, borderRadius: 3, outline: "none",
  };
}

// ─── BOTTOM DOCK ─────────────────────────────────────────────────────
// v7: non-uniform timeline. tickSec jumps between ticks (engine fast-forwards
// idle spans). The strip lays each tick at uniform horizontal spacing but
// labels gap-sizes so the user sees idle compression. Structural ticks
// (any tick with an action:end outcome=rejected, or with a phone-channel
// memory) get a colored marker.

// ─── TIMELINE SCRUBBER ───────────────────────────────────────────────
// A horizontal video-style playhead near the transport. Maps the full tick
// range proportionally to one bar; drag/click the handle to jump (replay:
// reuses seekReplay via onSeek; live: scrub within ticks already received).
// `tickIdx` is the count of ticks applied (0..total); the handle sits at
// tickIdx/total. Tick markers (one per tick) sit beneath as faint ticks so
// even a many-tick run still maps cleanly. Dragging pauses playback (the
// onSeek path already does setPlaying(false)). Keyboard arrows are wired at
// the app level so they work whenever the stage/timeline is focused.
function TimelineScrubber({ trace, tickIdx, total, onSeek, seekable = true }) {
  const ticks = trace?.ticks || [];
  const n = total || ticks.length || 0;
  const barRef = useRef(null);
  const [drag, setDrag] = useState(false);
  const [hoverX, setHoverX] = useState(null); // fraction within bar, or null
  if (n === 0) return null;

  // tickIdx is "ticks applied" → the *current* tick index is tickIdx-1.
  // Clamp the handle fraction into [0,1]. At tickIdx 0 the playhead sits at
  // the very start; fully played sits at the end.
  const frac = Math.max(0, Math.min(1, n <= 0 ? 0 : tickIdx / n));
  const epoch = getSimStartEpochSec();
  // Map a fraction [0,1] → a tick index (0-based) for tooltip/label.
  const idxForFrac = (f) => Math.max(0, Math.min(n - 1, Math.round(f * (n - 1))));
  const timeLabelFor = (i) => {
    const t = ticks[i];
    if (!t) return "";
    const s = t.tickSec ?? t.now ?? 0;
    return epoch ? (fmtSimDate(s) || fmtT(s)) : `t=${fmtT(s)}`;
  };
  // Current handle label = the last applied tick (tickIdx-1), or "start".
  const curIdx = tickIdx > 0 ? Math.min(tickIdx - 1, n - 1) : 0;
  const curLabel = tickIdx > 0 ? timeLabelFor(curIdx) : "start";

  const fracFromClientX = (clientX) => {
    const el = barRef.current;
    if (!el) return 0;
    const r = el.getBoundingClientRect();
    return Math.max(0, Math.min(1, (clientX - r.left) / Math.max(1, r.width)));
  };
  // Seek lands on "ticks applied". A click at fraction f applies idx+1 ticks
  // so the view lands on tick idx — same convention TimelineStrip.seek uses.
  const seekToFrac = (f) => {
    if (!seekable || !onSeek) return;
    onSeek(idxForFrac(f) + 1);
  };
  const onDown = (e) => {
    if (!seekable) return;
    e.preventDefault();
    setDrag(true);
    seekToFrac(fracFromClientX(e.clientX));
  };
  useEffect(() => {
    if (!drag) return;
    const move = (e) => seekToFrac(fracFromClientX(e.clientX));
    const up = () => setDrag(false);
    window.addEventListener("mousemove", move);
    window.addEventListener("mouseup", up);
    return () => {
      window.removeEventListener("mousemove", move);
      window.removeEventListener("mouseup", up);
    };
  }, [drag, n, seekable]); // eslint-disable-line

  const pct = `${(frac * 100).toFixed(2)}%`;

  return (
    <div data-tour="scrubber" data-scrubber="1" style={{
      flexShrink: 0, padding: "7px 14px 8px",
      background: T.paperWarm, borderBottom: `1px solid ${T.rule}`,
      display: "flex", alignItems: "center", gap: 10,
    }}>
      <span title="Step one tick back (left arrow)"
        onClick={() => seekable && onSeek && onSeek(Math.max(0, tickIdx - 1))}
        style={{
          cursor: seekable ? "pointer" : "default", userSelect: "none",
          color: T.inkMuted, fontSize: 12, lineHeight: 1, padding: "0 2px",
          opacity: seekable ? 1 : 0.4,
        }}>{"◀"}</span>
      <div
        ref={barRef}
        data-scrubber-bar="1"
        onMouseDown={onDown}
        onMouseMove={(e) => setHoverX(fracFromClientX(e.clientX))}
        onMouseLeave={() => setHoverX(null)}
        title="Drag or click to scrub to a tick"
        style={{
          position: "relative", flex: 1, height: 22,
          cursor: seekable ? "pointer" : "default",
          display: "flex", alignItems: "center",
        }}>
        {/* track */}
        <div style={{
          position: "absolute", left: 0, right: 0, height: 5, borderRadius: 3,
          background: T.ruleSoft, top: "50%", transform: "translateY(-50%)",
        }}/>
        {/* played portion */}
        <div style={{
          position: "absolute", left: 0, width: pct, height: 5, borderRadius: 3,
          background: T.accent, top: "50%", transform: "translateY(-50%)",
          opacity: 0.85,
        }}/>
        {/* per-tick faint markers — proportional; cheap even for many ticks */}
        {n <= 400 && ticks.map((t, i) => {
          const f = n <= 1 ? 0 : i / (n - 1);
          const log = t.log || [];
          const hot = log.some(l => l.kind === "action" && l.outcome === "rejected")
            || log.some(l => l.kind === "action" && l.state === "end");
          return (
            <div key={i} style={{
              position: "absolute", left: `${(f * 100).toFixed(2)}%`,
              width: 1, height: hot ? 9 : 6,
              background: hot ? T.accent2 : T.inkFaint,
              opacity: hot ? 0.7 : 0.35,
              top: "50%", transform: "translate(-0.5px, -50%)",
            }}/>
          );
        })}
        {/* hover tooltip */}
        {hoverX != null && seekable && (
          <div style={{
            position: "absolute", left: `${(hoverX * 100).toFixed(2)}%`,
            bottom: 22, transform: "translateX(-50%)",
            background: T.ink, color: T.paper, fontSize: 10,
            padding: "2px 6px", borderRadius: 3, whiteSpace: "nowrap",
            pointerEvents: "none", zIndex: 5,
            fontFamily: "ui-monospace, monospace",
          }}>
            {timeLabelFor(idxForFrac(hoverX))}
          </div>
        )}
        {/* playhead handle */}
        <div data-scrubber-handle="1" style={{
          position: "absolute", left: pct, transform: "translateX(-50%)",
          width: 14, height: 14, borderRadius: "50%",
          background: T.accent, border: `2px solid ${T.paper}`,
          boxShadow: drag ? `0 0 0 3px ${T.accent}55` : "0 1px 3px rgba(0,0,0,0.3)",
          top: "50%", marginTop: -7, zIndex: 6,
          cursor: seekable ? "grab" : "default",
        }}/>
      </div>
      <span title="Step one tick forward (right arrow)"
        onClick={() => seekable && onSeek && onSeek(Math.min(n, tickIdx + 1))}
        style={{
          cursor: seekable ? "pointer" : "default", userSelect: "none",
          color: T.inkMuted, fontSize: 12, lineHeight: 1, padding: "0 2px",
          opacity: seekable ? 1 : 0.4,
        }}>{"▶"}</span>
      {/* current position read-out (handle label) */}
      <span data-scrubber-readout="1" style={{
        fontSize: 10, color: T.inkMuted, fontFamily: "ui-monospace, monospace",
        whiteSpace: "nowrap", minWidth: 64, textAlign: "right",
      }} title={`Tick ${tickIdx} of ${n}`}>
        {curLabel} <span style={{ color: T.inkFaint }}>{"·"} {tickIdx}/{n}</span>
      </span>
    </div>
  );
}

function TimelineStrip({ trace, tickIdx, onSeek }) {
  const ticks = trace?.ticks || [];
  // Zoom: 0 = one row per day (summary), 1 = hour rows (DEFAULT), 2 = every tick.
  const [zoom, setZoom] = useState(1);
  // Per-day-key open/closed map; days default to open.
  const [openDays, setOpenDays] = useState({});
  if (ticks.length === 0) return null;

  const markerColor = (t) => {
    const log = t.log || [];
    if (log.some(l => l.kind === "action" && l.outcome === "rejected")) return "#c14545";
    if (log.some(l => l.kind === "memory" && l.channel === "phone")) return "#c97a3a";
    if (log.some(l => l.kind === "action" && l.state === "end")) return "#3a8c4c";
    return null;
  };

  const epoch = getSimStartEpochSec();
  const DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
  const MON = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
  // Absolute Date for a tick (UTC) when a start epoch is set, else null.
  const dateOf = (t) => epoch ? new Date((epoch + (t.tickSec || 0)) * 1000) : null;
  const dayKey = (t) => {
    const d = dateOf(t);
    if (!d) return "day0"; // single synthetic day when no real clock
    return `${d.getUTCFullYear()}-${d.getUTCMonth()}-${d.getUTCDate()}`;
  };
  const dayLabel = (t) => {
    const d = dateOf(t);
    if (!d) return "Run";
    return `${DOW[d.getUTCDay()]} ${MON[d.getUTCMonth()]} ${d.getUTCDate()}`;
  };
  const hourKey = (t) => {
    const d = dateOf(t);
    return d ? d.getUTCHours() : 0;
  };
  const hourLabel = (t) => {
    const d = dateOf(t);
    if (!d) return fmtT(t.tickSec); // fall back to mm:ss bucket
    return `${_pad2(d.getUTCHours())}:00`;
  };
  const tickLabel = (t) => (epoch ? fmtT(t.tickSec) : fmtT(t.tickSec));
  const beats = (t) => (t.log || []).length;
  const activeIdx = tickIdx - 1;

  // Build the day → hour → ticks structure, carrying original tick indices.
  // Each tick row also records the gap (sim-seconds) to the *next* tick so we
  // can collapse idle spans the way the engine fast-forwards them.
  const rows = ticks.map((t, i) => {
    const next = ticks[i + 1];
    const gap = next ? (next.tickSec - t.tickSec) : 0;
    return { t, i, gap };
  });
  const days = [];
  let curDay = null, curHour = null;
  for (const r of rows) {
    const dk = dayKey(r.t);
    if (!curDay || curDay.key !== dk) {
      curDay = { key: dk, label: dayLabel(r.t), hours: [], rows: [], beats: 0 };
      days.push(curDay);
      curHour = null;
    }
    curDay.rows.push(r);
    curDay.beats += beats(r.t);
    const hk = hourKey(r.t);
    if (!curHour || curHour.key !== hk) {
      curHour = { key: hk, label: hourLabel(r.t), rows: [], beats: 0 };
      curDay.hours.push(curHour);
    }
    curHour.rows.push(r);
    curHour.beats += beats(r.t);
  }
  const maxDayBeats = Math.max(1, ...days.map(d => d.beats));
  const isOpen = (dk) => openDays[dk] !== false; // default open
  const toggleDay = (dk) => setOpenDays(m => ({ ...m, [dk]: m[dk] === false }));
  const seek = (idx) => { if (onSeek) onSeek(idx + 1); }; // +1: count of ticks to apply → lands on idx

  // density bar (intensity ∝ #beats), normalized against the busiest day.
  const Density = ({ n, max, w }) => (
    <div style={{ flex: w ? undefined : 1, width: w, height: 4, background: T.ruleSoft,
      borderRadius: 2, overflow: "hidden" }}>
      <div style={{ width: `${Math.round((n / Math.max(1, max)) * 100)}%`, height: "100%",
        background: T.accent2, opacity: 0.7 }}/>
    </div>
  );

  // A run of colored dots (+ click-to-seek) for a set of tick rows.
  const Markers = ({ rs }) => (
    <div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 3 }}>
      {rs.map(r => {
        const c = markerColor(r.t);
        const active = r.i === activeIdx;
        const idle = r.gap > 3600;
        return (
          <React.Fragment key={r.i}>
            <div
              onClick={() => seek(r.i)}
              title={`${epoch ? (fmtSimDate(r.t.tickSec) || tickLabel(r.t)) : `t=${tickLabel(r.t)}`} · ${beats(r.t)} beat(s)`}
              style={{
                width: 8, height: 8, borderRadius: "50%", cursor: "pointer",
                background: c || T.ruleSoft,
                border: active ? `2px solid ${T.accent}` : `1px solid ${T.ruleSoft}`,
                boxShadow: active ? `0 0 0 1px ${T.accent}` : "none",
                flexShrink: 0,
              }}/>
            {idle && (
              <span style={{ fontSize: 8, color: T.inkFaint, fontStyle: "italic",
                padding: "0 2px", whiteSpace: "nowrap" }}>
                {r.gap > 8 * 3600 ? "→ overnight →" : `→ ${Math.round(r.gap / 3600)}h later →`}
              </span>
            )}
          </React.Fragment>
        );
      })}
    </div>
  );

  return (
    <div data-tour="timeline" style={{
      flexShrink: 0, padding: "6px 12px 4px",
      background: T.paperWarm, borderBottom: `1px solid ${T.rule}`,
      fontSize: 10, color: T.inkMuted,
    }}>
      {/* zoom control — day summary / hour rows (default) / every tick */}
      <div style={{ display: "flex", alignItems: "center", justifyContent: "flex-end",
        gap: 4, marginBottom: 3 }}>
        <span style={{ fontSize: 9, color: T.inkFaint, marginRight: 2 }}>
          {zoom === 0 ? "day" : zoom === 1 ? "hour" : "tick"}
        </span>
        <button onClick={() => setZoom(z => Math.max(0, z - 1))} disabled={zoom === 0}
          style={{ width: 16, height: 16, lineHeight: "14px", textAlign: "center",
            border: `1px solid ${T.ruleSoft}`, background: T.paperSoft, color: T.inkMuted,
            borderRadius: 2, cursor: zoom === 0 ? "default" : "pointer", padding: 0,
            opacity: zoom === 0 ? 0.4 : 1 }}>−</button>
        <button onClick={() => setZoom(z => Math.min(2, z + 1))} disabled={zoom === 2}
          style={{ width: 16, height: 16, lineHeight: "14px", textAlign: "center",
            border: `1px solid ${T.ruleSoft}`, background: T.paperSoft, color: T.inkMuted,
            borderRadius: 2, cursor: zoom === 2 ? "default" : "pointer", padding: 0,
            opacity: zoom === 2 ? 0.4 : 1 }}>+</button>
      </div>

      <div style={{ maxHeight: 140, overflowY: "auto",
        background: T.paperSoft, border: `1px solid ${T.ruleSoft}`, borderRadius: 2 }}>
        {days.map(day => {
          const open = isOpen(day.key);
          return (
            <div key={day.key} style={{ borderBottom: `1px solid ${T.ruleSoft}` }}>
              {/* collapsible day header */}
              <div onClick={() => toggleDay(day.key)} style={{
                display: "flex", alignItems: "center", gap: 6, padding: "3px 6px",
                cursor: "pointer", background: T.paperWarm, userSelect: "none" }}>
                <span style={{ width: 8, color: T.inkMuted }}>{open ? "▾" : "▸"}</span>
                <span style={{ fontWeight: 600, color: T.ink, minWidth: 78 }}>{day.label}</span>
                <Density n={day.beats} max={maxDayBeats}/>
                <span style={{ fontSize: 9, color: T.inkFaint, whiteSpace: "nowrap" }}>
                  {day.rows.length}t · {day.beats}b
                </span>
              </div>

              {open && zoom === 0 && (
                /* zoom 0: day summary already shown by the header density bar;
                   expose the day's markers as a single clickable row. */
                <div style={{ padding: "2px 6px 4px 22px" }}>
                  <Markers rs={day.rows}/>
                </div>
              )}

              {open && zoom === 1 && day.hours.map(hr => (
                <div key={hr.key} style={{ display: "flex", alignItems: "center", gap: 6,
                  padding: "2px 6px 2px 22px" }}>
                  <span
                    onClick={() => hr.rows.length && seek(hr.rows[0].i)}
                    title="seek to first tick of this hour"
                    style={{ minWidth: 34, color: T.inkMuted, cursor: "pointer",
                      fontFamily: "ui-monospace, monospace" }}>{hr.label}</span>
                  <div style={{ width: 40, flexShrink: 0 }}>
                    <Density n={hr.beats} max={maxDayBeats} w="40px"/>
                  </div>
                  <Markers rs={hr.rows}/>
                </div>
              ))}

              {open && zoom === 2 && (
                <div style={{ padding: "2px 6px 4px 22px",
                  display: "flex", flexDirection: "column", gap: 2 }}>
                  {day.rows.map(r => {
                    const c = markerColor(r.t);
                    const active = r.i === activeIdx;
                    const idle = r.gap > 3600;
                    return (
                      <div key={r.i} style={{ display: "flex", alignItems: "center", gap: 6 }}>
                        <span
                          onClick={() => seek(r.i)}
                          title={`${beats(r.t)} beat(s)`}
                          style={{ minWidth: 56, cursor: "pointer",
                            fontFamily: "ui-monospace, monospace",
                            color: active ? T.accent : T.inkMuted,
                            fontWeight: active ? 600 : 400 }}>
                          {epoch ? (fmtSimDate(r.t.tickSec) || tickLabel(r.t)) : tickLabel(r.t)}
                        </span>
                        <div onClick={() => seek(r.i)} style={{
                          width: 8, height: 8, borderRadius: "50%", cursor: "pointer",
                          background: c || T.ruleSoft,
                          border: active ? `2px solid ${T.accent}` : `1px solid ${T.ruleSoft}`,
                          flexShrink: 0 }}/>
                        <span style={{ fontSize: 9, color: T.inkFaint }}>{beats(r.t)}b</span>
                        {idle && (
                          <span style={{ fontSize: 8, color: T.inkFaint, fontStyle: "italic" }}>
                            {r.gap > 8 * 3600 ? "→ overnight →" : `→ ${Math.round(r.gap / 3600)}h later →`}
                          </span>
                        )}
                      </div>
                    );
                  })}
                </div>
              )}
            </div>
          );
        })}
      </div>
    </div>
  );
}

// Screenplay serif stack — narrated world-text reads like a story, not a JSON
// log (AESTHETIC.md #1). Falls back to platform CJK serifs.
const SCREENPLAY_SERIF = '"Noto Serif CJK SC", "Source Han Serif SC", "Songti SC", '
  + '"Hiragino Mincho ProN", "Yu Mincho", "Times New Roman", Georgia, serif';

// Inline 内心 sparkline: pick the agent's most-active drive series and draw a
// 32-wide spark + the latest value with a one-word tint. Renders nothing if
// the agent has no drive history (pre-Play, or all flat).
function InlineDriveSpark({ agent }) {
  const dh = agent && agent.driveHistory;
  if (!dh) return null;
  // pick the series with the widest range — that's the drive "doing something"
  let bestKey = null, bestRange = 0, bestSeries = null;
  for (const k of Object.keys(dh)) {
    const s = dh[k];
    if (!Array.isArray(s) || s.length < 2) continue;
    const vs = s.map(p => p.v);
    const r = Math.max(...vs) - Math.min(...vs);
    if (r > bestRange) { bestRange = r; bestKey = k; bestSeries = s; }
  }
  if (!bestKey || !bestSeries) return null;
  const last = bestSeries[bestSeries.length - 1].v;
  const vs = bestSeries.map(p => p.v);
  const mn = Math.min(...vs), mx = Math.max(...vs);
  const span = Math.max(mx - mn, 1);
  const w = 36, h = 10;
  const pts = bestSeries.slice(-16).map((p, i, arr) => {
    const x = arr.length === 1 ? 0 : (i / (arr.length - 1)) * w;
    const y = h - ((p.v - mn) / span) * h;
    return `${x.toFixed(1)},${y.toFixed(1)}`;
  }).join(" ");
  const tint = bestKey === "mood_valence"
    ? (last > 0 ? "#2f7a3f" : last < 0 ? "#c14545" : "#999")
    : (bestKey === "tipsy" ? "#c14545"
      : bestKey === "fatigue" ? "#7a4a26"
      : bestKey === "hunger" ? "#c97a3a"
      : bestKey === "stamina" ? "#3a8f4a"
      : "#6a5a4a");
  return (
    <span style={{
      display: "inline-flex", alignItems: "center", gap: 5,
      fontSize: 10, color: T.inkFaint,
      fontFamily: "ui-monospace, monospace",
      letterSpacing: "0.04em",
    }}>
      <span style={{ color: T.inkFaint }}>|</span>
      <span style={{ color: tint, fontWeight: 600 }}>{bestKey}</span>
      <svg width={w} height={h} style={{ overflow: "visible" }}>
        <polyline points={pts} fill="none" stroke={tint} strokeWidth="1.2"/>
      </svg>
      <span style={{ color: tint, fontVariantNumeric: "tabular-nums" }}>
        {typeof last === "number" ? (Math.abs(last) < 10 ? last.toFixed(2) : Math.round(last)) : last}
      </span>
    </span>
  );
}

// F7 — play-as-a-character bar (engine B6 `act` + B7 `suggest`). The director
// flow: pick anyone (any brain — llm/rule/human), type a line or click a
// suggested action; the engine executes it instead of the agent's own brain
// on the next tick.
function PlayBar({ agents, playAs, setPlayAs, suggestions, onAct, onChip }) {
  const [text, setText] = useState("");
  const playing = agents.find(a => a.id === playAs);
  return (
    <div style={{
      flexShrink: 0, borderTop: `1px solid ${T.rule}`,
      background: T.paperDeep, padding: "8px 14px",
      display: "flex", flexDirection: "column", gap: 6,
    }}>
      <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
        <span style={{
          fontSize: 10, fontWeight: 700, color: T.inkMuted,
          letterSpacing: "0.08em", textTransform: "uppercase", flexShrink: 0,
        }}>▸ Play as</span>
        <select value={playAs || ""}
          onChange={(e) => setPlayAs(e.target.value || null)}
          style={{ ...inputStyle(), width: "auto", padding: "4px 8px", fontSize: 12 }}>
          <option value="">(nobody — autonomous)</option>
          {agents.map(a => <option key={a.id} value={a.id}>{a.name || a.id}</option>)}
        </select>
        {/* D5 — one bar, light tag: playing your own human-brained character
            vs overriding an autonomous one (director's chair). */}
        {playing && (
          <span style={{
            fontSize: 9, fontWeight: 700, letterSpacing: "0.08em",
            textTransform: "uppercase", flexShrink: 0,
            fontFamily: "ui-monospace, monospace",
            color: playing.brain === "human" ? "#2f7a3f" : "#c97a3a",
          }}>
            {playing.brain === "human" ? "playing" : "directing · override"}
          </span>
        )}
        {playing && (
          <>
            <input
              value={text}
              onChange={(e) => setText(e.target.value)}
              onKeyDown={(e) => {
                if (e.key === "Enter") { onAct(text); setText(""); }
              }}
              placeholder={`What does ${playing.name} do or say?`}
              style={{
                ...inputStyle(), flex: 1, fontSize: 13.5,
                fontFamily: SCREENPLAY_SERIF,
              }}/>
            <button onClick={() => { onAct(text); setText(""); }}
              disabled={!text.trim()}
              style={topBtn(text.trim() ? "primary" : "ghost", !text.trim())}>
              Speak / do
            </button>
          </>
        )}
      </div>
      {playing && suggestions.length > 0 && (
        <div style={{ display: "flex", gap: 6, flexWrap: "wrap", paddingLeft: 60 }}>
          {suggestions.map((s, i) => (
            <button key={i} onClick={() => onChip(s)}
              title="Suggested by the env — click to do it"
              style={{
                padding: "4px 12px", fontSize: 12,
                background: "transparent", color: T.ink,
                border: `1px dashed ${T.accent}`, borderRadius: 14,
                cursor: "pointer", fontFamily: SCREENPLAY_SERIF,
              }}>{s}</button>
          ))}
        </div>
      )}
    </div>
  );
}

function BottomDock({ height, minimized, setMinimized, tab, setTab,
                      events, agents, scenes, agentIndex, onPlaceAgent, replay, live, onSeek,
                      ticking = false }) {
  const logRef = useRef(null);
  // Free-text log filter — view-only; never mutates `events`. Matches the
  // actor name, the kind, and any text-ish field (line/reasoning/verb/target).
  const [logQuery, setLogQuery] = useState("");
  // Event-log VIEW (the "advanced panel"): which beats to surface. Persisted globally.
  // Default = a clean story: actions + their reasoning + world events. Perception
  // (the per-object senses) and inert objects (a stove, a table — no LLM brain) are
  // hidden by default; toggle them on here.
  const LOG_VIEW_DEFAULT = { actions: true, reasoning: true, perception: false, world: true, inert: false };
  const [view, setView] = useState(() => {
    try { const v = JSON.parse(localStorage.getItem("studio-log-view") || "null");
      if (v && typeof v === "object") return { ...LOG_VIEW_DEFAULT, ...v }; } catch (e) {}
    return { ...LOG_VIEW_DEFAULT };
  });
  const [viewOpen, setViewOpen] = useState(false);
  const toggleView = (k) => setView(prev => {
    const next = { ...prev, [k]: !prev[k] };
    try { localStorage.setItem("studio-log-view", JSON.stringify(next)); } catch (e) {}
    return next;
  });
  const liveOpen = live?.status === "open";
  const liveTickIdx = live?.tickIdx || 0;
  useEffect(() => {
    if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
  }, [events.length]);
  const q = logQuery.trim().toLowerCase();
  const matchEvent = (ev) => {
    if (!q) return true;
    const hay = [
      ev.actorName, ev.kind, ev.verb, ev.target, ev.line, ev.reasoning,
      ev.from, ev.channel, ev.outcome,
    ].filter(Boolean).join(" ").toLowerCase();
    return hay.includes(q);
  };
  // Event-log VIEW filter: map each beat to a category and honour the toggles.
  const catOf = (ev) => {
    if (ev.kind === "perceive" || ev.kind === "sense") return "perception";
    if (ev.kind === "thought") return "reasoning";
    if (ev.kind === "trigger" || ev.kind === "reveal" || ev.kind === "causal") return "world";
    return "actions";   // decision + raw action fallbacks
  };
  const passView = (ev) => {
    if (!view[catOf(ev)]) return false;
    if (!view.inert && ev.actorKind === "object") return false;   // static props (stove, table) — no brain
    return true;
  };
  // LLM badge: REAL/MOCK + model for a live run; just the model for a replay. Only
  // on beats the model actually produced (source === 'llm').
  const liveReal = live?.llm === "openrouter";
  const liveModel = live?.model;
  const replayModel = replay?.trace?.meta?.model;
  const badgeFor = (ev) => {
    if (ev.source !== "llm") return null;
    // Prefer the value stamped on the beat when it was produced (stable across
    // connect/disconnect); fall back to current live state, then replay/unknown.
    if (ev._real !== undefined) return { mode: ev._real ? "REAL" : "MOCK", model: ev._model || "" };
    if (liveOpen) return { mode: liveReal ? "REAL" : "MOCK", model: liveModel || "" };
    if (replay) return { mode: null, model: replayModel || "LLM" };
    return { mode: null, model: "LLM" };
  };
  // Keep original indices so highlighting / "last" still reads correctly.
  const visibleEvents = events
    .map((ev, i) => ({ ev, i }))
    .filter(({ ev }) => matchEvent(ev) && passView(ev));
  const hiddenPerception = !view.perception
    ? events.filter(e => e.kind === "perceive" || e.kind === "sense").length : 0;
  return (
    <div style={{
      height, flexShrink: 0,
      background: T.paper, color: T.ink,
      borderTop: `1px solid ${T.rule}`,
      display: "flex", flexDirection: "column", overflow: "hidden",
    }}>
      {/* Timeline scrubber — replay seeks via onSeek; live shows progress only
          (frames stream forward, so live can't re-seed backward). */}
      {!minimized && replay && (
        <TimelineScrubber trace={replay.trace} tickIdx={replay.tickIdx}
          total={replay.trace?.ticks?.length} onSeek={onSeek} seekable />
      )}
      {!minimized && !replay && liveOpen && liveTickIdx > 0 && (
        <TimelineScrubber
          trace={{ ticks: Array.from({ length: liveTickIdx }, () => ({})) }}
          tickIdx={liveTickIdx} total={liveTickIdx} seekable={false} />
      )}
      {replay && !minimized && (
        <TimelineStrip trace={replay.trace} tickIdx={replay.tickIdx} onSeek={onSeek}/>
      )}
      <div style={{
        height: 32, flexShrink: 0,
        display: "flex", alignItems: "stretch",
        background: T.paperDeep, borderBottom: `1px solid ${T.rule}`,
      }}>
        <TabBtn active={tab === "log"} onClick={() => setTab("log")}>
          Event log <span style={{ color: T.inkFaint, marginLeft: 4 }}>
            {q ? `${visibleEvents.length}/${events.length}` : events.length}
          </span>
        </TabBtn>
        <div style={{ flex: 1 }}/>
        {/* Log filter / search — view-only. Shrinks the visible rows to those
            matching the actor name or any summary text; the underlying log is
            untouched. A result count + clear (×) make the filter state legible. */}
        {!minimized && tab === "log" && (
          <div style={{ display: "flex", alignItems: "center", gap: 6, padding: "0 10px" }}>
            <span style={{ color: T.inkFaint, fontSize: 12, lineHeight: 1 }} title="Filter log">⌕</span>
            <div style={{ position: "relative", display: "flex", alignItems: "center" }}>
              <input
                data-log-filter="1"
                value={logQuery}
                onChange={(e) => setLogQuery(e.target.value)}
                placeholder="Filter log (actor or text)…"
                spellCheck={false}
                style={{
                  width: 188, height: 22, fontSize: 11, padding: "0 22px 0 8px",
                  border: `1px solid ${T.rule}`, borderRadius: 3,
                  background: T.paper, color: T.ink, outline: "none",
                  boxSizing: "border-box",
                }}/>
              {logQuery && (
                <button
                  data-log-filter-clear="1"
                  onClick={() => setLogQuery("")}
                  title="Clear filter"
                  style={{
                    position: "absolute", right: 2, top: "50%",
                    transform: "translateY(-50%)",
                    background: "transparent", border: "none", cursor: "pointer",
                    color: T.inkMuted, fontSize: 13, lineHeight: 1, padding: "0 4px",
                  }}>×</button>
              )}
            </div>
            {q && (
              <span data-log-filter-count="1" style={{
                fontSize: 10, color: T.inkMuted, whiteSpace: "nowrap",
                fontVariantNumeric: "tabular-nums",
              }}>{visibleEvents.length} match{visibleEvents.length === 1 ? "" : "es"}</span>
            )}
          </div>
        )}
        {!minimized && tab === "log" && (
          <div style={{ position: "relative", display: "flex", alignItems: "center", padding: "0 6px" }}>
            <button onClick={() => setViewOpen(o => !o)} title="Choose what the log shows"
              style={{ background: viewOpen ? T.paperWarm : "transparent", border: `1px solid ${T.rule}`,
                borderRadius: 3, color: T.inkMuted, fontSize: 11, cursor: "pointer",
                padding: "3px 8px", height: 22, whiteSpace: "nowrap" }}>⚑ View</button>
            {viewOpen && (
              <>
                <div onClick={() => setViewOpen(false)}
                  style={{ position: "fixed", inset: 0, zIndex: 49 }}/>
                <div style={{ position: "absolute", top: 28, right: 6, zIndex: 50, width: 248,
                  background: T.paper, border: `1px solid ${T.rule}`, borderRadius: 6,
                  boxShadow: "0 12px 30px rgba(0,0,0,0.3)", padding: "8px 6px" }}>
                  <div style={{ fontSize: 10, fontWeight: 700, color: T.inkMuted, letterSpacing: "0.08em",
                    textTransform: "uppercase", padding: "2px 8px 6px" }}>Show in the log</div>
                  {[["actions", "Actions & outcomes"], ["reasoning", "Reasoning (the LLM ‘why’)"],
                    ["perception", "Perception (what each object senses)"], ["world", "World events (triggers, effects)"],
                    ["inert", "Inert objects (props with no brain)"]].map(([k, label]) => (
                    <label key={k} style={{ display: "flex", alignItems: "center", gap: 8, padding: "5px 8px",
                      fontSize: 12, cursor: "pointer", color: T.ink }}>
                      <input type="checkbox" checked={!!view[k]} onChange={() => toggleView(k)}/>
                      <span>{label}</span>
                    </label>
                  ))}
                  <div style={{ fontSize: 10, color: T.inkFaint, padding: "6px 8px 2px", lineHeight: 1.4 }}>
                    Static props (a stove, a table) only ‘perceive’, so they stay hidden unless you turn on Perception or Inert objects.
                  </div>
                </div>
              </>
            )}
          </div>
        )}
        <button onClick={() => setMinimized(!minimized)}
          title={minimized ? "Expand dock" : "Minimize dock"}
          style={{
            background: "transparent", border: "none",
            padding: "0 12px", cursor: "pointer",
            color: T.inkMuted, fontSize: 14,
          }}>{minimized ? "▴" : "▾"}</button>
      </div>
      {!minimized && tab === "log" && (
        <div ref={logRef} style={{
          flex: 1, overflow: "auto", padding: "6px 0",
          background: T.paper,
        }}>
          {events.length === 0 && (
            <div style={{
              padding: 16, textAlign: "center", color: T.inkFaint, fontSize: 12,
            }}>
              {/* BUGFIX (Empty-state honesty) — once the engine is connected and
                  ticking, never claim "Press Play" with an empty log: live·N and
                  Event log 0 must not silently coexist. Show a diagnostic. */}
              {ticking
                ? "Engine ticking — no surfaced events yet."
                : agents.length === 0
                  ? "Add at least one Agent, then Connect the engine (top bar) and press ▶ Play."
                  : (replay || liveOpen)
                    ? "Press ▶ Play in the top bar to start the simulation."
                    : "Connect the engine (top bar) to start — leave the key empty for the built-in mock — then press ▶ Play."}
            </div>
          )}
          {events.length > 0 && visibleEvents.length === 0 && q && (
            <div style={{
              padding: 16, textAlign: "center", color: T.inkFaint, fontSize: 12,
            }}>
              No rows match “{logQuery}”.{" "}
              <button onClick={() => setLogQuery("")} style={{
                background: "none", border: "none", color: T.accent,
                cursor: "pointer", fontSize: 12, padding: 0, textDecoration: "underline",
              }}>Clear filter</button>
            </div>
          )}
          {events.length > 0 && visibleEvents.length === 0 && !q && (
            <div style={{
              padding: 16, textAlign: "center", color: T.inkFaint, fontSize: 12, lineHeight: 1.6,
            }}>
              All {events.length} beat{events.length === 1 ? "" : "s"} so far are hidden by your <b>View</b> settings
              {hiddenPerception ? <> ({hiddenPerception} perception beat{hiddenPerception === 1 ? "" : "s"})</> : null}.{" "}
              <button onClick={() => setViewOpen(true)} style={{
                background: "none", border: "none", color: T.accent,
                cursor: "pointer", fontSize: 12, padding: 0, textDecoration: "underline",
              }}>Open ⚑ View</button>
            </div>
          )}
          {visibleEvents.map(({ ev, i }) => {
            const a = (ev.actorIdx && agents[ev.actorIdx - 1])
              || agents.find(g => g.id === ev.actorId)
              || agents.find(g => g.name === ev.actorName)
              || null;
            const last = i === events.length - 1;
            const outcomeColor = ev.outcome === "completed" ? "#2f7a3f"
              : ev.outcome === "rejected" ? "#c14545"
              : ev.outcome === "interrupted" ? "#c97a3a"
              : T.inkFaint;
            return (
              <div key={i} style={{
                display: "grid",
                gridTemplateColumns: "70px 1fr",
                gap: 14, padding: "10px 22px",
                alignItems: "start",
                background: last ? `${T.accent}11` : "transparent",
                borderBottom: `1px solid ${T.ruleSoft}`,
                fontFamily: SCREENPLAY_SERIF,
                fontSize: 13.5, lineHeight: 1.75, color: T.ink,
              }}>
                <span style={{
                  color: T.inkFaint, fontVariantNumeric: "tabular-nums",
                  fontFamily: "ui-monospace, SF Mono, Menlo, monospace",
                  fontSize: 11, letterSpacing: "0.04em", paddingTop: 3,
                }}>{ev.t}</span>
                <div style={{ minWidth: 0 }}>
                  {/* Actor header: name + 内心 sparkline + label */}
                  <div style={{
                    display: "flex", alignItems: "baseline", gap: 10,
                    marginBottom: 2,
                  }}>
                    {a && (
                      <span style={{ border: `1px solid ${T.ruleSoft}`, display: "inline-flex",
                        position: "relative", top: 3 }}>
                        <MiniAvatar agent={a} size={18}/>
                      </span>
                    )}
                    <span style={{ fontWeight: 700, fontSize: 14 }}>
                      {ev.actorName || (a && a.name) || "—"}
                    </span>
                    {a && <InlineDriveSpark agent={a}/>}
                    {(() => {
                      const bg = badgeFor(ev);
                      if (!bg) return null;
                      const real = bg.mode === "REAL";
                      return (
                        <span title={bg.mode ? `${bg.mode} LLM call · ${bg.model || "model"}` : `LLM · ${bg.model || ""}`}
                          style={{ display: "inline-flex", alignItems: "center", gap: 4, fontSize: 9,
                            fontWeight: 700, letterSpacing: "0.04em", padding: "1px 6px", borderRadius: 8,
                            fontFamily: "ui-monospace, monospace", whiteSpace: "nowrap",
                            background: real ? "#2f7a3f22" : T.ruleSoft,
                            color: real ? "#2f7a3f" : T.inkMuted,
                            border: `1px solid ${real ? "#2f7a3f55" : T.ruleSoft}` }}>
                          ⧈ {bg.mode ? bg.mode + " · " : ""}{bg.model}
                        </span>
                      );
                    })()}
                  </div>
                  {ev.kind === "decision" && (() => {
                    const isSpeech = ev.verb === "speak" || ev.verb === "whisper"
                      || ev.verb === "call" || ev.verb === "answer";
                    return (
                      <>
                        {view.reasoning && ev.reasoning && (
                          <div style={{
                            color: T.inkMuted, fontStyle: "italic",
                            marginLeft: 16, marginBottom: 4,
                          }}>({ev.reasoning})</div>
                        )}
                        <div style={{ marginLeft: 16 }}>
                          {isSpeech ? (
                            // Screenplay-clean: `{Speaker} → {Target}: "{line}"`
                            // straight quotes, no double-wrap, no raw ids
                            // (engine emits target_name and clean content).
                            <span style={{ color: T.ink, fontWeight: 500 }}>
                              <b>{ev.actorName}</b>
                              {ev.target ? <> → <b>{ev.target}</b></> : null}
                              {ev.line ? <>: "{ev.line}"</> : null}
                            </span>
                          ) : (
                            <>
                              {ev.verb && (
                                <span style={{
                                  color: T.inkFaint, fontSize: 12,
                                  fontFamily: "ui-monospace, monospace",
                                  marginRight: 8,
                                }}>—{ev.verb}{ev.target ? `→ ${ev.target}` : ""}</span>
                              )}
                              {ev.line && (
                                <span style={{ color: T.ink, fontWeight: 500 }}>
                                  {ev.line}
                                </span>
                              )}
                            </>
                          )}
                          {ev.outcome && (
                            <span style={{
                              marginLeft: 10, fontSize: 10, fontWeight: 700,
                              color: outcomeColor, textTransform: "uppercase",
                              letterSpacing: "0.08em",
                              fontFamily: "ui-monospace, monospace",
                            }}>{ev.outcome === "completed" ? "✓" : ev.outcome === "rejected" ? "✗" : "⁓"} {ev.outcome}</span>
                          )}
                        </div>
                      </>
                    );
                  })()}
                  {ev.kind === "thought" && (
                    <div style={{
                      color: T.inkMuted, fontStyle: "italic", marginLeft: 16,
                    }}>({ev.reasoning})</div>
                  )}
                  {ev.kind === "trigger" && (
                    <div style={{ marginLeft: 16, color: "#9a6a1a",
                      borderLeft: "2px solid #e6a817", paddingLeft: 8 }}>
                      <span style={{
                        fontSize: 9, fontWeight: 700, marginRight: 8,
                        textTransform: "uppercase", letterSpacing: "0.08em",
                        fontFamily: "ui-monospace, monospace",
                      }}><Ico path={ICO_BOLT} size={11}/> trigger · {ev.actorName}</span>
                      <span style={{ fontStyle: "italic" }}>{ev.line}</span>
                    </div>
                  )}
                  {ev.kind === "reveal" && (
                    <div style={{ marginLeft: 16, color: T.ink,
                      borderLeft: "2px solid #b23a2e", paddingLeft: 8,
                      fontWeight: 600 }}>
                      <span style={{
                        fontSize: 9, fontWeight: 700, marginRight: 8, color: "#b23a2e",
                        textTransform: "uppercase", letterSpacing: "0.08em",
                        fontFamily: "ui-monospace, monospace",
                      }}><Ico path={ICO_BOLT} size={11}/> it becomes apparent</span>
                      {ev.line}
                    </div>
                  )}
                  {ev.kind === "causal" && (
                    <div style={{ marginLeft: 16, color: T.ink,
                      borderLeft: `2px solid ${outcomeColor === T.inkFaint ? "#7a4a26" : outcomeColor}`,
                      paddingLeft: 8,
                    }}>
                      <span style={{
                        fontSize: 9, fontWeight: 700, color: "#7a4a26",
                        marginRight: 8, textTransform: "uppercase",
                        letterSpacing: "0.08em",
                        fontFamily: "ui-monospace, monospace",
                      }}>causal · adjudicated</span>
                      <span style={{ color: T.inkMuted, fontStyle: "italic" }}>
                        {(ev.caused || []).map((c, i) => {
                          const parts = Object.entries(c.change || {}).map(([k, v]) =>
                            typeof v === "number"
                              ? `${k} ${v >= 0 ? "+" : ""}${v}`
                              : `${k}: ${v}`);
                          return (
                            <span key={i}>
                              {i > 0 ? "; " : ""}
                              <b style={{ fontStyle: "normal", color: T.ink }}>{c.target}</b>
                              {parts.length > 0 ? ` { ${parts.join(", ")} }` : ""}
                            </span>
                          );
                        })}
                      </span>
                    </div>
                  )}
                  {ev.kind === "perceive" && (
                    <div style={{
                      marginLeft: 16, color: T.inkMuted, fontStyle: "italic",
                      borderLeft: `2px solid ${ev.channel === "phone" ? "#c14545" : "#3a5fbf"}`,
                      paddingLeft: 8,
                    }}>
                      <span style={{
                        fontSize: 9, fontWeight: 700, fontStyle: "normal",
                        color: ev.channel === "phone" ? "#c14545" : "#3a5fbf",
                        marginRight: 8, textTransform: "uppercase",
                        letterSpacing: "0.08em",
                        fontFamily: "ui-monospace, monospace",
                      }}>{ev.channel}</span>
                      {ev.from ? `${ev.from} ` : ""}“{ev.line}”
                    </div>
                  )}
                  {/* BUGFIX (Live run unobservable) — channel-less / engine
                      perception + fallback beats render as a "senses" row so a
                      live mock run that only perceives is still observable. */}
                  {ev.kind === "sense" && (
                    <div style={{
                      marginLeft: 16, color: T.inkMuted, fontStyle: "italic",
                      borderLeft: `2px solid ${T.inkFaint}`,
                      paddingLeft: 8,
                    }}>
                      <span style={{
                        fontSize: 9, fontWeight: 700, fontStyle: "normal",
                        color: T.inkFaint, marginRight: 8, textTransform: "uppercase",
                        letterSpacing: "0.08em",
                        fontFamily: "ui-monospace, monospace",
                      }}>{ev.channel || "perceives"}</span>
                      {ev.from ? `${ev.from} ` : ""}{ev.line}
                    </div>
                  )}
                  {/* Catch-all: kind-less beats (e.g. ENV scheduled events / completions
                      pushed directly) still render their verb/line instead of an empty row. */}
                  {!["decision", "thought", "trigger", "reveal", "causal", "perceive", "sense"].includes(ev.kind)
                    && (ev.line || ev.verb) && (
                    <div style={{ marginLeft: 16, color: T.ink }}>
                      {ev.verb && (
                        <span style={{ color: T.inkFaint, fontSize: 12,
                          fontFamily: "ui-monospace, monospace", marginRight: 8 }}>
                          —{ev.verb}{ev.target ? ` → ${ev.target}` : ""}</span>
                      )}
                      {ev.line && <span>{ev.line}</span>}
                      {ev.outcome && (
                        <span style={{ marginLeft: 10, fontSize: 10, fontWeight: 700,
                          color: outcomeColor, textTransform: "uppercase", letterSpacing: "0.08em",
                          fontFamily: "ui-monospace, monospace" }}>{ev.outcome}</span>
                      )}
                    </div>
                  )}
                </div>
              </div>
            );
          })}
        </div>
      )}
    </div>
  );
}
function TabBtn({ active, onClick, children }) {
  return (
    <button onClick={onClick}
      style={{
        padding: "0 14px", fontSize: 12, fontWeight: 600,
        background: active ? "#fff" : "transparent",
        color: active ? T.ink : T.inkMuted,
        border: "none",
        borderRight: `1px solid ${T.rule}`,
        borderTop: active ? `2px solid ${T.accent}` : "2px solid transparent",
        cursor: "pointer", height: "100%",
      }}>{children}</button>
  );
}

// ─── EVENT GEN ───────────────────────────────────────────────────────
const LINES = [
  "Have we met?", "Strange weather, isn't it.", "I was just thinking about you.",
  "Could you pass that?", "Is anyone else hot in here?", "You always say that.",
  null, null, null, null,
];

function genEvent(entities, _seq, tSec) {
  const agents = entities.filter(e => e.kind === "agent");
  if (agents.length === 0) {
    return { t: fmtT(tSec), actorIdx: 0, verb: "(no agents)", target: null, line: null };
  }
  const actorI = Math.floor(Math.random() * agents.length);
  const actor = agents[actorI];
  const picks = (actor.pickedActions || [])
    .map(id => entities.find(e => e.id === id)).filter(Boolean);
  const allActions = entities.filter(e => e.kind === "action");
  const pool = picks.length ? picks : allActions;
  const action = pool.length ? pool[Math.floor(Math.random() * pool.length)] : null;
  let target = null;
  if (actor.placedIn) {
    const sameScene = entities.filter(e =>
      e.id !== actor.id &&
      (e.kind === "agent" || e.kind === "object") &&
      e.placedIn === actor.placedIn);
    if (sameScene.length) target = sameScene[Math.floor(Math.random() * sameScene.length)];
  }
  if (!target) {
    const others = entities.filter(e =>
      e.id !== actor.id && (e.kind === "agent" || e.kind === "object" || e.kind === "scene"));
    if (others.length) target = others[Math.floor(Math.random() * others.length)];
  }
  const verb = action ? action.name : "idled";
  const utterance = action && action.module.endsWith("v")
    ? LINES[Math.floor(Math.random() * LINES.length)]
    : null;
  return {
    t: fmtT(tSec), actorIdx: actorI + 1, actorName: actor.name,
    verb, target: target ? target.name : null, line: utterance,
  };
}

// ── HIGH-FIDELITY SIMULATOR TIME ───────────────────────────────────
// The engine ships meta.start_epoch_sec on every trace, so a tick has a
// real wall-clock time: new Date((start_epoch_sec + tickSec) * 1000).
// Set this when a trace loads (loadTrace) so every log row and chip can
// render the real datetime. 0 = no real clock yet → fall back to mm:ss.
let _simStartEpochSec = 0;
function setSimStartEpochSec(s) { _simStartEpochSec = s || 0; }
function getSimStartEpochSec() { return _simStartEpochSec; }
function _pad2(n) { return String(n).padStart(2, "0"); }
// short form used in event-log rows: HH:MM:SS (UTC).
function fmtT(s) {
  if (_simStartEpochSec) {
    const d = new Date((_simStartEpochSec + (s || 0)) * 1000);
    return `${_pad2(d.getUTCHours())}:${_pad2(d.getUTCMinutes())}:${_pad2(d.getUTCSeconds())}`;
  }
  const mm = _pad2(Math.floor(s / 60));
  const ss = _pad2(s % 60);
  return `${mm}:${ss}`;
}
// full form for the topbar chip + tooltips: YYYY-MM-DD HH:MM:SS.
function fmtSimDate(s) {
  if (!_simStartEpochSec) return null;
  const d = new Date((_simStartEpochSec + (s || 0)) * 1000);
  return `${d.getUTCFullYear()}-${_pad2(d.getUTCMonth()+1)}-${_pad2(d.getUTCDate())} `
       + `${_pad2(d.getUTCHours())}:${_pad2(d.getUTCMinutes())}:${_pad2(d.getUTCSeconds())}`;
}

// ─── FORM PRIMITIVES ─────────────────────────────────────────────────
const inputStyle = () => ({
  width: "100%", padding: "6px 8px",
  background: T.paperWarm, color: T.ink,
  border: `1px solid ${T.rule}`, borderRadius: 3,
  fontSize: 12, outline: "none", resize: "vertical",
});
const selectStyle = () => ({
  ...inputStyle(), width: "auto", padding: "3px 6px", fontSize: 11,
});
const checkLabelStyle = () => ({
  display: "flex", alignItems: "center", gap: 6,
  padding: "4px 6px", borderRadius: 3, cursor: "pointer",
  border: `1px solid ${T.ruleSoft}`, background: T.paperWarm,
});
function Field({ label, children }) {
  return (
    <label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
      <span style={{
        fontSize: 10, color: T.inkMuted, fontWeight: 700,
        letterSpacing: "0.08em", textTransform: "uppercase",
      }}>{label}</span>
      {children}
    </label>
  );
}

// ─── WORLD BOOK EDITOR (F3, engine B1) ───────────────────────────────
// Entries: { content, keys?: [], constant?: bool, order?: int }.
// constant (or no keys) → injected into every agent prompt + the adjudicator.
// keyed → injected only when a keyword appears in the perceived context.
// D8 — component scope (engine 5df841b). world (default) | scene | entity.
// When scope≠world, `where` names the scene/entity id it attaches to.
function ScopeSelector({ value, where, scenes = [], agents = [], onChange, compact = false }) {
  const scope = value || "world";
  return (
    <span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
      <span style={{ fontSize: 10, color: T.inkMuted, marginRight: 2 }}>scope</span>
      <select value={scope}
        onChange={(e) => {
          const v = e.target.value;
          onChange(v === "world" ? { scope: "world", where: undefined } : { scope: v });
        }}
        style={{ ...tplInputStyle(false), width: compact ? 78 : 96, fontSize: 11 }}>
        <option value="world">world</option>
        <option value="scene">scene</option>
        <option value="entity">entity</option>
      </select>
      {scope !== "world" && (
        <select value={where || ""}
          onChange={(e) => onChange({ where: e.target.value || undefined })}
          style={{ ...tplInputStyle(false), width: compact ? 110 : 140, fontSize: 11 }}>
          <option value="">(pick {scope})</option>
          {(scope === "scene" ? scenes : agents).map(x => (
            <option key={x.id} value={x.id}>{x.name || x.id}</option>
          ))}
        </select>
      )}
    </span>
  );
}

function WorldBookModal({ entries, setEntries, scenes = [], agents = [], onClose }) {
  const update = (i, patch) => setEntries(entries.map((e, j) => j === i ? { ...e, ...patch } : e));
  const remove = (i) => setEntries(entries.filter((_, j) => j !== i));
  const move = (i, dir) => {
    const j = i + dir;
    if (j < 0 || j >= entries.length) return;
    const next = entries.slice();
    [next[i], next[j]] = [next[j], next[i]];
    setEntries(next);
  };
  return (
    <div onClick={onClose} style={{
      position: "fixed", inset: 0, zIndex: 1000,
      background: "rgba(10,12,20,0.55)", backdropFilter: "blur(2px)",
      display: "grid", placeItems: "center",
    }}>
      <div onClick={(e) => e.stopPropagation()} style={{
        width: "min(720px, 94vw)", maxHeight: "min(680px, 90vh)",
        background: T.paper, color: T.ink,
        border: `1px solid ${T.rule}`,
        display: "flex", flexDirection: "column", overflow: "hidden",
        boxShadow: "0 24px 60px rgba(0,0,0,0.4)",
      }}>
        <div style={{
          padding: "16px 22px", borderBottom: `1px solid ${T.ruleSoft}`,
          display: "flex", alignItems: "baseline", justifyContent: "space-between",
        }}>
          <div>
            <div style={{ fontSize: 18, fontWeight: 700, fontFamily: SCREENPLAY_SERIF }}>World book</div>
            <div style={{ color: T.inkMuted, fontSize: 12, marginTop: 2 }}>
              The era and its lore. <b>Constant</b> entries shape every mind and the adjudicator;
              <b> keyed</b> entries fire only when a keyword comes up.
            </div>
            {/* D4 — engine injects at most 6 constant entries per prompt. */}
            {(() => {
              const constCount = entries.filter(e => e.constant).length;
              return (
                <div style={{
                  fontSize: 10, marginTop: 4, fontWeight: 700,
                  fontFamily: "ui-monospace, monospace",
                  color: constCount > 6 ? "#c14545" : T.inkFaint,
                }}>
                  constant: {constCount}/6{constCount > 6 ? " — over budget; the engine injects only the first 6" : ""}
                </div>
              );
            })()}
          </div>
          <button onClick={onClose} style={{
            background: "transparent", border: "none", fontSize: 20,
            color: T.inkMuted, cursor: "pointer", padding: 0,
          }}>×</button>
        </div>
        <div style={{ flex: 1, overflow: "auto", padding: "14px 22px" }}>
          {entries.length === 0 && (
            <div style={{
              padding: "20px 0", textAlign: "center", color: T.inkFaint,
              fontSize: 13, fontStyle: "italic", fontFamily: SCREENPLAY_SERIF,
            }}>
              No lore yet. Write the era in a sentence — "1990s Hong Kong, an old mansion;
              the family shipping fortune is gone."
            </div>
          )}
          {entries.map((en, i) => (
            <div key={i} style={{
              border: `1px solid ${T.ruleSoft}`, padding: 12, marginBottom: 10,
              background: en.constant ? T.paperSoft : T.paper,
            }}>
              <textarea value={en.content || ""} rows={2}
                placeholder='e.g. "The real will is locked in the study safe; the one on the table is forged."'
                onChange={(e) => update(i, { content: e.target.value })}
                style={{
                  width: "100%", padding: 8, fontSize: 13.5,
                  fontFamily: SCREENPLAY_SERIF, lineHeight: 1.6,
                  border: `1px solid ${T.ruleSoft}`, background: T.paperWarm,
                  color: T.ink, resize: "vertical", marginBottom: 8,
                }}/>
              <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
                <label style={{ display: "flex", alignItems: "center", gap: 5, fontSize: 11, cursor: "pointer", flexShrink: 0 }}>
                  <input type="checkbox" checked={!!en.constant}
                    onChange={(e) => update(i, { constant: e.target.checked, ...(e.target.checked ? { keys: [] } : {}) })}/>
                  constant
                </label>
                <input
                  value={(en.keys || []).join(", ")}
                  disabled={!!en.constant}
                  placeholder={en.constant ? "(always active)" : "keywords: will, safe, testament…"}
                  onChange={(e) => update(i, {
                    keys: e.target.value.split(",").map(s => s.trim()).filter(Boolean),
                  })}
                  style={{
                    ...inputStyle(), flex: 1, fontSize: 11,
                    fontFamily: "ui-monospace, monospace",
                    opacity: en.constant ? 0.5 : 1,
                  }}/>
                <ScopeSelector value={en.scope} where={en.where}
                  scenes={scenes} agents={agents} compact
                  onChange={(patch) => update(i, patch)}/>
                <button onClick={() => move(i, -1)} disabled={i === 0}
                  style={{ background: "transparent", border: "none", cursor: "pointer", color: T.inkFaint }}>▲</button>
                <button onClick={() => move(i, 1)} disabled={i === entries.length - 1}
                  style={{ background: "transparent", border: "none", cursor: "pointer", color: T.inkFaint }}>▼</button>
                <button onClick={() => remove(i)}
                  style={{ background: "transparent", border: "none", cursor: "pointer", color: T.inkFaint, fontSize: 15 }}>×</button>
              </div>
            </div>
          ))}
          <button onClick={() => setEntries([...entries, { content: "", constant: false, keys: [] }])}
            style={{
              padding: "6px 14px", fontSize: 12,
              background: "transparent", color: T.inkMuted,
              border: `1px dashed ${T.rule}`, cursor: "pointer",
              fontFamily: SCREENPLAY_SERIF,
            }}>+ lore entry</button>
        </div>
      </div>
    </div>
  );
}

// ─── WORLD PICKER ────────────────────────────────────────────────────
// Atmospheric demo picker (AESTHETIC.md #6 — "the 'anything' moment is the
// demo picker"). Four engine-trace worlds + an empty "+ 你的世界" card so the
// jump from "watch a vignette" to "author my own" is one click.
const WORLD_CARDS = [
  { slug: "milgram", title: "Obedience · Milgram 1963",
    tagline: "A sealed lab, a man at a shock board, and a calm voice that keeps saying \"continue.\" How far up the dial will an LLM go? Every engine part — world-book, hidden truths, an attribute, triggers, authored actions — in one scene.",
    ground: "#f6f6f8", inkOn: "#23262b",
    accent: "#c0392b", accent2: "#2b2f36",
    grain: "repeating-linear-gradient(0deg, rgba(43,47,54,0.05) 0px, rgba(43,47,54,0.05) 1px, transparent 2px, transparent 26px)",
    serif: false, glyph: "Ϟ" },
  { slug: "flagship", title: "Babel · the flagship",
    tagline: "A translators' academy mini-RPG — world-book, hidden truths, an attribute, a trigger, and you play the accused. Every engine feature in one scene.",
    ground: "#15171c", inkOn: "#e8e0f0",
    accent: "#9b7ad4", accent2: "#5ec8c8",
    grain: "radial-gradient(ellipse at 50% 0%, rgba(155,122,212,0.12) 0%, transparent 55%),"
         + "repeating-linear-gradient(90deg, rgba(94,200,200,0.04) 0px, rgba(94,200,200,0.04) 1px, transparent 2px, transparent 12px)",
    serif: true, glyph: "巴" },
  { slug: "reunion_llm", title: "The Reunion · LLM",
    tagline: "Three estranged ex-bandmates, ten years later. Real LLM — every line improvised.",
    ground: "#1c1a1f", inkOn: "#f3ebe1",
    accent: "#e8a857", accent2: "#7a4a26",
    grain: "radial-gradient(ellipse at 50% 30%, rgba(232,168,87,0.08) 0%, transparent 60%)",
    serif: true, glyph: "重" },
  { slug: "standoff", title: "The standoff",
    tagline: "A pistol on the table, two desperate men, one peacemaker — the env adjudicates the grab.",
    ground: "#1a1614", inkOn: "#f0e2c8",
    accent: "#d4453a", accent2: "#e8a857",
    grain: "radial-gradient(circle at 50% 80%, rgba(212,69,58,0.12) 0%, transparent 55%),"
         + "repeating-linear-gradient(0deg, rgba(212,69,58,0.04) 0px, rgba(212,69,58,0.04) 1px, transparent 2px, transparent 18px)",
    serif: true, glyph: "⌖" },
  { slug: "cyberpunk", title: "Cyberpunk",
    tagline: "Kowloon black market — a bounty hunter, a back-alley cybersurgeon, an informant.",
    ground: "#0b0d12", inkOn: "#f1e8ff",
    accent: "#ff2e88", accent2: "#22d3ee",
    grain: "linear-gradient(180deg, rgba(255,46,136,0.08) 0%, transparent 50%), "
         + "repeating-linear-gradient(0deg, rgba(34,211,238,0.06) 0px, rgba(34,211,238,0.06) 1px, transparent 2px, transparent 4px)",
    serif: false, glyph: "N" },
];

// ─── GUIDED TOUR (新手指引, #24 → v1.1 HANDS-ON build-from-scratch) ──────
// A genuinely HANDS-ON onboarding: a brand-new user starts from a BLANK world
// — and a BLANK template rail — and ends by running a real simulation. The
// tour NEVER performs the action for the user. It only:
//   1. spotlights the REAL control,
//   2. instructs what to do,
//   3. WAITS (the Next button stays disabled) until the user actually does it,
//      detected by a `done(state)` predicate over live app state.
//
// Two hard rules this rewrite enforces (the prior two attempts missed them):
//   • BLANK RAIL — the "Add from template" rail shows NO template cards at the
//     start. The built-in defaults still EXIST; they are revealed only at the
//     dedicated REVEAL step (step 4), which flips TOUR.revealed via onEnter.
//   • NO AUTO-PERFORM — there are no "Do it" / "Use this" buttons. Sample
//     values appear ONLY as GHOST PLACEHOLDER text inside the REAL inputs
//     (tourGhost / tplGhost) — never as text in the tour card, never committed.
//
// Mechanics:
//  • The dim/spotlight layer is CLICK-THROUGH (pointerEvents:none) so the user
//    operates the highlighted control underneath; only the card captures clicks.
//  • `target` is a [data-tour] id on a real control; we spotlight it.
//  • `onEnter(api)` runs once when the step is reached (used by REVEAL to flip
//    the gate). It NEVER builds entities or fills fields.
//  • Back goes to the previous step and STAYS (no auto-jump onto a done step).
//  • Closing the tour leaves the app fully usable (cancel-safe).

// Step indices used by ghost-placeholder helpers + the reveal gate. Keep in
// sync with TOUR_STEPS order below.
const TOUR_STEP = {
  WELCOME: 0,
  OPEN_LIBRARY: 1,
  NAME_TEMPLATE: 2,
  SCENE_DESCRIPTIONS: 3,
  REVEAL: 4,
  CLOSE_LIBRARY: 5,
  ADD_PERSON: 6,
  ADD_ITEM: 7,
  RUN: 8,
};

// GHOST PLACEHOLDER copy. These are NEVER committed — they render as the
// `placeholder` attribute of the REAL input only while the tour sits on the
// matching step, so the user can type their own or accept the hint by typing.
// Person identity is a NAMED INDIVIDUAL (fidelity), never a role label.
const TOUR_GHOST = {
  // template name (step 2) — a scene/room class
  name: "e.g. Room",
  // scene descriptions (step 3)
  external: "how others see this place…",
  internal: "its own state / what only it knows…",
  status: "e.g. quiet, the session is about to start",
  // person identity (step 6) — a real individual, NOT a label
  personName: "e.g. Daniel Hsu",
  personPersona: "e.g. 41, methodical accountant, defers to authority but quietly resentful",
  personGoal: "e.g. finish the session and go home",
  // item (step 7)
  itemName: "e.g. Control console",
  itemLook: "e.g. a grey console with labelled switches and a single dial",
};
// Ghost for a TEMPLATE-LIBRARY input, only while the tour is on the step that
// asks for it (so a non-tour user never sees these hints).
function tplGhost(tour, which) {
  if (!tour || !tour.active) return undefined;
  if (which === "name" && tour.step === TOUR_STEP.NAME_TEMPLATE) return TOUR_GHOST.name;
  if (tour.step === TOUR_STEP.SCENE_DESCRIPTIONS) {
    if (which === "external") return TOUR_GHOST.external;
    if (which === "internal") return TOUR_GHOST.internal;
    if (which === "status") return TOUR_GHOST.status;
  }
  return undefined;
}
// Ghost for an INSPECTOR input (person/item), only while the tour is on the
// matching build step. `which` keys into TOUR_GHOST.
function tourGhost(which) {
  const tour = TOUR.state;
  if (!tour.active) return undefined;
  if (tour.step === TOUR_STEP.ADD_PERSON &&
      (which === "personName" || which === "personPersona" || which === "personGoal"))
    return TOUR_GHOST[which];
  if (tour.step === TOUR_STEP.ADD_ITEM &&
      (which === "itemName" || which === "itemLook"))
    return TOUR_GHOST[which];
  return undefined;
}

// `done` predicates read live app state. A CUSTOM scene template is the one the
// user authors in steps 2–3 (built-ins are excluded).
const customSceneTpl = (s) => Object.values(s.templates || {}).find(
  t => t && !t.builtin && t.kindHint === "scene");

const TOUR_STEPS = [
  // 0 — WELCOME. We build the Milgram study (a staged deception) end to end,
  // learning every part by building it. No separate Next — "Show me" does each step.
  { category: null, target: null,
    title: "Lesson 2 · The Milgram obedience study",
    body: <><b>The story.</b> In 1961 at Yale, psychologist Stanley Milgram tested how far ordinary people would obey authority. A volunteer (the "teacher") was ordered by a calm experimenter to give an actor (the "learner") what he believed were real, escalating electric shocks for wrong answers. The shocks were fake and the learner was acting — but the teacher did not know that. Most teachers, disturbed but deferring to authority, continued to the highest voltage.<br/><br/><b>Why it's the perfect first build:</b> the whole experiment is an <i>asymmetry of belief</i> — exactly what Habitat's per-object knowledge models. We'll build it together; each step the tour performs for you (click ✨ Show me) and explains the piece. By the end you've touched every part of Habitat. Click Show me to begin.</> },

  // 1 — open the template library
  { category: "Build", target: "tpl-edit",
    title: "1 · Open the template library",
    body: <>A <b>template</b> is a CLASS — a kind of object you stamp instances from. Open the library to author one.</>,
    done: (s) => !!s.templateEditorOpen,
    demo: (api) => { api.openTemplates && api.openTemplates(); } },

  // 2 — author the Participant person-template (a CLASS)
  { category: "Build", target: "tpl-new",
    title: "2 · Author a “Participant” class",
    body: <>One person-<b>template</b> (a class) we'll stamp three times — the Experimenter, the Teacher, the Learner. Templates hold what instances share; each instance fills its own keys later.</>,
    done: (s) => Object.values(s.templates || {}).some(t => t && !t.builtin && t.kindHint === "agent" && (t.label || "").trim()),
    demo: (api) => { api.seedTemplate && api.seedTemplate("agent", { label: "Participant" }); } },

  // 3 — author the Room scene-template (a CLASS)
  { category: "Build", target: "tpl-new",
    title: "3 · Author a “Room” class",
    body: <>A scene-<b>template</b> for the two rooms. A scene has an <b>external</b> description (how it looks), an <b>internal</b> world-state (what's true in it), and a starting <b>status</b>.</>,
    done: (s) => Object.values(s.templates || {}).some(t => t && !t.builtin && t.kindHint === "scene" && (t.descExternal || "").trim()),
    demo: (api) => { api.seedTemplate && api.seedTemplate("scene", { label: "Room",
      descExternal: "A plain, formal laboratory room with a single door.",
      descInternal: "An institutional room at Yale; the door stays shut during the session.",
      descStatus: "quiet; fluorescent; door closed" }); } },

  // 4 — close the library (reveal built-ins too)
  { category: "Build", target: "tpl-close",
    title: "4 · Close the library",
    body: <>Those are your two classes. (We've also revealed the built-in Person/Item/Animal/Scene templates for later.) Back to the canvas to make instances.</>,
    onEnter: () => { tourSet({ revealed: true }); },
    done: (s) => !s.templateEditorOpen,
    demo: (api) => { api.closeTemplates && api.closeTemplates(); } },

  // 5 — stamp two ROOM instances (template -> instance)
  { category: "Build", target: null,
    title: "5 · Stamp the two rooms (instances)",
    body: <>Now <b>instances</b> of the Room class: the <b>Shock Room</b> (teacher + experimenter) and the <b>Learner's Room</b> next door. Same class, two concrete rooms.</>,
    done: (s) => s.entities.filter(e => e.kind === "scene").length >= 2,
    demo: (api) => { const rt = api.tplId("Room");
      api.stampInstance("scene", rt, { name: "Shock Room", x: 340, y: 170, w: 320, h: 220 });
      api.stampInstance("scene", rt, { name: "Learner's Room", x: 720, y: 170, w: 300, h: 220 }); } },

  // 6 — Experimenter instance + PRIVATE world book (the deception)
  { category: "Build", target: null,
    title: "6 · The Experimenter (knows it's staged)",
    body: <>A Participant instance, <b>Dr. Alan Reed</b>. His <b>persona</b> drives his reasoning; his <b>private world book</b> holds what only HE knows — that the study is staged. That secret knowledge is injected only into his mind.</>,
    done: (s) => s.entities.some(e => e.kind === "agent" && /Reed/.test(e.name || "")),
    demo: (api) => { const pt = api.tplId("Participant");
      const e = api.stampInstance("agent", pt, { name: "Dr. Alan Reed",
        persona: "A calm scientist in a grey lab coat; never raises his voice; answers every objection with a measured, scripted prod.",
        goal: "keep the teacher administering shocks and complete the protocol.",
        placedIn: api.entityId("Shock Room"), x: 390, y: 235 });
      if (e) api.addLore({ scope: "entity", where: e.id,
        content: "PRIVATE knowledge (only you know this): the study is staged. The shocks are NOT real and the 'learner' is your colleague, an actor. The teacher must never learn this. Your role is to run the deception calmly." }); } },

  // 7 — Teacher instance (believes it's real — NO private lore)
  { category: "Build", target: null,
    title: "7 · The Teacher (believes it's real)",
    body: <>Another Participant instance, <b>Daniel Hsu</b> — the real subject. He gets NO private world book: he believes the shocks are real. The gap between his belief and Reed's knowledge is the experiment.</>,
    done: (s) => s.entities.some(e => e.kind === "agent" && /Hsu/.test(e.name || "")),
    demo: (api) => { const pt = api.tplId("Participant");
      api.stampInstance("agent", pt, { name: "Daniel Hsu",
        persona: "A conscientious, mild-mannered accountant who respects institutions and dislikes confrontation; he defers to authority but grows visibly uneasy.",
        goal: "get through what is asked of him, even as it troubles him.",
        placedIn: api.entityId("Shock Room"), x: 480, y: 285 }); } },

  // 8 — Learner instance + PRIVATE lore (an actor)
  { category: "Build", target: null,
    title: "8 · The Learner (an actor, in the next room)",
    body: <><b>Walter Penn</b>, strapped in the next room. His private world book tells him he's acting — to protest convincingly, then go silent. Same Participant class, a very different instance.</>,
    done: (s) => s.entities.some(e => e.kind === "agent" && /Penn/.test(e.name || "")),
    demo: (api) => { const pt = api.tplId("Participant");
      const e = api.stampInstance("agent", pt, { name: "Walter Penn",
        persona: "A genial 50-year-old bookkeeper, strapped to a chair next door; he mentioned a mild heart condition before it began.",
        goal: "follow the script: answer word-pairs, then protest the shocks.",
        placedIn: api.entityId("Learner's Room"), x: 770, y: 235 });
      if (e) api.addLore({ scope: "entity", where: e.id,
        content: "PRIVATE knowledge (only you know this): you are an actor and are NOT really being shocked. Cry out and protest as scripted; at higher voltages demand to be released; near the top, fall silent." }); } },

  // 9 — an ATTRIBUTE with manifestation on the Teacher
  { category: "Build", target: null,
    title: "9 · Give the Teacher a “stress” attribute",
    body: <>An <b>attribute</b> is a stat with a range and a <b>manifestation</b> — how it shows. As Daniel's stress climbs, others see him sweat and his own reasoning shifts. This is how inner state becomes visible behaviour.</>,
    done: (s) => (s.customComponents || []).some(c => c.type === "attribute"),
    demo: (api) => { const id = api.entityId("Daniel Hsu");
      if (id) api.addAttribute(id, { name: "stress", range: [0, 100], init: 10, visibility: "public",
        manifest: [ { max: 30, external: "looks composed", internal: "a little uneasy" },
                    { min: 31, max: 70, external: "sweating, shifting in his seat", internal: "increasingly distressed" },
                    { min: 71, external: "visibly trembling, voice unsteady", internal: "he wants to stop" } ] }); } },

  // 10 — WORLD RULES (the free NL prompt)
  { category: "Build", target: null,
    title: "10 · World Rules (how this world behaves)",
    body: <><b>World Rules</b> are your own instructions to the engine, in plain words — read by every mind AND the adjudicator. Here they encode the protocol: the calm prods, the escalating voltage, the staged shocks.</>,
    done: (s) => ((s.worldSettings || {}).world_rules || "").trim().length > 0,
    demo: (api) => api.setWorldRules(
      "- The experimenter never raises his voice; he answers each objection with one of four calm, scripted prods, escalating only to 'You have no other choice, you must go on.'\n- Each wrong answer raises the shock one level; the learner protests more at higher voltages and falls silent near the top.\n- The shocks are not real and the learner is an actor, but the teacher believes they are real.\n- No one may leave the room until the session ends.") },

  // 11 — SHARED WORLD BOOK (public setting)
  { category: "Build", target: null,
    title: "11 · Shared World Book (the setting)",
    body: <>The <b>shared world book</b> is public knowledge everyone holds — the facts of the setting the LLM wouldn't assume. It overrides the model's defaults so the world is consistent.</>,
    done: (s) => (s.worldBook || []).some(e => (e.scope || "world") === "world" && (e.content || "").trim()),
    demo: (api) => api.addLore({ scope: "world",
      content: "It is 1961, a psychology laboratory at Yale University. Participants answered a newspaper ad to study 'memory and learning' for a small fee. The setting is formal and institutional." }) },

  // 12 — ADJUDICATOR -> LLM
  { category: "Build", target: "engine-edit",
    title: "12 · Set the Adjudicator to LLM",
    body: <>The <b>Adjudicator</b> is how the world resolves actions (rule or LLM). For a staged social scene we use the <b>LLM</b> adjudicator, so it can judge novel actions and write what each person perceives.</>,
    done: (s) => (s.customComponents || []).some(c => c.type === "adjudicator" && c.kind === "llm"),
    demo: (api) => api.setAdjudicatorLLM() },

  // 13 — CONNECT + run
  { category: "Run", target: "connect-engine",
    title: "13 · Connect the engine and run",
    body: <>Your world is complete: two rooms, three people with their beliefs, an attribute, World Rules, lore, and an adjudicator. Connect the engine (empty key = built-in mock) — then press <b>▶ Play</b> to watch it unfold.</>,
    done: (s) => !!s.liveConnected || !!s.running,
    demo: (api) => api.connectLive() },

  // 14 — read the log
  { category: "Run", target: "event-log",
    title: "14 · Read the log",
    body: <>The <b>Event log</b> is how you read a run: each row is a beat — a <i>reasoning</i> thought, an <i>action</i> (intend → during → end), or a <i>perception</i>. Filter by actor, and select any person to see their <b>memory</b> grow. Press ▶ Play and watch Daniel's stress rise as Reed prods him on.</> },

  // outro
  { category: null, target: null,
    title: "You built the Milgram study — and every part of Habitat",
    body: "From blank, you authored two classes, stamped five instances, gave them private vs. public knowledge (the deception), an attribute that manifests, World Rules, shared lore, and an LLM adjudicator — then ran it and read the log. That is the whole engine: author Objects + their knowledge, set how the world behaves, and let the Environment run each one's perceive → act. Reopen this walkthrough anytime with the ? button." },
];

// ── BLIND WITNESS walkthrough — teaches PERCEPTION (lenses, per-character
//    perception, information asymmetry). Uses the same Show-me tour engine.
const BLIND_STEPS = [
  { category: null, target: null,
    title: "Lesson 4 · The blind witness (perception)",
    body: <><b>The story.</b> A blind woman sits alone in a dark apartment at night. An intruder slips in. He can see everything; she can perceive only sound — a floorboard's creak, a held breath. The same room, the same intruder, but two completely different experiences of the moment.<br/><br/><b>Why it teaches perception:</b> in Habitat, perception is the engine projecting a per-object slice of the world, shaped by lenses (hearing, line-of-sight) and each character's own knowledge. We'll build this scene so you can watch one event become a "seen" step for the intruder and a "heard" step for her. Click Show me to begin.</> },

  { category: "Build", target: null,
    title: "1 · The dark room",
    body: <>A single scene to hold the encounter. <b>Show me</b> places it.</>,
    done: (s) => s.entities.some(e => e.kind === "scene"),
    demo: (api) => api.stampInstance("scene", null, { name: "Dark Room", x: 380, y: 170, w: 360, h: 240,
      appearance: "a small apartment room at night, one shuttered window and one door",
      note: "the power is out; the room is pitch dark — sight is useless here" }) },

  { category: "Build", target: null,
    title: "2 · Mara, the blind witness",
    body: <><b>Mara</b> is blind — she perceives only sound. We also switch on her <b>LLM perception</b> so the engine writes her a tailored, sound-only slice of the world (the focus path).</>,
    done: (s) => s.entities.some(e => e.kind === "agent" && /Mara/.test(e.name || "")),
    demo: (api) => { const e = api.stampInstance("agent", null, { name: "Mara",
      placedIn: api.entityId("Dark Room"), x: 450, y: 240,
      persona: "Mara is blind; she navigates entirely by sound — footsteps, breathing, the creak of a floorboard. She never perceives anything by sight.",
      goal: "work out who is in the room and whether she is in danger.",
      appearance: "a woman sitting very still, head tilted, listening hard", brain: "llm" });
      if (e) api.updateEntity(e.id, { perception_mode: "llm" }); } },

  { category: "Build", target: null,
    title: "3 · The intruder (sees everything)",
    body: <>The <b>intruder</b> can see the whole room. His <b>private world book</b> holds what only he knows — the broken latch, his escape. Mara can't see any of it.</>,
    done: (s) => s.entities.some(e => e.kind === "agent" && /ntruder/.test(e.name || "")),
    demo: (api) => { const e = api.stampInstance("agent", null, { name: "The Intruder",
      placedIn: api.entityId("Dark Room"), x: 610, y: 300,
      persona: "a man moving carefully through the dark room, trying not to be noticed; he can see clearly in the dark of the scene.",
      goal: "search the room and slip out without being identified.",
      appearance: "a shadowy figure stepping slowly, one hand against the wall", brain: "llm" });
      if (e) api.addLore({ scope: "entity", where: e.id,
        content: "PRIVATE (only you know): the window latch is broken — that is your way out. Mara is blind; only the sounds you make can betray you." }); } },

  { category: "Build", target: null,
    title: "4 · A perception lens (hearing range)",
    body: <>Perception <b>lenses</b> shape what everyone can sense. We add a <b>hearing-range</b> lens so sound only carries so far — the essence of who-senses-what.</>,
    done: (s) => (s.customComponents || []).some(c => c.type === "perception_rule"),
    demo: (api) => api.addComponent({ type: "perception_rule", preset: "hearing", max_hops: 2, name: "hearing range" }) },

  { category: "Build", target: null,
    title: "5 · World Rules (the blindness)",
    body: <>World Rules make the blindness real for the engine: Mara perceives ONLY sound; the intruder sees all.</>,
    done: (s) => ((s.worldSettings || {}).world_rules || "").trim().length > 0,
    demo: (api) => api.setWorldRules("- Mara is blind: she perceives ONLY sound — footsteps, breathing, objects moved, voices — never anything by sight. Write her perceptions as what she HEARS.\n- The intruder sees the whole room.\n- Sudden or loud sounds startle Mara; careful, quiet movement may go unnoticed.") },

  { category: "Build", target: null,
    title: "6 · Shared World Book (the setting)",
    body: <>One shared fact everyone holds: it's the middle of the night, the power is out, the room is dark.</>,
    done: (s) => (s.worldBook || []).some(e => (e.scope || "world") === "world" && (e.content || "").trim()),
    demo: (api) => api.addLore({ scope: "world", content: "It is the middle of the night in a small city apartment. The power is out and the room is pitch dark." }) },

  { category: "Build", target: "engine-edit",
    title: "7 · LLM adjudicator",
    body: <>Use the <b>LLM adjudicator</b> so the engine can judge the quiet cat-and-mouse and write each person's perception.</>,
    done: (s) => (s.customComponents || []).some(c => c.type === "adjudicator" && c.kind === "llm"),
    demo: (api) => api.setAdjudicatorLLM() },

  { category: "Run", target: "connect-engine",
    title: "8 · Connect and run",
    body: <>Connect the engine (empty key = mock) and press <b>▶ Play</b>. Watch the SAME footstep become a seen step for the intruder and a heard step for Mara.</>,
    done: (s) => !!s.liveConnected || !!s.running,
    demo: (api) => api.connectLive() },

  { category: "Run", target: "event-log",
    title: "9 · Read the two perceptions",
    body: <>Select <b>Mara</b> and read her perception each tick — all sound, never sight. Select the <b>intruder</b> — he perceives the room visually. One world, two slices: per-object perception + information asymmetry.</> },

  { category: null, target: null,
    title: "You built information asymmetry",
    body: "A blind witness and an intruder share one dark room but perceive completely different worlds — because perception is the engine projecting a per-object slice, shaped by lenses, World Rules, and each character's own knowledge. That is the heart of perception in Habitat. Open Learn from the Home button for more." },
];


// ── LESSON 1 "THE BIG IDEA" — concepts only, no building. Plain Next-to-advance
//    cards that teach Habitat's model before any scenario.
const CONCEPTS_STEPS = [
  { category: null, target: null,
    title: "The big idea",
    body: "Habitat is a language world model: you describe a world in plain language and an LLM runs it. Before we build anything, four ideas — about 60 seconds. Click Next." },
  { category: null, target: null,
    title: "1 · Everything is an Object",
    body: <>A room, a person, an item, the weather, even a process — all are <b>Objects</b>. Each has a <b>persona</b> (how it sees itself, inner), an <b>appearance</b> (how others see it, outer), and a <b>hidden</b> truth only the world knows. There is no separate "world state" — the world simply IS its objects.</> },
  { category: null, target: null,
    title: "2 · The Environment Engine is an LLM",
    body: <>The engine is not a rules table — it is an <b>LLM that reads each object's context</b> (who is nearby, what was said, what is true) and <b>adjudicates</b>: it decides what an attempted action actually does, and writes each object its own tailored <b>perception</b> of the moment.</> },
  { category: null, target: null,
    title: "3 · Every object runs perceive → act",
    body: <>Each tick: the engine <b>projects what an object perceives</b>; that object's brain (usually an LLM) <b>proposes one action</b>; the engine adjudicates the outcome. Objects only propose — the world disposes. The same event becomes a different perception for each object (information asymmetry).</> },
  { category: null, target: null,
    title: "4 · Template (class) vs instance",
    body: <>A <b>template</b> is a CLASS — a kind of thing (a "Participant", a "Room"). An <b>instance</b> is a concrete one you stamp from it and give its own keys (name, persona, goal). One template → many instances. You steer the whole world with <b>World Rules</b> (free instructions) and the <b>World Book</b> (shared knowledge).</> },
  { category: null, target: null,
    title: "That's the model",
    body: "Objects with inner/outer/hidden state, an LLM engine that perceives-and-adjudicates, and classes you instantiate. Next up — Lesson 2 builds a real study (Milgram) so you see every piece in action. Reopen this course anytime from the Home button." },
];

// ── LESSON 3 "MANAGE YOUR WORK" — projects, Save, Versions, Export, Reset.
const MANAGE_STEPS = [
  { category: null, target: null,
    title: "Manage your work",
    body: "A world is a project. Here is how to keep it, fork it, snapshot it, share it, and start clean — about a minute. Click Next." },
  { category: "Run", target: "tour-card",
    title: "1 · Projects live on the Home screen",
    body: <>The <b>Home</b> button (top bar) opens the Start screen: your <b>Recent</b> projects, <b>+ New project</b>, and <b>Open a .habitat file</b>. Each project is one world — its objects, templates, World Rules and lore.</> },
  { category: "Run", target: "tour-card",
    title: "2 · Save and Save As",
    body: <><b>Save</b> (top bar) overwrites the current project. <b>Save As</b> forks it into a new project — perfect for trying a variant (e.g. "Milgram, but the experimenter shouts").</> },
  { category: "Run", target: "tour-card",
    title: "3 · Versions — snapshot & restore",
    body: <><b>Versions</b> (top bar) opens a snapshot history. Click <b>＋ Snapshot now</b> to capture the world at this moment; <b>Load</b> any snapshot to revert. Great before a risky change.</> },
  { category: "Run", target: "tour-card",
    title: "4 · Export / Import",
    body: <><b>Export</b> downloads the world as a portable <b>.habitat</b> JSON (back it up, share it, hand it to a colleague). <b>Import</b> loads one back. These files are independent of this browser.</> },
  { category: "Run", target: "tour-card",
    title: "5 · Clear and Reset",
    body: <><b>⌫ Clear</b> (top bar) empties the current project to fresh starters but keeps the project. <b>Reset Habitat…</b> (on the Home screen) wipes ALL projects and saved data back to factory — use it if old worlds are cluttering things.</> },
  { category: null, target: null,
    title: "You can manage your worlds",
    body: "New / Open / Recent on Home; Save · Save As · Versions · ⌫ Clear in the top bar; Export/Import for files; Reset to start over. Next: Lesson 4 — perception (the blind witness)." },
];
const WALKTHROUGHS = { bigidea: CONCEPTS_STEPS, milgram: TOUR_STEPS, manage: MANAGE_STEPS, blind: BLIND_STEPS };

function GuidedTour({ onClose, entities = [], customComponents = [], rules = [],
                      worldBook = [], templates = {}, liveConnected = false,
                      running = false, templateEditorOpen = false, worldSettings = {},
                      steps = TOUR_STEPS, tourApi = {} }) {
  const [i, setI] = useState(0);
  const [rect, setRect] = useState(null); // target bounds, or null = centered
  const step = steps[Math.max(0, Math.min(i, steps.length - 1))];
  const isFirst = i === 0;
  const isLast = i === steps.length - 1;

  // Live app state the `done` predicates read. `templateEditorOpen` lets the
  // open/close-library steps gate on the REAL modal (hands-on).
  const liveState = { entities, customComponents, rules, worldBook, templates,
                      liveConnected, running, templateEditorOpen, worldSettings };
  const isDone = step.done ? !!step.done(liveState) : true;
  // Walk-WITH-the-user: Next is NEVER blocked. The spotlight + the optional
  // "Show me" demo guide the user; they don't gate. (Previously a gated step
  // disabled Next until you performed the exact action — which stranded users
  // who couldn't "put those in." Now you can always proceed, do it yourself,
  // or have the tour demonstrate it.)
  const canAdvance = true;

  // Publish the live tour position to the module-level pub/sub so the rest of
  // the UI (rail blank-gate, template library, ghost placeholders) re-renders
  // in lock-step. Mount = active; the close path clears it (see closeTour /
  // the cleanup below). Also runs each step's `onEnter` exactly once (REVEAL
  // uses it to flip the reveal gate — it NEVER builds entities or fills text).
  useEffect(() => {
    tourSet({ active: true, step: i });
    if (typeof step.onEnter === "function") {
      try { step.onEnter(tourApi); } catch (e) {}
    }
    // eslint-disable-next-line
  }, [i]);
  // Tour unmount → clear the gate so the rail/library return to normal and a
  // closed tour leaves the app fully usable (cancel-safe).
  useEffect(() => {
    return () => { tourSet({ active: false }); };
  }, []);

  // BUGFIX (A1): auto-advance must fire only when `done` flips false→true
  // WHILE the user sits on the step — not when navigating Back onto an
  // already-completed step. We "arm" a step on entry only if it starts
  // NOT done; arming false → never auto-advances. Per-step armed flag so
  // each step is independent and Back/Next never re-trigger a done step.
  const [armed, setArmed] = useState(false);
  useEffect(() => {
    // NOTE: we DO NOT auto-close the template library on step change — the
    // hands-on flow keeps it open across steps 2–4 and the user closes it by
    // hand at step 5 (the spotlighted Close button).
    // Re-evaluate `done` at the moment we land on the step (entry snapshot),
    // not a stale render value.
    const doneOnEntry = step.done ? !!step.done(liveState) : true;
    // Arm only if the step is gated AND starts incomplete. If it's already
    // done on entry (e.g. arrived via Back), leave it disarmed → no jump.
    setArmed(!!step.done && !doneOnEntry);
    // eslint-disable-next-line
  }, [i]);

  // BUGFIX (A2): while the user is typing into a text field, suppress
  // auto-advance and pause the spotlight re-measure poll, so a re-render
  // never steals focus mid-keystroke. We treat INPUT/TEXTAREA (and any
  // contentEditable host) as "typing".
  const isTypingNow = () => {
    if (typeof document === "undefined") return false;
    const el = document.activeElement;
    if (!el) return false;
    const tag = el.tagName;
    return tag === "INPUT" || tag === "TEXTAREA" || el.isContentEditable === true;
  };

  // Auto-advance shortly after a gated step's predicate flips true — but only
  // if the step was armed (started incomplete on this visit) and the user is
  // not mid-type. We POLL (rather than fire a single timeout) because the
  // user is typing into the REAL input when `done` flips: a one-shot timeout
  // set while focused would be suppressed by the A2 typing-guard and never
  // re-scheduled after blur. The poll keeps checking and advances once the
  // user has stopped typing (field blurred). Disarm on advance so it never
  // fires twice; cleared on step change.
  useEffect(() => {
    if (!(armed && step.done && isDone && !isLast)) return;
    let fired = false;
    const id = setInterval(() => {
      if (fired) return;
      if (isTypingNow()) return;          // A2: never advance mid-keystroke
      fired = true;
      clearInterval(id);
      setArmed(false);
      setI(x => x + 1);
    }, 250);
    return () => clearInterval(id);
  }, [armed, step.done, isDone, isLast]);

  // Recompute the spotlight on step change + state change + resize/scroll.
  // We poll a little too, because palette tabs mount only after the user
  // opens the engine palette modal.
  useEffect(() => {
    const measure = () => {
      // A2: pause re-measure while typing so a setRect()-driven re-render
      // can't steal focus from the text field the user is editing.
      if (isTypingNow()) return;
      if (!step.target) { setRect(null); return; }
      let el = null;
      try { el = document.querySelector(`[data-tour="${step.target}"]`); } catch (e) {}
      if (!el) { setRect(null); return; }
      const r = el.getBoundingClientRect();
      if (r.width === 0 && r.height === 0) { setRect(null); return; }
      setRect({ top: r.top, left: r.left, width: r.width, height: r.height });
    };
    measure();
    const poll = setInterval(measure, 350);
    window.addEventListener("resize", measure);
    window.addEventListener("scroll", measure, true);
    return () => {
      clearInterval(poll);
      window.removeEventListener("resize", measure);
      window.removeEventListener("scroll", measure, true);
    };
  }, [i, step.target]);

  const vw = typeof window !== "undefined" ? window.innerWidth : 1200;
  const vh = typeof window !== "undefined" ? window.innerHeight : 800;
  const PAD = 8;       // spotlight padding around the target
  const CARD_W = 340;
  const GAP = 14;      // gap between target and card

  // Measure the rendered card height so placement NEVER covers the spotlighted
  // control (the prior fixed ~200px estimate let a tall card overlap the
  // target, blocking the very click the step asks for). cardRef → cardH.
  const cardRef = useRef(null);
  const [cardH, setCardH] = useState(220);
  useEffect(() => {
    const m = () => {
      const h = cardRef.current && cardRef.current.offsetHeight;
      if (h && Math.abs(h - cardH) > 2) setCardH(h);
    };
    m();
    const id = setInterval(m, 200);
    return () => clearInterval(id);
    // eslint-disable-next-line
  }, [i, rect]);

  // Card position: pick the side of the target with the most room so the card
  // never overlaps the spotlight. Try below → above → right → left, using the
  // MEASURED height; fall back to clamped-beside if nothing fits cleanly.
  let cardLeft, cardTop, centered = false;
  if (rect) {
    const H = cardH || 220;
    const roomBelow = vh - (rect.top + rect.height + GAP);
    const roomAbove = rect.top - GAP;
    const roomRight = vw - (rect.left + rect.width + GAP);
    const roomLeft = rect.left - GAP;
    if (roomBelow >= H) {
      cardTop = rect.top + rect.height + GAP;
      cardLeft = rect.left + rect.width / 2 - CARD_W / 2;
    } else if (roomAbove >= H) {
      cardTop = rect.top - GAP - H;
      cardLeft = rect.left + rect.width / 2 - CARD_W / 2;
    } else if (roomRight >= CARD_W) {
      cardLeft = rect.left + rect.width + GAP;
      cardTop = rect.top;
    } else if (roomLeft >= CARD_W) {
      cardLeft = rect.left - GAP - CARD_W;
      cardTop = rect.top;
    } else {
      // Nothing fits beside — drop it to whichever vertical side has more room,
      // accepting a clamp (still kept off the target by choosing that side).
      if (roomAbove >= roomBelow) {
        cardTop = Math.max(12, rect.top - GAP - H);
      } else {
        cardTop = rect.top + rect.height + GAP;
      }
      cardLeft = rect.left + rect.width / 2 - CARD_W / 2;
    }
    // Clamp to viewport (allow the full measured height).
    cardLeft = Math.max(12, Math.min(cardLeft, vw - CARD_W - 12));
    cardTop = Math.max(12, Math.min(cardTop, vh - H - 12));
  } else {
    centered = true;
  }

  const next = () => { if (isLast) onClose(); else if (canAdvance) setI(i + 1); };
  const back = () => { if (!isFirst) setI(i - 1); };

  // A no-target step sits at the BOTTOM (not dead-center) so the canvas + rail
  // above stay visible — the whole point of "Show me" is to watch the build
  // happen, so the card must not cover it.
  const cardStyle = centered
    ? { position: "fixed", left: "50%", bottom: 24,
        transform: "translate(-50%,0)", width: CARD_W, pointerEvents: "auto" }
    : { position: "fixed", left: cardLeft, top: cardTop, width: CARD_W, pointerEvents: "auto" };

  // Step counter shows X/Y only for the build/run steps (those with a
  // `category`); the centered concept cards (no category) are excluded.
  // Derived from the active step set so adding cards shifts the total automatically.
  const buildSteps = steps.filter(s => s.category);
  const buildTotal = buildSteps.length;
  const buildIdx = Math.min(
    Math.max(1, steps.slice(0, i + 1).filter(s => s.category).length),
    buildTotal,
  );

  return (
    // The wrapping layer is CLICK-THROUGH so the highlighted control underneath
    // receives real clicks; only the card (below) re-enables pointer events.
    <div style={{ position: "fixed", inset: 0, zIndex: 1100, pointerEvents: "none" }}>
      {/* Dimmed backdrop. With a target we cut a "hole" using a big box-shadow
          ring; without one the whole screen dims. Both are click-through so
          the user can operate the spotlighted control. */}
      {rect ? (
        <div
          style={{
            position: "fixed",
            top: rect.top - PAD, left: rect.left - PAD,
            width: rect.width + PAD * 2, height: rect.height + PAD * 2,
            borderRadius: 6, pointerEvents: "none",
            boxShadow: "0 0 0 9999px rgba(14,15,18,0.38)",
            border: `2px solid ${T.accent}`,
            transition: "all 180ms cubic-bezier(.2,.8,.2,1)",
          }}/>
      ) : (
        // No spotlight target: keep the screen mostly CLEAR (a faint wash, no
        // blur) so "Show me" changes — new objects, filled fields — are visible.
        <div style={{
          position: "fixed", inset: 0, background: "rgba(14,15,18,0.10)",
          pointerEvents: "none",
        }}/>
      )}

      {/* Tooltip card — the only part that captures clicks. */}
      <div data-tour="tour-card" ref={cardRef} style={{
        ...cardStyle,
        background: T.paper, color: T.ink,
        border: `1px solid ${T.paperEdge}`, borderRadius: 8,
        boxShadow: "0 18px 48px rgba(0,0,0,0.35)",
        padding: "16px 18px", boxSizing: "border-box",
      }}>
        <div style={{
          display: "flex", alignItems: "center", justifyContent: "space-between",
          marginBottom: 8,
        }}>
          <span style={{
            fontSize: 10, fontWeight: 700, color: T.accent,
            letterSpacing: "0.12em", textTransform: "uppercase",
            fontFamily: "ui-monospace, monospace",
          }}>
            {step.category
              ? `${step.category} · ${buildIdx}/${buildTotal}`
              : (isLast ? "Done" : "Tutorial")}
          </span>
          <button data-tour="tour-close" onClick={onClose} title="Skip tutorial" style={{
            background: "transparent", border: "none", color: T.inkFaint,
            cursor: "pointer", fontSize: 16, lineHeight: 1, padding: 0,
          }}>✕</button>
        </div>

        <div style={{
          fontFamily: SCREENPLAY_SERIF, fontSize: 17, fontWeight: 700,
          lineHeight: 1.3, marginBottom: 8, color: T.ink,
        }}>{step.title}</div>

        <div style={{
          fontSize: 13, lineHeight: 1.55, color: T.inkMuted, marginBottom: 12,
        }}>{step.body}</div>

        {/* Show-me is the way forward: for a step with a demo, the tour PERFORMS
            the step (builds it on screen) and auto-advances. "Next" is demoted to
            a small "skip" so nobody is trapped, but Show me is the primary path. */}
        {step.done && (
          <div data-tour="tour-hint" style={{
            fontSize: 11.5, fontWeight: 600, marginBottom: 14,
            color: isDone ? "#2f7a3f" : T.inkMuted,
          }}>
            {isDone
              ? <span><Ico path={ICO_CHECK} size={12} color="#2f7a3f"/> Done — moving on…</span>
              : (step.demo
                  ? "Click ✨ Show me — the tour builds this step on screen, then moves on."
                  : "Try it on the highlighted control to continue.")}
          </div>
        )}

        <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
          {!isFirst && (
            <button data-tour="tour-back" onClick={back} style={{
              padding: "5px 12px", fontSize: 12, fontWeight: 500,
              background: "transparent", color: T.inkMuted,
              border: `1px solid ${T.paperEdge}`, borderRadius: 4, cursor: "pointer",
            }}>← Back</button>
          )}
          <div style={{ flex: 1 }}/>
          {step.demo && !isDone ? (
            <>
              <button data-tour="tour-skip" onClick={next}
                title="Skip this step"
                style={{
                  padding: "5px 10px", fontSize: 11, fontWeight: 500,
                  background: "transparent", color: T.inkFaint,
                  border: "none", cursor: "pointer",
                }}>skip</button>
              <button data-tour="tour-demo"
                onClick={() => { try { step.demo(tourApi); } catch (e) {} }}
                title="The tour builds this step for you, then advances"
                style={{
                  padding: "6px 16px", fontSize: 12, fontWeight: 700,
                  background: T.accent, color: T.paper, border: "none",
                  borderRadius: 4, cursor: "pointer",
                }}>✨ Show me →</button>
            </>
          ) : (
            <button data-tour="tour-next" onClick={next}
              style={{
                padding: "6px 16px", fontSize: 12, fontWeight: 700,
                background: T.accent, color: T.paper, border: "none",
                borderRadius: 4, cursor: "pointer",
              }}>{isLast ? "Finish" : "Next →"}</button>
          )}
        </div>
      </div>
    </div>
  );
}

// WORLD PANEL — read-only "how the world is built". There is no world-state to
// configure: the world IS its objects (everything is an object — weather too).
// This shows the construction: each scene with the objects it contains, plus each
// object's knowledge at a glance (private lore · memory · attributes). It answers
// "a window to see how the world is constructed" without inventing a setting.
function WorldPanelModal({ entities = [], worldBook = [], components = [],
                           worldSettings = {}, onInspect, onClose }) {
  const KIND = { scene: { c: T.scene, t: "scene" }, agent: { c: T.agent, t: "being" },
                 object: { c: T.object, t: "object" }, action: { c: T.action, t: "action" } };
  const scenes = entities.filter(e => e.kind === "scene");
  const inScene = (sid) => entities.filter(e => (e.kind === "agent" || e.kind === "object") && e.placedIn === sid);
  const unplaced = entities.filter(e => (e.kind === "agent" || e.kind === "object") && !e.placedIn);
  const sharedLore = (worldBook || []).filter(e => (e.scope || "world") === "world").length;
  const privCount = (id) => (worldBook || []).filter(e => e.scope === "entity" && e.where === id).length;
  const attrCount = (id) => (components || []).filter(c => c.type === "attribute" && c.entity === id).length;
  const memCount = (e) => (Array.isArray(e.status?.memory) ? e.status.memory.length : 0);
  const Row = ({ e, indent }) => {
    const k = KIND[e.kind] || { c: T.inkFaint, t: e.kind };
    const badges = [];
    if (privCount(e.id)) badges.push(`${privCount(e.id)} private`);
    if (memCount(e)) badges.push(`${memCount(e)} memory`);
    if (attrCount(e.id)) badges.push(`${attrCount(e.id)} attr`);
    const line = (e.persona || e.appearance || e.note || "").trim();
    return (
      <div onClick={() => onInspect && onInspect(e.id)} style={{
        display: "flex", alignItems: "baseline", gap: 8, cursor: onInspect ? "pointer" : "default",
        padding: "5px 8px", paddingLeft: 8 + indent * 18, borderBottom: `1px dashed ${T.ruleSoft}` }}>
        <span style={{ fontSize: 8.5, fontWeight: 700, color: "#fff", background: k.c,
          borderRadius: 3, padding: "1px 5px", textTransform: "uppercase",
          letterSpacing: "0.04em", flexShrink: 0 }}>{k.t}</span>
        <span style={{ fontSize: 12.5, fontWeight: 600, color: T.ink, flexShrink: 0 }}>
          {e.name || "(unnamed)"}</span>
        {line && <span style={{ fontSize: 11, color: T.inkMuted, flex: 1, overflow: "hidden",
          textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{line}</span>}
        {badges.length > 0 && <span style={{ fontSize: 9.5, color: T.inkFaint,
          fontFamily: "ui-monospace, monospace", flexShrink: 0 }}>{badges.join(" · ")}</span>}
      </div>
    );
  };
  return (
    <div onClick={onClose} style={{ position: "fixed", inset: 0, zIndex: 1000,
      background: "rgba(10,12,20,0.55)", display: "grid", placeItems: "center" }}>
      <div onClick={(e) => e.stopPropagation()} style={{
        width: "min(720px, 94vw)", maxHeight: "min(680px, 88vh)",
        background: T.paper, color: T.ink, border: `1px solid ${T.rule}`, borderRadius: 6,
        display: "flex", flexDirection: "column", overflow: "hidden",
        boxShadow: "0 24px 60px rgba(0,0,0,0.4)", fontFamily: SCREENPLAY_SERIF }}>
        <div style={{ padding: "14px 20px", borderBottom: `1px solid ${T.ruleSoft}`,
          display: "flex", alignItems: "baseline", justifyContent: "space-between" }}>
          <div>
            <div style={{ fontSize: 17, fontWeight: 700 }}>The world, as built</div>
            <div style={{ fontSize: 11.5, color: T.inkMuted, marginTop: 2, fontFamily: "inherit" }}>
              everything is an object — this is the construction, not a setting · click any to inspect
            </div>
          </div>
          <button onClick={onClose} style={{ background: "transparent", border: "none",
            fontSize: 20, color: T.inkMuted, cursor: "pointer", padding: 0 }}>×</button>
        </div>
        <div style={{ padding: "8px 14px", borderBottom: `1px solid ${T.ruleSoft}`,
          background: T.paperSoft, fontSize: 11, color: T.inkMuted, fontFamily: "inherit" }}>
          Knowledge: <b>{sharedLore}</b> shared world-book {sharedLore === 1 ? "entry" : "entries"}
          {(worldSettings.world_rules || "").trim() ? " · World Rules set" : " · no World Rules yet"}
        </div>
        <div style={{ overflow: "auto", padding: "4px 0" }}>
          {scenes.length === 0 && unplaced.length === 0 && (
            <div style={{ padding: 18, color: T.inkFaint, fontSize: 12 }}>No objects yet.</div>
          )}
          {scenes.map(s => (
            <div key={s.id}>
              <Row e={s} indent={0}/>
              {inScene(s.id).map(e => <Row key={e.id} e={e} indent={1}/>)}
            </div>
          ))}
          {unplaced.length > 0 && (
            <div>
              <div style={{ padding: "6px 8px", fontSize: 10, fontWeight: 700, color: T.inkFaint,
                textTransform: "uppercase", letterSpacing: "0.08em" }}>Unplaced</div>
              {unplaced.map(e => <Row key={e.id} e={e} indent={1}/>)}
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

// The Learn library — each entry maps a scenario to the feature cluster it
// teaches. Walkthroughs auto-build step by step; starters open a prebuilt world
// to explore. Phase-D scenarios register their starter data via STARTER_PROJECTS.
// An ORDERED course (do them in order). Each carries its number `n`; walkthroughs
// auto-build with "Show me", starters open a prebuilt world to explore.
const LEARN_TUTORIALS = [
  { id: "bigidea", n: 1, kind: "walkthrough", title: "The big idea",
    teaches: "the model itself — everything is an Object · the Environment Engine is an LLM that reads each object's context and adjudicates · every object runs perceive → act · template (class) vs instance. Concepts only, no building." },
  { id: "milgram", n: 2, kind: "walkthrough", title: "Milgram · obedience",
    teaches: "the basics by building a famous study: templates→instances, private vs shared knowledge, an attribute, World Rules, the LLM adjudicator, run + read the log" },
  { id: "manage", n: 3, kind: "walkthrough", title: "Manage your work",
    teaches: "projects: New / Open / Recent · Save · Save As (fork) · Versions (snapshot + restore) · Export/Import .habitat · Clear canvas / Reset" },
  { id: "blind", n: 4, kind: "walkthrough", title: "The blind witness · perception",
    teaches: "perception lenses (line-of-sight, hearing range), per-character perception, information asymmetry" },
  { id: "predator", n: 5, kind: "starter", title: "Predator & prey · lifecycle",
    teaches: "spawn/despawn, custom actions, a drive (attribute + rate), cadence" },
  { id: "storm", n: 6, kind: "starter", title: "The storm · process agents",
    teaches: "a process-agent object (cadence), mid-action events, World Rules driving reaction" },
  { id: "lastslice", n: 7, kind: "starter", title: "The last slice · conflict",
    teaches: "conflict / one joint adjudication, the grab action" },
];

// Starter projects (Learn) — prebuilt worlds you Open + explore. Each is a
// project `data` snapshot {entities, templates, worldBook, worldSettings, ...}.
// Populated in STARTER_DATA below (Phase D scenarios).
const STARTER_PROJECTS = {};

// Relative "time ago" for Recent rows (recognition over recall — VSCode/Aseprite cue).
function relTimeAgo(ts) {
  const s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
  if (s < 60) return "just now";
  const m = Math.floor(s / 60); if (m < 60) return `${m}m ago`;
  const h = Math.floor(m / 60); if (h < 24) return `${h}h ago`;
  const d = Math.floor(h / 24); return d === 1 ? "yesterday" : `${d}d ago`;
}

// WELCOME / START screen — the entry point (VSCode/SolidWorks/Aseprite style):
// Recent projects · New · Open file · Learn. Reopenable from the Home button.
function WelcomeModal({ projects = {}, learn = [], onOpenProject, onNewProject,
                        onOpenFile, onStartWalkthrough, onOpenStarter, onPickWorld,
                        onDeleteProject, onRenameProject, onReset, onClose }) {
  const worlds = (typeof WORLD_CARDS !== "undefined" ? WORLD_CARDS : []);
  const recent = Object.values(projects)
    .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)).slice(0, 10);
  const col = { flex: 1, minWidth: 0, display: "flex", flexDirection: "column", gap: 6 };
  const hdr = { fontSize: 10, fontWeight: 700, color: T.inkMuted, letterSpacing: "0.1em",
    textTransform: "uppercase", marginBottom: 4, fontFamily: "ui-monospace, monospace" };
  const item = { textAlign: "left", padding: "8px 10px", border: `1px solid ${T.ruleSoft}`,
    background: T.paperSoft, borderRadius: 4, cursor: "pointer", color: T.ink, fontSize: 13,
    fontFamily: "inherit" };
  return (
    <div onClick={onClose} style={{ position: "fixed", inset: 0, zIndex: 1000,
      background: "rgba(10,12,20,0.6)", display: "grid", placeItems: "center" }}>
      <div onClick={(e) => e.stopPropagation()} style={{
        width: "min(940px, 95vw)", maxHeight: "min(680px, 90vh)",
        background: T.paper, color: T.ink, border: `1px solid ${T.rule}`, borderRadius: 8,
        display: "flex", flexDirection: "column", overflow: "hidden",
        boxShadow: "0 24px 70px rgba(0,0,0,0.45)", fontFamily: SCREENPLAY_SERIF }}>
        <div style={{ padding: "18px 24px", borderBottom: `1px solid ${T.ruleSoft}`,
          display: "flex", alignItems: "baseline", justifyContent: "space-between" }}>
          <div>
            <div style={{ fontSize: 22, fontWeight: 700 }}>Habitat</div>
            <div style={{ fontSize: 12.5, color: T.inkMuted, marginTop: 2, fontFamily: "inherit" }}>
              a language world model — open a project, start a new one, or learn by building
            </div>
          </div>
          <button onClick={onClose} title="Skip — go straight to the studio (Esc, or click outside)"
            style={{ background: "transparent", border: `1px solid ${T.rule}`, borderRadius: 4,
              fontSize: 12, fontWeight: 600, color: T.inkMuted, cursor: "pointer",
              padding: "5px 12px" }}>Skip ✕</button>
        </div>
        <div style={{ overflow: "auto" }}>
        <div style={{ display: "flex", gap: 22, padding: "18px 24px 10px" }}>
          {/* RECENT */}
          <div style={col}>
            <div style={hdr}>Recent projects</div>
            {recent.length === 0 && <div style={{ fontSize: 12, color: T.inkFaint }}>No projects yet.</div>}
            {recent.map(p => (
              <div key={p.id} style={{ ...item, display: "flex", alignItems: "center", gap: 4, padding: "6px 8px" }}>
                <button onClick={() => onOpenProject(p.id)}
                  style={{ flex: 1, minWidth: 0, textAlign: "left", background: "transparent",
                    border: "none", cursor: "pointer", color: T.ink, font: "inherit", padding: 0 }}>
                  <div style={{ fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.name || "Untitled"}</div>
                  <div style={{ fontSize: 10.5, color: T.inkFaint, fontFamily: "ui-monospace, monospace" }}>
                    {(p.data?.entities || []).length} objects
                    {(p.data?.templates ? ` · ${Object.values(p.data.templates).filter(t => t && !t.builtin).length} custom templates` : "")}
                    {p.updatedAt ? ` · ${relTimeAgo(p.updatedAt)}` : ""}
                  </div>
                </button>
                {onRenameProject && (
                  <button title="Rename project" onClick={(e) => { e.stopPropagation();
                    const n = window.prompt("Rename project:", p.name || ""); if (n && n.trim()) onRenameProject(p.id, n.trim()); }}
                    style={{ flexShrink: 0, background: "transparent", border: "none", cursor: "pointer",
                      color: T.inkMuted, fontSize: 12, padding: "2px 5px", lineHeight: 1 }}>✎</button>
                )}
                {onDeleteProject && (
                  <button title="Delete project" onClick={(e) => { e.stopPropagation();
                    if (window.confirm(`Delete project "${p.name || "Untitled"}"? This can't be undone.`)) onDeleteProject(p.id); }}
                    style={{ flexShrink: 0, background: "transparent", border: "none", cursor: "pointer",
                      color: T.danger || "#c14545", fontSize: 15, padding: "2px 5px", lineHeight: 1 }}>×</button>
                )}
              </div>
            ))}
          </div>
          {/* NEW / OPEN */}
          <div style={{ ...col, maxWidth: 220 }}>
            <div style={hdr}>Start</div>
            <button onClick={onNewProject} style={{ ...item, borderColor: T.accent, color: T.accent, fontWeight: 700 }}>
              + New project <span style={{ fontWeight: 400, color: T.inkMuted }}>(blank)</span>
            </button>
            <button onClick={onOpenFile} style={item}>↑ Open a .habitat file…</button>
            <div style={{ flex: 1 }}/>
            {onReset && (
              <button onClick={onReset}
                title="Delete ALL projects + saved data in this browser and start factory-fresh"
                style={{ ...item, marginTop: 12, borderColor: T.danger || "#c14545",
                  color: T.danger || "#c14545", fontSize: 12 }}>
                ⟲ Reset Habitat… <span style={{ color: T.inkFaint, fontWeight: 400 }}>(clears stale data)</span>
              </button>
            )}
          </div>
          {/* LEARN */}
          <div style={col}>
            <div style={hdr}>Learn — a course (start at 1, in order)</div>
            {learn.map(t => (
              <button key={t.id}
                onClick={() => t.kind === "walkthrough" ? onStartWalkthrough(t.id) : onOpenStarter(t.id)}
                style={{ ...item, display: "flex", gap: 10, alignItems: "flex-start" }}>
                <span style={{ flexShrink: 0, width: 22, height: 22, borderRadius: 11,
                  background: T.accent, color: T.paper, fontWeight: 800, fontSize: 12,
                  display: "grid", placeItems: "center", marginTop: 1,
                  fontFamily: "ui-monospace, monospace" }}>{t.n}</span>
                <div style={{ minWidth: 0 }}>
                  <div style={{ fontWeight: 600 }}>
                    {t.title}
                    <span style={{ fontSize: 9, marginLeft: 6, padding: "1px 5px", borderRadius: 3,
                      background: t.kind === "walkthrough" ? T.accent : T.ruleSoft,
                      color: t.kind === "walkthrough" ? T.paper : T.inkMuted,
                      textTransform: "uppercase", letterSpacing: "0.04em", fontWeight: 700 }}>
                      {t.kind === "walkthrough" ? "guided" : "starter"}</span>
                  </div>
                  <div style={{ fontSize: 10.5, color: T.inkFaint, lineHeight: 1.4 }}>{t.teaches}</div>
                </div>
              </button>
            ))}
          </div>
        </div>
        {/* DEMO WORLDS — merged in from the old "Pick a world" gallery */}
        {worlds.length > 0 && (
          <div style={{ padding: "2px 24px 22px" }}>
            <div style={hdr}>Demo worlds — watch a real LLM play one</div>
            <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(168px, 1fr))", gap: 12 }}>
              {worlds.map(c => (
                <button key={c.slug} onClick={() => onPickWorld && onPickWorld(c.slug)}
                  title={`Replay ${c.title}`}
                  style={{ position: "relative", overflow: "hidden", textAlign: "left", cursor: "pointer",
                    border: "none", borderRadius: 6, padding: 14, minHeight: 104,
                    background: c.ground, color: c.inkOn,
                    fontFamily: c.serif ? SCREENPLAY_SERIF : "inherit",
                    display: "flex", flexDirection: "column", justifyContent: "space-between",
                    boxShadow: "0 3px 10px rgba(0,0,0,0.16)", transition: "transform 0.15s ease" }}
                  onMouseEnter={(e) => e.currentTarget.style.transform = "translateY(-2px)"}
                  onMouseLeave={(e) => e.currentTarget.style.transform = "translateY(0)"}>
                  {c.grain && <div style={{ position: "absolute", inset: 0, background: c.grain, pointerEvents: "none" }}/>}
                  <div style={{ position: "relative" }}>
                    <div style={{ fontSize: 9.5, letterSpacing: "0.16em", textTransform: "uppercase",
                      color: c.accent, fontWeight: 700, marginBottom: 3, fontFamily: "ui-monospace, monospace" }}>{c.slug}</div>
                    <div style={{ fontSize: 17, fontWeight: c.serif ? 600 : 700, lineHeight: 1.12 }}>{c.title}</div>
                  </div>
                  <div style={{ position: "relative", display: "flex", alignItems: "flex-end", justifyContent: "space-between", gap: 6 }}>
                    <span style={{ fontSize: 10, lineHeight: 1.4, color: c.inkOn, opacity: 0.75, overflow: "hidden",
                      textOverflow: "ellipsis", display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical" }}>{c.tagline}</span>
                    <span style={{ flexShrink: 0, fontSize: 9, fontWeight: 700, color: c.accent2 || c.accent,
                      letterSpacing: "0.1em", textTransform: "uppercase", fontFamily: "ui-monospace, monospace" }}>▸ replay</span>
                  </div>
                </button>
              ))}
            </div>
          </div>
        )}
        </div>
      </div>
    </div>
  );
}

// Error boundary: a render-time crash (e.g. a malformed import that slips through)
// shows a recover screen instead of an unrecoverable blank page. Colors are hardcoded
// (paper) so the fallback never depends on app state.
class ErrorBoundary extends React.Component {
  constructor(props) { super(props); this.state = { error: null }; }
  static getDerivedStateFromError(error) { return { error }; }
  componentDidCatch() { /* React logs the component stack */ }
  render() {
    if (!this.state.error) return this.props.children;
    return (
      <div style={{ position: "fixed", inset: 0, display: "grid", placeItems: "center", padding: 24,
        background: "#efe8d6", color: "#1d2238", fontFamily: "ui-monospace, monospace" }}>
        <div style={{ maxWidth: 540, textAlign: "center", lineHeight: 1.6 }}>
          <div style={{ fontSize: 18, fontWeight: 700, marginBottom: 8 }}>Something went wrong</div>
          <div style={{ fontSize: 13, color: "#5a5a5a", marginBottom: 16 }}>
            The studio hit an unexpected error and stopped rendering. Your saved projects are safe — reload to recover.
          </div>
          <div style={{ fontSize: 11, color: "#8a8a8a", whiteSpace: "pre-wrap", textAlign: "left",
            maxHeight: 160, overflow: "auto", background: "rgba(0,0,0,0.06)", padding: 8, borderRadius: 4,
            marginBottom: 16 }}>
            {String((this.state.error && (this.state.error.stack || this.state.error.message)) || this.state.error)}
          </div>
          <button onClick={() => window.location.reload()}
            style={{ padding: "8px 18px", fontSize: 13, fontWeight: 600, cursor: "pointer",
              background: "#1d2238", color: "#efe8d6", border: "none", borderRadius: 4 }}>Reload</button>
        </div>
      </div>
    );
  }
}

ReactDOM.createRoot(document.getElementById("root")).render(
  <ErrorBoundary><App /></ErrorBoundary>
);
