#!/usr/bin/env python3
"""Local authoring UI for the Org published website."""

from __future__ import annotations

import json
import os
import posixpath
import re
import struct
import subprocess
import sys
import threading
import time
from dataclasses import dataclass, field
from datetime import datetime
from email.parser import BytesParser
from email.policy import default as email_default_policy
from email.utils import formatdate
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any, Callable
from urllib.parse import parse_qs, urlparse


def looks_like_content_root(path: Path) -> bool:
    return (path / "authoring_server.py").exists() and (
        (path / "blogs").exists()
        or (path / "posts").exists()
        or (path / "lima").exists()
    )


def resolve_root() -> Path:
    env_root = os.environ.get("AUTHOR_ROOT")
    if env_root:
        resolved = Path(env_root).expanduser().resolve()
        if looks_like_content_root(resolved):
            return resolved
    candidates = [
        Path.cwd(),
        Path(__file__).resolve().parent,
    ]
    workspace = os.environ.get("GITHUB_WORKSPACE")
    if workspace:
        candidates.insert(0, Path(workspace))
    for base in list(candidates):
        candidates.extend(base.parents)
    seen = set()
    for candidate in candidates:
        resolved = candidate.expanduser().resolve()
        if resolved in seen:
            continue
        seen.add(resolved)
        if looks_like_content_root(resolved):
            return resolved
    return Path(__file__).resolve().parent


ROOT = resolve_root()
BLOGS_DIR = ROOT / "blogs"
POSTS_DIR = ROOT / "posts"
LIMA_DIR = ROOT / "lima"
IMAGE_ASSETS_DIR = ROOT / "assets" / "images"
HZONE_ASSETS_DIR = IMAGE_ASSETS_DIR / "hzone"
HIDDEN_DETAILS_JS = ROOT / "assets" / "scripts" / "hidden-details.js"
HIDDEN_CONTENT_JSON = ROOT / "assets" / "content" / "hidden-details.json"
HIDDEN_BACKUP_DIR = ROOT / "backups" / "hidden-details"
EXCLUDED_CONTENT_DIR_NAMES = {
    ".agents",
    ".codex",
    ".git",
    ".packages",
    ".venv",
    "__pycache__",
    "assets",
    "backups",
    "output",
    "tags",
}
GENERATED_ORG_NAMES = {
    "blogs-list.org",
    "books-list.org",
    "posts-list.org",
    "career-list.org",
    "sitemap.org",
    "recently-updated.org",
    "wip.org",
}
GENERATED_CONTENT_NAMES = GENERATED_ORG_NAMES | {"lima-list.org"}
ALLOWED_UPLOAD_EXTENSIONS = {
    ".png",
    ".jpg",
    ".jpeg",
    ".gif",
    ".webp",
    ".svg",
}
MONTH_NAMES = [
    "january",
    "february",
    "march",
    "april",
    "may",
    "june",
    "july",
    "august",
    "september",
    "october",
    "november",
    "december",
]

HIDDEN_CONTENT_TYPES = [
    "quote",
    "poem",
    "hidden dialogue",
    "journal entry",
    "rare event",
    "loading screen message",
    "secret interaction",
    "hidden tooltip",
    "future z message",
    "young z memory fragment",
    "sensei chi wisdom entry",
    "aphy system message",
    "lima note/message",
    "dream sequence",
    "terminal log",
    "fake error message",
    "recurring joke",
    "seasonal event",
    "weather-based event",
    "hover message",
    "hidden achievement",
    "guestbook entry",
    "hidden conversation",
    "family layer",
    "search toast",
    "search route",
    "keyboard secret",
]

CHARACTER_REGISTRY = {
    "young z": {
        "id": "young z",
        "displayLabel": "young z",
        "aliases": ["Young Z", "young z", "young-z", "young_z", "young"],
        "territoryColor": "#d8a95a",
        "symbol": "Y",
        "motifs": ["crayon sun", "blanket cape", "childhood desk"],
        "themes": ["childhood", "play", "memory", "safety"],
        "affinities": ["z", "future z", "aphy", "lima"],
    },
    "z": {
        "id": "z",
        "displayLabel": "z",
        "aliases": ["Z", "z", "zaine"],
        "territoryColor": "#d6c38a",
        "symbol": "Z",
        "motifs": ["archive", "website", "home"],
        "themes": ["selfhood", "return", "making"],
        "affinities": ["lima", "young z", "future z"],
    },
    "aphy": {
        "id": "aphy",
        "displayLabel": "aphy",
        "aliases": ["Aphy", "aphy", "aphy_bot", "aphy bot"],
        "territoryColor": "#7fb089",
        "symbol": "A",
        "motifs": ["console", "diagnostic", "backup"],
        "themes": ["humor", "systems", "care through tools"],
        "affinities": ["z", "lima", "sensei chi"],
    },
    "lima": {
        "id": "lima",
        "displayLabel": "lima",
        "aliases": ["Lima", "lima"],
        "territoryColor": "#d06b78",
        "symbol": "L",
        "motifs": ["warmth", "kitchen light", "ring"],
        "themes": ["love", "home", "grounding"],
        "affinities": ["z", "aphy", "future z", "young z"],
    },
    "sensei chi": {
        "id": "sensei chi",
        "displayLabel": "sensei chi",
        "aliases": ["Sensei Chi", "sensei chi", "sensei-chi", "sensei_chi", "sensei"],
        "territoryColor": "#75a9bd",
        "symbol": "S",
        "motifs": ["tea", "garden", "quiet lesson"],
        "themes": ["reflection", "patience", "wisdom"],
        "affinities": ["aphy", "future z"],
    },
    "future z": {
        "id": "future z",
        "displayLabel": "future z",
        "aliases": ["Future Z", "future z", "future-z", "future_z", "future"],
        "territoryColor": "#a58ac9",
        "symbol": "F",
        "motifs": ["clock", "age 40", "future log"],
        "themes": ["time", "reassurance", "continuity"],
        "affinities": ["z", "young z", "lima", "sensei chi"],
    },
}
HIDDEN_CHARACTERS = list(CHARACTER_REGISTRY)
HIDDEN_TONES = ["warm", "funny", "nostalgic", "wise", "strange", "soft", "hopeful", "protective", "melancholy"]
HIDDEN_RARITIES = ["common", "uncommon", "rare", "very rare", "seasonal", "timed"]
HIDDEN_LAYER_DEPTHS = [
    {
        "id": "0",
        "name": "Surface Reality",
        "meaning": "Normal visible website content, visible warmth, and ordinary interactions.",
    },
    {
        "id": "1",
        "name": "Hidden Personality",
        "meaning": "Small hidden jokes, hover text, tiny discoveries, and recurring symbols.",
    },
    {
        "id": "2",
        "name": "Memory Layer",
        "meaning": "young z memories, lima notes, nostalgia, and emotional fragments.",
    },
    {
        "id": "3",
        "name": "Reflection Layer",
        "meaning": "sensei chi philosophy, aphy conversations, and introspection.",
    },
    {
        "id": "4",
        "name": "Time Layer",
        "meaning": "future z logs, time anomalies, long-term revisits, and future/past echoes.",
    },
    {
        "id": "5",
        "name": "Core Layer",
        "meaning": "Rare deeply emotional truths found by patient exploration.",
    },
]


def slugify(value: str) -> str:
    slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
    return slug or "untitled"


def normalise_tags(value: Any) -> list[str]:
    if isinstance(value, str):
        raw = re.split(r"[,:\s]+", value)
    elif isinstance(value, list):
        raw = [str(item) for item in value]
    else:
        raw = []
    tags = []
    for tag in raw:
        if not tag.strip():
            continue
        clean = slugify(tag)
        if clean and clean not in tags:
            tags.append(clean)
    return tags


def org_date(dt: datetime) -> str:
    return dt.strftime("<%Y-%m-%d %a %H:%M>")


def parse_org_datetime(value: str | None) -> datetime | None:
    if not value:
        return None
    match = re.search(r"<(\d{4})-(\d{2})-(\d{2})(?:\s+\w+)?(?:\s+(\d{2}):(\d{2}))?>", value)
    if not match:
        return None
    year, month, day, hour, minute = match.groups()
    return datetime(
        int(year),
        int(month),
        int(day),
        int(hour or 12),
        int(minute or 0),
    )


def html_escape(value: str) -> str:
    return (
        value.replace("&", "&amp;")
        .replace("<", "&lt;")
        .replace(">", "&gt;")
        .replace('"', "&quot;")
    )


@dataclass
class OrgPage:
    path: str
    page_type: str
    title: str
    slug: str
    tags: list[str]
    content: str
    date: str
    comments: bool
    options: str
    wip: str | None = None


@dataclass
class ContentPage:
    path: str
    page_type: str
    title: str
    slug: str
    tags: list[str]
    content: str
    date: str
    comments: bool
    options: str
    format: str
    wip: str | None = None


@dataclass
class BuildJob:
    id: int
    path: str
    title: str
    queued_at: float = field(default_factory=time.time)
    started_at: float | None = None
    finished_at: float | None = None
    ok: bool | None = None
    message: str = "Queued"
    log: str = ""

    @property
    def status(self) -> str:
        if self.finished_at is not None:
            return "done" if self.ok else "failed"
        if self.started_at is not None:
            return "running"
        return "queued"

    def to_dict(self, include_log: bool = False) -> dict[str, Any]:
        data = {
            "id": self.id,
            "path": self.path,
            "title": self.title,
            "queuedAt": self.queued_at,
            "startedAt": self.started_at,
            "finishedAt": self.finished_at,
            "ok": self.ok,
            "status": self.status,
            "message": self.message,
        }
        if include_log:
            data["log"] = self.log[-12000:]
        return data


class BuildQueue:
    def __init__(self) -> None:
        self._lock = threading.Lock()
        self._next_id = 1
        self._pending: list[BuildJob] = []
        self._current: BuildJob | None = None
        self._recent: list[BuildJob] = []
        self._worker: threading.Thread | None = None

    def snapshot(self) -> dict[str, Any]:
        with self._lock:
            current = self._current.to_dict(include_log=True) if self._current else None
            recent = [job.to_dict(include_log=True) for job in self._recent[-10:]]
            pending = [job.to_dict() for job in self._pending]
            latest = current or (recent[-1] if recent else None)
            message = latest["message"] if latest else "No builds have run yet."
            return {
                "running": current is not None,
                "queued": len(pending),
                "message": message,
                "current": current,
                "pending": pending,
                "recent": recent,
                "log": latest.get("log", "") if latest else "",
            }

    def enqueue(self, path: str, title: str) -> BuildJob:
        with self._lock:
            job = BuildJob(self._next_id, path, title)
            self._next_id += 1
            self._pending.append(job)
            if self._worker is None or not self._worker.is_alive():
                self._worker = threading.Thread(target=self._run_worker, daemon=True)
                self._worker.start()
            return job

    def _run_worker(self) -> None:
        while True:
            with self._lock:
                if not self._pending:
                    self._current = None
                    return
                job = self._pending.pop(0)
                job.started_at = time.time()
                job.message = "Publishing site and search index."
                self._current = job
            def append_log(chunk: str) -> None:
                with self._lock:
                    job.log = (job.log + chunk)[-200000:]

            ok, message, log = run_build_commands(append_log)
            with self._lock:
                job.finished_at = time.time()
                job.ok = ok
                job.message = message
                job.log = log[-200000:]
                self._recent.append(job)
                self._recent = self._recent[-20:]
                self._current = None


BUILD_QUEUE = BuildQueue()


def safe_relative_path(path: str) -> Path:
    rel = Path(path)
    if rel.is_absolute() or ".." in rel.parts:
        raise ValueError("Path must stay inside this repository.")
    full = (ROOT / rel).resolve()
    if not full.is_relative_to(ROOT):
        raise ValueError("Path must stay inside this repository.")
    if full.name in GENERATED_CONTENT_NAMES or "sync-conflict" in full.name:
        raise ValueError("Generated and sync-conflict files are not editable here.")
    rel_parts = full.relative_to(ROOT).parts
    if any(part in EXCLUDED_CONTENT_DIR_NAMES for part in rel_parts):
        raise ValueError("This path is outside the editable content folders.")
    if full.suffix == ".org":
        return full
    if full.suffix == ".md" and full.is_relative_to(LIMA_DIR):
        return full
    raise ValueError("Only .org content files and .md files under lima can be edited here.")


def safe_target_path(path: str, slug: str, page_type: str) -> Path:
    candidate = path.strip()
    if not candidate:
        raise ValueError("Path is required.")
    default_ext = ".md" if page_type == "lima" else ".org"
    if candidate.endswith("/"):
        candidate = f"{candidate}{slug}{default_ext}"
    elif not Path(candidate).suffix:
        candidate = f"{candidate}{default_ext}"
    return safe_relative_path(candidate)


def markdown_title(content: str, fallback: str) -> str:
    for line in content.splitlines():
        match = re.match(r"^#{1,6}\s+(.+?)\s*$", line)
        if match:
            return match.group(1).strip()
    return fallback.replace("-", " ").replace("_", " ").title()


def read_markdown_page(path: Path) -> ContentPage:
    content = path.read_text(encoding="utf-8")
    rel = path.relative_to(ROOT).as_posix()
    title = markdown_title(content, path.stem)
    return ContentPage(
        path=rel,
        page_type="lima",
        title=title,
        slug=path.stem,
        tags=[],
        content=content,
        date="",
        comments=True,
        options="",
        format="markdown",
    )


def read_page(path: Path) -> ContentPage:
    if path.suffix == ".md" and path.is_relative_to(LIMA_DIR):
        return read_markdown_page(path)
    text = path.read_text(encoding="utf-8")
    meta: dict[str, str] = {}
    body_lines: list[str] = []
    in_header = True
    for line in text.splitlines():
        if in_header and line.startswith("#+"):
            key, _, value = line[2:].partition(":")
            meta[key.strip().upper()] = value.strip()
        else:
            in_header = False
            body_lines.append(line)
    rel = path.relative_to(ROOT).as_posix()
    if path.is_relative_to(BLOGS_DIR):
        page_type = "blog"
    elif path.is_relative_to(POSTS_DIR):
        page_type = "post"
    else:
        page_type = "page"
    slug = meta.get("SLUG") or path.stem
    tags = normalise_tags(meta.get("FILETAGS", ""))
    return ContentPage(
        path=rel,
        page_type=page_type,
        title=meta.get("TITLE", path.stem),
        slug=slug,
        tags=tags,
        content="\n".join(body_lines).lstrip("\n"),
        date=meta.get("DATE", org_date(datetime.fromtimestamp(path.stat().st_mtime))),
        comments=meta.get("COMMENTS", "t").lower() == "t",
        options=meta.get("OPTIONS", "num:nil"),
        format="org",
        wip=meta.get("WIP"),
    )


def page_to_dict(page: ContentPage) -> dict[str, Any]:
    return {
        "path": page.path,
        "pageType": page.page_type,
        "title": page.title,
        "slug": page.slug,
        "tags": page.tags,
        "content": page.content,
        "date": page.date,
        "comments": page.comments,
        "options": page.options,
        "format": page.format,
        "wip": page.wip or "",
    }


def server_diagnostics() -> dict[str, Any]:
    pages = list_pages()
    try:
        cwd = Path.cwd().as_posix()
    except OSError as exc:
        cwd = f"<unavailable: {exc}>"
    return {
        "root": ROOT.as_posix(),
        "cwd": cwd,
        "executable": sys.executable,
        "pid": os.getpid(),
        "pageCount": len(pages),
        "firstPage": pages[0]["path"] if pages else "",
    }


def list_pages() -> list[dict[str, Any]]:
    pages = []
    org_paths = []
    if ROOT.exists():
        try:
            for path in ROOT.rglob("*.org"):
                try:
                    rel_parts = path.relative_to(ROOT).parts
                except ValueError:
                    continue
                if any(part in EXCLUDED_CONTENT_DIR_NAMES for part in rel_parts):
                    continue
                org_paths.append(path)
        except OSError:
            org_paths = []
    try:
        md_paths = list(LIMA_DIR.rglob("*.md")) if LIMA_DIR.exists() else []
    except OSError:
        md_paths = []
    for path in sorted(org_paths + md_paths):
        if path.name in GENERATED_CONTENT_NAMES or "sync-conflict" in path.name:
            continue
        try:
            page = read_page(path)
            parsed = parse_org_datetime(page.date)
            timestamp = parsed.timestamp() if parsed else path.stat().st_mtime
        except (OSError, UnicodeDecodeError, ValueError):
            continue
        pages.append(
            {
                "path": page.path,
                "pageType": page.page_type,
                "title": page.title,
                "slug": page.slug,
                "tags": page.tags,
                "date": page.date,
                "format": page.format,
                "timestamp": timestamp,
            }
        )
    return sorted(pages, key=lambda item: item["timestamp"], reverse=True)


def target_path(data: dict[str, Any], existing_path: str | None) -> Path:
    if existing_path:
        return safe_relative_path(existing_path)
    title = str(data.get("title") or "").strip()
    slug = slugify(str(data.get("slug") or title))
    page_type = str(data.get("pageType") or "blog")
    explicit_path = str(data.get("targetPath") or "").strip()
    if explicit_path:
        return safe_target_path(explicit_path, slug, page_type)
    if page_type == "blog":
        dt = parse_org_datetime(str(data.get("date") or "")) or datetime.now()
        folder = BLOGS_DIR / str(dt.year) / f"{dt.month:02d}-{MONTH_NAMES[dt.month - 1]}"
        return folder / f"{slug}.org"
    if page_type == "post":
        raw_section = str(data.get("section") or "").strip()
        section = slugify(raw_section) if raw_section else ""
        folder = POSTS_DIR / section if section else POSTS_DIR
        return folder / f"{slug}.org"
    if page_type == "lima":
        return LIMA_DIR / f"{slug}.md"
    if page_type == "page":
        return ROOT / f"{slug}.org"
    raise ValueError("pageType must be blog, post, page, or lima.")


def render_markdown(data: dict[str, Any]) -> str:
    content = str(data.get("content") or "").replace("\r\n", "\n").strip()
    title = str(data.get("title") or "").strip()
    if not title:
        raise ValueError("Title is required.")
    content = re.sub(
        r'<a\b[^>]*>\s*<img\b[^>]*\bsrc="([^"]+)"[^>]*\balt="([^"]*)"[^>]*>\s*</a>',
        lambda match: f"![{match.group(2)}]({match.group(1)})",
        content,
        flags=re.IGNORECASE,
    )
    if content:
        if re.search(r"^#{1,6}[^\S\r\n]+.+?[^\S\r\n]*$", content, flags=re.MULTILINE):
            content = re.sub(
                r"^#{1,6}[^\S\r\n]+.+?[^\S\r\n]*$",
                f"# {title}",
                content,
                count=1,
                flags=re.MULTILINE,
            )
        else:
            content = f"# {title}\n\n{content}"
        return content + "\n"
    return f"# {title}\n"


def render_org(data: dict[str, Any], previous: ContentPage | None) -> str:
    title = str(data.get("title") or "").strip()
    if not title:
        raise ValueError("Title is required.")
    slug = slugify(str(data.get("slug") or title))
    tags = normalise_tags(data.get("tags", []))
    content = str(data.get("content") or "").replace("\r\n", "\n").strip()
    date = str(data.get("date") or "").strip()
    if not parse_org_datetime(date):
        date = previous.date if previous else org_date(datetime.now())
    options = str(data.get("options") or (previous.options if previous else "num:nil")).strip()
    comments = bool(data.get("comments", True))
    lines = [
        f"#+TITLE: {title}",
        f"#+OPTIONS: {options}",
        f"#+DATE: {date}",
        f"#+filetags: {''.join(f':{tag}' for tag in tags)}:",
    ]
    wip = str(data.get("wip") or (previous.wip if previous else "") or "").strip()
    if wip:
        lines.append(f"#+WIP: {wip}")
    lines.extend(
        [
            f"#+COMMENTS: {'t' if comments else ''}",
            f"#+SLUG: {slug}",
            "",
            content,
            "",
        ]
    )
    return "\n".join(lines)


def save_page(data: dict[str, Any]) -> dict[str, Any]:
    existing_path = data.get("path") or None
    target = target_path(data, str(existing_path) if existing_path else None)
    previous = read_page(target) if target.exists() else None
    if not target.parent.exists():
        target.parent.mkdir(parents=True)
    if target.exists() and not existing_path:
        raise ValueError(f"{target.relative_to(ROOT)} already exists.")
    if target.suffix == ".md":
        target.write_text(render_markdown(data), encoding="utf-8")
    else:
        target.write_text(render_org(data, previous), encoding="utf-8")
    return page_to_dict(read_page(target))


def image_dimensions(payload: bytes, ext: str) -> tuple[int, int] | None:
    if ext == ".png" and payload.startswith(b"\x89PNG\r\n\x1a\n") and len(payload) >= 24:
        width, height = struct.unpack(">II", payload[16:24])
        return width, height
    if ext == ".gif" and payload[:6] in {b"GIF87a", b"GIF89a"} and len(payload) >= 10:
        width, height = struct.unpack("<HH", payload[6:10])
        return width, height
    if ext in {".jpg", ".jpeg"} and payload.startswith(b"\xff\xd8"):
        i = 2
        while i + 9 < len(payload):
            if payload[i] != 0xFF:
                i += 1
                continue
            marker = payload[i + 1]
            i += 2
            if marker in {0xD8, 0xD9}:
                continue
            if i + 2 > len(payload):
                break
            size = int.from_bytes(payload[i:i + 2], "big")
            if size < 2:
                break
            if marker in {0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF}:
                if i + 7 <= len(payload):
                    height = int.from_bytes(payload[i + 3:i + 5], "big")
                    width = int.from_bytes(payload[i + 5:i + 7], "big")
                    return width, height
                break
            i += size
    return None


def relative_asset_path(page_path: str, asset_path: str) -> str:
    page = safe_relative_path(page_path) if page_path else ROOT / "index.org"
    page_rel = page.relative_to(ROOT).as_posix()
    page_output_dir = posixpath.dirname(page_rel)
    return posixpath.relpath(asset_path, page_output_dir or ".")


def gallery_image_html(filename: str, asset_path: str, page_path: str, payload: bytes, ext: str) -> str:
    absolute_url = f"https://zainezq.com/{asset_path}"
    relative_url = relative_asset_path(page_path, asset_path)
    dims = image_dimensions(payload, ext)
    width, height = dims if dims else (1920, 1080)
    alt = html_escape(filename)
    return (
        f'<a href="{absolute_url}" data-img="{absolute_url}" data-alt="{alt}" '
        f'data-width="{width}" data-height="{height}">'
        f'<img src="{relative_url}" alt="{alt}" style="cursor: zoom-in;"></a>'
    )


def attachment_image_dir(page_path: str) -> Path:
    page = safe_relative_path(page_path) if page_path else ROOT / "index.org"
    if page.suffix == ".md" and page.is_relative_to(LIMA_DIR):
        return HZONE_ASSETS_DIR
    if page.is_relative_to(POSTS_DIR):
        rel = page.relative_to(POSTS_DIR)
        section = rel.parts[0] if len(rel.parts) > 1 else "posts"
        return IMAGE_ASSETS_DIR / slugify(section)
    if page.is_relative_to(BLOGS_DIR):
        return IMAGE_ASSETS_DIR / "blogs"
    rel = page.relative_to(ROOT)
    if len(rel.parts) > 1:
        return IMAGE_ASSETS_DIR / slugify(rel.parts[0])
    return IMAGE_ASSETS_DIR / "pages"


def save_upload(filename: str, payload: bytes, page_path: str = "") -> dict[str, str]:
    original = Path(filename or "attachment").name
    ext = Path(original).suffix.lower()
    if ext not in ALLOWED_UPLOAD_EXTENSIONS:
        raise ValueError("Only common image files can be uploaded.")
    now = datetime.now()
    target_dir = attachment_image_dir(page_path)
    target_dir.mkdir(parents=True, exist_ok=True)
    stem = slugify(Path(original).stem)
    prefix = "" if re.match(r"^\d{4}-\d{2}-\d{2}-", stem) else f"{now.strftime('%Y-%m-%d')}-"
    target = target_dir / f"{prefix}{stem}{ext}"
    counter = 2
    while target.exists():
        target = target_dir / f"{prefix}{stem}-{counter}{ext}"
        counter += 1
    target.write_bytes(payload)
    rel = target.relative_to(ROOT).as_posix()
    absolute_url = f"https://zainezq.com/{rel}"
    relative_url = relative_asset_path(page_path, rel)
    is_markdown = page_path.endswith(".md")
    insert_text = f"![{target.name}]({absolute_url})" if is_markdown else f"[[{relative_url}]]"
    return {
        "url": absolute_url,
        "relativeUrl": relative_url,
        "path": rel,
        "markdown": insert_text,
        "insertText": insert_text,
        "filename": target.name,
    }


def find_js_const_literal(source: str, name: str) -> str:
    marker = f"const {name} ="
    start = source.find(marker)
    if start == -1:
        raise ValueError(f"Could not find const {name}.")
    value_start = source.find("=", start) + 1
    while value_start < len(source) and source[value_start].isspace():
        value_start += 1
    opener = source[value_start]
    pairs = {"[": "]", "{": "}"}
    if opener not in pairs:
        raise ValueError(f"const {name} is not an array or object.")
    closer = pairs[opener]
    depth = 0
    in_string = False
    escape = False
    for index in range(value_start, len(source)):
        char = source[index]
        if in_string:
            if escape:
                escape = False
            elif char == "\\":
                escape = True
            elif char == '"':
                in_string = False
            continue
        if char == '"':
            in_string = True
        elif char == opener:
            depth += 1
        elif char == closer:
            depth -= 1
            if depth == 0:
                return source[value_start:index + 1]
    raise ValueError(f"Could not parse const {name}.")


def js_literal_to_json(value: str) -> Any:
    cleaned = re.sub(r"//.*", "", value)
    cleaned = re.sub(r"(/\*.*?\*/)", "", cleaned, flags=re.DOTALL)
    cleaned = re.sub(r"([{\[,]\s*)([A-Za-z_$][\w$]*)\s*:", r'\1"\2":', cleaned)
    cleaned = re.sub(r",(\s*[\]}])", r"\1", cleaned)
    return json.loads(cleaned)


def character_alias_lookup() -> dict[str, str]:
    aliases = {}
    for canonical, config in CHARACTER_REGISTRY.items():
        aliases[canonical] = canonical
        for alias in config["aliases"]:
            aliases[slugify(str(alias)).replace("-", " ")] = canonical
            aliases[str(alias).strip().lower()] = canonical
    return aliases


def normalize_character_name(value: Any) -> str | None:
    raw = str(value or "").strip()
    if not raw:
        return None
    simplified = slugify(raw).replace("-", " ")
    return character_alias_lookup().get(raw.lower()) or character_alias_lookup().get(simplified)


def normalize_character_list(values: Any) -> list[str]:
    if isinstance(values, str):
        raw_values = re.split(r"[,/]", values)
    elif isinstance(values, list):
        raw_values = values
    else:
        raw_values = []
    normalized = []
    for item in raw_values:
        canonical = normalize_character_name(item)
        if canonical and canonical not in normalized:
            normalized.append(canonical)
    return normalized


def normalize_character_text_refs(value: Any) -> Any:
    if isinstance(value, list):
        return [normalize_character_text_refs(item) for item in value]
    if isinstance(value, dict):
        return {key: normalize_character_text_refs(item) for key, item in value.items()}
    if not isinstance(value, str):
        return value
    text = value
    replacements = []
    for canonical, config in CHARACTER_REGISTRY.items():
        for alias in config["aliases"]:
            if alias == canonical:
                continue
            if alias.lower() in {"young", "future", "sensei"}:
                continue
            replacements.append((alias, canonical))
    replacements.sort(key=lambda item: len(item[0]), reverse=True)
    for alias, canonical in replacements:
        pattern = r"(?<![A-Za-z0-9_-])" + re.escape(alias) + r"(?![A-Za-z0-9_-])"
        text = re.sub(pattern, canonical, text, flags=re.IGNORECASE)
    return text


def detect_hidden_characters(text: str) -> list[str]:
    found = []
    normalized_text = normalize_character_text_refs(text).lower()
    for character in HIDDEN_CHARACTERS:
        if re.search(r"(?<![A-Za-z0-9_-])" + re.escape(character) + r"(?![A-Za-z0-9_-])", normalized_text):
            found.append(character)
    return found


def hidden_entry_id(content_type: str, index: int, title: str) -> str:
    return f"{slugify(content_type)}-{index + 1:03d}-{slugify(title)[:42]}"


def hidden_title(content_type: str, content: str, index: int) -> str:
    text = re.sub(r"\s+", " ", content).strip()
    if not text:
        return f"{content_type.title()} {index + 1}"
    return text[:58] + ("..." if len(text) > 58 else "")


def make_hidden_entry(content_type: str, content: str, index: int, **extra: Any) -> dict[str, Any]:
    now_iso = datetime.now().date().isoformat()
    title = str(extra.pop("title", "") or hidden_title(content_type, content, index))
    characters = extra.pop("characters", None) or detect_hidden_characters(f"{title} {content}")
    tags = extra.pop("tags", None) or [slugify(character) for character in characters]
    entry = {
        "id": hidden_entry_id(content_type, index, title),
        "type": content_type,
        "title": title,
        "content": content,
        "characters": characters,
        "emotionalTone": extra.pop("emotionalTone", "warm"),
        "rarity": extra.pop("rarity", "common"),
        "triggerConditions": extra.pop("triggerConditions", ""),
        "tags": tags,
        "category": extra.pop("category", content_type),
        "pageLocation": extra.pop("pageLocation", ""),
        "familyLayer": extra.pop("familyLayer", ""),
        "enabled": extra.pop("enabled", True),
        "createdDate": extra.pop("createdDate", now_iso),
        "modifiedDate": extra.pop("modifiedDate", now_iso),
        "notes": extra.pop("notes", ""),
        "audioSettings": extra.pop("audioSettings", ""),
        "animationTrigger": extra.pop("animationTrigger", ""),
        "cssClassHooks": extra.pop("cssClassHooks", ""),
        "chainReferences": extra.pop("chainReferences", []),
        "continuationLinks": extra.pop("continuationLinks", []),
        "emotionalRole": extra.pop("emotionalRole", ""),
        "discoveryDifficulty": extra.pop("discoveryDifficulty", "gentle"),
        "mysteryLevel": extra.pop("mysteryLevel", "quiet"),
        "resonanceScore": extra.pop("resonanceScore", 3),
        "symbols": extra.pop("symbols", []),
        "narrativeArcs": extra.pop("narrativeArcs", []),
        "parentLinks": extra.pop("parentLinks", []),
        "childLinks": extra.pop("childLinks", []),
        "echoes": extra.pop("echoes", []),
        "mirroredEntries": extra.pop("mirroredEntries", []),
        "thematicLinks": extra.pop("thematicLinks", []),
        "symbolicLinks": extra.pop("symbolicLinks", []),
        "triggerLinks": extra.pop("triggerLinks", []),
    }
    entry.update(extra)
    return entry


def migrate_hidden_entries_from_js() -> list[dict[str, Any]]:
    source = HIDDEN_DETAILS_JS.read_text(encoding="utf-8")
    family_layers = js_literal_to_json(find_js_const_literal(source, "familyLayers"))
    details = js_literal_to_json(find_js_const_literal(source, "details"))
    poems = js_literal_to_json(find_js_const_literal(source, "poems"))
    greetings = js_literal_to_json(find_js_const_literal(source, "greetings"))
    night_messages = js_literal_to_json(find_js_const_literal(source, "nightMessages"))
    lore = js_literal_to_json(find_js_const_literal(source, "lore"))
    search_toasts = js_literal_to_json(find_js_const_literal(source, "SEARCH_TOASTS"))
    search_routes = js_literal_to_json(find_js_const_literal(source, "SEARCH_ROUTES"))
    keyboard_secrets = js_literal_to_json(find_js_const_literal(source, "KEYBOARD_SECRETS"))
    long_keyboard_secrets = js_literal_to_json(find_js_const_literal(source, "LONG_KEYBOARD_SECRETS"))

    entries: list[dict[str, Any]] = []
    add = entries.append
    for index, item in enumerate(family_layers):
        add(make_hidden_entry("family layer", item, index, familyLayer=str(index), category="Family Layer Index"))
    for index, item in enumerate(details):
        add(make_hidden_entry("hidden tooltip", item, index, category="footer and whispers"))
    for index, item in enumerate(poems):
        add(make_hidden_entry("poem", item, index, category="poems", emotionalTone="soft"))
    for index, item in enumerate(greetings):
        add(make_hidden_entry("loading screen message", item, index, category="homepage greeting"))
    for index, item in enumerate(night_messages):
        add(make_hidden_entry("rare event", item, index, category="late night", rarity="timed", triggerConditions="hour >= 22 or hour < 5"))
    for key, content_type in [
        ("quotes", "quote"),
        ("journals", "journal entry"),
        ("warnings", "fake error message"),
        ("dreams", "dream sequence"),
        ("cassettes", "terminal log"),
        ("fakeUsers", "guestbook entry"),
        ("homepageTakeovers", "rare event"),
    ]:
        for index, item in enumerate(lore.get(key, [])):
            add(make_hidden_entry(content_type, item, index, category=key, rarity="rare" if key in {"warnings", "dreams", "homepageTakeovers"} else "common"))
    for index, item in enumerate(lore.get("conversations", [])):
        title = " / ".join(str(part) for part in item[::2]) or f"Conversation {index + 1}"
        add(make_hidden_entry("hidden conversation", "\n".join(str(part) for part in item), index, title=title, category="conversations", dialogue=item))
    for index, (season, item) in enumerate((lore.get("seasonal", {}) or {}).items()):
        add(make_hidden_entry("seasonal event", item, index, title=f"{season.title()} note", category="seasonal", rarity="seasonal", triggerConditions=f"season is {season}", season=season))
    for index, item in enumerate(lore.get("roomLinks", [])):
        href, label = item
        add(make_hidden_entry("secret interaction", label, index, title=label, category="room links", pageLocation=href, triggerConditions="secret link injected into page"))
    for index, (query, message) in enumerate(search_toasts.items()):
        add(make_hidden_entry("search toast", message, index, title=f"Search: {query}", category="search", triggerConditions=query, query=query))
    for index, (query, route) in enumerate(search_routes.items()):
        add(make_hidden_entry("search route", route, index, title=f"Route: {query}", category="search", pageLocation=route, triggerConditions=query, query=query))
    for index, item in enumerate(keyboard_secrets + long_keyboard_secrets):
        content = item.get("message") or item.get("route") or ("play tiny aphy song" if item.get("song") else "")
        add(make_hidden_entry("keyboard secret", content, index, title=f"Keyboard: {item.get('phrase')}", category="keyboard", pageLocation=item.get("route", ""), triggerConditions=item.get("phrase", ""), keyboard=item))
    return entries


def normalize_hidden_entry(raw: dict[str, Any], existing: dict[str, Any] | None = None) -> dict[str, Any]:
    today = datetime.now().date().isoformat()
    entry = existing.copy() if existing else {}
    entry.update(raw)
    title = str(normalize_character_text_refs(entry.get("title") or "")).strip()
    content = str(normalize_character_text_refs(entry.get("content") or "")).replace("\r\n", "\n")
    content_type = str(entry.get("type") or "quote").strip()
    content_type = str(normalize_character_text_refs(content_type))
    if content_type not in HIDDEN_CONTENT_TYPES:
        raise ValueError(f"Unsupported hidden content type: {content_type}")
    if not title:
        raise ValueError("Every hidden entry needs a title.")
    if not content and content_type not in {"search route"}:
        raise ValueError(f"{title} needs content.")
    entry["id"] = slugify(str(entry.get("id") or title))
    entry["title"] = title
    entry["type"] = content_type
    entry["content"] = content
    detected_characters = detect_hidden_characters(" ".join([
        title,
        content,
        str(entry.get("triggerConditions") or ""),
        str(entry.get("notes") or ""),
        str(entry.get("category") or ""),
    ]))
    entry["characters"] = normalize_character_list(entry.get("characters", []))
    for character in detected_characters:
        if character not in entry["characters"]:
            entry["characters"].append(character)
    if not entry["characters"]:
        entry["characters"] = ["z"]
    entry["emotionalTone"] = str(entry.get("emotionalTone") or "warm")
    entry["rarity"] = str(entry.get("rarity") or "common")
    entry["triggerConditions"] = str(normalize_character_text_refs(entry.get("triggerConditions") or ""))
    entry["tags"] = normalise_tags(entry.get("tags", []))
    entry["category"] = str(normalize_character_text_refs(entry.get("category") or content_type))
    entry["pageLocation"] = str(entry.get("pageLocation") or "")
    entry["familyLayer"] = str(entry.get("familyLayer") or "")
    entry["enabled"] = bool(entry.get("enabled", True))
    entry["createdDate"] = str(entry.get("createdDate") or today)
    entry["modifiedDate"] = today
    entry["notes"] = str(normalize_character_text_refs(entry.get("notes") or ""))
    entry["audioSettings"] = str(entry.get("audioSettings") or "")
    entry["animationTrigger"] = str(entry.get("animationTrigger") or "")
    entry["cssClassHooks"] = str(entry.get("cssClassHooks") or "")
    entry["chainReferences"] = [str(item).strip() for item in entry.get("chainReferences", []) if str(item).strip()]
    entry["continuationLinks"] = [str(item).strip() for item in entry.get("continuationLinks", []) if str(item).strip()]
    entry["emotionalRole"] = str(entry.get("emotionalRole") or "")
    entry["discoveryDifficulty"] = str(entry.get("discoveryDifficulty") or "gentle")
    entry["mysteryLevel"] = str(entry.get("mysteryLevel") or "quiet")
    try:
        entry["resonanceScore"] = max(1, min(10, int(entry.get("resonanceScore") or 3)))
    except (TypeError, ValueError):
        entry["resonanceScore"] = 3
    for key in [
        "symbols",
        "narrativeArcs",
        "parentLinks",
        "childLinks",
        "echoes",
        "mirroredEntries",
        "thematicLinks",
        "symbolicLinks",
        "triggerLinks",
    ]:
        entry[key] = [str(normalize_character_text_refs(item)).strip() for item in entry.get(key, []) if str(item).strip()]
    for key in ["dialogue", "keyboard"]:
        if key in entry:
            entry[key] = normalize_character_text_refs(entry[key])
    return entry


def load_hidden_store() -> dict[str, Any]:
    migrated = False
    if HIDDEN_CONTENT_JSON.exists():
        data = json.loads(HIDDEN_CONTENT_JSON.read_text(encoding="utf-8"))
        entries = data.get("entries", [])
    else:
        entries = migrate_hidden_entries_from_js()
        migrated = True
        data = {
            "schemaVersion": 1,
            "generatedFrom": "assets/scripts/hidden-details.js",
            "generatedAt": datetime.now().isoformat(timespec="seconds"),
            "entries": entries,
        }
    normalized = [normalize_hidden_entry(entry, entry) for entry in entries]
    return {
        "schemaVersion": 1,
        "source": HIDDEN_DETAILS_JS.relative_to(ROOT).as_posix(),
        "contentPath": HIDDEN_CONTENT_JSON.relative_to(ROOT).as_posix(),
        "migratedFromJs": migrated,
        "types": HIDDEN_CONTENT_TYPES,
        "characters": HIDDEN_CHARACTERS,
        "characterRegistry": CHARACTER_REGISTRY,
        "validation": validate_hidden_integrity(normalized),
        "tones": HIDDEN_TONES,
        "rarities": HIDDEN_RARITIES,
        "layers": HIDDEN_LAYER_DEPTHS,
        "entries": normalized,
        "recommendations": hidden_architecture_recommendations(),
    }


def validate_hidden_integrity(entries: list[dict[str, Any]]) -> dict[str, Any]:
    ids = {entry["id"] for entry in entries}
    unknown_characters = []
    orphan_nodes = []
    stale_links = []
    for entry in entries:
        characters = entry.get("characters", [])
        if not characters:
            orphan_nodes.append(entry["id"])
        for character in characters:
            if character not in CHARACTER_REGISTRY:
                unknown_characters.append({"entry": entry["id"], "character": character})
        for key in ["chainReferences", "continuationLinks", "parentLinks", "childLinks", "echoes", "mirroredEntries", "thematicLinks", "symbolicLinks", "triggerLinks"]:
            for target in entry.get(key, []):
                if target and target not in ids and not str(target).startswith("/"):
                    stale_links.append({"entry": entry["id"], "field": key, "target": target})
    return {
        "ok": not unknown_characters and not orphan_nodes and not stale_links,
        "unknownCharacters": unknown_characters[:50],
        "orphanNodes": orphan_nodes[:50],
        "staleLinks": stale_links[:50],
        "summary": {
            "unknownCharacterCount": len(unknown_characters),
            "orphanNodeCount": len(orphan_nodes),
            "staleLinkCount": len(stale_links),
        },
        "repairPolicy": "Unknown aliases are normalized through the registry. Empty character lists are repaired to z so every memory has a territory.",
    }


def hidden_architecture_recommendations() -> dict[str, Any]:
    return {
        "storage": "Use assets/content/hidden-details.json as the friendly source of truth for narrative nodes, then regenerate the editable constants in assets/scripts/hidden-details.js.",
        "backups": "Every save writes timestamped backups for both JSON and JS under backups/hidden-details/.",
        "versioning": "Commit the JSON and generated JS together so the live site and authoring history stay aligned.",
        "collaboration": "For simultaneous editing, resolve conflicts by narrative node id and relationship fields, not by whole-file ownership.",
        "scalability": "The graph model can grow into arc folders, richer discovery flows, symbolic indexes, and per-character constellation views without changing the live hidden-details runtime.",
    }


def hidden_entries_by_type(entries: list[dict[str, Any]], content_type: str) -> list[dict[str, Any]]:
    return [entry for entry in entries if entry.get("enabled", True) and entry.get("type") == content_type]


def hidden_contents(entries: list[dict[str, Any]], content_type: str) -> list[str]:
    return [str(entry.get("content", "")) for entry in hidden_entries_by_type(entries, content_type)]


def generated_hidden_content_block(entries: list[dict[str, Any]]) -> str:
    family_layers = hidden_contents(entries, "family layer")
    details = hidden_contents(entries, "hidden tooltip") + hidden_contents(entries, "hover message")
    poems = hidden_contents(entries, "poem")
    greetings = hidden_contents(entries, "loading screen message")
    night_messages = [
        entry["content"] for entry in hidden_entries_by_type(entries, "rare event")
        if "night" in f"{entry.get('category', '')} {entry.get('triggerConditions', '')}".lower()
    ]
    quote_like = hidden_contents(entries, "quote") + hidden_contents(entries, "sensei chi wisdom entry") + hidden_contents(entries, "aphy system message") + hidden_contents(entries, "lima note/message") + hidden_contents(entries, "future z message") + hidden_contents(entries, "young z memory fragment")
    lore = {
        "quotes": quote_like,
        "conversations": [
            entry.get("dialogue") if isinstance(entry.get("dialogue"), list) else [line for line in str(entry.get("content", "")).splitlines() if line.strip()]
            for entry in hidden_entries_by_type(entries, "hidden conversation")
        ],
        "journals": hidden_contents(entries, "journal entry"),
        "warnings": hidden_contents(entries, "fake error message"),
        "dreams": hidden_contents(entries, "dream sequence"),
        "cassettes": hidden_contents(entries, "terminal log"),
        "fakeUsers": hidden_contents(entries, "guestbook entry"),
        "seasonal": {
            str(entry.get("season") or entry.get("triggerConditions") or entry.get("title", "")).lower().replace("season is ", ""): entry.get("content", "")
            for entry in hidden_entries_by_type(entries, "seasonal event")
        },
        "homepageTakeovers": [
            entry["content"] for entry in hidden_entries_by_type(entries, "rare event")
            if "homepage" in f"{entry.get('category', '')} {entry.get('triggerConditions', '')}".lower()
        ],
        "roomLinks": [
            [entry.get("pageLocation", ""), entry.get("content", "") or entry.get("title", "")]
            for entry in hidden_entries_by_type(entries, "secret interaction")
            if entry.get("pageLocation")
        ],
    }
    search_toasts = {
        entry.get("query") or entry.get("triggerConditions") or entry.get("title", ""): entry.get("content", "")
        for entry in hidden_entries_by_type(entries, "search toast")
    }
    search_routes = {
        entry.get("query") or entry.get("triggerConditions") or entry.get("title", ""): entry.get("pageLocation") or entry.get("content", "")
        for entry in hidden_entries_by_type(entries, "search route")
    }
    keyboard_secrets = []
    long_keyboard_secrets = []
    for entry in hidden_entries_by_type(entries, "keyboard secret"):
        secret = dict(entry.get("keyboard") or {})
        secret["phrase"] = secret.get("phrase") or entry.get("triggerConditions") or entry.get("title", "")
        if entry.get("pageLocation"):
            secret["route"] = entry.get("pageLocation")
        elif entry.get("content") == "play tiny aphy song":
            secret["song"] = True
        else:
            secret["message"] = entry.get("content", "")
        target = long_keyboard_secrets if len(secret["phrase"]) > 8 or " " in secret["phrase"] else keyboard_secrets
        target.append(secret)

    def js_const(name: str, value: Any) -> str:
        return f"  const {name} = {json.dumps(value, ensure_ascii=False, indent=4)};\n"

    return (
        "  // -----------------------------\n"
        "  // EDITABLE CONTENT\n"
        "  // -----------------------------\n"
        "  // Generated by the hidden narrative authoring page.\n"
        "  // Friendly source of truth: assets/content/hidden-details.json\n\n"
        f"{js_const('familyLayers', family_layers)}\n"
        f"{js_const('details', details)}\n"
        f"{js_const('poems', poems)}\n"
        f"{js_const('greetings', greetings)}\n"
        f"{js_const('nightMessages', night_messages)}\n"
        f"{js_const('lore', lore)}\n"
        "  // Exact search text -> toast message.\n"
        f"{js_const('SEARCH_TOASTS', search_toasts)}\n"
        "  // Exact search text -> hidden page route.\n"
        f"{js_const('SEARCH_ROUTES', search_routes)}\n"
        "  // Short typed phrases. Stored in sessionStorage as a rolling key chain.\n"
        f"{js_const('KEYBOARD_SECRETS', keyboard_secrets)}\n"
        "  // Longer typed phrases and character names.\n"
        f"{js_const('LONG_KEYBOARD_SECRETS', long_keyboard_secrets)}\n"
    )


def backup_hidden_file(path: Path, stamp: str) -> None:
    if not path.exists():
        return
    HIDDEN_BACKUP_DIR.mkdir(parents=True, exist_ok=True)
    target = HIDDEN_BACKUP_DIR / f"{stamp}-{path.name}"
    target.write_text(path.read_text(encoding="utf-8"), encoding="utf-8")


def replace_hidden_editable_block(source: str, entries: list[dict[str, Any]]) -> str:
    start = source.find("  // -----------------------------\n  // EDITABLE CONTENT")
    end = source.find("  // -----------------------------\n  // STATE HELPERS")
    if start == -1 or end == -1 or end <= start:
        raise ValueError("Could not find editable hidden content block.")
    return source[:start] + generated_hidden_content_block(entries) + "\n" + source[end:]


def save_hidden_store(payload: dict[str, Any]) -> dict[str, Any]:
    current = {entry["id"]: entry for entry in load_hidden_store()["entries"]}
    entries = [normalize_hidden_entry(entry, current.get(str(entry.get("id", "")))) for entry in payload.get("entries", [])]
    ids = [entry["id"] for entry in entries]
    if len(ids) != len(set(ids)):
        raise ValueError("Entry ids must be unique.")
    stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
    backup_hidden_file(HIDDEN_DETAILS_JS, stamp)
    backup_hidden_file(HIDDEN_CONTENT_JSON, stamp)
    HIDDEN_CONTENT_JSON.parent.mkdir(parents=True, exist_ok=True)
    HIDDEN_CONTENT_JSON.write_text(
        json.dumps(
            {
                "schemaVersion": 1,
                "generatedAt": datetime.now().isoformat(timespec="seconds"),
                "entries": entries,
            },
            ensure_ascii=False,
            indent=2,
        ) + "\n",
        encoding="utf-8",
    )
    source = HIDDEN_DETAILS_JS.read_text(encoding="utf-8")
    HIDDEN_DETAILS_JS.write_text(replace_hidden_editable_block(source, entries), encoding="utf-8")
    saved = load_hidden_store()
    saved["message"] = "stored safely. future-you will probably smile at this one."
    saved["backupStamp"] = stamp
    return saved


def parse_upload_form(content_type: str, body: bytes) -> tuple[str, bytes, str]:
    if not content_type.lower().startswith("multipart/form-data"):
        raise ValueError("Uploads must use multipart/form-data.")
    message = BytesParser(policy=email_default_policy).parsebytes(
        f"Content-Type: {content_type}\r\nMIME-Version: 1.0\r\n\r\n".encode("utf-8") + body
    )
    if not message.is_multipart():
        raise ValueError("Upload form is not multipart.")

    filename = ""
    payload = b""
    page_path = ""
    for part in message.iter_parts():
        name = part.get_param("name", header="content-disposition")
        if name == "attachment":
            filename = part.get_filename() or ""
            payload = part.get_payload(decode=True) or b""
        elif name == "pagePath":
            raw_value = part.get_payload(decode=True) or b""
            page_path = raw_value.decode(part.get_content_charset() or "utf-8", errors="replace")

    if not filename:
        raise ValueError("No attachment was uploaded.")
    if not payload:
        raise ValueError("Attachment is empty.")
    return filename, payload, page_path


def run_build_commands(log_callback: Callable[[str], None] | None = None) -> tuple[bool, str, str]:
    venv_python = ROOT / ".venv" / "bin" / "python"
    venv_pip = ROOT / ".venv" / "bin" / "pip"
    commands = [["emacs", "-Q", "--script", "build-site.el"]]
    if not venv_python.exists():
        commands.extend(
            [
                [sys.executable, "-m", "venv", ".venv"],
                [str(venv_pip), "install", "-r", "requirements.txt"],
            ]
        )
    commands.append([str(venv_python), "search-index-json.py"])
    combined = []

    def append_log(text: str) -> None:
        combined.append(text)
        if log_callback:
            log_callback(text)

    ok = True
    for command in commands:
        append_log(f"$ {' '.join(command)}\n")
        env = os.environ.copy()
        env["PYTHONUNBUFFERED"] = "1"
        proc = subprocess.Popen(
            command,
            cwd=ROOT,
            env=env,
            text=True,
            bufsize=1,
            stderr=subprocess.STDOUT,
            stdout=subprocess.PIPE,
        )
        assert proc.stdout is not None
        for line in proc.stdout:
            append_log(line)
        return_code = proc.wait()
        if return_code != 0:
            ok = False
            append_log(f"\nCommand exited with {return_code}.\n")
            break
    message = "Build complete. The site output and search index were regenerated." if ok else "Build failed. Check the log below."
    return ok, message, "".join(combined)


def queue_build(page: dict[str, Any]) -> dict[str, Any]:
    return BUILD_QUEUE.enqueue(page["path"], page["title"]).to_dict()


APP_HTML = r"""<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <link rel="icon" type="image/png" sizes="48x48" href="https://zainezq.com/assets/icons/pen-icon.png" />
  <title>Org Site Authoring</title>
  <style>
    :root {
      color-scheme: dark;
      --bg: #11100d;
      --bg-2: #191814;
      --sidebar: #1d1a16;
      --panel: #f3ead7;
      --panel-2: #e7dac0;
      --panel-dark: #242019;
      --ink: #282016;
      --ink-light: #f7ecd5;
      --muted: #9d9078;
      --muted-dark: #695d4b;
      --line: rgba(202, 179, 126, 0.28);
      --line-strong: rgba(217, 190, 124, 0.56);
      --accent: #b89145;
      --accent-2: #7d2f36;
      --accent-3: #446a57;
      --focus: rgba(184, 145, 69, 0.3);
      --shadow: 0 24px 70px rgba(0, 0, 0, 0.34);
      --paper-shadow: 0 22px 48px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.52);
    }
    * { box-sizing: border-box; }
    body {
      margin: 0;
      font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      background:
        radial-gradient(circle at 18% 12%, rgba(125, 47, 54, 0.24), transparent 28%),
        radial-gradient(circle at 84% 18%, rgba(68, 106, 87, 0.24), transparent 24%),
        linear-gradient(135deg, #0f0e0b 0%, #17140f 42%, #211b14 100%);
      color: var(--ink-light);
    }
    body::before {
      content: "";
      position: fixed;
      inset: 0;
      pointer-events: none;
      opacity: 0.2;
      background-image:
        linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px),
        linear-gradient(90deg, rgba(255, 255, 255, 0.025) 1px, transparent 1px);
      background-size: 34px 34px, 34px 34px;
      mask-image: linear-gradient(90deg, rgba(0,0,0,0.9), rgba(0,0,0,0.35));
    }
    button, input, textarea, select { font: inherit; }
    button {
      border: 1px solid var(--line);
      background: rgba(243, 234, 215, 0.08);
      color: var(--ink-light);
      min-height: 38px;
      padding: 0 12px;
      border-radius: 7px;
      cursor: pointer;
      transition: transform 140ms ease, background 140ms ease, border-color 140ms ease, box-shadow 140ms ease, color 140ms ease;
    }
    button:hover { transform: translateY(-1px); border-color: var(--line-strong); background: rgba(243, 234, 215, 0.14); box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18); }
    button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible, summary:focus-visible { outline: 0; box-shadow: 0 0 0 3px var(--focus); border-color: var(--accent); }
    button.primary { background: linear-gradient(180deg, #c79d4e, #96702f); color: #18130d; border-color: #d6b566; font-weight: 820; box-shadow: 0 12px 28px rgba(184, 145, 69, 0.22); }
    button.primary:hover { background: linear-gradient(180deg, #d3ad5b, #a87d35); border-color: #e0c378; }
    button.icon { min-width: 38px; padding: 0 10px; font-weight: 800; font-family: Georgia, "Times New Roman", serif; }
    .app { position: relative; min-height: 100vh; display: grid; grid-template-columns: minmax(320px, 430px) 1fr; }
    aside {
      border-right: 1px solid var(--line);
      background: linear-gradient(180deg, rgba(29, 26, 22, 0.96), rgba(18, 17, 14, 0.98));
      padding: 20px;
      overflow: auto;
      max-height: 100vh;
      box-shadow: 18px 0 42px rgba(0, 0, 0, 0.22);
    }
    main { padding: 28px clamp(20px, 3vw, 46px); overflow: auto; max-height: 100vh; }
    .topbar { display: flex; align-items: center; gap: 12px; justify-content: space-between; margin-bottom: 18px; }
    h1 { font-family: Georgia, "Times New Roman", serif; font-size: 25px; margin: 0; font-weight: 700; letter-spacing: 0; color: #f8edd7; }
    main h1 { font-size: 31px; color: var(--panel); text-shadow: 0 2px 24px rgba(0, 0, 0, 0.34); }
    h2 { font-size: 12px; margin: 24px 0 10px; color: #c4a766; font-weight: 840; text-transform: uppercase; letter-spacing: 0.08em; }
    .status {
      border: 1px solid var(--line);
      background: rgba(243, 234, 215, 0.08);
      padding: 11px 13px;
      border-radius: 8px;
      font-size: 13px;
      margin-bottom: 14px;
      color: #dfd0b5;
      box-shadow: inset 3px 0 0 rgba(184, 145, 69, 0.68);
    }
    .status.running { border-color: rgba(214, 181, 102, 0.62); background: rgba(214, 181, 102, 0.12); }
    .status.ok { border-color: rgba(88, 139, 111, 0.7); background: rgba(68, 106, 87, 0.16); box-shadow: inset 3px 0 0 #5e9a78; }
    .status.fail { border-color: rgba(158, 62, 70, 0.76); background: rgba(125, 47, 54, 0.18); box-shadow: inset 3px 0 0 #9d3d46; }
    .quick-link { display: inline-block; color: #cfb36e; font-size: 13px; margin: -10px 0 14px; text-decoration: none; border-bottom: 1px solid rgba(207, 179, 110, 0.35); }
    .quick-link:hover { color: #f1d68d; border-bottom-color: currentColor; }
    .filters { display: grid; gap: 9px; margin-bottom: 16px; }
    .page-list { display: grid; gap: 5px; }
    .tree-dir { margin: 2px 0; }
    .tree-dir summary {
      list-style: none;
      display: grid;
      grid-template-columns: auto minmax(0, 1fr) auto;
      gap: 8px;
      align-items: center;
      min-height: 38px;
      padding: 7px 9px;
      border: 1px solid transparent;
      border-radius: 7px;
      cursor: pointer;
      color: #eee0c4;
    }
    .tree-dir summary::-webkit-details-marker { display: none; }
    .tree-dir summary:hover, .page-item:hover { background: rgba(243, 234, 215, 0.08); border-color: rgba(217, 190, 124, 0.32); }
    .tree-dir summary::before { content: ">"; display: inline-block; width: 14px; color: #c8ad69; font-family: "SFMono-Regular", Consolas, monospace; }
    .tree-dir[open] > summary::before { content: "v"; }
    .tree-label { display: flex; align-items: baseline; gap: 6px; min-width: 0; }
    .tree-label strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; font-weight: 760; }
    .tree-label span { color: var(--muted); font-size: 12px; white-space: nowrap; }
    .tree-children { display: grid; gap: 4px; margin-left: 15px; padding-left: 11px; border-left: 1px solid rgba(217, 190, 124, 0.22); }
    .tree-add { min-height: 28px; padding: 0 8px; font-size: 12px; color: #e0c680; background: rgba(184, 145, 69, 0.1); opacity: 0.9; }
    .tree-add:hover { opacity: 1; background: rgba(184, 145, 69, 0.18); }
    .page-item { width: 100%; text-align: left; min-height: auto; padding: 10px 11px; background: transparent; border-color: transparent; }
    .page-item.active { border-color: rgba(214, 181, 102, 0.65); background: rgba(243, 234, 215, 0.13); box-shadow: inset 3px 0 0 var(--accent), 0 12px 24px rgba(0, 0, 0, 0.18); }
    .page-item.unsaved { border-color: rgba(203, 130, 75, 0.78); }
    .page-item strong { display: block; font-size: 14px; margin-bottom: 3px; overflow-wrap: anywhere; font-weight: 780; color: #f3e7ce; }
    .page-item span { display: block; color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
    .page-item .filename { color: #cab68d; }
    form {
      display: grid;
      gap: 16px;
      border: 1px solid rgba(217, 190, 124, 0.32);
      border-radius: 8px;
      background: linear-gradient(180deg, rgba(36, 32, 25, 0.96), rgba(26, 23, 18, 0.96));
      padding: clamp(16px, 2.2vw, 26px);
      box-shadow: var(--shadow);
    }
    .grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
    .toolbar {
      display: flex;
      flex-wrap: wrap;
      gap: 7px;
      align-items: center;
      border: 1px solid rgba(217, 190, 124, 0.28);
      background: rgba(17, 16, 13, 0.52);
      border-radius: 8px;
      padding: 8px;
    }
    .toolbar input[type="file"] { display: none; }
    .link-picker { display: grid; grid-template-columns: minmax(220px, 1fr) auto auto; gap: 8px; align-items: center; border: 1px solid rgba(217, 190, 124, 0.28); background: rgba(17, 16, 13, 0.52); border-radius: 8px; padding: 8px; }
    label { display: grid; gap: 6px; font-size: 12px; font-weight: 820; color: #c7ad74; text-transform: uppercase; letter-spacing: 0.06em; }
    label span { text-transform: none; letter-spacing: 0; color: #ddcfb6; }
    input, textarea, select {
      width: 100%;
      border: 1px solid rgba(107, 91, 57, 0.6);
      border-radius: 8px;
      background: var(--panel);
      color: var(--ink);
      padding: 11px 12px;
      transition: border-color 140ms ease, box-shadow 140ms ease, background 140ms ease;
      box-shadow: inset 0 1px 5px rgba(55, 36, 16, 0.1);
    }
    input::placeholder, textarea::placeholder { color: #8a7b61; }
    input:hover, textarea:hover, select:hover { border-color: rgba(214, 181, 102, 0.86); }
    textarea {
      min-height: 49vh;
      resize: vertical;
      font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
      line-height: 1.58;
      font-size: 14px;
      background:
        linear-gradient(rgba(83, 63, 32, 0.06) 1px, transparent 1px),
        linear-gradient(90deg, rgba(125, 47, 54, 0.1) 1px, transparent 1px),
        var(--panel);
      background-size: 100% 30px, 54px 100%, auto;
      box-shadow: var(--paper-shadow);
    }
    .content-layout { display: grid; gap: 12px; }
    .content-layout.previewing { grid-template-columns: minmax(0, 1fr) minmax(280px, 1fr); align-items: start; }
    .preview-panel {
      border: 1px solid rgba(107, 91, 57, 0.6);
      border-radius: 8px;
      background:
        linear-gradient(rgba(83, 63, 32, 0.045) 1px, transparent 1px),
        var(--panel);
      background-size: 100% 30px, auto;
      color: var(--ink);
      padding: 18px 20px;
      min-height: 49vh;
      overflow: auto;
      overflow-wrap: anywhere;
      text-transform: none;
      letter-spacing: 0;
      box-shadow: var(--paper-shadow);
    }
    .preview-panel h1, .preview-panel h2, .preview-panel h3 { color: #3a2416; margin: 0.8em 0 0.35em; font-family: Georgia, "Times New Roman", serif; letter-spacing: 0; text-transform: none; }
    .preview-panel h1 { font-size: 27px; }
    .preview-panel h2 { font-size: 22px; }
    .preview-panel h3 { font-size: 18px; }
    .preview-panel p, .preview-panel ul, .preview-panel ol, .preview-panel blockquote { margin: 0 0 0.8em; }
    .preview-panel blockquote { border-left: 3px solid #9f7d3d; padding-left: 11px; color: #604f3a; background: rgba(184, 145, 69, 0.08); }
    .preview-panel img, .preview-panel video { max-width: 100%; height: auto; border-radius: 6px; border: 1px solid rgba(107, 91, 57, 0.24); }
    .actions { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
    .hint { color: #b3a181; font-size: 13px; overflow-wrap: anywhere; }
    .dirty-hint { color: #e0b06f; }
    .full { grid-column: 1 / -1; }
    .tags { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 5px; }
    .tag { background: rgba(68, 106, 87, 0.25); color: #d5eadb; border: 1px solid rgba(104, 151, 121, 0.36); padding: 2px 7px; border-radius: 999px; font-size: 12px; }
    .queue-list { display: grid; gap: 8px; margin-bottom: 12px; }
    .queue-item { border: 1px solid rgba(217, 190, 124, 0.26); border-radius: 8px; background: rgba(243, 234, 215, 0.08); color: #e7dac0; padding: 11px 12px; font-size: 13px; box-shadow: 0 10px 24px rgba(0, 0, 0, 0.16); }
    .queue-item strong { display: block; overflow-wrap: anywhere; color: #f3e6c9; }
    .queue-item span { color: #b9a789; overflow-wrap: anywhere; }
    pre { white-space: pre-wrap; overflow: auto; max-height: 280px; background: #0d0c0a; color: #ecdcbf; border: 1px solid rgba(217, 190, 124, 0.24); padding: 15px; border-radius: 8px; font-size: 12px; box-shadow: var(--shadow); }
    @media (max-width: 980px) { .content-layout.previewing { grid-template-columns: 1fr; } }
    @media (max-width: 860px) { .app { grid-template-columns: 1fr; } aside, main { max-height: none; } aside { border-right: 0; border-bottom: 1px solid var(--line); } .grid { grid-template-columns: 1fr; } .link-picker { grid-template-columns: 1fr; } }
  </style>
</head>
<body>
  <div class="app">
    <aside>
      <div class="topbar">
        <h1>Authoring</h1>
        <button id="newBtn" type="button">New</button>
      </div>
      <a class="quick-link" href="https://author.zainezq.com">author.zainezq.com</a>
      <a class="quick-link" href="/hidden">Hidden narrative desk</a>
      <div id="status" class="status"></div>
      <div class="filters">
        <input id="search" type="search" placeholder="Filter pages" />
        <select id="typeFilter">
          <option value="">All files</option>
          <option value="blog">Blogs</option>
          <option value="post">Posts</option>
          <option value="page">Pages</option>
          <option value="lima">Lima</option>
        </select>
      </div>
      <div id="pages" class="page-list"></div>
    </aside>
    <main>
      <form id="editor">
        <div class="topbar">
          <h1 id="formTitle">New page</h1>
          <span id="pathLabel" class="hint"></span>
        </div>
        <div class="grid">
          <label>Page
            <select name="pageType">
              <option value="blog">Blog</option>
              <option value="post">Post</option>
              <option value="page">Page</option>
              <option value="lima">Lima</option>
            </select>
          </label>
          <label>Post section
            <input name="section" placeholder="Optional, for example career" />
          </label>
          <label>Title
            <input name="title" required />
          </label>
          <label>Slug
            <input name="slug" required />
          </label>
          <label>Tags
            <input name="tags" placeholder="life, review" />
          </label>
          <label>Date
            <input name="date" placeholder="<2026-05-07 Thu 12:00>" />
          </label>
          <label class="full">Org file path
            <input name="targetPath" placeholder="blogs/2026/05-may/my-page.org" />
          </label>
        </div>
        <div id="mdToolbar" class="toolbar">
          <button class="icon" type="button" data-md="bold" title="Bold">B</button>
          <button class="icon" type="button" data-md="italic" title="Italic"><i>I</i></button>
          <button class="icon" type="button" data-md="underline" title="Underline"><u>U</u></button>
          <button type="button" data-md="h1">H1</button>
          <button type="button" data-md="h2">H2</button>
          <button type="button" data-md="h3">H3</button>
          <button type="button" data-md="bullet">List</button>
          <button type="button" data-md="numbered">1. List</button>
          <button type="button" data-md="quote">Quote</button>
          <button type="button" data-md="link">Link</button>
          <button type="button" id="pageLinkBtn">Link to page</button>
          <button type="button" id="attachBtn">Insert image</button>
          <input id="attachInput" type="file" accept="image/*" />
        </div>
        <div id="pageLinkPicker" class="link-picker" hidden>
          <select id="pageLinkSelect"></select>
          <button type="button" id="insertPageLinkBtn">Insert link</button>
          <button type="button" id="cancelPageLinkBtn">Cancel</button>
        </div>
        <div id="contentLayout" class="content-layout">
          <label>Content
            <textarea name="content" spellcheck="true"></textarea>
          </label>
          <label id="previewWrap" hidden>Preview
            <div id="markdownPreview" class="preview-panel"></div>
          </label>
        </div>
        <label><span><input name="comments" type="checkbox" checked style="width:auto" /> Comments enabled</span></label>
        <div class="actions">
          <button class="primary" id="saveBtn" type="submit">Save and build</button>
          <button id="discardBtn" type="button">Discard changes</button>
          <span id="saveMessage" class="hint"></span>
        </div>
      </form>
      <h2>Build queue</h2>
      <div id="queue" class="queue-list"></div>
      <h2>Latest build log</h2>
      <pre id="buildLog"></pre>
    </main>
  </div>
  <script>
    const draftStorageKey = "orgAuthoringDrafts:v1";
    const state = { pages: [], currentPath: "", build: null, pathManual: false, treeOpen: {}, drafts: {}, loadingForm: false };
    const $ = (selector) => document.querySelector(selector);
    const editor = $("#editor");
    const mdToolbar = $("#mdToolbar");
    const attachInput = $("#attachInput");
    const attachBtn = $("#attachBtn");
    const pageLinkPicker = $("#pageLinkPicker");
    const pageLinkSelect = $("#pageLinkSelect");
    const contentLayout = $("#contentLayout");
    const previewWrap = $("#previewWrap");
    const markdownPreview = $("#markdownPreview");
    const statusBox = $("#status");
    const pagesBox = $("#pages");
    const buildLog = $("#buildLog");
    const saveMessage = $("#saveMessage");

    function slugify(value) {
      return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "untitled";
    }

    function currentOrgDate() {
      const d = new Date();
      const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
      const pad = (n) => String(n).padStart(2, "0");
      return `<${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${days[d.getDay()]} ${pad(d.getHours())}:${pad(d.getMinutes())}>`;
    }

    function loadDrafts() {
      try {
        return JSON.parse(localStorage.getItem(draftStorageKey) || "{}");
      } catch (_err) {
        return {};
      }
    }

    function saveDrafts() {
      try {
        localStorage.setItem(draftStorageKey, JSON.stringify(state.drafts));
      } catch (_err) {
        saveMessage.textContent = "Browser storage is full; draft could not be saved.";
      }
    }

    function draftKey(path = undefined) {
      if (path !== undefined) return path || "__new__";
      return state.currentPath || editor.targetPath.value || "__new__";
    }

    function hasDraft(path = undefined) {
      return Object.prototype.hasOwnProperty.call(state.drafts, draftKey(path));
    }

    function editorSnapshot() {
      return {
        pageType: editor.pageType.value,
        section: editor.section.value,
        targetPath: editor.targetPath.value,
        title: editor.title.value,
        slug: editor.slug.value,
        tags: editor.tags.value,
        date: editor.date.value,
        content: editor.content.value,
        comments: editor.comments.checked,
        pathManual: state.pathManual,
      };
    }

    function applyDraft(page, draft) {
      return {
        ...page,
        pageType: draft.pageType,
        section: draft.section,
        path: page.path || draft.targetPath || "",
        title: draft.title,
        slug: draft.slug,
        tags: typeof draft.tags === "string" ? draft.tags.split(",").map((tag) => tag.trim()).filter(Boolean) : page.tags,
        date: draft.date,
        content: draft.content,
        comments: draft.comments,
        targetPath: draft.targetPath,
        pathManual: draft.pathManual,
      };
    }

    function rememberDraft() {
      if (state.loadingForm) return;
      const key = draftKey();
      state.drafts[key] = { ...editorSnapshot(), updatedAt: Date.now() };
      saveDrafts();
      renderDraftState("Unsaved changes.");
      if (state.pages.length) renderPages();
    }

    function clearDraft(path = undefined) {
      const key = draftKey(path);
      if (!hasDraft(key)) return;
      delete state.drafts[key];
      saveDrafts();
      renderDraftState("");
      if (state.pages.length) renderPages();
    }

    function renderDraftState(message = null) {
      const dirty = hasDraft();
      saveMessage.classList.toggle("dirty-hint", dirty);
      if (message !== null) saveMessage.textContent = message;
      if (!message && dirty) saveMessage.textContent = "Unsaved changes.";
    }

    async function api(path, options = {}) {
      const res = await fetch(path, { headers: { "Content-Type": "application/json" }, ...options });
      let data = null;
      try {
        data = await res.clone().json();
      } catch (_err) {
        data = { error: await res.text().catch(() => "") };
      }
      if (!res.ok) throw new Error(data.error || "Request failed");
      return data;
    }

    function renderStatus() {
      const build = state.build || {};
      const latest = build.current || (build.recent && build.recent[build.recent.length - 1]);
      statusBox.className = "status" + (build.running ? " running" : latest && latest.ok === true ? " ok" : latest && latest.ok === false ? " fail" : "");
      statusBox.textContent = build.message || "Checking build status.";
      buildLog.textContent = build.log || "";
      renderQueue();
    }

    function renderQueue() {
      const box = $("#queue");
      const build = state.build || {};
      const items = [];
      if (build.current) items.push(build.current);
      (build.pending || []).forEach((job) => items.push(job));
      (build.recent || []).slice().reverse().slice(0, 5).forEach((job) => items.push(job));
      box.innerHTML = "";
      if (!items.length) {
        box.innerHTML = `<div class="queue-item"><span>No queued builds.</span></div>`;
        return;
      }
      items.forEach((job) => {
        const row = document.createElement("div");
        row.className = "queue-item";
        row.innerHTML = `<strong>#${job.id} ${html(job.status)} · ${html(job.title)}</strong><span>${html(job.path)} · ${html(job.message)}</span>`;
        box.appendChild(row);
      });
    }

    function renderPages() {
      const query = $("#search").value.toLowerCase();
      const type = $("#typeFilter").value;
      pagesBox.innerHTML = "";
      const matches = state.pages
        .filter((page) => (!type || page.pageType === type))
        .filter((page) => `${page.title} ${page.path} ${page.tags.join(" ")}`.toLowerCase().includes(query))
        .sort((a, b) => a.path.localeCompare(b.path));
      if (!matches.length) {
        pagesBox.innerHTML = `<div class="queue-item"><span>No files matched.</span></div>`;
        renderStatus();
        return;
      }
      const tree = buildPageTree(matches);
      renderTree(tree, pagesBox, query !== "");
      renderStatus();
    }

    function buildPageTree(pages) {
      const root = { dirs: new Map(), pages: [], path: "" };
      pages.forEach((page) => {
        const parts = page.path.split("/");
        const filename = parts.pop();
        let node = root;
        let dirPath = "";
        parts.forEach((part) => {
          dirPath = dirPath ? `${dirPath}/${part}` : part;
          if (!node.dirs.has(part)) node.dirs.set(part, { dirs: new Map(), pages: [], path: dirPath });
          node = node.dirs.get(part);
        });
        node.pages.push({ ...page, filename });
      });
      return root;
    }

    function renderTree(node, container, expandAll = false) {
      [...node.dirs.entries()]
        .sort(([a], [b]) => a.localeCompare(b))
        .forEach(([name, child]) => container.appendChild(renderDirectory(name, child, expandAll)));
      node.pages
        .sort((a, b) => a.filename.localeCompare(b.filename))
        .forEach((page) => container.appendChild(renderPageButton(page)));
    }

    function renderDirectory(name, node, expandAll) {
      const details = document.createElement("details");
      details.className = "tree-dir";
      const savedOpen = Object.prototype.hasOwnProperty.call(state.treeOpen, node.path) ? state.treeOpen[node.path] : null;
      details.open = expandAll || (savedOpen === null ? node.path.split("/").length <= 2 || containsCurrentPath(node) : savedOpen);
      details.addEventListener("toggle", () => {
        if (!expandAll) state.treeOpen[node.path] = details.open;
      });

      const summary = document.createElement("summary");
      const label = document.createElement("span");
      label.className = "tree-label";
      label.innerHTML = `<strong>${html(name)}</strong><span>${countPages(node)} files</span>`;
      const add = document.createElement("button");
      add.type = "button";
      add.className = "tree-add";
      add.textContent = "New";
      add.title = `New file in ${node.path}`;
      add.onclick = (event) => {
        event.preventDefault();
        event.stopPropagation();
        newPageInDirectory(node.path);
      };
      summary.append(label, add);

      const children = document.createElement("div");
      children.className = "tree-children";
      renderTree(node, children, expandAll);
      details.append(summary, children);
      return details;
    }

    function renderPageButton(page) {
      const btn = document.createElement("button");
      btn.type = "button";
      btn.className = `page-item${page.path === state.currentPath ? " active" : ""}${hasDraft(page.path) ? " unsaved" : ""}`;
      const draftLabel = hasDraft(page.path) ? `<span class="dirty-hint">Unsaved changes</span>` : "";
      btn.innerHTML = `<strong>${html(page.title)}</strong><span class="filename">${html(page.filename || page.path)}</span><span>${html(page.path)}</span>${draftLabel}${page.tags.length ? `<span class="tags">${page.tags.map((tag) => `<span class="tag">${html(tag)}</span>`).join("")}</span>` : ""}`;
      btn.onclick = () => loadPage(page.path);
      return btn;
    }

    function countPages(node) {
      let count = node.pages.length;
      node.dirs.forEach((child) => { count += countPages(child); });
      return count;
    }

    function containsCurrentPath(node) {
      return Boolean(state.currentPath && (state.currentPath.startsWith(`${node.path}/`) || node.pages.some((page) => page.path === state.currentPath)));
    }

    function newPageInDirectory(dirPath) {
      const pageType = pageTypeForDirectory(dirPath);
      setForm({ pageType, date: currentOrgDate(), comments: true });
      editor.section.value = pageType === "post" ? postSectionForDirectory(dirPath) : "";
      editor.targetPath.value = `${dirPath}/`;
      state.pathManual = true;
      updateEditorMode();
      saveMessage.textContent = `New file will be created in ${dirPath}.`;
      editor.title.focus();
    }

    function pageTypeForDirectory(dirPath) {
      if (dirPath === "lima" || dirPath.startsWith("lima/")) return "lima";
      if (dirPath === "posts" || dirPath.startsWith("posts/")) return "post";
      if (dirPath === "blogs" || dirPath.startsWith("blogs/")) return "blog";
      return "page";
    }

    function postSectionForDirectory(dirPath) {
      if (!dirPath.startsWith("posts/")) return "";
      return dirPath.slice("posts/".length);
    }

    function html(value) {
      return String(value).replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char]));
    }

    function previewImageUrl(src) {
      const value = String(src || "");
      if (value.startsWith("https://author.zainezq.com/assets/")) {
        return value.replace("https://author.zainezq.com/assets/", "https://zainezq.com/assets/");
      }
      const relativeAsset = value.match(/^(?:\.\.\/)+assets\/(.+)$/);
      if (relativeAsset) return `https://zainezq.com/assets/${relativeAsset[1]}`;
      if (value.startsWith("/assets/")) return `https://zainezq.com${value}`;
      if (value.startsWith("assets/")) return `https://zainezq.com/${value}`;
      return value;
    }

    function renderInlineMarkdown(value) {
      let out = html(value);
      out = out.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, src) => `<img src="${previewImageUrl(src)}" alt="${alt}">`);
      out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
      out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
      out = out.replace(/\*([^*]+)\*/g, '<em>$1</em>');
      out = out.replace(/&lt;u&gt;(.+?)&lt;\/u&gt;/g, '<u>$1</u>');
      return out;
    }

    function markdownToHtml(markdown) {
      const lines = String(markdown || "").replace(/\r\n/g, "\n").split("\n");
      const blocks = [];
      let paragraph = [];
      let list = null;

      function flushParagraph() {
        if (!paragraph.length) return;
        blocks.push(`<p>${renderInlineMarkdown(paragraph.join(" "))}</p>`);
        paragraph = [];
      }

      function closeList() {
        if (!list) return;
        blocks.push(`<${list.type}>${list.items.map((item) => `<li>${renderInlineMarkdown(item)}</li>`).join("")}</${list.type}>`);
        list = null;
      }

      lines.forEach((line) => {
        const trimmed = line.trim();
        if (!trimmed) {
          flushParagraph();
          closeList();
          return;
        }
        const heading = trimmed.match(/^(#{1,3})\s+(.+)$/);
        if (heading) {
          flushParagraph();
          closeList();
          blocks.push(`<h${heading[1].length}>${renderInlineMarkdown(heading[2])}</h${heading[1].length}>`);
          return;
        }
        const bullet = trimmed.match(/^[-*]\s+(.+)$/);
        const numbered = trimmed.match(/^\d+\.\s+(.+)$/);
        if (bullet || numbered) {
          flushParagraph();
          const type = bullet ? "ul" : "ol";
          if (!list || list.type !== type) {
            closeList();
            list = { type, items: [] };
          }
          list.items.push((bullet || numbered)[1]);
          return;
        }
        const quote = trimmed.match(/^>\s?(.+)$/);
        if (quote) {
          flushParagraph();
          closeList();
          blocks.push(`<blockquote>${renderInlineMarkdown(quote[1])}</blockquote>`);
          return;
        }
        closeList();
        paragraph.push(trimmed);
      });
      flushParagraph();
      closeList();
      return blocks.join("\n");
    }

    function updateMarkdownPreview() {
      markdownPreview.innerHTML = markdownToHtml(editor.content.value);
    }

    function setForm(page, options = {}) {
      const draft = options.useDraft === false ? null : state.drafts[draftKey(page.path || page.targetPath || "")];
      const restoredDraft = Boolean(draft);
      if (draft) page = applyDraft(page, draft);
      state.loadingForm = true;
      state.currentPath = page.path || "";
      state.pathManual = Object.prototype.hasOwnProperty.call(page, "pathManual") ? Boolean(page.pathManual) : Boolean(page.path);
      editor.pageType.value = page.pageType || "blog";
      editor.section.value = page.section || "";
      editor.targetPath.value = page.targetPath || page.path || "";
      editor.title.value = page.title || "";
      editor.slug.value = page.slug || slugify(page.title || "");
      editor.tags.value = (page.tags || []).join(", ");
      editor.date.value = page.date || currentOrgDate();
      editor.content.value = page.content || "";
      editor.comments.checked = page.comments !== false;
      $("#formTitle").textContent = state.currentPath ? "Edit page" : "New page";
      $("#pathLabel").textContent = state.currentPath;
      editor.targetPath.disabled = Boolean(state.currentPath);
      updateEditorMode();
      updateSuggestedPath();
      updateMarkdownPreview();
      state.loadingForm = false;
      if (state.pages.length) renderPages();
      renderDraftState(restoredDraft ? "Unsaved changes restored." : "");
    }

    async function loadPage(path, options = {}) {
      const page = await api(`/api/page?path=${encodeURIComponent(path)}`);
      setForm(page, options);
    }

    async function discardChanges() {
      const path = state.currentPath;
      const key = draftKey();
      if (!state.drafts[key]) {
        saveMessage.textContent = "No unsaved changes to discard.";
        return;
      }
      if (!confirm("Discard unsaved changes for this file?")) return;
      delete state.drafts[key];
      saveDrafts();
      if (path) {
        await loadPage(path, { useDraft: false });
      } else {
        setForm({ pageType: "blog", date: currentOrgDate(), comments: true }, { useDraft: false });
      }
      saveMessage.textContent = "Unsaved changes discarded.";
      renderPages();
    }

    async function refreshPages() {
      try {
        state.pages = await api("/api/pages");
        populatePageLinkSelect();
        renderPages();
      } catch (err) {
        if (state.pages.length) {
          renderPages();
          saveMessage.textContent = `Could not refresh file list: ${err.message}`;
        } else {
          pagesBox.innerHTML = `<div class="queue-item"><span>${html(err.message)}</span></div>`;
        }
      }
    }

    async function refreshBuild() {
      const wasRunning = Boolean(state.build && state.build.running);
      try {
        state.build = await api("/api/build");
        renderStatus();
        if (wasRunning && !state.build.running) await refreshPages();
      } catch (err) {
        statusBox.className = "status fail";
        statusBox.textContent = `Could not refresh build status: ${err.message}`;
      }
    }

    editor.title.addEventListener("input", () => {
      if (!state.currentPath) {
        editor.slug.value = slugify(editor.title.value);
        updateSuggestedPath();
      }
    });
    editor.slug.addEventListener("input", updateSuggestedPath);
    editor.pageType.addEventListener("change", () => {
      if (!state.currentPath) {
        state.pathManual = false;
        editor.targetPath.value = "";
      }
      updateEditorMode();
      updateSuggestedPath();
    });
    editor.section.addEventListener("input", updateSuggestedPath);
    editor.date.addEventListener("input", updateSuggestedPath);
    editor.targetPath.addEventListener("input", () => { state.pathManual = true; });
    mdToolbar.querySelectorAll("[data-md]").forEach((button) => {
      button.addEventListener("click", () => applyMarkdown(button.dataset.md));
    });
    attachBtn.addEventListener("click", () => attachInput.click());
    $("#pageLinkBtn").addEventListener("click", showPageLinkPicker);
    $("#insertPageLinkBtn").addEventListener("click", insertSelectedPageLink);
    $("#cancelPageLinkBtn").addEventListener("click", () => { pageLinkPicker.hidden = true; });
    attachInput.addEventListener("change", uploadAttachment);
    editor.content.addEventListener("input", updateMarkdownPreview);
    editor.addEventListener("input", rememberDraft);
    editor.addEventListener("change", rememberDraft);
    $("#search").addEventListener("input", renderPages);
    $("#typeFilter").addEventListener("change", renderPages);
    $("#newBtn").addEventListener("click", () => setForm({ pageType: "blog", date: currentOrgDate(), comments: true }));
    $("#discardBtn").addEventListener("click", discardChanges);
    editor.addEventListener("submit", async (event) => {
      event.preventDefault();
      saveMessage.textContent = "Saving and queueing build.";
      const payload = Object.fromEntries(new FormData(editor).entries());
      payload.path = state.currentPath;
      payload.comments = editor.comments.checked;
      const previousDraftKey = draftKey();
      try {
        const saved = await api("/api/page", { method: "POST", body: JSON.stringify(payload) });
        delete state.drafts[previousDraftKey];
        delete state.drafts[draftKey(saved.path)];
        saveDrafts();
        setForm(saved, { useDraft: false });
        saveMessage.textContent = "Saved. Build queued.";
        await refreshPages();
        await refreshBuild();
      } catch (err) {
        saveMessage.textContent = err.message;
      }
    });

    function updateSuggestedPath() {
      if (state.currentPath || state.pathManual) return;
      const slug = slugify(editor.slug.value || editor.title.value);
      if (editor.pageType.value === "lima") {
        editor.targetPath.value = `lima/${slug}.md`;
        return;
      }
      if (editor.pageType.value === "page") {
        editor.targetPath.value = `${slug}.org`;
        return;
      }
      if (editor.pageType.value === "post") {
        const section = slugify(editor.section.value || "");
        editor.targetPath.value = section === "untitled" ? `posts/${slug}.org` : `posts/${section}/${slug}.org`;
        return;
      }
      const match = editor.date.value.match(/<(\d{4})-(\d{2})-/);
      const year = match ? match[1] : String(new Date().getFullYear());
      const month = match ? Number(match[2]) : new Date().getMonth() + 1;
      const months = ["january","february","march","april","may","june","july","august","september","october","november","december"];
      editor.targetPath.value = `blogs/${year}/${String(month).padStart(2, "0")}-${months[month - 1]}/${slug}.org`;
    }

    function updateEditorMode() {
      const isLima = editor.pageType.value === "lima";
      const isOrg = !isLima;
      mdToolbar.querySelectorAll("[data-md='link']").forEach((button) => {
        button.textContent = isLima ? "Link" : "Org link";
      });
      editor.tags.closest("label").hidden = isLima;
      editor.date.closest("label").hidden = isLima;
      editor.comments.closest("label").hidden = isLima;
      editor.section.closest("label").hidden = editor.pageType.value !== "post";
      attachBtn.hidden = false;
      previewWrap.hidden = !isLima;
      contentLayout.classList.toggle("previewing", isLima);
      pageLinkPicker.hidden = true;
      const pathLabel = editor.targetPath.closest("label").firstChild;
      if (pathLabel) pathLabel.textContent = isLima ? "Markdown file path" : "Org file path";
      editor.targetPath.placeholder = isLima ? "lima/family-update.md" : "blogs/2026/05-may/my-page.org";
      updateMarkdownPreview();
    }

    function selectedText() {
      const area = editor.content;
      return {
        start: area.selectionStart,
        end: area.selectionEnd,
        text: area.value.slice(area.selectionStart, area.selectionEnd),
      };
    }

    function replaceSelection(value, selectStart = null, selectEnd = null) {
      const area = editor.content;
      const { start, end } = selectedText();
      area.setRangeText(value, start, end, "end");
      area.focus();
      if (selectStart !== null && selectEnd !== null) {
        area.setSelectionRange(start + selectStart, start + selectEnd);
      }
      updateMarkdownPreview();
      rememberDraft();
    }

    function toggleLinePrefix(prefix, fallback, ordered = false) {
      const area = editor.content;
      let { start, end, text } = selectedText();
      if (start === end) {
        start = area.value.lastIndexOf("\n", start - 1) + 1;
        const lineEnd = area.value.indexOf("\n", end);
        end = lineEnd === -1 ? area.value.length : lineEnd;
        text = area.value.slice(start, end);
      }
      const value = text || fallback;
      const lines = value.split("\n");
      const hasPrefix = lines.every((line) => ordered ? /^\d+\.\s/.test(line) : line.startsWith(prefix));
      const replacement = lines.map((line, index) => {
        if (hasPrefix) return ordered ? line.replace(/^\d+\.\s/, "") : line.slice(prefix.length);
        return ordered ? `${index + 1}. ${line || fallback}` : `${prefix}${line || fallback}`;
      }).join("\n");
      area.setRangeText(replacement, start, end, "end");
      area.setSelectionRange(start, start + replacement.length);
      area.focus();
      updateMarkdownPreview();
      rememberDraft();
    }

    function toggleWrap(prefix, suffix, fallback) {
      const area = editor.content;
      const { start, end, text } = selectedText();
      const before = area.value.slice(start - prefix.length, start);
      const after = area.value.slice(end, end + suffix.length);
      if (text && text.startsWith(prefix) && text.endsWith(suffix)) {
        const inner = text.slice(prefix.length, text.length - suffix.length);
        area.setRangeText(inner, start, end, "select");
        area.setSelectionRange(start, start + inner.length);
      } else if (text && before === prefix && after === suffix) {
        area.setSelectionRange(start - prefix.length, end + suffix.length);
        area.setRangeText(text, start - prefix.length, end + suffix.length, "select");
        area.setSelectionRange(start - prefix.length, start - prefix.length + text.length);
      } else {
        const value = text || fallback;
        area.setRangeText(`${prefix}${value}${suffix}`, start, end, "select");
        area.setSelectionRange(start + prefix.length, start + prefix.length + value.length);
      }
      area.focus();
      updateMarkdownPreview();
      rememberDraft();
    }

    function applyMarkdown(action) {
      const { text } = selectedText();
      const sample = text || "text";
      const isLima = editor.pageType.value === "lima";
      if (action === "bold") toggleWrap(isLima ? "**" : "*", isLima ? "**" : "*", sample);
      if (action === "italic") toggleWrap(isLima ? "*" : "/", isLima ? "*" : "/", sample);
      if (action === "underline") toggleWrap(isLima ? "<u>" : "_", isLima ? "</u>" : "_", sample);
      if (action === "h1") toggleLinePrefix(isLima ? "# " : "* ", "Heading");
      if (action === "h2") toggleLinePrefix(isLima ? "## " : "** ", "Heading");
      if (action === "h3") toggleLinePrefix(isLima ? "### " : "*** ", "Heading");
      if (action === "bullet") toggleLinePrefix(isLima ? "- " : "- ", "List item");
      if (action === "numbered") toggleLinePrefix("", "List item", true);
      if (action === "quote") {
        if (isLima) {
          toggleLinePrefix("> ", "Quote");
        } else {
          const body = text || "Quote";
          if (body.startsWith("#+begin_quote") && body.trimEnd().endsWith("#+end_quote")) {
            replaceSelection(body.replace(/^#\+begin_quote\s*\n?/i, "").replace(/\n?#\+end_quote\s*$/i, ""));
          } else {
            replaceSelection(`#+begin_quote\n${body}\n#+end_quote`, 14, 14 + body.length);
          }
        }
      }
      if (action === "link") {
        const label = sample === "text" ? "link text" : sample;
        if (isLima) {
          replaceSelection(`[${label}](https://)`, 1, 1 + label.length);
        } else {
          replaceSelection(`[[https://][${label}]]`, 11, 11);
        }
      }
    }

    function pageUrl(page) {
      const path = page.path.replace(/\.(org|md)$/i, ".html");
      return `https://zainezq.com/${path}`;
    }

    function currentEditorPath() {
      return state.currentPath || editor.targetPath.value || "";
    }

    function dirname(path) {
      const index = path.lastIndexOf("/");
      return index === -1 ? "" : path.slice(0, index);
    }

    function relativePath(fromDir, toPath) {
      const fromParts = fromDir ? fromDir.split("/").filter(Boolean) : [];
      const toParts = toPath.split("/").filter(Boolean);
      while (fromParts.length && toParts.length && fromParts[0] === toParts[0]) {
        fromParts.shift();
        toParts.shift();
      }
      return [...fromParts.map(() => ".."), ...toParts].join("/") || toPath;
    }

    function orgFileLink(page) {
      const current = currentEditorPath();
      const target = current ? relativePath(dirname(current), page.path) : page.path;
      return `[[file:${target}][${page.title}]]`;
    }

    function markdownPageLink(page, label) {
      return `[${label || page.title}](${pageUrl(page)})`;
    }

    function populatePageLinkSelect() {
      const selected = pageLinkSelect.value;
      pageLinkSelect.innerHTML = "";
      state.pages.forEach((page) => {
        const option = document.createElement("option");
        option.value = page.path;
        option.textContent = `${page.title} - ${page.path}`;
        pageLinkSelect.appendChild(option);
      });
      if (selected && state.pages.some((page) => page.path === selected)) {
        pageLinkSelect.value = selected;
      }
    }

    function showPageLinkPicker() {
      populatePageLinkSelect();
      pageLinkPicker.hidden = false;
      pageLinkSelect.focus();
    }

    function insertSelectedPageLink() {
      const page = state.pages.find((item) => item.path === pageLinkSelect.value);
      if (!page) return;
      const { text } = selectedText();
      const isLima = editor.pageType.value === "lima";
      replaceSelection(isLima ? markdownPageLink(page, text || page.title) : orgFileLink(page));
      pageLinkPicker.hidden = true;
    }

    async function uploadAttachment() {
      const file = attachInput.files[0];
      if (!file) return;
      saveMessage.textContent = "Uploading image.";
      const body = new FormData();
      body.append("attachment", file);
      body.append("pagePath", currentEditorPath() || "index.org");
      try {
        const res = await fetch("/api/upload", { method: "POST", body });
        const data = await res.json();
        if (!res.ok) throw new Error(data.error || "Upload failed");
        replaceSelection(`\n${data.insertText || data.markdown}\n`);
        saveMessage.textContent = "Image inserted.";
      } catch (err) {
        saveMessage.textContent = err.message;
      } finally {
        attachInput.value = "";
      }
    }

    state.drafts = loadDrafts();
    setForm({ pageType: "blog", date: currentOrgDate(), comments: true });
    refreshPages();
    refreshBuild();
    setInterval(refreshBuild, 2500);
  </script>
</body>
</html>
"""


HIDDEN_APP_HTML = r"""<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Hidden Memory Observatory</title>
  <style>
    :root {
      color-scheme: dark;
      --bg: #12100d;
      --ink: #f8ead1;
      --muted: #b9a98e;
      --paper: #fbf1dc;
      --paper-ink: #32251a;
      --line: rgba(232, 202, 139, 0.24);
      --line-strong: rgba(232, 202, 139, 0.52);
      --rose: #d06b78;
      --moss: #7fb089;
      --gold: #d3a64d;
      --blue: #75a9bd;
      --violet: #a58ac9;
      --shadow: 0 28px 80px rgba(0, 0, 0, 0.34);
    }
    * { box-sizing: border-box; }
    body {
      margin: 0;
      min-height: 100vh;
      overflow: hidden;
      font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      color: var(--ink);
      background:
        radial-gradient(circle at 50% 50%, rgba(211, 166, 77, 0.13), transparent 12%),
        radial-gradient(circle at 52% 48%, rgba(208, 107, 120, 0.14), transparent 28%),
        radial-gradient(circle at 16% 18%, rgba(117, 169, 189, 0.16), transparent 24%),
        linear-gradient(145deg, #0f0d0b 0%, #1b1611 48%, #241b17 100%);
    }
    body::before {
      content: "";
      position: fixed;
      inset: 0;
      pointer-events: none;
      opacity: 0.18;
      background-image:
        linear-gradient(rgba(255,255,255,0.055) 1px, transparent 1px),
        linear-gradient(90deg, rgba(255,255,255,0.035) 1px, transparent 1px);
      background-size: 34px 34px;
    }
    button, input, textarea, select { font: inherit; }
    button {
      min-height: 36px;
      border: 1px solid var(--line);
      border-radius: 7px;
      background: rgba(251, 241, 220, 0.09);
      color: var(--ink);
      padding: 0 11px;
      cursor: pointer;
    }
    button:hover { border-color: var(--line-strong); background: rgba(251, 241, 220, 0.15); }
    button.primary { background: #d3a64d; border-color: #edc778; color: #1a120b; font-weight: 850; }
    button.danger { color: #ffd8dd; border-color: rgba(208, 107, 120, 0.6); }
    input, textarea, select {
      width: 100%;
      border: 1px solid rgba(92, 67, 34, 0.34);
      border-radius: 7px;
      background: var(--paper);
      color: var(--paper-ink);
      padding: 9px 10px;
    }
    textarea { min-height: 122px; resize: vertical; line-height: 1.55; }
    label { display: grid; gap: 5px; color: #d9c28d; font-size: 12px; font-weight: 800; }
    h1, h2, h3 { font-family: Georgia, "Times New Roman", serif; letter-spacing: 0; }
    h1 { margin: 0; font-size: clamp(24px, 3vw, 42px); }
    h2 { margin: 0 0 10px; font-size: 18px; }
    h3 { margin: 0; font-size: 16px; }
    .observatory { display: grid; grid-template-columns: 280px minmax(0, 1fr) 390px; height: 100vh; }
    .left, .drawer {
      z-index: 3;
      overflow: auto;
      border-color: var(--line);
      background: rgba(19, 16, 13, 0.84);
      backdrop-filter: blur(18px);
    }
    .left { border-right: 1px solid var(--line); padding: 18px; }
    .drawer { border-left: 1px solid var(--line); padding: 18px; }
    .map { position: relative; min-width: 0; overflow: hidden; }
    .map-head {
      position: absolute;
      z-index: 2;
      top: 18px;
      left: 20px;
      right: 20px;
      display: flex;
      align-items: flex-start;
      justify-content: space-between;
      gap: 12px;
      pointer-events: none;
    }
    .map-head > * { pointer-events: auto; }
    .subtle { color: var(--muted); font-size: 13px; line-height: 1.45; }
    .status {
      margin: 14px 0;
      border: 1px solid var(--line);
      border-left: 4px solid var(--gold);
      border-radius: 7px;
      padding: 10px 11px;
      color: #e9d7b8;
      background: rgba(251, 241, 220, 0.08);
      font-size: 13px;
    }
    .stack { display: grid; gap: 10px; }
    .filters { display: grid; gap: 9px; margin: 14px 0; }
    .modebar, .actions, .pills { display: flex; flex-wrap: wrap; gap: 7px; align-items: center; }
    .modebar button.active { background: rgba(211, 166, 77, 0.26); border-color: var(--gold); }
    .pill {
      border: 1px solid var(--line);
      border-radius: 999px;
      padding: 3px 8px;
      color: #efd9a6;
      background: rgba(251, 241, 220, 0.08);
      font-size: 12px;
    }
    .layer-key { display: grid; gap: 8px; margin-top: 14px; }
    .layer-row {
      display: grid;
      grid-template-columns: 28px 1fr;
      gap: 9px;
      align-items: start;
      padding: 9px;
      border: 1px solid rgba(232, 202, 139, 0.16);
      border-radius: 8px;
      background: rgba(251, 241, 220, 0.055);
    }
    .layer-row strong { display: block; color: #ffe2a6; font-size: 13px; }
    .depth-mark {
      width: 24px;
      height: 24px;
      border-radius: 50%;
      display: grid;
      place-items: center;
      background: rgba(211, 166, 77, 0.16);
      border: 1px solid rgba(211, 166, 77, 0.48);
      color: #ffe2a6;
      font-size: 12px;
      font-weight: 850;
    }
    #graph {
      position: absolute;
      inset: 0;
      width: 100%;
      height: 100%;
      cursor: grab;
      touch-action: none;
      user-select: none;
    }
    #graph.is-panning { cursor: grabbing; }
    .ring { fill: none; stroke: rgba(232, 202, 139, 0.14); stroke-width: 1; }
    .ring-label { fill: rgba(248, 234, 209, 0.45); font-size: 10px; letter-spacing: 0; }
    .edge { stroke: rgba(248, 234, 209, 0.18); stroke-width: 1; }
    .edge.continuation { stroke: rgba(211, 166, 77, 0.5); stroke-width: 1.6; }
    .edge.echo { stroke: rgba(117, 169, 189, 0.45); stroke-dasharray: 4 5; }
    .edge.theme, .edge.symbol { stroke: rgba(127, 176, 137, 0.38); }
    .memory-node { cursor: grab; transition: opacity 160ms ease, filter 180ms ease; }
    .memory-node:active { cursor: grabbing; }
    .memory-node text, .node-label text, .cluster-label text { fill: #f7ead3; paint-order: stroke; stroke: rgba(14, 11, 8, 0.82); stroke-width: 3px; stroke-linejoin: round; }
    .memory-node circle { stroke: rgba(255, 246, 223, 0.82); stroke-width: 1.4; filter: drop-shadow(0 0 7px rgba(255, 232, 177, 0.18)); transition: r 160ms ease, stroke 160ms ease, opacity 160ms ease; }
    .memory-node.selected circle { stroke: #fff3c7; stroke-width: 3; }
    .memory-node.dragging circle { stroke: #f3cd7a; stroke-width: 3; opacity: 0.72; }
    .memory-node.compatible circle { stroke: rgba(255, 243, 199, 0.92); stroke-width: 2.4; filter: drop-shadow(0 0 16px rgba(255, 232, 177, 0.46)); }
    .memory-node.drop-target circle { stroke: #fff5c9; stroke-width: 4; filter: drop-shadow(0 0 24px rgba(255, 232, 177, 0.7)); }
    .memory-node.invalid-drop circle { stroke: rgba(208, 107, 120, 0.9); stroke-width: 3; }
    .cluster-node { cursor: pointer; transition: transform 160ms ease, filter 160ms ease; }
    .cluster-node circle {
      stroke: rgba(255, 243, 199, 0.75);
      stroke-width: 1.4;
      filter: drop-shadow(0 0 16px rgba(211, 166, 77, 0.18));
      transition: r 160ms ease, stroke 160ms ease, filter 160ms ease;
    }
    .cluster-node.drop-target circle { stroke: #fff5c9; stroke-width: 3; filter: drop-shadow(0 0 28px rgba(211, 166, 77, 0.62)); }
    .cluster-node text {
      fill: #f7ead3;
      font-size: 12px;
      paint-order: stroke;
      stroke: rgba(14, 11, 8, 0.86);
      stroke-width: 3px;
      stroke-linejoin: round;
    }
    .cluster-node .count { font-size: 18px; font-weight: 850; }
    .cluster-halo { fill: rgba(211, 166, 77, 0.08); stroke: rgba(211, 166, 77, 0.22); stroke-dasharray: 3 8; }
    .drag-preview-line { stroke: rgba(255, 232, 177, 0.78); stroke-width: 3; stroke-dasharray: 8 8; pointer-events: none; }
    .drag-preview-halo { fill: rgba(211, 166, 77, 0.1); stroke: rgba(255, 232, 177, 0.58); stroke-width: 2; stroke-dasharray: 4 7; pointer-events: none; }
    .drag-preview-label rect { fill: rgba(18, 16, 13, 0.86); stroke: rgba(255, 232, 177, 0.42); rx: 7; }
    .drag-preview-label text { fill: #ffe7ad; font-size: 16px; paint-order: stroke; stroke: rgba(14, 11, 8, 0.9); stroke-width: 3px; }
    .node-label rect, .cluster-label rect {
      fill: rgba(18, 16, 13, 0.74);
      stroke: rgba(248, 234, 209, 0.12);
      rx: 7;
    }
    .node-meta { fill: rgba(248, 234, 209, 0.72); stroke-width: 2px; }
    .world-faded { opacity: 0.2; }
    .breadcrumb, .minimap, .guide-card, .active-state, .context-menu {
      border: 1px solid var(--line);
      border-radius: 8px;
      background: rgba(18, 16, 13, 0.72);
      color: #e7d5b8;
      font-size: 13px;
    }
    .breadcrumb {
      position: absolute;
      z-index: 2;
      left: 20px;
      top: 112px;
      padding: 8px 10px;
      display: flex;
      flex-wrap: wrap;
      gap: 6px;
      max-width: min(720px, calc(100% - 40px));
    }
    .breadcrumb button { min-height: 26px; padding: 0 8px; }
    .active-state {
      position: absolute;
      z-index: 2;
      left: 20px;
      top: 160px;
      max-width: min(760px, calc(100% - 40px));
      padding: 8px 10px;
      display: flex;
      flex-wrap: wrap;
      gap: 7px;
      align-items: center;
    }
    .filter-pill {
      min-height: 26px;
      border-radius: 999px;
      display: inline-flex;
      align-items: center;
      gap: 6px;
      background: rgba(251, 241, 220, 0.1);
      border: 1px solid rgba(232, 202, 139, 0.26);
      color: #f3ddb2;
      padding: 2px 7px 2px 9px;
    }
    .filter-pill button { min-height: 20px; width: 22px; padding: 0; border-radius: 999px; }
    .minimap {
      position: absolute;
      z-index: 2;
      right: 20px;
      bottom: 18px;
      width: 164px;
      padding: 8px;
    }
    .minimap svg { width: 100%; height: 92px; display: block; }
    .guide-card { padding: 10px 11px; margin-top: 10px; }
    .guide-card strong { color: #ffe2a6; }
    .tour-list { display: grid; gap: 7px; margin-top: 10px; }
    .tour-list button { text-align: left; height: auto; min-height: 34px; padding: 8px 9px; }
    .map-note {
      position: absolute;
      left: 20px;
      bottom: 18px;
      z-index: 2;
      max-width: 520px;
      border: 1px solid var(--line);
      border-radius: 8px;
      padding: 10px 12px;
      background: rgba(18, 16, 13, 0.72);
      color: #e7d5b8;
      font-size: 13px;
    }
    .context-menu {
      position: fixed;
      z-index: 20;
      min-width: 230px;
      padding: 7px;
      box-shadow: var(--shadow);
    }
    .context-menu button {
      width: 100%;
      justify-content: flex-start;
      text-align: left;
      display: block;
      border-color: transparent;
      background: transparent;
    }
    .context-menu button:hover { border-color: var(--line); background: rgba(251, 241, 220, 0.12); }
    .empty-recovery {
      position: absolute;
      z-index: 2;
      left: 50%;
      top: 50%;
      transform: translate(-50%, -50%);
      width: min(420px, calc(100% - 36px));
      padding: 16px;
      border: 1px solid var(--line);
      border-radius: 8px;
      background: rgba(18, 16, 13, 0.82);
      box-shadow: var(--shadow);
    }
    .panel {
      border: 1px solid var(--line);
      border-radius: 8px;
      padding: 12px;
      background: rgba(251, 241, 220, 0.07);
      box-shadow: var(--shadow);
    }
    .editor-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; }
    .full { grid-column: 1 / -1; }
    .preview {
      white-space: pre-wrap;
      min-height: 118px;
      line-height: 1.6;
      border: 1px solid rgba(92, 67, 34, 0.3);
      border-radius: 8px;
      background: var(--paper);
      color: var(--paper-ink);
      padding: 12px;
      font-family: Georgia, "Times New Roman", serif;
    }
    .connection-list, .timeline-list { display: grid; gap: 8px; }
    .connection {
      text-align: left;
      display: block;
      width: 100%;
      height: auto;
      min-height: 0;
      padding: 8px 9px;
    }
    .flow-row { display: grid; gap: 3px; border-left: 3px solid rgba(211, 166, 77, 0.45); padding-left: 9px; font-size: 13px; }
    .hidden { display: none !important; }
    @keyframes breathe {
      0%, 100% { opacity: 0.72; }
      50% { opacity: 1; }
    }
    .core-glow { animation: breathe 7s ease-in-out infinite; }
    @media (max-width: 1120px) {
      body { overflow: auto; }
      .observatory { grid-template-columns: 1fr; height: auto; min-height: 100vh; }
      .left, .drawer { max-height: none; border: 0; border-bottom: 1px solid var(--line); }
      .map { height: 72vh; min-height: 560px; }
    }
    @media (max-width: 720px) {
      .map-head { position: static; padding: 16px; display: grid; }
      #graph { top: 128px; height: calc(100% - 128px); }
      .editor-grid { grid-template-columns: 1fr; }
    }
  </style>
</head>
<body>
  <div class="observatory">
    <aside class="left">
      <h1>Memory Observatory</h1>
      <div class="subtle">A constellation map for the hidden soul of the website.</div>
      <div id="status" class="status">aphy is dimming the room lights.</div>
      <div class="modebar" id="modebar">
        <button data-mode="graph" class="active">Emotional graph</button>
        <button data-mode="timeline">Timeline</button>
        <button data-mode="character">Character</button>
        <button data-mode="layer">Layer dive</button>
        <button data-mode="flow">Discovery flow</button>
      </div>
      <div class="filters">
        <input id="search" type="search" placeholder="Search memories, symbols, triggers" />
        <select id="layerFilter"></select>
        <select id="characterFilter"></select>
        <select id="toneFilter"></select>
        <select id="rarityFilter"></select>
      </div>
      <div class="actions">
        <button id="newBtn" type="button">New memory</button>
        <button id="saveBtn" class="primary" type="button">Save constellation</button>
      </div>
      <div class="actions" style="margin-top:8px">
        <button id="softResetBtn" type="button">Clear Focus</button>
        <button id="fullResetBtn" type="button">Return to the Whole Sky</button>
      </div>
      <div class="guide-card">
        <h2>How to Read the Observatory</h2>
        <p class="subtle">This is not a database. It is a map of emotional relationships. Layers are depths, nodes are memories and hidden interactions, and threads show meaning, echoes, symbols, or continuation.</p>
        <p class="subtle"><strong>aphy:</strong> zoomed out, I show islands. zoom in, I show memories. this prevents the sky from becoming noise.</p>
        <p class="subtle"><strong>sensei chi:</strong> depth is not importance. Depth is how quietly a thing asks to be approached.</p>
      </div>
      <div id="dragGuide" class="guide-card hidden">
        <h2>Shaping Threads</h2>
        <p class="subtle"><strong>aphy:</strong> hold a memory and move it toward another light. the sky will show what kind of relationship you are about to make.</p>
        <p class="subtle">Drop on a memory for a continuation. Hold Shift for an echo, Ctrl/Command for a symbolic link, or Alt for a mirror. Drop on empty sky to begin a new thread.</p>
      </div>
      <div class="guide-card">
        <h2>Guided Exploration</h2>
        <div class="tour-list">
          <button data-tour="lima" type="button">show hidden lima memories</button>
          <button data-tour="rare" type="button">show the rarest memories</button>
          <button data-tour="future" type="button">show discoveries tied to future z</button>
          <button data-tour="unresolved" type="button">show unresolved threads</button>
          <button data-tour="connected" type="button">show emotionally connected nodes</button>
        </div>
      </div>
      <div class="guide-card">
        <h2>Integrity</h2>
        <div id="integrityStatus" class="subtle">checking character territories.</div>
      </div>
      <div class="layer-key" id="layerKey"></div>
    </aside>

    <main class="map">
      <div class="map-head">
        <div>
          <h1 id="mapTitle">Emotional Graph</h1>
          <div id="mapSub" class="subtle">Layer 0 lives near the edge. Layer 5 rests at the quiet core.</div>
        </div>
        <div class="actions">
          <button id="zoomOutBtn" type="button">Zoom out</button>
          <button id="zoomInBtn" type="button">Zoom in</button>
          <button id="backBtn" type="button">Back</button>
          <button id="homeBtn" type="button">Whole map</button>
          <button id="exploreUndoBtn" type="button">Undo exploration</button>
          <button id="focusBtn" type="button">Focus thread</button>
          <button id="undoBtn" type="button">Undo</button>
          <button id="redoBtn" type="button">Redo</button>
          <button id="duplicateBtn" type="button">Duplicate</button>
          <button id="deleteBtn" class="danger" type="button">Delete</button>
        </div>
      </div>
      <div id="breadcrumb" class="breadcrumb"></div>
      <div id="activeState" class="active-state"></div>
      <svg id="graph" viewBox="0 0 1000 760" role="img" aria-label="Hidden narrative constellation"></svg>
      <div class="map-note" id="mapNote">Click a memory to open it. Drag one memory onto another to weave a continuation thread.</div>
      <div id="emptyRecovery" class="empty-recovery hidden"></div>
      <div class="minimap"><strong>Atlas</strong><svg id="minimap" viewBox="0 0 100 70"></svg></div>
    </main>

    <aside class="drawer">
      <section class="panel">
        <h2 id="drawerTitle">Choose a memory</h2>
        <div id="drawerSub" class="subtle">The side panel opens from the constellation.</div>
        <div id="preview" class="preview" style="margin-top:10px"></div>
      </section>
      <section class="panel" style="margin-top:12px">
        <h2>Memory editor</h2>
        <div class="editor-grid">
          <label class="full">Name<input id="title" /></label>
          <label>Kind<select id="type"></select></label>
          <label>Depth<select id="familyLayer"></select></label>
          <label>Characters<input id="characters" placeholder="lima, aphy" /></label>
          <label>Tone<select id="tone"></select></label>
          <label>Rarity<select id="rarity"></select></label>
          <label>Discovery<select id="discoveryDifficulty" placeholder="gentle, patient, hidden" /></label>
          <label>Mystery<input id="mysteryLevel" placeholder="quiet, strange, deep" /></label>
          <label>Resonance<input id="resonanceScore" type="number" min="1" max="10" /></label>
          <label>Where it appears<input id="pageLocation" placeholder="/play/lima-note.html" /></label>
          <label class="full">Trigger path<input id="triggerConditions" placeholder="search: lima, hover, idle 90 seconds" /></label>
          <label class="full">Memory text<textarea id="content" spellcheck="true"></textarea></label>
          <label>Symbols<input id="symbols" placeholder="ring, tea, crayon sun" /></label>
          <label>Arcs<input id="narrativeArcs" placeholder="lima arc, time echoes" /></label>
          <label>Tags<input id="tags" placeholder="warm, october" /></label>
          <label>Role<input id="emotionalRole" placeholder="reassurance, invitation, warning" /></label>
          <label class="full">Continuations<input id="continuationLinks" placeholder="memory-id, memory-id" /></label>
          <label class="full">Echoes<input id="echoes" placeholder="memory-id, memory-id" /></label>
          <label class="full">Themes<input id="thematicLinks" placeholder="memory-id, memory-id" /></label>
          <label class="full">Symbol links<input id="symbolicLinks" placeholder="memory-id, memory-id" /></label>
          <label class="full">Trigger links<input id="triggerLinks" placeholder="memory-id, memory-id" /></label>
          <label class="full">Parent memories<input id="parentLinks" placeholder="memory-id" /></label>
          <label class="full">Child memories<input id="childLinks" placeholder="memory-id" /></label>
          <label class="full">Mirrors<input id="mirroredEntries" placeholder="memory-id" /></label>
          <label>Enabled<select id="enabled"><option value="true">Awake</option><option value="false">Resting</option></select></label>
          <label>CSS hooks<input id="cssClassHooks" /></label>
          <label>Audio<input id="audioSettings" /></label>
          <label>Animation<input id="animationTrigger" /></label>
          <label class="full">Private notes<textarea id="notes"></textarea></label>
        </div>
        <div id="autosave" class="subtle" style="margin-top:10px"></div>
      </section>
      <section class="panel" style="margin-top:12px">
        <h2>Nearby memories</h2>
        <div id="connections" class="connection-list"></div>
      </section>
      <section class="panel" style="margin-top:12px">
        <h2>Architecture</h2>
        <div class="subtle">
          <p><strong>Updated model:</strong> each hidden piece is a narrative node. The old Family Layer Index is now a depth zone, not a type.</p>
          <p><strong>Relationship engine:</strong> links come from continuations, echoes, themes, symbols, triggers, page locations, characters, and arcs.</p>
          <p><strong>Storage:</strong> the friendly graph lives in <code>assets/content/hidden-details.json</code>; the live site still receives generated constants in <code>assets/scripts/hidden-details.js</code>.</p>
        </div>
      </section>
    </aside>
  </div>
  <div id="contextMenu" class="context-menu hidden"></div>

  <script>
    const draftKey = "hiddenNarrativeObservatoryDraft:v1";
    const WORLD = { width: 4600, height: 3300, cx: 2300, cy: 1650 };
    const SCREEN = { width: 1000, height: 760 };
    const state = { entries: [], meta: {}, mode: "graph", selectedId: "", undo: [], redo: [], draggingId: "", positions: new Map(), clusterPositions: new Map(), zoom: 0, focusCluster: "", focusKind: "", focusIds: null, focusLabel: "", history: [], explorationUndo: [], explorationRedo: [], camera: { x: 0, y: 0, k: 0.22 }, pan: null, drag: null, renderTimer: 0 };
    const fields = ["title","type","familyLayer","characters","tone","rarity","discoveryDifficulty","mysteryLevel","resonanceScore","pageLocation","triggerConditions","content","symbols","narrativeArcs","tags","emotionalRole","continuationLinks","echoes","thematicLinks","symbolicLinks","triggerLinks","parentLinks","childLinks","mirroredEntries","enabled","cssClassHooks","audioSettings","animationTrigger","notes"];
    const $ = (id) => document.getElementById(id);
    const toneColors = { warm: "#d3a64d", funny: "#7fb089", nostalgic: "#d06b78", wise: "#75a9bd", strange: "#a58ac9", soft: "#e6bd8c", hopeful: "#9fcf9f", protective: "#d98f71", melancholy: "#8fa7c6" };

    function html(value) {
      return String(value || "").replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char]));
    }
    function splitList(value) {
      return String(value || "").split(",").map((item) => item.trim()).filter(Boolean);
    }
    function normalizeCharacterName(value) {
      const raw = String(value || "").trim();
      if (!raw) return "";
      const simplified = slugify(raw).replace(/-/g, " ");
      const registry = state.meta.characterRegistry || {};
      for (const [id, config] of Object.entries(registry)) {
        const aliases = [id, ...(config.aliases || [])].map((item) => String(item).toLowerCase());
        if (aliases.includes(raw.toLowerCase()) || aliases.map((item) => slugify(item).replace(/-/g, " ")).includes(simplified)) return id;
      }
      return "";
    }
    function normalizeCharacters(value) {
      const normalized = [];
      splitList(value).forEach((item) => {
        const character = normalizeCharacterName(item);
        if (character && !normalized.includes(character)) normalized.push(character);
      });
      return normalized.length ? normalized : ["z"];
    }
    function slugify(value) {
      return String(value || "memory").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "memory";
    }
    async function api(path, options = {}) {
      const res = await fetch(path, { headers: { "Content-Type": "application/json" }, ...options });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(data.error || "Request failed");
      return data;
    }
    function setStatus(message) {
      $("status").textContent = message;
      $("autosave").textContent = message;
    }
    function cameraTransform() {
      return `translate(${state.camera.x} ${state.camera.y}) scale(${state.camera.k})`;
    }
    function applyCamera() {
      const world = document.getElementById("world");
      if (world) world.setAttribute("transform", cameraTransform());
      updateSemanticZoom();
      renderMinimap(visibleScope());
    }
    function updateSemanticZoom() {
      const k = state.camera.k;
      state.zoom = k < 0.3 ? 0 : k < 0.62 ? 1 : k < 1.15 ? 2 : 3;
    }
    function screenToWorld(x, y) {
      return { x: (x - state.camera.x) / state.camera.k, y: (y - state.camera.y) / state.camera.k };
    }
    function worldToScreen(x, y) {
      return { x: x * state.camera.k + state.camera.x, y: y * state.camera.k + state.camera.y };
    }
    function fitCameraTo(bounds, animate = true) {
      const pad = 110;
      const width = Math.max(260, bounds.maxX - bounds.minX + pad * 2);
      const height = Math.max(220, bounds.maxY - bounds.minY + pad * 2);
      const nextK = Math.max(0.14, Math.min(2.8, Math.min(SCREEN.width / width, SCREEN.height / height)));
      const next = {
        k: nextK,
        x: SCREEN.width / 2 - ((bounds.minX + bounds.maxX) / 2) * nextK,
        y: SCREEN.height / 2 - ((bounds.minY + bounds.maxY) / 2) * nextK,
      };
      if (!animate) {
        state.camera = next;
        return;
      }
      animateCamera(next);
    }
    function animateCamera(next) {
      const start = { ...state.camera };
      const started = performance.now();
      const duration = 420;
      function step(now) {
        const t = Math.min(1, (now - started) / duration);
        const eased = 1 - Math.pow(1 - t, 3);
        state.camera = {
          x: start.x + (next.x - start.x) * eased,
          y: start.y + (next.y - start.y) * eased,
          k: start.k + (next.k - start.k) * eased,
        };
        applyCamera();
        if (t < 1) requestAnimationFrame(step);
        else render();
      }
      requestAnimationFrame(step);
    }
    function zoomAt(screenX, screenY, factor) {
      const before = screenToWorld(screenX, screenY);
      state.camera.k = Math.max(0.13, Math.min(3.8, state.camera.k * factor));
      state.camera.x = screenX - before.x * state.camera.k;
      state.camera.y = screenY - before.y * state.camera.k;
      applyCamera();
      window.clearTimeout(state.renderTimer);
      state.renderTimer = window.setTimeout(render, 90);
    }
    function snapshot() {
      state.undo.push(JSON.stringify(state.entries));
      state.undo = state.undo.slice(-60);
      state.redo = [];
    }
    function restore(serialized) {
      state.entries = JSON.parse(serialized);
      rememberDraft();
      render();
      selectNode(state.selectedId || state.entries[0]?.id);
    }
    function filterState() {
      return {
        search: $("search").value,
        layer: $("layerFilter").value,
        character: $("characterFilter").value,
        tone: $("toneFilter").value,
        rarity: $("rarityFilter").value,
      };
    }
    function setFilterState(filters = {}) {
      $("search").value = filters.search || "";
      $("layerFilter").value = filters.layer || "";
      $("characterFilter").value = filters.character || "";
      $("toneFilter").value = filters.tone || "";
      $("rarityFilter").value = filters.rarity || "";
    }
    function snapshotExploration(label = "exploration step") {
      state.explorationUndo.push({
        label,
        mode: state.mode,
        selectedId: state.selectedId,
        focusCluster: state.focusCluster,
        focusIds: state.focusIds ? [...state.focusIds] : null,
        focusLabel: state.focusLabel || "",
        filters: filterState(),
        camera: { ...state.camera },
      });
      state.explorationUndo = state.explorationUndo.slice(-40);
      state.explorationRedo = [];
    }
    function applyExploration(snapshot) {
      if (!snapshot) return;
      state.mode = snapshot.mode || "graph";
      state.selectedId = snapshot.selectedId || state.selectedId;
      state.focusCluster = snapshot.focusCluster || "";
      state.focusIds = snapshot.focusIds ? new Set(snapshot.focusIds) : null;
      state.focusLabel = snapshot.focusLabel || "";
      setFilterState(snapshot.filters || {});
      state.camera = snapshot.camera ? { ...snapshot.camera } : state.camera;
      $("modebar").querySelectorAll("button").forEach((item) => item.classList.toggle("active", item.dataset.mode === state.mode));
      render();
      if (state.selectedId) fillForm(currentEntry());
      applyCamera();
    }
    function undoExploration() {
      const previous = state.explorationUndo.pop();
      if (!previous) {
        setStatus("you are already at the first clearing.");
        return;
      }
      state.explorationRedo.push({
        mode: state.mode,
        selectedId: state.selectedId,
        focusCluster: state.focusCluster,
        focusIds: state.focusIds ? [...state.focusIds] : null,
        focusLabel: state.focusLabel || "",
        filters: filterState(),
        camera: { ...state.camera },
      });
      applyExploration(previous);
      setStatus("one exploration step undone.");
    }
    function rememberDraft() {
      localStorage.setItem(draftKey, JSON.stringify({ entries: state.entries, selectedId: state.selectedId, savedAt: Date.now() }));
      $("autosave").textContent = "local draft kept warm.";
    }
    function depth(entry) {
      const value = String(entry.familyLayer || "").match(/[0-5]/);
      if (value) return Number(value[0]);
      if ((entry.characters || []).includes("future z")) return 4;
      if ((entry.characters || []).includes("sensei chi")) return 3;
      if ((entry.characters || []).includes("young z") || (entry.characters || []).includes("lima")) return 2;
      return entry.rarity === "rare" || entry.rarity === "very rare" ? 5 : 1;
    }
    function currentEntry() {
      return state.entries.find((entry) => entry.id === state.selectedId);
    }
    function entryFromForm() {
      const current = currentEntry() || {};
      return {
        ...current,
        id: current.id || slugify($("title").value),
        title: $("title").value.trim() || "Untitled memory",
        type: $("type").value,
        familyLayer: $("familyLayer").value,
        characters: normalizeCharacters($("characters").value),
        emotionalTone: $("tone").value,
        rarity: $("rarity").value,
        discoveryDifficulty: $("discoveryDifficulty").value,
        mysteryLevel: $("mysteryLevel").value,
        resonanceScore: Number($("resonanceScore").value || 3),
        pageLocation: $("pageLocation").value,
        triggerConditions: $("triggerConditions").value,
        content: $("content").value,
        symbols: splitList($("symbols").value),
        narrativeArcs: splitList($("narrativeArcs").value),
        tags: splitList($("tags").value),
        emotionalRole: $("emotionalRole").value,
        continuationLinks: splitList($("continuationLinks").value),
        echoes: splitList($("echoes").value),
        thematicLinks: splitList($("thematicLinks").value),
        symbolicLinks: splitList($("symbolicLinks").value),
        triggerLinks: splitList($("triggerLinks").value),
        parentLinks: splitList($("parentLinks").value),
        childLinks: splitList($("childLinks").value),
        mirroredEntries: splitList($("mirroredEntries").value),
        enabled: $("enabled").value === "true",
        cssClassHooks: $("cssClassHooks").value,
        audioSettings: $("audioSettings").value,
        animationTrigger: $("animationTrigger").value,
        notes: $("notes").value,
      };
    }
    function applyFormToState() {
      if (!state.selectedId) return;
      const index = state.entries.findIndex((entry) => entry.id === state.selectedId);
      if (index < 0) return;
      state.entries[index] = entryFromForm();
      state.selectedId = state.entries[index].id;
      rememberDraft();
      render();
    }
    function fillForm(entry) {
      if (!entry) return;
      $("drawerTitle").textContent = entry.title || "Untitled memory";
      $("drawerSub").textContent = `${layerName(depth(entry))} / ${entry.type || "memory"}`;
      $("title").value = entry.title || "";
      $("type").value = entry.type || "quote";
      $("familyLayer").value = String(depth(entry));
      $("characters").value = (entry.characters || []).join(", ");
      $("tone").value = entry.emotionalTone || "warm";
      $("rarity").value = entry.rarity || "common";
      $("discoveryDifficulty").value = entry.discoveryDifficulty || "gentle";
      $("mysteryLevel").value = entry.mysteryLevel || "quiet";
      $("resonanceScore").value = entry.resonanceScore || 3;
      $("pageLocation").value = entry.pageLocation || "";
      $("triggerConditions").value = entry.triggerConditions || "";
      $("content").value = entry.content || "";
      $("symbols").value = (entry.symbols || []).join(", ");
      $("narrativeArcs").value = (entry.narrativeArcs || []).join(", ");
      $("tags").value = (entry.tags || []).join(", ");
      $("emotionalRole").value = entry.emotionalRole || "";
      $("continuationLinks").value = (entry.continuationLinks || []).join(", ");
      $("echoes").value = (entry.echoes || []).join(", ");
      $("thematicLinks").value = (entry.thematicLinks || []).join(", ");
      $("symbolicLinks").value = (entry.symbolicLinks || []).join(", ");
      $("triggerLinks").value = (entry.triggerLinks || []).join(", ");
      $("parentLinks").value = (entry.parentLinks || []).join(", ");
      $("childLinks").value = (entry.childLinks || []).join(", ");
      $("mirroredEntries").value = (entry.mirroredEntries || []).join(", ");
      $("enabled").value = entry.enabled === false ? "false" : "true";
      $("cssClassHooks").value = entry.cssClassHooks || "";
      $("audioSettings").value = entry.audioSettings || "";
      $("animationTrigger").value = entry.animationTrigger || "";
      $("notes").value = entry.notes || "";
      renderPreview(entry);
      renderConnections(entry);
    }
    function layerName(layer) {
      return (state.meta.layers || []).find((item) => item.id === String(layer))?.name || `Layer ${layer}`;
    }
    function filteredEntries() {
      const query = $("search").value.toLowerCase();
      return state.entries.filter((entry) => {
        const haystack = [entry.title, entry.content, entry.type, entry.triggerConditions, entry.pageLocation, entry.emotionalRole, entry.mysteryLevel, ...(entry.characters || []), ...(entry.tags || []), ...(entry.symbols || []), ...(entry.narrativeArcs || [])].join(" ").toLowerCase();
        return (!query || haystack.includes(query))
          && (!$("layerFilter").value || String(depth(entry)) === $("layerFilter").value)
          && (!$("characterFilter").value || (entry.characters || []).includes($("characterFilter").value))
          && (!$("toneFilter").value || entry.emotionalTone === $("toneFilter").value)
          && (!$("rarityFilter").value || entry.rarity === $("rarityFilter").value);
      });
    }
    function relationshipPairs(entries) {
      const byId = new Map(state.entries.map((entry) => [entry.id, entry]));
      const visible = new Set(entries.map((entry) => entry.id));
      const pairs = [];
      const add = (source, target, kind) => {
        if (!target || !byId.has(target) || !visible.has(source.id) || !visible.has(target)) return;
        pairs.push({ source: source.id, target, kind });
      };
      entries.forEach((entry) => {
        (entry.continuationLinks || []).forEach((id) => add(entry, id, "continuation"));
        (entry.chainReferences || []).forEach((id) => add(entry, id, "continuation"));
        (entry.echoes || []).forEach((id) => add(entry, id, "echo"));
        (entry.thematicLinks || []).forEach((id) => add(entry, id, "theme"));
        (entry.symbolicLinks || []).forEach((id) => add(entry, id, "symbol"));
        (entry.triggerLinks || []).forEach((id) => add(entry, id, "trigger"));
        (entry.parentLinks || []).forEach((id) => add(entry, id, "parent"));
        (entry.childLinks || []).forEach((id) => add(entry, id, "child"));
        (entry.mirroredEntries || []).forEach((id) => add(entry, id, "echo"));
      });
      if (state.mode === "character" || state.mode === "graph") {
        entries.forEach((entry, index) => {
          entries.slice(index + 1).forEach((other) => {
            if ((entry.characters || []).some((name) => (other.characters || []).includes(name))) pairs.push({ source: entry.id, target: other.id, kind: "character" });
            if ((entry.symbols || []).some((name) => (other.symbols || []).includes(name))) pairs.push({ source: entry.id, target: other.id, kind: "symbol" });
          });
        });
      }
      return pairs.slice(0, 520);
    }

    function clusterKey(entry) {
      if (state.mode === "timeline") return `${entry.modifiedDate || entry.createdDate || "undated"}`.slice(0, 7);
      if (state.mode === "character") return (entry.characters || [])[0] || "z";
      if (state.mode === "layer") return `Layer ${depth(entry)} - ${layerName(depth(entry))}`;
      if (state.mode === "flow") return entry.triggerConditions ? `Trigger: ${entry.triggerConditions.split(/[,:]/)[0]}` : `Layer ${depth(entry)} discovery`;
      return (entry.narrativeArcs || [])[0] || (entry.symbols || [])[0] || entry.emotionalTone || "Loose memories";
    }

    function clusterKindLabel() {
      return { graph: "arc/tone island", timeline: "era", character: "territory", layer: "depth zone", flow: "discovery gate" }[state.mode] || "island";
    }

    function clusteredEntries(entries) {
      const clusters = new Map();
      entries.forEach((entry) => {
        const key = clusterKey(entry);
        if (!clusters.has(key)) clusters.set(key, { id: slugify(key), label: key, entries: [], depthTotal: 0, tones: new Map(), characters: new Map(), rarity: 0 });
        const cluster = clusters.get(key);
        cluster.entries.push(entry);
        cluster.depthTotal += depth(entry);
        cluster.tones.set(entry.emotionalTone || "warm", (cluster.tones.get(entry.emotionalTone || "warm") || 0) + 1);
        (entry.characters || ["z"]).forEach((name) => cluster.characters.set(name, (cluster.characters.get(name) || 0) + 1));
        if (["rare", "very rare", "seasonal", "timed"].includes(entry.rarity)) cluster.rarity += 1;
      });
      return [...clusters.values()].map((cluster) => {
        cluster.depth = cluster.entries.length ? Math.round(cluster.depthTotal / cluster.entries.length) : 1;
        cluster.tone = [...cluster.tones.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || "warm";
        cluster.character = [...cluster.characters.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || "";
        cluster.resonance = cluster.entries.reduce((total, entry) => total + Number(entry.resonanceScore || 3), 0) / Math.max(1, cluster.entries.length);
        return cluster;
      });
    }

    function visibleScope() {
      const entries = filteredEntries();
      if (state.focusIds) return { entries: entries.filter((entry) => state.focusIds.has(entry.id)), clusters: [], focusedCluster: null };
      if (state.focusCluster) {
        const cluster = clusteredEntries(entries).find((item) => item.id === state.focusCluster);
        return { entries: cluster ? cluster.entries : entries, clusters: [], focusedCluster: cluster || null };
      }
      if (state.zoom < 2 && !$("search").value) {
        const clusters = clusteredEntries(entries)
          .sort((a, b) => b.entries.length - a.entries.length)
          .slice(0, state.zoom === 0 ? 26 : 48);
        return { entries: [], clusters, focusedCluster: null };
      }
      return { entries: entries.slice(0, state.zoom === 2 ? 140 : 260), clusters: [], focusedCluster: null };
    }

    function relationshipPairsForClusters(clusters) {
      const index = new Map();
      clusters.forEach((cluster) => cluster.entries.forEach((entry) => index.set(entry.id, cluster.id)));
      const counts = new Map();
      clusters.forEach((cluster) => {
        cluster.entries.forEach((entry) => {
          ["continuationLinks","chainReferences","echoes","thematicLinks","symbolicLinks","triggerLinks","parentLinks","childLinks","mirroredEntries"].forEach((key) => {
            (entry[key] || []).forEach((id) => {
              const target = index.get(id);
              if (!target || target === cluster.id) return;
              const edge = [cluster.id, target].sort().join("::");
              counts.set(edge, (counts.get(edge) || 0) + 1);
            });
          });
        });
      });
      return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 80).map(([edge, count]) => {
        const [source, target] = edge.split("::");
        return { source, target, count };
      });
    }
    function layoutEntries(entries) {
      const cx = WORLD.cx;
      const cy = WORLD.cy;
      const byLayer = new Map();
      entries.forEach((entry) => {
        const layer = state.mode === "layer" ? depth(entry) : state.mode === "character" ? characterBucket(entry) : depth(entry);
        if (!byLayer.has(layer)) byLayer.set(layer, []);
        byLayer.get(layer).push(entry);
      });
      state.positions = new Map();
      [...byLayer.entries()].forEach(([layer, items]) => {
        const layerNum = Number(layer);
        const radius = state.mode === "layer" ? 210 + (5 - layerNum) * 250 : 280 + (5 - depth({ familyLayer: String(layerNum) })) * 235;
        const ringSpread = Math.max(1, Math.ceil(Math.sqrt(items.length)));
        items.forEach((entry, index) => {
          const salt = hash(entry.id) / 9999;
          const angle = ((Math.PI * 2) / Math.max(1, items.length)) * index + salt;
          const lane = (index % ringSpread) * 42;
          let x = cx + Math.cos(angle) * (radius + lane);
          let y = cy + Math.sin(angle) * (radius + lane) * 0.72;
          if (state.mode === "timeline") {
            x = 420 + (index % 7) * 420;
            y = 520 + Math.floor(index / 7) * 190;
          }
          if (state.mode === "flow") {
            x = 430 + depth(entry) * 560;
            y = 420 + (index % 9) * 175;
          }
          state.positions.set(entry.id, { x, y });
        });
      });
      relaxPositions(entries);
    }

    function layoutClusters(clusters) {
      const cx = WORLD.cx;
      const cy = WORLD.cy;
      state.clusterPositions = new Map();
      const byDepth = new Map();
      clusters.forEach((cluster) => {
        const layer =
          state.mode === "character"
            ? "characters"
            : state.mode === "timeline" || state.mode === "flow"
              ? 1
              : cluster.depth;

        if (!byDepth.has(layer)) byDepth.set(layer, []);
        byDepth.get(layer).push(cluster);
      });
      [...byDepth.entries()].forEach(([layer, items]) => {
        const radius = state.mode === "timeline" || state.mode === "flow" ? 0 : 340 + (5 - Number(layer)) * 265;
        items.forEach((cluster, index) => {
          let x;
          let y;
          if (state.mode === "timeline") {
            x = 520 + (index % 4) * 860;
            y = 480 + Math.floor(index / 4) * 360;
          } else if (state.mode === "flow") {
            x = 420 + Math.min(5, index % 6) * 700;
            y = 500 + Math.floor(index / 6) * 330;
          } else if (state.mode === "character") {
            const angle = (Math.PI * 2 * index) / Math.max(1, items.length);
            x = cx + Math.cos(angle) * 1180;
            y = cy + Math.sin(angle) * 840;
          } else {
            const angle = (Math.PI * 2 * index) / Math.max(1, items.length) + hash(cluster.id) / 8000;
            x = cx + Math.cos(angle) * radius;
            y = cy + Math.sin(angle) * radius * 0.72;
          }
          state.clusterPositions.set(cluster.id, { x, y });
        });
      });
    }
    function relaxPositions(entries) {
      const ids = entries.map((entry) => entry.id);
      const minDistance = state.focusCluster || state.focusIds ? 112 : 86;
      for (let pass = 0; pass < 3; pass += 1) {
        for (let i = 0; i < ids.length; i += 1) {
          for (let j = i + 1; j < ids.length; j += 1) {
            const a = state.positions.get(ids[i]);
            const b = state.positions.get(ids[j]);
            if (!a || !b) continue;
            const dx = b.x - a.x;
            const dy = b.y - a.y;
            const dist = Math.sqrt(dx * dx + dy * dy) || 1;
            if (dist >= minDistance) continue;
            const push = (minDistance - dist) / 2;
            const ux = dx / dist;
            const uy = dy / dist;
            a.x -= ux * push;
            a.y -= uy * push;
            b.x += ux * push;
            b.y += uy * push;
          }
        }
      }
    }
    function characterBucket(entry) {
      const first = (entry.characters || [])[0] || "z";
      const index = Math.max(0, state.meta.characters.indexOf(first));
      return index % 6;
    }
    function characterSymbol(character) {
      return state.meta.characterRegistry?.[character]?.symbol || "*";
    }
    function characterColor(character) {
      return state.meta.characterRegistry?.[character]?.territoryColor || "";
    }
    function hash(value) {
      let h = 0;
      for (let i = 0; i < value.length; i += 1) h = (h * 31 + value.charCodeAt(i)) % 9999;
      return h;
    }
    function render() {
      updateSemanticZoom();
      const scope = visibleScope();
      const entries = scope.entries;
      const clusters = scope.clusters;
      if (clusters.length) layoutClusters(clusters);
      if (entries.length) layoutEntries(entries);
      const graph = $("graph");
      const rings = state.mode === "timeline" || state.mode === "flow" ? "" : [0,1,2,3,4,5].map((layer) => {
        const radius = 280 + (5 - layer) * 235;
        return `<ellipse class="ring ${layer === 5 ? "core-glow" : ""}" cx="${WORLD.cx}" cy="${WORLD.cy}" rx="${radius}" ry="${radius * 0.72}"></ellipse><text class="ring-label" x="${WORLD.cx + radius + 28}" y="${WORLD.cy}">Layer ${layer} - ${html(layerName(layer))}</text>`;
      }).join("");
      const clusterEdges = relationshipPairsForClusters(clusters).map((pair) => {
        const a = state.clusterPositions.get(pair.source);
        const b = state.clusterPositions.get(pair.target);
        if (!a || !b) return "";
        return `<line class="edge theme" x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" opacity="${Math.min(0.55, 0.12 + pair.count * 0.06)}"></line>`;
      }).join("");
      const pairs = entries.length ? relationshipPairs(entries).slice(0, state.focusCluster ? 160 : 90) : [];
      const edges = pairs.map((pair) => {
        const a = state.positions.get(pair.source);
        const b = state.positions.get(pair.target);
        if (!a || !b) return "";
        return `<line class="edge ${html(pair.kind)}" x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}"></line>`;
      }).join("");
      const occupied = [];
      const clusterNodes = clusters.map((cluster) => {
        const pos = state.clusterPositions.get(cluster.id);
        const color = state.mode === "character" ? (characterColor(cluster.label) || toneColors[cluster.tone] || "#d3a64d") : (toneColors[cluster.tone] || "#d3a64d");
        const size = Math.min(120, 48 + Math.sqrt(cluster.entries.length) * 18 + cluster.rarity * 3);
        const label = labelForCluster(cluster, pos, size, occupied);
        const activeDrop = state.drag?.targetKind === "cluster" && state.drag.targetId === cluster.id;
        const grow = activeDrop ? 1.08 : 1;
        return `<g class="cluster-node${activeDrop ? " drop-target" : ""}" data-id="${html(cluster.id)}" tabindex="0" transform="translate(${pos.x} ${pos.y})">
          <circle class="cluster-halo" r="${(size + 13) * grow}"></circle>
          <circle r="${size * grow}" fill="${color}" opacity="0.58"></circle>
          <text class="count" x="0" y="7" text-anchor="middle">${cluster.entries.length}</text>
        </g>${label}`;
      }).join("");
      const nodes = entries.map((entry) => {
        const pos = state.positions.get(entry.id);
        if (!isWorldVisible(pos.x, pos.y, 220)) return "";
        const color = toneColors[entry.emotionalTone] || "#d3a64d";
        const size = 18 + Math.min(18, Number(entry.resonanceScore || 3) * 2.2) + (entry.rarity === "rare" || entry.rarity === "very rare" ? 8 : 0);
        const char = characterSymbol((entry.characters || [])[0]);
        const label = labelForEntry(entry, pos, size, occupied);
        const dragClass = dragClassForEntry(entry.id);
        return `<g class="memory-node${entry.id === state.selectedId ? " selected" : ""}${entry.enabled === false ? " resting" : ""}${dragClass}" data-id="${html(entry.id)}" tabindex="0" transform="translate(${pos.x} ${pos.y})">
          <circle r="${size}" fill="${color}" opacity="${entry.enabled === false ? 0.42 : 0.92}"></circle>
          <text x="0" y="4" text-anchor="middle">${html(char)}</text>
        </g>${label}`;
      }).join("");
      graph.innerHTML = `<g id="world" transform="${cameraTransform()}">${rings}${clusterEdges}${edges}${clusterNodes}${nodes}<g id="dragLayer"></g></g>`;
      graph.querySelectorAll(".cluster-node").forEach((node) => {
        node.addEventListener("click", () => openCluster(node.dataset.id));
        node.addEventListener("dblclick", () => openCluster(node.dataset.id));
        node.addEventListener("contextmenu", (event) => showContextMenu(event, node.dataset.id));
      });
      graph.querySelectorAll(".memory-node").forEach((node) => {
        node.addEventListener("click", () => selectNode(node.dataset.id));
        node.addEventListener("dblclick", () => zoomToNode(node.dataset.id));
        node.addEventListener("pointerdown", (event) => beginNodeDrag(event, node.dataset.id));
        node.addEventListener("contextmenu", (event) => showContextMenu(event, node.dataset.id));
      });
      renderModeSupport(entries.length ? entries : filteredEntries(), clusters);
      renderBreadcrumb(scope);
      renderActiveState(scope);
      renderEmptyRecovery(scope);
      renderMinimap(scope);
      updateDragPreview();
    }
    function dragClassForEntry(id) {
      if (!state.drag) return "";
      if (id === state.drag.sourceId) return " dragging";
      if (id === state.drag.targetId && state.drag.targetKind === "node") return " drop-target";
      return state.drag.compatibleIds?.has(id) ? " compatible" : "";
    }
    function renderModeSupport(entries, clusters = []) {
      const titles = {
        graph: ["Emotional Graph", "Relationship web: continuations, echoes, symbols, characters, triggers."],
        timeline: ["Timeline View", "Memories arranged by modified date so the constellation becomes a diary."],
        character: ["Character View", "Nodes gather around lima, aphy, sensei chi, young z, future z, and z."],
        layer: ["Layer Dive", "Depth zones show the emotional pressure from surface reality to the core."],
        flow: ["Discovery Flow", "A possible path from ordinary encounter to patient secret."],
      };
      $("mapTitle").textContent = titles[state.mode][0];
      $("mapSub").textContent = titles[state.mode][1];
      const scopeText = clusters.length ? `${clusters.length} ${clusterKindLabel()}s visible, representing ${clusters.reduce((n, cluster) => n + cluster.entries.length, 0)} memories.` : `${entries.length} memories visible.`;
      $("mapNote").textContent = `${scopeText} Zoom in or open an island to reveal detail. Drag memories to weave continuation threads.`;
    }

    function renderValidation() {
      const validation = state.meta.validation || {};
      const summary = validation.summary || {};
      const ok = validation.ok && !summary.unknownCharacterCount && !summary.orphanNodeCount && !summary.staleLinkCount;
      $("integrityStatus").innerHTML = ok
        ? "all memories belong to canonical character territories. no orphan nodes, unknown aliases, or stale relationship links found."
        : `needs attention: ${summary.unknownCharacterCount || 0} unknown characters, ${summary.orphanNodeCount || 0} orphan memories, ${summary.staleLinkCount || 0} stale links. ${html(validation.repairPolicy || "")}`;
    }

    function isWorldVisible(x, y, pad = 0) {
      const p = worldToScreen(x, y);
      return p.x >= -pad && p.x <= SCREEN.width + pad && p.y >= -pad && p.y <= SCREEN.height + pad;
    }

    function labelScale() {
      return Math.max(0.42, Math.min(1.6, 1 / Math.max(0.42, state.camera.k)));
    }

    function overlaps(box, boxes) {
      return boxes.some((item) => !(box.x + box.w < item.x || item.x + item.w < box.x || box.y + box.h < item.y || item.y + item.h < box.y));
    }

    function reserveLabel(pos, width, height, occupied) {
      const screen = worldToScreen(pos.x, pos.y);
      const box = { x: screen.x, y: screen.y, w: width * state.camera.k, h: height * state.camera.k };
      if (overlaps(box, occupied)) return false;
      occupied.push(box);
      return true;
    }

    function labelForCluster(cluster, pos, size, occupied) {
      if (state.camera.k < 0.16) return "";
      const scale = labelScale();
      const text = truncate(cluster.label, state.camera.k > 0.35 ? 46 : 28);
      const width = Math.max(180, text.length * 8 + 34);
      const height = state.camera.k > 0.34 ? 68 : 40;
      const labelPos = { x: pos.x - width * scale / 2, y: pos.y + size + 28 };
      if (!reserveLabel(labelPos, width * scale, height * scale, occupied)) return "";
      const detail = state.camera.k > 0.34 ? `<text x="14" y="45" font-size="15">${cluster.entries.length} memories / ${html(layerName(cluster.depth))}</text>` : "";
      return `<g class="cluster-label" transform="translate(${labelPos.x} ${labelPos.y}) scale(${scale})">
        <rect width="${width}" height="${height}"></rect>
        <text x="14" y="25" font-size="20">${html(text)}</text>
        ${detail}
      </g>`;
    }

    function labelForEntry(entry, pos, size, occupied) {
      const selected = entry.id === state.selectedId;
      const important = selected || Number(entry.resonanceScore || 0) >= 7 || ["rare", "very rare", "seasonal", "timed"].includes(entry.rarity);
      if (!selected && state.camera.k < 0.68 && !important) return "";
      if (!isWorldVisible(pos.x, pos.y, 260)) return "";
      const scale = labelScale();
      const deep = state.camera.k > 1.25 || selected;
      const text = truncate(entry.title, deep ? 72 : state.camera.k > 0.85 ? 42 : 24);
      const meta = `${entry.type || "memory"} / ${entry.emotionalTone || "warm"}`;
      const width = Math.max(190, Math.min(420, text.length * 8 + 42));
      const height = deep ? 92 : 44;
      const labelPos = { x: pos.x + size + 18, y: pos.y - height * scale / 2 };
      if (!selected && !reserveLabel(labelPos, width * scale, height * scale, occupied)) return "";
      const snippet = deep && entry.content ? `<text class="node-meta" x="14" y="72" font-size="14">${html(truncate(entry.content.replace(/\s+/g, " "), 58))}</text>` : "";
      const metaLine = deep ? `<text class="node-meta" x="14" y="48" font-size="14">${html(meta)}</text>` : "";
      return `<g class="node-label${selected ? " selected" : ""}" transform="translate(${labelPos.x} ${labelPos.y}) scale(${scale})">
        <rect width="${width}" height="${height}"></rect>
        <text x="14" y="27" font-size="19">${html(text)}</text>
        ${metaLine}${snippet}
      </g>`;
    }

    function truncate(value, length) {
      const text = String(value || "");
      return text.length > length ? `${text.slice(0, Math.max(0, length - 3))}...` : text;
    }

    function openCluster(clusterId) {
      snapshotExploration("open island");
      state.history.push({ focusCluster: state.focusCluster, focusIds: state.focusIds ? [...state.focusIds] : null, focusLabel: state.focusLabel, filters: filterState(), camera: { ...state.camera }, zoom: state.zoom, selectedId: state.selectedId });
      state.focusCluster = clusterId;
      state.focusIds = null;
      state.focusLabel = "";
      render();
      const scope = visibleScope();
      if (scope.entries.length) {
        fitCameraTo(boundsForEntries(scope.entries), true);
        selectNode(scope.entries[0].id);
      }
    }

    function renderBreadcrumb(scope) {
      const filters = activeFilters();
      const parts = [`<button type="button" data-action="home">Observatory</button>`, `<button type="button" data-clear="mode">${html(state.mode)}</button>`];
      filters.forEach((filter) => parts.push(`<button type="button" title="${html(filter.help)}" data-clear="${html(filter.id)}">${html(filter.label)}</button>`));
      if (scope.focusedCluster) parts.push(`<button type="button" data-clear="focus">${html(scope.focusedCluster.label)}</button>`);
      if (state.focusIds) parts.push(`<button type="button" data-clear="focus">${html(state.focusLabel || "Focused thread")}</button>`);
      $("breadcrumb").innerHTML = parts.join("");
      $("breadcrumb").querySelector("[data-action='home']")?.addEventListener("click", fullReset);
      $("breadcrumb").querySelectorAll("[data-clear]").forEach((button) => button.addEventListener("click", () => clearExplorationPart(button.dataset.clear)));
    }

    function activeFilters() {
      const filters = [];
      const value = (id) => $(id).value;
      if (value("search")) filters.push({ id: "search", label: `Search: ${value("search")}`, help: "Only memories matching this text are visible." });
      if (value("layerFilter")) filters.push({ id: "layerFilter", label: `Depth: ${layerName(value("layerFilter"))}`, help: "Only this depth layer is visible." });
      if (value("characterFilter")) filters.push({ id: "characterFilter", label: `Character: ${value("characterFilter")}`, help: "Only memories in this character territory are visible." });
      if (value("toneFilter")) filters.push({ id: "toneFilter", label: `Tone: ${value("toneFilter")}`, help: "Only this emotional tone is visible." });
      if (value("rarityFilter")) filters.push({ id: "rarityFilter", label: `Rarity: ${value("rarityFilter")}`, help: "Only this rarity is visible." });
      return filters;
    }

    function renderActiveState(scope) {
      const filters = activeFilters();
      const focus = [];
      if (scope.focusedCluster) focus.push({ id: "focus", label: `Island: ${scope.focusedCluster.label}`, help: "The observatory is focused on one island." });
      if (state.focusIds) focus.push({ id: "focus", label: state.focusLabel || "Focused thread", help: "Only nearby thread memories are visible." });
      const active = [...filters, ...focus];
      if (!active.length) {
        $("activeState").innerHTML = `<span class="subtle">Whole sky visible. You can always return here.</span>`;
        return;
      }
      $("activeState").innerHTML = active.map((item) => `<span class="filter-pill" title="${html(item.help)}">${html(item.label)}<button type="button" aria-label="Remove ${html(item.label)}" data-clear="${html(item.id)}">x</button></span>`).join("") + `<button type="button" data-action="soft">Clear Focus</button><button type="button" data-action="full">Whole Sky</button>`;
      $("activeState").querySelectorAll("[data-clear]").forEach((button) => button.addEventListener("click", () => clearExplorationPart(button.dataset.clear)));
      $("activeState").querySelector("[data-action='soft']")?.addEventListener("click", softReset);
      $("activeState").querySelector("[data-action='full']")?.addEventListener("click", fullReset);
    }

    function clearExplorationPart(part) {
      snapshotExploration(`clear ${part}`);
      if (part === "mode") state.mode = "graph";
      if (part === "focus") {
        state.focusCluster = "";
        state.focusIds = null;
        state.focusLabel = "";
      }
      if (["search","layerFilter","characterFilter","toneFilter","rarityFilter"].includes(part)) $(part).value = "";
      $("modebar").querySelectorAll("button").forEach((item) => item.classList.toggle("active", item.dataset.mode === state.mode));
      render();
      setStatus("that thread of exploration has been lifted.");
    }

    function renderEmptyRecovery(scope) {
      const filtered = filteredEntries();
      const empty = !scope.entries.length && !scope.clusters.length && (activeFilters().length || state.focusCluster || state.focusIds);
      if (!empty) {
        $("emptyRecovery").classList.add("hidden");
        return;
      }
      $("emptyRecovery").classList.remove("hidden");
      $("emptyRecovery").innerHTML = `<h2>No lights in this small patch</h2>
        <p class="subtle">This view is narrowed by the current exploration state. You can step back, clear the focus, or return to the whole sky.</p>
        <div class="actions"><button type="button" data-action="undo">Undo Exploration</button><button type="button" data-action="soft">Clear Focus</button><button type="button" data-action="full">Return to the Whole Sky</button></div>
        <p class="subtle">${filtered.length ? `${filtered.length} memories still match the filters outside this focus.` : "Try rare memories, lima territory, or connected warm echoes from the guide."}</p>`;
      $("emptyRecovery").querySelector("[data-action='undo']").addEventListener("click", undoExploration);
      $("emptyRecovery").querySelector("[data-action='soft']").addEventListener("click", softReset);
      $("emptyRecovery").querySelector("[data-action='full']").addEventListener("click", fullReset);
    }

    function renderMinimap(scope) {
      const clusters = scope.clusters.length ? scope.clusters : clusteredEntries(filteredEntries()).slice(0, 40);
      const previousPositions = state.clusterPositions;
      layoutClusters(clusters);
      $("minimap").innerHTML = clusters.map((cluster) => {
        const p = state.clusterPositions.get(cluster.id);
        const x = Math.max(4, Math.min(96, p.x / WORLD.width * 100));
        const y = Math.max(4, Math.min(66, p.y / WORLD.height * 70));
        return `<circle cx="${x}" cy="${y}" r="${Math.max(2, Math.min(6, Math.sqrt(cluster.entries.length)))}" fill="${toneColors[cluster.tone] || "#d3a64d"}" opacity="0.68"></circle>`;
      }).join("");
      if (scope.clusters.length) state.clusterPositions = previousPositions;
    }

    function resetMap() {
      softReset();
    }

    function softReset() {
      snapshotExploration("clear focus");
      state.focusCluster = "";
      state.focusIds = null;
      state.focusLabel = "";
      render();
      fitCameraTo({ minX: 250, minY: 220, maxX: WORLD.width - 250, maxY: WORLD.height - 220 }, true);
      setStatus("focus cleared. the current filters are still in place.");
    }

    function fullReset() {
      snapshotExploration("return to whole sky");
      state.focusCluster = "";
      state.focusIds = null;
      state.focusLabel = "";
      setFilterState({});
      state.mode = "graph";
      state.zoom = 0;
      $("modebar").querySelectorAll("button").forEach((item) => item.classList.toggle("active", item.dataset.mode === state.mode));
      render();
      fitCameraTo({ minX: 250, minY: 220, maxX: WORLD.width - 250, maxY: WORLD.height - 220 }, true);
      setStatus("returned to the whole sky.");
    }

    function boundsForEntries(entries) {
      const points = entries.map((entry) => state.positions.get(entry.id)).filter(Boolean);
      if (!points.length) return { minX: 250, minY: 220, maxX: WORLD.width - 250, maxY: WORLD.height - 220 };
      return {
        minX: Math.min(...points.map((p) => p.x)),
        minY: Math.min(...points.map((p) => p.y)),
        maxX: Math.max(...points.map((p) => p.x)),
        maxY: Math.max(...points.map((p) => p.y)),
      };
    }

    function zoomToNode(id) {
      const entry = state.entries.find((item) => item.id === id);
      if (!entry) return;
      snapshotExploration("zoom to memory");
      state.history.push({ focusCluster: state.focusCluster, focusIds: state.focusIds ? [...state.focusIds] : null, focusLabel: state.focusLabel, filters: filterState(), camera: { ...state.camera }, zoom: state.zoom, selectedId: state.selectedId });
      selectNode(id);
      const point = state.positions.get(id);
      if (!point) return;
      animateCamera({ k: 1.75, x: SCREEN.width / 2 - point.x * 1.75, y: SCREEN.height / 2 - point.y * 1.75 });
    }
    function selectNode(id) {
      const entry = state.entries.find((item) => item.id === id) || state.entries[0];
      if (!entry) return;
      state.selectedId = entry.id;
      fillForm(entry);
      render();
    }
    function relationForEvent(event) {
      if (event?.altKey) return { key: "mirroredEntries", kind: "mirror", label: "Create mirror" };
      if (event?.shiftKey) return { key: "echoes", kind: "echo", label: "Add symbolic echo" };
      if (event?.ctrlKey || event?.metaKey) return { key: "symbolicLinks", kind: "symbolic", label: "Create symbolic link" };
      return { key: "continuationLinks", kind: "continuation", label: "Create continuation" };
    }
    function connectNodes(sourceId, targetId, relation = relationForEvent()) {
      if (!sourceId || !targetId || sourceId === targetId) return;
      snapshot();
      const entry = state.entries.find((item) => item.id === sourceId);
      if (!entry) return;
      entry[relation.key] = Array.from(new Set([...(entry[relation.key] || []), targetId]));
      state.selectedId = sourceId;
      rememberDraft();
      fillForm(entry);
      render();
      setStatus(`${relation.label.toLowerCase()} woven between those memories.`);
    }

    function addToCluster(sourceId, clusterId) {
      const entry = state.entries.find((item) => item.id === sourceId);
      const cluster = clusteredEntries(filteredEntries()).find((item) => item.id === clusterId);
      if (!entry || !cluster) return;
      snapshot();
      if (state.mode === "character" && cluster.label) entry.characters = Array.from(new Set([...(entry.characters || []), cluster.label]));
      else if (state.mode === "layer") entry.familyLayer = String(cluster.depth);
      else entry.narrativeArcs = Array.from(new Set([...(entry.narrativeArcs || []), cluster.label]));
      state.focusCluster = "";
      state.focusIds = null;
      state.focusLabel = "";
      state.selectedId = sourceId;
      rememberDraft();
      fillForm(entry);
      render();
      setStatus(`memory moved into ${cluster.label}.`);
    }

    function createThreadFromEmptyDrop(sourceId) {
      const entry = state.entries.find((item) => item.id === sourceId);
      if (!entry) return;
      snapshot();
      const name = `hand-shaped thread ${new Date().toISOString().slice(0, 10)}`;
      entry.narrativeArcs = Array.from(new Set([...(entry.narrativeArcs || []), name]));
      state.selectedId = sourceId;
      rememberDraft();
      fillForm(entry);
      render();
      setStatus("a new thread has begun around that memory.");
    }

    function beginNodeDrag(event, id) {
      if (event.button !== 0) return;

      event.preventDefault();
      event.stopPropagation();
      hideContextMenu();

      // First click/press only loads/selects the node.
      // Dragging is only allowed when the node is already selected.
      if (state.selectedId !== id) {
        state.drag = null;
        state.draggingId = "";
        selectNode(id);
        updateDragPreview();
        setStatus("memory loaded. click and drag it again to create a connection.");
        return;
      }

      // Second click + drag starts the connection.
      const point = pointerWorld(event);
      const entry = state.entries.find((item) => item.id === id);
      const related = new Set([
        ...(entry?.continuationLinks || []),
        ...(entry?.echoes || []),
        ...(entry?.thematicLinks || []),
        ...(entry?.symbolicLinks || []),
        ...(entry?.mirroredEntries || []),
      ]);

      state.drag = {
        sourceId: id,
        pointerId: event.pointerId,
        x: point.x,
        y: point.y,
        targetId: "",
        targetKind: "",
        relation: relationForEvent(event),
        compatibleIds: related,
        startedAt: Date.now(),
      };

      state.draggingId = id;
      $("dragGuide").classList.remove("hidden");
      $("graph").setPointerCapture(event.pointerId);
      updateDragTarget(point, event);
      render();
    }

    function pointerWorld(event) {
      const graph = $("graph");
      const rect = graph.getBoundingClientRect();
      const x = ((event.clientX - rect.left) / rect.width) * SCREEN.width;
      const y = ((event.clientY - rect.top) / rect.height) * SCREEN.height;
      return screenToWorld(x, y);
    }

    function updateDragTarget(point, event) {
      if (!state.drag) return;
      state.drag.x = point.x;
      state.drag.y = point.y;
      state.drag.relation = relationForEvent(event);
      const hitNode = nearestNode(point, state.drag.sourceId);
      const hitCluster = hitNode ? null : nearestCluster(point);
      state.drag.targetId = hitNode?.id || hitCluster?.id || "";
      state.drag.targetKind = hitNode ? "node" : hitCluster ? "cluster" : "empty";
      updateDragClasses();
      updateDragPreview();
    }

    function nearestNode(point, excludeId = "") {
      let best = null;
      filteredEntries().forEach((entry) => {
        if (entry.id === excludeId) return;
        const pos = state.positions.get(entry.id);
        if (!pos) return;
        const distance = Math.hypot(point.x - pos.x, point.y - pos.y);
        if (distance < 92 && (!best || distance < best.distance)) best = { id: entry.id, distance };
      });
      return best;
    }

    function nearestCluster(point) {
      let best = null;
      clusteredEntries(filteredEntries()).forEach((cluster) => {
        const pos = state.clusterPositions.get(cluster.id);
        if (!pos) return;
        const distance = Math.hypot(point.x - pos.x, point.y - pos.y);
        if (distance < 170 && (!best || distance < best.distance)) best = { id: cluster.id, distance };
      });
      return best;
    }

    function updateDragClasses() {
      $("graph").querySelectorAll(".memory-node").forEach((node) => {
        node.classList.toggle("dragging", Boolean(state.drag && node.dataset.id === state.drag.sourceId));
        node.classList.toggle("drop-target", Boolean(state.drag && state.drag.targetKind === "node" && node.dataset.id === state.drag.targetId));
        node.classList.toggle("compatible", Boolean(state.drag?.compatibleIds?.has(node.dataset.id) && node.dataset.id !== state.drag.sourceId));
      });
      $("graph").querySelectorAll(".cluster-node").forEach((node) => {
        node.classList.toggle("drop-target", Boolean(state.drag && state.drag.targetKind === "cluster" && node.dataset.id === state.drag.targetId));
      });
    }

    function updateDragPreview() {
      const layer = $("dragLayer");
      if (!layer) return;
      if (!state.drag) {
        layer.innerHTML = "";
        return;
      }
      const source = state.positions.get(state.drag.sourceId);
      if (!source) return;
      const target = state.drag.targetKind === "node" ? state.positions.get(state.drag.targetId) : state.drag.targetKind === "cluster" ? state.clusterPositions.get(state.drag.targetId) : { x: state.drag.x, y: state.drag.y };
      if (!target) return;
      const label = state.drag.targetKind === "cluster" ? "Move into thread" : state.drag.targetKind === "empty" ? "Begin new thread" : state.drag.relation.label;
      const invalid = state.drag.targetKind === "empty" && Math.hypot(source.x - state.drag.x, source.y - state.drag.y) < 90;
      const x = (source.x + target.x) / 2;
      const y = (source.y + target.y) / 2 - 24;
      layer.innerHTML = `<line class="drag-preview-line" x1="${source.x}" y1="${source.y}" x2="${target.x}" y2="${target.y}" opacity="${invalid ? 0.38 : 1}"></line>
        <circle class="drag-preview-halo" cx="${target.x}" cy="${target.y}" r="${state.drag.targetKind === "cluster" ? 138 : 48}" opacity="${invalid ? 0.25 : 1}"></circle>
        <g class="drag-preview-label" transform="translate(${x - 92} ${y - 20})"><rect width="184" height="34"></rect><text x="92" y="22" text-anchor="middle">${html(invalid ? "Move farther to begin" : label)}</text></g>`;
    }

    function finishNodeDrag(event) {
      if (!state.drag || state.drag.pointerId !== event.pointerId) return;
      const drag = state.drag;
      state.drag = null;
      state.draggingId = "";
      try { $("graph").releasePointerCapture(event.pointerId); } catch (err) {}
      updateDragPreview();
      if (Date.now() - drag.startedAt < 120 && !drag.targetId) return render();
      if (drag.targetKind === "node") connectNodes(drag.sourceId, drag.targetId, drag.relation);
      else if (drag.targetKind === "cluster") addToCluster(drag.sourceId, drag.targetId);
      else if (Math.hypot((state.positions.get(drag.sourceId)?.x || drag.x) - drag.x, (state.positions.get(drag.sourceId)?.y || drag.y) - drag.y) > 90) createThreadFromEmptyDrop(drag.sourceId);
      else {
        render();
        setStatus("drop farther away, onto another memory, or into an island to shape a relationship.");
      }
    }

    function showContextMenu(event, id) {
      event.preventDefault();
      event.stopPropagation();
      const entry = state.entries.find((item) => item.id === id);
      if (!entry) return;
      state.selectedId = id;
      fillForm(entry);
      const menu = $("contextMenu");
      menu.innerHTML = [
        ["continuation", "Create continuation"],
        ["echo", "Create echo"],
        ["mirror", "Create mirror"],
        ["symbolic", "Create symbolic link"],
        ["focus", "Focus thread"],
        ["isolate", "Isolate constellation"],
        ["arc", "Add to arc"],
        ["duplicate", "Duplicate memory"],
        ["layer", "Move to layer"],
        ["character", "Connect to character territory"],
      ].map(([action, label]) => `<button type="button" data-action="${action}">${label}</button>`).join("");
      menu.style.left = `${Math.min(window.innerWidth - 250, event.clientX)}px`;
      menu.style.top = `${Math.min(window.innerHeight - 360, event.clientY)}px`;
      menu.classList.remove("hidden");
      menu.querySelectorAll("button").forEach((button) => button.addEventListener("click", () => runContextAction(button.dataset.action, id)));
    }

    function hideContextMenu() {
      $("contextMenu").classList.add("hidden");
    }

    function chooseTarget(sourceId, promptText) {
      const value = prompt(promptText);
      if (!value) return "";
      const lowered = value.trim().toLowerCase();
      return state.entries.find((entry) => entry.id === value.trim() || (entry.title || "").toLowerCase() === lowered)?.id || "";
    }

    function runContextAction(action, id) {
      hideContextMenu();
      selectNode(id);
      const relationMap = {
        continuation: { key: "continuationLinks", label: "Create continuation" },
        echo: { key: "echoes", label: "Create echo" },
        mirror: { key: "mirroredEntries", label: "Create mirror" },
        symbolic: { key: "symbolicLinks", label: "Create symbolic link" },
      };
      if (relationMap[action]) {
        const targetId = chooseTarget(id, "Connect to which memory? Type an id or exact title.");
        if (targetId) connectNodes(id, targetId, relationMap[action]);
        else setStatus("no matching memory found for that connection.");
      }
      if (action === "focus") focusThread();
      if (action === "isolate") {
        const entry = currentEntry();
        if (!entry) return;
        snapshotExploration("isolate constellation");
        state.focusCluster = "";
        state.focusIds = new Set([entry.id, ...state.entries.filter((other) => other.id !== entry.id && (entry.characters || []).some((name) => (other.characters || []).includes(name))).map((item) => item.id)]);
        state.focusLabel = `Constellation: ${entry.title}`;
        render();
        fitCameraTo(boundsForEntries(visibleScope().entries), true);
      }
      if (action === "arc") {
        const entry = currentEntry();
        const arc = prompt("Add this memory to which arc?", (entry?.narrativeArcs || [])[0] || "");
        if (!entry || !arc) return;
        snapshot();
        entry.narrativeArcs = Array.from(new Set([...(entry.narrativeArcs || []), arc.trim()]));
        rememberDraft();
        fillForm(entry);
        render();
      }
      if (action === "duplicate") duplicateEntry();
      if (action === "layer") {
        const entry = currentEntry();
        const layer = prompt("Move to which depth layer? 0-5", String(depth(entry)));
        if (!entry || !/^[0-5]$/.test(layer || "")) return;
        snapshot();
        entry.familyLayer = layer;
        rememberDraft();
        fillForm(entry);
        render();
      }
      if (action === "character") {
        const entry = currentEntry();
        const character = prompt("Connect to which character territory?", (entry?.characters || [])[0] || "lima");
        const normalized = normalizeCharacterName(character);
        if (!entry || !normalized) return setStatus("that character territory is not in the registry.");
        snapshot();
        entry.characters = Array.from(new Set([...(entry.characters || []), normalized]));
        rememberDraft();
        fillForm(entry);
        render();
      }
    }

    function zoomBy(delta) {
      zoomAt(SCREEN.width / 2, SCREEN.height / 2, delta > 0 ? 1.35 : 1 / 1.35);
    }

    function goBack() {
      const previous = state.history.pop();
      if (!previous) return undoExploration();
      state.focusCluster = previous.focusCluster;
      state.focusIds = previous.focusIds ? new Set(previous.focusIds) : null;
      state.focusLabel = previous.focusLabel || "";
      state.zoom = previous.zoom;
      state.selectedId = previous.selectedId;
      if (previous.filters) setFilterState(previous.filters);
      if (previous.camera) state.camera = { ...previous.camera };
      render();
      if (state.selectedId) fillForm(currentEntry());
      applyCamera();
    }

    function focusThread() {
      const entry = currentEntry();
      if (!entry) return;
      const ids = new Set([entry.id, ...(entry.continuationLinks || []), ...(entry.echoes || []), ...(entry.thematicLinks || []), ...(entry.symbolicLinks || []), ...(entry.triggerLinks || []), ...(entry.parentLinks || []), ...(entry.childLinks || []), ...(entry.mirroredEntries || [])]);
      snapshotExploration("focus thread");
      state.history.push({ focusCluster: state.focusCluster, focusIds: state.focusIds ? [...state.focusIds] : null, focusLabel: state.focusLabel, filters: filterState(), camera: { ...state.camera }, zoom: state.zoom, selectedId: state.selectedId });
      state.focusCluster = "";
      state.focusIds = ids;
      state.focusLabel = `Thread: ${entry.title || entry.id}`;
      render();
      fitCameraTo(boundsForEntries(visibleScope().entries), true);
      setStatus("focus mode is showing this memory's nearest thread first.");
    }

    function runTour(name) {
      snapshotExploration(`guided path: ${name}`);
      state.focusCluster = "";
      state.focusIds = null;
      state.focusLabel = "";
      state.zoom = 2;
      if (name === "lima") {
        $("characterFilter").value = "lima";
        $("rarityFilter").value = "";
        $("toneFilter").value = "";
        $("search").value = "";
      }
      if (name === "rare") {
        $("rarityFilter").value = "rare";
        $("characterFilter").value = "";
        $("toneFilter").value = "";
        $("search").value = "";
      }
      if (name === "future") {
        $("characterFilter").value = "future z";
        $("rarityFilter").value = "";
        $("toneFilter").value = "";
        $("search").value = "";
      }
      if (name === "unresolved") {
        $("search").value = "untitled unresolved maybe hidden";
        $("characterFilter").value = "";
        $("rarityFilter").value = "";
        $("toneFilter").value = "";
      }
      if (name === "connected") {
        $("search").value = "echo memory warm";
        $("characterFilter").value = "";
        $("rarityFilter").value = "";
        $("toneFilter").value = "";
      }
      render();
      window.setTimeout(() => fitCameraTo(boundsForEntries(visibleScope().entries), true), 0);
      setStatus("guided path opened. adjust the filters when you want to wander differently.");
    }
    function renderPreview(entry) {
      const body = html(entry.content || "").replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/\*([^*]+)\*/g, "<em>$1</em>");
      $("preview").innerHTML = `${body || "<span class='subtle'>This memory is waiting for words.</span>"}<div class="pills" style="margin-top:10px"><span class="pill">${html(layerName(depth(entry)))}</span><span class="pill">${html(entry.emotionalTone)}</span><span class="pill">${html(entry.rarity)}</span>${(entry.symbols || []).map((item) => `<span class="pill">${html(item)}</span>`).join("")}</div>`;
    }
    function renderConnections(entry) {
      const ids = new Set([...(entry.continuationLinks || []), ...(entry.echoes || []), ...(entry.thematicLinks || []), ...(entry.symbolicLinks || []), ...(entry.triggerLinks || []), ...(entry.parentLinks || []), ...(entry.childLinks || []), ...(entry.mirroredEntries || [])]);
      const sameCharacter = state.entries.filter((other) => other.id !== entry.id && (entry.characters || []).some((name) => (other.characters || []).includes(name))).slice(0, 4);
      const linked = [...ids].map((id) => state.entries.find((item) => item.id === id)).filter(Boolean);
      const items = [...linked, ...sameCharacter].slice(0, 10);
      $("connections").innerHTML = items.length ? items.map((item) => `<button class="connection" type="button" data-id="${html(item.id)}"><strong>${html(item.title)}</strong><br><span class="subtle">${html(layerName(depth(item)))} / ${html(item.type)}</span></button>`).join("") : "<div class='subtle'>No nearby memories yet. Drag from this node to another to begin a thread.</div>";
      $("connections").querySelectorAll("button").forEach((button) => button.addEventListener("click", () => selectNode(button.dataset.id)));
    }
    function newEntry(type = "quote") {
      snapshot();
      const now = new Date().toISOString().slice(0, 10);
      const entry = {
        id: `memory-${Date.now()}`,
        title: "Untitled memory",
        type,
        content: "",
        characters: [],
        emotionalTone: "warm",
        rarity: "common",
        triggerConditions: "",
        tags: [],
        category: type,
        pageLocation: "",
        familyLayer: "1",
        enabled: true,
        createdDate: now,
        modifiedDate: now,
        notes: "",
        emotionalRole: "",
        discoveryDifficulty: "gentle",
        mysteryLevel: "quiet",
        resonanceScore: 3,
        symbols: [],
        narrativeArcs: [],
        continuationLinks: [],
        echoes: [],
        thematicLinks: [],
        symbolicLinks: [],
        triggerLinks: [],
        parentLinks: [],
        childLinks: [],
        mirroredEntries: [],
      };
      state.entries.unshift(entry);
      selectNode(entry.id);
      rememberDraft();
    }
    function duplicateEntry() {
      const entry = currentEntry();
      if (!entry) return;
      snapshot();
      const copy = { ...entry, id: `${entry.id}-echo-${Date.now()}`, title: `${entry.title} echo`, createdDate: new Date().toISOString().slice(0, 10), modifiedDate: new Date().toISOString().slice(0, 10) };
      state.entries.unshift(copy);
      selectNode(copy.id);
      rememberDraft();
    }
    function deleteEntry() {
      const entry = currentEntry();
      if (!entry || !confirm(`Let "${entry.title}" rest outside the constellation?`)) return;
      snapshot();
      state.entries = state.entries.filter((item) => item.id !== entry.id);
      state.entries.forEach((item) => {
        ["continuationLinks","echoes","thematicLinks","symbolicLinks","triggerLinks","parentLinks","childLinks","mirroredEntries"].forEach((key) => {
          item[key] = (item[key] || []).filter((id) => id !== entry.id);
        });
      });
      selectNode(state.entries[0]?.id);
      rememberDraft();
    }
    function populateControls() {
      const opt = (value, label = value) => `<option value="${html(value)}">${html(label || "All")}</option>`;
      $("type").innerHTML = state.meta.types.map((value) => opt(value)).join("");
      $("tone").innerHTML = state.meta.tones.map((value) => opt(value)).join("");
      $("rarity").innerHTML = state.meta.rarities.map((value) => opt(value)).join("");
      $("layerFilter").innerHTML = opt("", "All depths") + (state.meta.layers || []).map((layer) => opt(layer.id, `Layer ${layer.id} - ${layer.name}`)).join("");
      $("familyLayer").innerHTML = (state.meta.layers || []).map((layer) => opt(layer.id, `Layer ${layer.id} - ${layer.name}`)).join("");
      $("characterFilter").innerHTML = opt("", "All characters") + state.meta.characters.map((value) => opt(value)).join("");
      $("toneFilter").innerHTML = opt("", "All tones") + state.meta.tones.map((value) => opt(value)).join("");
      $("rarityFilter").innerHTML = opt("", "All rarities") + state.meta.rarities.map((value) => opt(value)).join("");
      $("layerKey").innerHTML = (state.meta.layers || []).map((layer) => `<div class="layer-row"><span class="depth-mark">${html(layer.id)}</span><span><strong>${html(layer.name)}</strong><span class="subtle">${html(layer.meaning)}</span></span></div>`).join("");
    }
    async function save() {
      applyFormToState();
      setStatus("Saving the constellation.");
      try {
        const data = await api("/api/hidden", { method: "POST", body: JSON.stringify({ entries: state.entries }) });
        state.entries = data.entries;
        state.meta = data;
        localStorage.removeItem(draftKey);
        setStatus(data.message || "constellation stored safely.");
        renderValidation();
        render();
      } catch (err) {
        setStatus(err.message);
      }
    }
    async function load() {
      try {
        const data = await api("/api/hidden");
        state.meta = data;
        state.entries = data.entries || [];
        const draft = JSON.parse(localStorage.getItem(draftKey) || "null");
        if (draft?.entries?.length && confirm("A local constellation draft exists. Restore it?")) {
          state.entries = draft.entries;
          state.selectedId = draft.selectedId || "";
        }
        populateControls();
        renderValidation();
        setStatus(data.migratedFromJs ? "Existing hidden details became a memory constellation." : "observatory open.");
        selectNode(state.selectedId || state.entries[0]?.id);
        fitCameraTo({ minX: 250, minY: 220, maxX: WORLD.width - 250, maxY: WORLD.height - 220 }, false);
        render();
      } catch (err) {
        setStatus(err.message);
      }
    }

    function setupCameraEvents() {
      const graph = $("graph");
      graph.addEventListener("wheel", (event) => {
        event.preventDefault();
        const rect = graph.getBoundingClientRect();
        const x = ((event.clientX - rect.left) / rect.width) * SCREEN.width;
        const y = ((event.clientY - rect.top) / rect.height) * SCREEN.height;
        zoomAt(x, y, event.deltaY < 0 ? 1.12 : 1 / 1.12);
      }, { passive: false });
      graph.addEventListener("pointerdown", (event) => {
        if (event.button !== 0 || event.target.closest(".memory-node, .cluster-node")) return;
        graph.setPointerCapture(event.pointerId);
        graph.classList.add("is-panning");
        state.pan = { id: event.pointerId, x: event.clientX, y: event.clientY, vx: 0, vy: 0, last: performance.now() };
      });
      graph.addEventListener("pointermove", (event) => {
        if (state.drag && state.drag.pointerId === event.pointerId) {
          updateDragTarget(pointerWorld(event), event);
          return;
        }
        if (!state.pan || state.pan.id !== event.pointerId) return;
        const dx = event.clientX - state.pan.x;
        const dy = event.clientY - state.pan.y;
        state.camera.x += dx * (SCREEN.width / graph.clientWidth);
        state.camera.y += dy * (SCREEN.height / graph.clientHeight);
        const now = performance.now();
        const dt = Math.max(16, now - state.pan.last);
        state.pan.vx = dx / dt;
        state.pan.vy = dy / dt;
        state.pan.x = event.clientX;
        state.pan.y = event.clientY;
        state.pan.last = now;
        applyCamera();
      });
      graph.addEventListener("pointerup", (event) => {
        if (state.drag && state.drag.pointerId === event.pointerId) finishNodeDrag(event);
        finishPan(event.pointerId);
      });
      graph.addEventListener("pointercancel", (event) => {
        if (state.drag && state.drag.pointerId === event.pointerId) {
          state.drag = null;
          updateDragPreview();
          render();
        }
        finishPan(event.pointerId);
      });
      graph.addEventListener("dblclick", (event) => {
        if (event.target.closest(".memory-node, .cluster-node")) return;
        const rect = graph.getBoundingClientRect();
        const x = ((event.clientX - rect.left) / rect.width) * SCREEN.width;
        const y = ((event.clientY - rect.top) / rect.height) * SCREEN.height;
        zoomAt(x, y, 1.55);
      });
    }

    function finishPan(pointerId) {
      const graph = $("graph");
      if (!state.pan || state.pan.id !== pointerId) return;
      graph.classList.remove("is-panning");
      const vx = state.pan.vx * 180;
      const vy = state.pan.vy * 180;
      state.pan = null;
      let decay = 1;
      function glide() {
        if (decay < 0.04 || state.pan) return;
        state.camera.x += vx * decay;
        state.camera.y += vy * decay;
        applyCamera();
        decay *= 0.82;
        requestAnimationFrame(glide);
      }
      requestAnimationFrame(glide);
      window.clearTimeout(state.renderTimer);
      state.renderTimer = window.setTimeout(render, 120);
    }
    fields.forEach((id) => {
      $(id).addEventListener("input", applyFormToState);
      $(id).addEventListener("change", () => { snapshot(); applyFormToState(); });
    });
    let filterSnapshotTimer = 0;
    ["search","layerFilter","characterFilter","toneFilter","rarityFilter"].forEach((id) => {
      $(id).addEventListener("focus", () => {
        window.clearTimeout(filterSnapshotTimer);
        filterSnapshotTimer = window.setTimeout(() => snapshotExploration("filter change"), 0);
      });
      $(id).addEventListener("input", () => {
        state.focusCluster = "";
        state.focusIds = null;
        state.focusLabel = "";
        render();
      });
      $(id).addEventListener("change", () => {
        state.focusCluster = "";
        state.focusIds = null;
        state.focusLabel = "";
        render();
      });
    });
    $("newBtn").onclick = () => newEntry();
    $("saveBtn").onclick = save;
    $("zoomOutBtn").onclick = () => zoomBy(-1);
    $("zoomInBtn").onclick = () => zoomBy(1);
    $("backBtn").onclick = goBack;
    $("homeBtn").onclick = resetMap;
    $("softResetBtn").onclick = softReset;
    $("fullResetBtn").onclick = fullReset;
    $("exploreUndoBtn").onclick = undoExploration;
    $("focusBtn").onclick = focusThread;
    $("duplicateBtn").onclick = duplicateEntry;
    $("deleteBtn").onclick = deleteEntry;
    $("undoBtn").onclick = () => { if (!state.undo.length) return; state.redo.push(JSON.stringify(state.entries)); restore(state.undo.pop()); };
    $("redoBtn").onclick = () => { if (!state.redo.length) return; state.undo.push(JSON.stringify(state.entries)); restore(state.redo.pop()); };
    $("modebar").querySelectorAll("button").forEach((button) => button.addEventListener("click", () => {
      snapshotExploration(`switch to ${button.dataset.mode}`);
      state.mode = button.dataset.mode;
      state.focusCluster = "";
      state.focusIds = null;
      state.focusLabel = "";
      state.zoom = 0;
      $("modebar").querySelectorAll("button").forEach((item) => item.classList.toggle("active", item === button));
      render();
    }));
    document.querySelectorAll("[data-tour]").forEach((button) => button.addEventListener("click", () => runTour(button.dataset.tour)));
    document.addEventListener("click", (event) => {
      if (!event.target.closest("#contextMenu")) hideContextMenu();
    });
    document.addEventListener("keydown", (event) => {
      if (event.key === "Escape") {
        hideContextMenu();
        if (state.drag) {
          state.drag = null;
          render();
          setStatus("drag cancelled. nothing changed.");
        }
      }
      if ((event.metaKey || event.ctrlKey) && event.key === "[") undoExploration();
    });
    setupCameraEvents();
    load();
  </script>
</body>
</html>
"""


class Handler(BaseHTTPRequestHandler):
    server_version = "OrgAuthoring/1.0"

    def log_message(self, fmt: str, *args: Any) -> None:
        sys.stderr.write("%s - %s\n" % (formatdate(time.time()), fmt % args))

    def send_json(self, data: Any, status: HTTPStatus = HTTPStatus.OK) -> None:
        body = json.dumps(data).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Cache-Control", "no-store")
        self.end_headers()
        self.wfile.write(body)

    def do_GET(self) -> None:
        parsed = urlparse(self.path)
        if parsed.path == "/":
            body = APP_HTML.encode("utf-8")
            self.send_response(HTTPStatus.OK)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)
            return
        if parsed.path == "/hidden":
            body = HIDDEN_APP_HTML.encode("utf-8")
            self.send_response(HTTPStatus.OK)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)
            return
        if parsed.path == "/api/pages":
            try:
                self.send_json(list_pages())
            except Exception as exc:
                self.send_json({"error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
            return
        if parsed.path == "/api/diagnostics":
            try:
                self.send_json(server_diagnostics())
            except Exception as exc:
                self.send_json({"error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
            return
        if parsed.path == "/api/page":
            query = parse_qs(parsed.query)
            try:
                path = safe_relative_path(query.get("path", [""])[0])
                self.send_json(page_to_dict(read_page(path)))
            except Exception as exc:
                self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
            return
        if parsed.path == "/api/build":
            self.send_json(BUILD_QUEUE.snapshot())
            return
        if parsed.path == "/api/hidden":
            try:
                self.send_json(load_hidden_store())
            except Exception as exc:
                self.send_json({"error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
            return
        self.send_error(HTTPStatus.NOT_FOUND)

    def do_POST(self) -> None:
        if self.path == "/api/upload":
            try:
                length = int(self.headers.get("Content-Length", "0"))
                filename, payload, page_path = parse_upload_form(
                    self.headers.get("Content-Type", ""),
                    self.rfile.read(length),
                )
                self.send_json(save_upload(filename, payload, page_path))
            except Exception as exc:
                self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
            return
        if self.path == "/api/hidden":
            try:
                length = int(self.headers.get("Content-Length", "0"))
                data = json.loads(self.rfile.read(length).decode("utf-8"))
                self.send_json(save_hidden_store(data))
            except Exception as exc:
                self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
            return
        if self.path != "/api/page":
            self.send_error(HTTPStatus.NOT_FOUND)
            return
        try:
            length = int(self.headers.get("Content-Length", "0"))
            data = json.loads(self.rfile.read(length).decode("utf-8"))
            saved = save_page(data)
            saved["queuedBuild"] = queue_build(saved)
            self.send_json(saved)
        except Exception as exc:
            self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)


def main() -> None:
    port = int(os.environ.get("AUTHOR_PORT", "8765"))
    server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
    page_count = len(list_pages())
    print(f"Authoring UI running at http://127.0.0.1:{port}")
    print(f"Content root: {ROOT}")
    print(f"Editable pages: {page_count}")
    if page_count == 0:
        print("WARNING: no editable pages were found. Check AUTHOR_ROOT and the launch directory.")
    print("Press Ctrl-C to stop.")
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass


if __name__ == "__main__":
    main()
