Files
authoring-service/authoring_server.py
Zaine 700764823b
All checks were successful
Build Authoring Service / build (push) Successful in 9s
update the new thing 3
2026-05-13 16:15:20 +01:00

5205 lines
231 KiB
Python
Executable File

#!/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
import mimetypes
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
APP_ROOT = Path(__file__).resolve().parent
DEFAULT_CONTENT_ROOT = Path("/home/zaine/master-folder/org_files/org_web")
def looks_like_content_root(path: Path) -> bool:
return (path / "blogs").exists() or (path / "posts").exists() or (path / "lima").exists()
def resolve_root() -> Path:
for env_name in ("AUTHOR_CONTENT_ROOT", "AUTHOR_ROOT"):
env_root = os.environ.get(env_name)
if not env_root:
continue
resolved = Path(env_root).expanduser().resolve()
if looks_like_content_root(resolved):
return resolved
candidates = [
Path.cwd(),
DEFAULT_CONTENT_ROOT,
APP_ROOT,
]
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 APP_ROOT
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 = Path(os.environ.get("AUTHOR_HIDDEN_BACKUP_DIR", APP_ROOT / "backups" / "hidden-details")).expanduser().resolve()
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",
"glow": "#f0b85c",
"avatar": "https://zainezq.com/assets/avatars/young-z.jpeg",
"fallbackAvatar": "https://zainezq.com/assets/avatars/z.jpeg",
"expressions": {
"default": "https://zainezq.com/assets/avatars/young-z.jpeg",
"nostalgic": "https://zainezq.com/assets/avatars/young-z.jpeg",
"playful": "https://zainezq.com/assets/avatars/young-z.jpeg",
},
"sizes": {"tiny": 18, "medium": 42, "close": 56, "focus": 82},
"observatory": {"territory": "nostalgic/playful memories", "shimmer": "soft"},
"presenceTones": ["nostalgic", "funny", "hopeful"],
"presenceKeywords": ["childhood", "play", "desk", "crayon", "young", "small", "blanket", "memory"],
"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",
"glow": "#ead68e",
"avatar": "https://zainezq.com/assets/avatars/z.jpeg",
"fallbackAvatar": "https://zainezq.com/assets/avatars/z.jpeg",
"expressions": {"default": "https://zainezq.com/assets/avatars/z.jpeg", "calm": "https://zainezq.com/assets/avatars/z.jpeg"},
"sizes": {"tiny": 18, "medium": 42, "close": 56, "focus": 82},
"observatory": {"territory": "self/archive memories", "shimmer": "steady"},
"presenceTones": ["warm", "soft", "hopeful"],
"presenceKeywords": ["website", "archive", "home", "self", "making", "return", "zaine"],
"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",
"glow": "#9dd3a6",
"avatar": "https://zainezq.com/assets/avatars/aphy.jpeg",
"fallbackAvatar": "https://zainezq.com/assets/avatars/z.jpeg",
"expressions": {
"default": "https://zainezq.com/assets/avatars/aphy.jpeg",
"system": "https://zainezq.com/assets/avatars/aphy.jpeg",
"amused": "https://zainezq.com/assets/avatars/aphy.jpeg",
},
"sizes": {"tiny": 18, "medium": 42, "close": 56, "focus": 82},
"observatory": {"territory": "system/symbolic areas", "shimmer": "diagnostic"},
"presenceTones": ["funny", "strange", "hopeful"],
"presenceKeywords": ["system", "console", "diagnostic", "keyboard", "search", "terminal", "backup", "symbolic", "query"],
"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",
"glow": "#f2c58b",
"avatar": "https://zainezq.com/assets/avatars/lima.jpg",
"fallbackAvatar": "https://zainezq.com/assets/avatars/z.jpeg",
"expressions": {
"default": "https://zainezq.com/assets/avatars/lima.jpg",
"calm": "https://zainezq.com/assets/avatars/lima.jpg",
"warm": "https://zainezq.com/assets/avatars/lima.jpg",
"protective": "https://zainezq.com/assets/avatars/lima.jpg",
},
"sizes": {"tiny": 18, "medium": 42, "close": 56, "focus": 82},
"observatory": {"territory": "warm/protective arcs", "shimmer": "warm"},
"presenceTones": ["warm", "protective", "soft"],
"presenceKeywords": ["lima", "love", "warm", "kitchen", "light", "ring", "home", "protect", "eat"],
"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",
"glow": "#9ccddd",
"avatar": "https://zainezq.com/assets/avatars/sensei-chi.jpeg",
"fallbackAvatar": "https://zainezq.com/assets/avatars/z.jpeg",
"expressions": {
"default": "https://zainezq.com/assets/avatars/sensei-chi.jpeg",
"reflective": "https://zainezq.com/assets/avatars/sensei-chi.jpeg",
"wise": "https://zainezq.com/assets/avatars/sensei-chi.jpeg",
},
"sizes": {"tiny": 18, "medium": 42, "close": 56, "focus": 82},
"observatory": {"territory": "reflective areas", "shimmer": "quiet"},
"presenceTones": ["wise", "melancholy", "soft"],
"presenceKeywords": ["reflection", "patience", "wisdom", "lesson", "tea", "garden", "quiet", "sensei"],
"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",
"glow": "#c2a4ee",
"avatar": "https://zainezq.com/assets/avatars/future-z.jpeg",
"fallbackAvatar": "https://zainezq.com/assets/avatars/z.jpeg",
"expressions": {
"default": "https://zainezq.com/assets/avatars/future-z.jpeg",
"temporal": "https://zainezq.com/assets/avatars/future-z.jpeg",
"reassuring": "https://zainezq.com/assets/avatars/future-z.jpeg",
},
"sizes": {"tiny": 18, "medium": 42, "close": 56, "focus": 82},
"observatory": {"territory": "temporal regions", "shimmer": "temporal"},
"presenceTones": ["hopeful", "melancholy", "wise"],
"presenceKeywords": ["future", "time", "clock", "older", "tomorrow", "age", "reassurance", "continuity"],
"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_STORY_MARKERS = ["public", "hidden", "rare", "emotional", "dream-like", "temporal"]
HIDDEN_DISCOVERY_STYLES = ["gradual", "direct", "hidden route", "character-led", "dream-like", "temporal"]
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 normalize_hidden_story(raw: dict[str, Any], entries: list[dict[str, Any]] | None = None, existing: dict[str, Any] | None = None) -> dict[str, Any]:
today = datetime.now().date().isoformat()
story = existing.copy() if existing else {}
story.update(raw)
title = str(normalize_character_text_refs(story.get("title") or "")).strip()
if not title:
raise ValueError("Every story needs a title.")
ids = {entry["id"] for entry in entries or []}
raw_nodes = story.get("nodes", [])
if isinstance(raw_nodes, str):
raw_nodes = re.split(r"[,:\s]+", raw_nodes)
nodes = []
for item in raw_nodes:
node_id = str(item).strip()
if node_id and node_id not in nodes and (not ids or node_id in ids):
nodes.append(node_id)
characters = normalize_character_list(story.get("characters", []))
symbols = [str(normalize_character_text_refs(item)).strip() for item in story.get("symbols", []) if str(item).strip()]
markers = [slugify(str(item)) for item in story.get("markers", []) if str(item).strip()]
layer_affinity = []
for item in story.get("layerAffinity", []):
match = re.search(r"[0-5]", str(item))
if match and int(match.group(0)) not in layer_affinity:
layer_affinity.append(int(match.group(0)))
if not layer_affinity and nodes:
by_id = {entry["id"]: entry for entry in entries or []}
layer_affinity = sorted({
int(match.group(0))
for node in nodes
if (match := re.search(r"[0-5]", str(by_id.get(node, {}).get("familyLayer", ""))))
})
story_id = slugify(str(story.get("id") or f"story-{title}"))
if not story_id.startswith("story-"):
story_id = f"story-{story_id}"
return {
"id": story_id,
"title": title,
"description": str(normalize_character_text_refs(story.get("description") or "")),
"tone": str(story.get("tone") or "warm"),
"characters": characters,
"symbols": symbols,
"nodes": nodes,
"discoveryStyle": str(story.get("discoveryStyle") or "gradual"),
"layerAffinity": layer_affinity,
"unlockConditions": [str(normalize_character_text_refs(item)).strip() for item in story.get("unlockConditions", []) if str(item).strip()],
"hidden": bool(story.get("hidden", False)),
"markers": markers,
"createdDate": str(story.get("createdDate") or today),
"modifiedDate": today,
}
def migrate_hidden_stories(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
story_groups: dict[str, dict[str, Any]] = {}
def add(group: str, entry: dict[str, Any], source: str) -> None:
if not group:
return
key = slugify(group)
if key not in story_groups:
story_groups[key] = {
"id": f"story-{key}",
"title": group.replace("-", " "),
"description": f"Migrated from old {source} relationships into a calmer story path.",
"tone": entry.get("emotionalTone") or "warm",
"characters": [],
"symbols": [],
"nodes": [],
"discoveryStyle": "gradual",
"layerAffinity": [],
"unlockConditions": [],
"hidden": False,
"markers": ["emotional"],
}
story = story_groups[key]
if entry["id"] not in story["nodes"]:
story["nodes"].append(entry["id"])
for character in entry.get("characters", []):
if character not in story["characters"]:
story["characters"].append(character)
for symbol in entry.get("symbols", []):
if symbol not in story["symbols"]:
story["symbols"].append(symbol)
layer = re.search(r"[0-5]", str(entry.get("familyLayer", "")))
if layer and int(layer.group(0)) not in story["layerAffinity"]:
story["layerAffinity"].append(int(layer.group(0)))
by_id = {entry["id"]: entry for entry in entries}
for entry in entries:
for arc in entry.get("narrativeArcs", []):
add(str(arc), entry, "arc")
for symbol in entry.get("symbols", []):
add(str(symbol), entry, "symbol")
for target in entry.get("continuationLinks", []) + entry.get("chainReferences", []):
if target in by_id:
name = (entry.get("narrativeArcs") or entry.get("symbols") or [entry.get("emotionalTone") or "quiet return"])[0]
add(str(name), entry, "continuation")
add(str(name), by_id[target], "continuation")
if not story_groups:
for character in HIDDEN_CHARACTERS:
character_entries = [entry for entry in entries if character in entry.get("characters", [])][:12]
if character_entries:
for entry in character_entries:
add(f"{character} stories", entry, "character territory")
return [normalize_hidden_story(story, entries) for story in story_groups.values() if len(story.get("nodes", [])) >= 2][:36]
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", [])
stories = data.get("stories", [])
else:
entries = migrate_hidden_entries_from_js()
stories = []
migrated = True
data = {
"schemaVersion": 2,
"generatedFrom": "assets/scripts/hidden-details.js",
"generatedAt": datetime.now().isoformat(timespec="seconds"),
"entries": entries,
"stories": stories,
}
normalized = [normalize_hidden_entry(entry, entry) for entry in entries]
normalized_stories = [normalize_hidden_story(story, normalized, story) for story in stories]
migrated_relationships = False
if not normalized_stories:
normalized_stories = migrate_hidden_stories(normalized)
migrated_relationships = bool(normalized_stories)
return {
"schemaVersion": 2,
"source": HIDDEN_DETAILS_JS.relative_to(ROOT).as_posix(),
"contentPath": HIDDEN_CONTENT_JSON.relative_to(ROOT).as_posix(),
"migratedFromJs": migrated,
"migratedRelationshipsToStories": migrated_relationships,
"types": HIDDEN_CONTENT_TYPES,
"characters": HIDDEN_CHARACTERS,
"characterRegistry": CHARACTER_REGISTRY,
"validation": validate_hidden_integrity(normalized, normalized_stories),
"tones": HIDDEN_TONES,
"rarities": HIDDEN_RARITIES,
"storyMarkers": HIDDEN_STORY_MARKERS,
"discoveryStyles": HIDDEN_DISCOVERY_STYLES,
"layers": HIDDEN_LAYER_DEPTHS,
"entries": normalized,
"stories": normalized_stories,
"recommendations": hidden_architecture_recommendations(),
}
def validate_hidden_integrity(entries: list[dict[str, Any]], stories: list[dict[str, Any]] | None = None) -> dict[str, Any]:
ids = {entry["id"] for entry in entries}
unknown_characters = []
orphan_nodes = []
stale_links = []
stale_story_nodes = []
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})
for story in stories or []:
for character in story.get("characters", []):
if character not in CHARACTER_REGISTRY:
unknown_characters.append({"story": story["id"], "character": character})
for node_id in story.get("nodes", []):
if node_id not in ids:
stale_story_nodes.append({"story": story["id"], "target": node_id})
return {
"ok": not unknown_characters and not orphan_nodes and not stale_links and not stale_story_nodes,
"unknownCharacters": unknown_characters[:50],
"orphanNodes": orphan_nodes[:50],
"staleLinks": stale_links[:50],
"staleStoryNodes": stale_story_nodes[:50],
"summary": {
"unknownCharacterCount": len(unknown_characters),
"orphanNodeCount": len(orphan_nodes),
"staleLinkCount": len(stale_links),
"staleStoryNodeCount": len(stale_story_nodes),
},
"repairPolicy": "Unknown aliases are normalized through the registry. Empty character lists are repaired to z. Old relationship links are retained as legacy data, but new authoring should happen through stories.",
}
def hidden_architecture_recommendations() -> dict[str, Any]:
return {
"storage": "Use assets/content/hidden-details.json as the friendly source of truth for memories and first-class stories, 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 memory id and story id rather than by whole-file ownership.",
"scalability": "Stories are the primary emotional routes. Legacy relationship fields remain readable for migration, but the observatory should stay story-first.",
}
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]:
loaded = load_hidden_store()
current = {entry["id"]: entry for entry in loaded["entries"]}
entries = [normalize_hidden_entry(entry, current.get(str(entry.get("id", "")))) for entry in payload.get("entries", [])]
current_stories = {story["id"]: story for story in loaded.get("stories", [])}
stories = [normalize_hidden_story(story, entries, current_stories.get(str(story.get("id", "")))) for story in payload.get("stories", loaded.get("stories", []))]
ids = [entry["id"] for entry in entries]
if len(ids) != len(set(ids)):
raise ValueError("Entry ids must be unique.")
story_ids = [story["id"] for story in stories]
if len(story_ids) != len(set(story_ids)):
raise ValueError("Story 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": 2,
"generatedAt": datetime.now().isoformat(timespec="seconds"),
"entries": entries,
"stories": stories,
},
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: minmax(260px, 300px) minmax(620px, 1fr) minmax(390px, 460px); height: 100vh; min-width: 0; }
.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: grid;
grid-template-columns: minmax(220px, 1fr) minmax(280px, auto);
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; min-width: 0; }
.map-head .actions { justify-content: flex-end; max-width: 560px; }
.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;
}
.avatar-pill, .avatar-row, .character-tag {
display: inline-flex;
align-items: center;
gap: 7px;
}
.avatar-thumb {
width: var(--avatar-size, 28px);
height: var(--avatar-size, 28px);
flex: 0 0 var(--avatar-size, 28px);
border-radius: 50%;
object-fit: cover;
border: 1px solid rgba(255, 246, 223, 0.72);
background: rgba(251, 241, 220, 0.08);
box-shadow: 0 0 18px color-mix(in srgb, var(--avatar-glow, #d3a64d) 42%, transparent);
}
.avatar-row {
width: 100%;
justify-content: space-between;
margin-top: 10px;
padding: 8px;
border: 1px solid rgba(232, 202, 139, 0.15);
border-radius: 8px;
background: rgba(251, 241, 220, 0.055);
}
.avatar-row .subtle { font-size: 12px; }
.character-tag {
min-height: 32px;
border: 1px solid rgba(232, 202, 139, 0.2);
border-radius: 999px;
padding: 3px 9px 3px 4px;
background: rgba(251, 241, 220, 0.075);
color: #f3ddb2;
font-size: 12px;
font-weight: 800;
}
.character-tag .avatar-thumb { --avatar-size: 24px; }
.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 .node-shell, .memory-node .node-portrait, .memory-node .node-marker {
stroke: rgba(255, 246, 223, 0.82);
stroke-width: 1.4;
filter: drop-shadow(0 0 7px var(--node-glow, rgba(255, 232, 177, 0.18)));
transition: r 160ms ease, stroke 160ms ease, opacity 160ms ease, filter 180ms ease;
}
.memory-node .node-breathe, .cluster-node .node-breathe { animation: avatar-breathe 8s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
.memory-node:hover .node-shell, .memory-node:hover .node-portrait { filter: drop-shadow(0 0 20px var(--node-glow, rgba(255, 232, 177, 0.4))); }
.memory-node.selected .node-shell, .memory-node.selected .node-portrait { stroke: #fff3c7; stroke-width: 3; }
.memory-node.dragging .node-shell, .memory-node.dragging .node-portrait { stroke: #f3cd7a; stroke-width: 3; opacity: 0.72; }
.memory-node.compatible .node-shell, .memory-node.compatible .node-portrait { 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 .node-shell, .memory-node.drop-target .node-portrait { stroke: #fff5c9; stroke-width: 4; filter: drop-shadow(0 0 24px rgba(255, 232, 177, 0.7)); }
.memory-node.invalid-drop .node-shell, .memory-node.invalid-drop .node-portrait { 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);
}
.panel + .panel { margin-top: 12px; }
.editor-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 10px; }
.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, .story-list, .story-flow-list, .memory-pool { 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; }
.story-card {
text-align: left;
width: 100%;
height: auto;
min-height: 0;
padding: 10px;
display: grid;
gap: 6px;
border-radius: 8px;
background: rgba(251, 241, 220, 0.075);
}
.story-card.active { border-color: var(--gold); background: rgba(211, 166, 77, 0.18); }
.story-cover {
border: 1px solid rgba(232, 202, 139, 0.18);
border-radius: 8px;
padding: 10px;
background: linear-gradient(135deg, rgba(251, 241, 220, 0.1), rgba(251, 241, 220, 0.035));
}
.story-flow-item {
display: grid;
grid-template-columns: 34px minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
padding: 8px;
border: 1px solid rgba(232, 202, 139, 0.16);
border-radius: 8px;
background: rgba(251, 241, 220, 0.055);
}
.story-flow-item strong, .story-card strong { overflow-wrap: anywhere; }
.story-step {
width: 26px;
height: 26px;
display: grid;
place-items: center;
border-radius: 50%;
background: rgba(211, 166, 77, 0.18);
color: #ffe2a6;
font-weight: 850;
font-size: 12px;
}
.memory-pool {
max-height: 240px;
overflow: auto;
padding-right: 4px;
}
.memory-pool button[draggable="true"], .story-flow-item[draggable="true"] { cursor: grab; }
.story-drop-zone {
border: 1px dashed rgba(232, 202, 139, 0.38);
border-radius: 8px;
padding: 10px;
color: var(--muted);
background: rgba(251, 241, 220, 0.045);
text-align: center;
}
.edge.story-path { stroke: rgba(211, 166, 77, 0.72); stroke-width: 3; stroke-linecap: round; }
.edge.story-echo { stroke: rgba(117, 169, 189, 0.28); stroke-dasharray: 3 10; }
.story-region { fill: rgba(211, 166, 77, 0.035); stroke: rgba(211, 166, 77, 0.18); stroke-width: 1.4; stroke-dasharray: 8 12; }
.hidden { display: none !important; }
@keyframes breathe {
0%, 100% { opacity: 0.72; }
50% { opacity: 1; }
}
@keyframes avatar-breathe {
0%, 100% { transform: scale(1); opacity: 0.92; }
50% { transform: scale(1.035); opacity: 1; }
}
@keyframes constellation-shimmer {
0%, 100% { opacity: 0.48; }
50% { opacity: 0.82; }
}
.constellation-shimmer { animation: constellation-shimmer 9s ease-in-out infinite; }
.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; }
.map-head { grid-template-columns: 1fr; }
.map-head .actions { justify-content: flex-start; max-width: none; }
}
@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">Story constellations</button>
<button data-mode="journey">Emotional journeys</button>
<button data-mode="hidden">Hidden routes</button>
<button data-mode="character">Character stories</button>
<button data-mode="dream">Dream paths</button>
<button data-mode="temporal">Temporal stories</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 a story constellation library. Stories are emotional routes, and memories are lights arranged along those routes.</p>
<p class="subtle"><strong>aphy:</strong> zoomed out, I show stories. zoom in, I show the memories inside them. the sky stays quieter this way.</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>Building Stories</h2>
<p class="subtle"><strong>aphy:</strong> drag a memory into a story or reorder the route in the builder. no more tangled relationship engineering.</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="hidden">show hidden routes</button>
<button data-tour="dream">show dream paths</button>
</div>
</div>
<div class="guide-card">
<h2>Story Covers</h2>
<div id="storyAtlas" class="story-list"></div>
</div>
<div class="guide-card">
<h2>Character Territories</h2>
<div id="characterAtlas" class="stack"></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 story</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 story to open its route. Drag memories in the Story Builder to shape the path.</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="drawerAvatars" class="pills" style="margin-top:10px"></div>
<div id="preview" class="preview" style="margin-top:10px"></div>
</section>
<section class="panel">
<h2>Story Builder</h2>
<div id="storyCover" class="story-cover"></div>
<div class="editor-grid" style="margin-top:10px">
<label class="full">Story title<input id="storyTitle" /></label>
<label class="full">Summary<textarea id="storyDescription"></textarea></label>
<label>Tone<select id="storyTone"></select></label>
<label>Discovery<select id="storyDiscovery"></select></label>
<label>Characters<input id="storyCharacters" placeholder="lima, z" /></label>
<label>Symbols<input id="storySymbols" placeholder="ring, kitchen light" /></label>
<label>Markers<select id="storyMarkers" multiple size="4"></select></label>
<label>Layers<input id="storyLayers" placeholder="1, 2, 3" /></label>
<label class="full">Hidden unlock conditions<input id="storyUnlock" placeholder="find kitchen light, return at night" /></label>
<label>Visibility<select id="storyHidden"><option value="false">Public</option><option value="true">Hidden</option></select></label>
</div>
<div class="actions" style="margin-top:10px">
<button id="newStoryBtn" type="button">New story</button>
<button id="storyFromSelectionBtn" type="button">Create story from memory</button>
<button id="deleteStoryBtn" class="danger" type="button">Delete story</button>
</div>
<h3 style="margin-top:14px">Story flow</h3>
<div id="storyFlow" class="story-flow-list"></div>
<div id="storyDropZone" class="story-drop-zone">Drag memories here to add them to this story.</div>
<h3 style="margin-top:14px">Memory pool</h3>
<div id="memoryPool" class="memory-pool"></div>
</section>
<section class="panel">
<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>
<div id="characterTags" class="full pills"></div>
<div id="presencePreview" class="full subtle"></div>
<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>Story hint<input id="narrativeArcs" placeholder="optional legacy arc note" /></label>
<label>Tags<input id="tags" placeholder="warm, october" /></label>
<label>Role<input id="emotionalRole" placeholder="reassurance, invitation, warning" /></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 class="hidden" aria-hidden="true">
<input id="continuationLinks" />
<input id="echoes" />
<input id="thematicLinks" />
<input id="symbolicLinks" />
<input id="triggerLinks" />
<input id="parentLinks" />
<input id="childLinks" />
<input id="mirroredEntries" />
</div>
<div id="autosave" class="subtle" style="margin-top:10px"></div>
</section>
<section class="panel">
<h2>This memory belongs to</h2>
<div id="connections" class="connection-list"></div>
</section>
<section class="panel">
<h2>Architecture</h2>
<div class="subtle">
<p><strong>Story model:</strong> stories are first-class emotional routes. Memories can belong to multiple stories, but routes stay readable and authored in order.</p>
<p><strong>Legacy links:</strong> old continuations, echoes, and symbolic links are retained for migration only. New authoring happens through Story Builder.</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: [], stories: [], meta: {}, mode: "graph", selectedId: "", selectedStoryId: "", 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 storyFields = ["storyTitle","storyDescription","storyTone","storyDiscovery","storyCharacters","storySymbols","storyMarkers","storyLayers","storyUnlock","storyHidden"];
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" };
const defaultAvatarPath = "/assets/avatars/z.jpeg";
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 selectedOptions(id) {
return Array.from($(id).selectedOptions || []).map((option) => option.value).filter(Boolean);
}
function storyById(id = state.selectedStoryId) {
return state.stories.find((story) => story.id === id);
}
function storiesForEntry(entryId) {
return state.stories.filter((story) => (story.nodes || []).includes(entryId));
}
function entryById(id) {
return state.entries.find((entry) => entry.id === id);
}
function avatarRegistry() {
return state.meta.characterRegistry || {};
}
function characterConfig(character) {
const registry = avatarRegistry();
return registry[character] || registry.z || {};
}
function characterLabel(character) {
return characterConfig(character).displayLabel || character || "z";
}
function avatarPath(character, expression = "default") {
const config = characterConfig(character);
const expressions = config.expressions || {};
return expressions[expression] || expressions.default || config.avatar || config.fallbackAvatar || defaultAvatarPath;
}
function avatarGlow(character) {
const config = characterConfig(character);
return config.glow || config.territoryColor || "#d3a64d";
}
function avatarSize(character, size = "medium") {
const sizes = characterConfig(character).sizes || {};
return Number(sizes[size] || sizes.medium || 42);
}
function avatarExpressionForEntry(entry) {
const tone = entry?.emotionalTone || "";
if (tone === "protective") return "protective";
if (tone === "wise") return "wise";
if (tone === "nostalgic") return "nostalgic";
if (tone === "funny") return "amused";
if (String(entry?.type || "").includes("system")) return "system";
if (String(entry?.type || "").includes("future")) return "temporal";
return "default";
}
function avatarImg(character, size = "medium", expression = "default", className = "") {
const px = avatarSize(character, size);
const label = characterLabel(character);
return `<img class="avatar-thumb ${html(className)}" src="${html(avatarPath(character, expression))}" alt="${html(label)} portrait" loading="lazy" style="--avatar-size:${px}px;--avatar-glow:${html(avatarGlow(character))}" onerror="this.onerror=null;this.src='${html(characterConfig(character).fallbackAvatar || defaultAvatarPath)}'">`;
}
function entryTextCorpus(entry) {
return [
entry.title,
entry.content,
entry.type,
entry.triggerConditions,
entry.pageLocation,
entry.emotionalRole,
entry.mysteryLevel,
...(entry.tags || []),
...(entry.symbols || []),
...(entry.narrativeArcs || []),
].join(" ").toLowerCase();
}
function characterPresenceScore(entry, character) {
const config = characterConfig(character);
const explicit = (entry.characters || []).includes(character) ? 80 : 0;
const tone = (config.presenceTones || []).includes(entry.emotionalTone) ? 12 : 0;
const corpus = entryTextCorpus(entry);
const keywords = (config.presenceKeywords || []).reduce((score, word) => score + (corpus.includes(String(word).toLowerCase()) ? 6 : 0), 0);
const motifs = (config.motifs || []).reduce((score, word) => score + (corpus.includes(String(word).toLowerCase()) ? 4 : 0), 0);
return explicit + tone + keywords + motifs;
}
function primaryCharacterForEntry(entry) {
const candidates = Object.keys(avatarRegistry());
const scored = candidates.map((character) => ({ character, score: characterPresenceScore(entry, character) })).sort((a, b) => b.score - a.score);
return scored[0]?.score > 0 ? scored[0].character : (entry.characters || [])[0] || "z";
}
function charactersForEntry(entry) {
const listed = entry.characters?.length ? entry.characters : [primaryCharacterForEntry(entry)];
const primary = primaryCharacterForEntry(entry);
return [primary, ...listed.filter((character) => character !== primary)].filter(Boolean);
}
function svgAvatar(character, radius, clipId, expression = "default", extraClass = "") {
const diameter = radius * 2;
const fallback = characterSymbol(character);
return `<circle class="node-shell ${html(extraClass)}" r="${radius}" fill="${html(characterColor(character) || avatarGlow(character))}" opacity="0.22"></circle>
<image class="node-portrait ${html(extraClass)}" href="${html(avatarPath(character, expression))}" x="${-radius}" y="${-radius}" width="${diameter}" height="${diameter}" preserveAspectRatio="xMidYMid slice" clip-path="url(#${html(clipId)})"></image>
<circle class="node-shell ${html(extraClass)}" r="${radius}" fill="none"></circle>
<text class="node-fallback" x="0" y="4" text-anchor="middle" opacity="0.32">${html(fallback)}</text>`;
}
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({ entries: state.entries, stories: state.stories, selectedStoryId: state.selectedStoryId }));
state.undo = state.undo.slice(-60);
state.redo = [];
}
function restore(serialized) {
const data = JSON.parse(serialized);
state.entries = data.entries || data || [];
state.stories = data.stories || state.stories || [];
state.selectedStoryId = data.selectedStoryId || state.selectedStoryId || state.stories[0]?.id || "";
rememberDraft();
render();
selectNode(state.selectedId || state.entries[0]?.id);
fillStoryForm(storyById());
}
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, stories: state.stories, selectedId: state.selectedId, selectedStoryId: state.selectedStoryId, 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();
renderStoryBuilder();
}
function storyFromForm() {
const current = storyById() || {};
return {
...current,
id: current.id || `story-${slugify($("storyTitle").value || "untitled-story")}-${Date.now()}`,
title: $("storyTitle").value.trim() || "untitled story",
description: $("storyDescription").value,
tone: $("storyTone").value || "warm",
characters: normalizeCharacters($("storyCharacters").value),
symbols: splitList($("storySymbols").value),
nodes: current.nodes || [],
discoveryStyle: $("storyDiscovery").value || "gradual",
layerAffinity: splitList($("storyLayers").value).map((item) => Number(item)).filter((item) => Number.isInteger(item) && item >= 0 && item <= 5),
unlockConditions: splitList($("storyUnlock").value),
hidden: $("storyHidden").value === "true",
markers: selectedOptions("storyMarkers"),
};
}
function applyStoryFormToState() {
if (!state.selectedStoryId) return;
const index = state.stories.findIndex((story) => story.id === state.selectedStoryId);
if (index < 0) return;
state.stories[index] = storyFromForm();
state.selectedStoryId = state.stories[index].id;
rememberDraft();
renderStoryBuilder();
render();
}
function fillStoryForm(story) {
if (!story) {
$("storyCover").innerHTML = "<span class='subtle'>Create a story to shape a route through the observatory.</span>";
storyFields.forEach((id) => { if ($(id)) $(id).value = ""; });
$("storyFlow").innerHTML = "";
renderMemoryPool();
return;
}
state.selectedStoryId = story.id;
$("storyTitle").value = story.title || "";
$("storyDescription").value = story.description || "";
$("storyTone").value = story.tone || "warm";
$("storyDiscovery").value = story.discoveryStyle || "gradual";
$("storyCharacters").value = (story.characters || []).join(", ");
$("storySymbols").value = (story.symbols || []).join(", ");
$("storyLayers").value = (story.layerAffinity || []).join(", ");
$("storyUnlock").value = (story.unlockConditions || []).join(", ");
$("storyHidden").value = story.hidden ? "true" : "false";
Array.from($("storyMarkers").options).forEach((option) => option.selected = (story.markers || []).includes(option.value));
renderStoryBuilder();
}
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 || "";
renderCharacterPresence(entry);
renderPreview(entry);
renderConnections(entry);
}
function renderCharacterPresence(entry) {
const characters = charactersForEntry(entry);
$("drawerAvatars").innerHTML = characters.map((character, index) => `<span class="avatar-pill pill" style="--avatar-glow:${html(avatarGlow(character))}">${avatarImg(character, index === 0 ? "medium" : "tiny", avatarExpressionForEntry(entry))}${html(characterLabel(character))}</span>`).join("");
$("characterTags").innerHTML = characters.map((character) => `<span class="character-tag" style="--avatar-glow:${html(avatarGlow(character))}">${avatarImg(character, "tiny", avatarExpressionForEntry(entry))}${html(characterLabel(character))}</span>`).join("");
const primary = primaryCharacterForEntry(entry);
const territory = characterConfig(primary).observatory?.territory || "this memory territory";
$("presencePreview").innerHTML = `<span class="avatar-row" style="--avatar-glow:${html(avatarGlow(primary))}"><span>${avatarImg(primary, "medium", avatarExpressionForEntry(entry))}</span><span><strong>${html(characterLabel(primary))}</strong><br><span class="subtle">presence preview: ${html(territory)}</span></span></span>`;
}
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 filteredStories() {
const entryIds = new Set(filteredEntries().map((entry) => entry.id));
const query = $("search").value.toLowerCase();
return state.stories.filter((story) => {
const text = [story.title, story.description, story.tone, story.discoveryStyle, ...(story.characters || []), ...(story.symbols || []), ...(story.markers || []), ...(story.unlockConditions || [])].join(" ").toLowerCase();
const hasVisibleNode = (story.nodes || []).some((id) => entryIds.has(id));
const modeMatches =
state.mode === "hidden" ? story.hidden || (story.markers || []).includes("hidden") :
state.mode === "dream" ? (story.markers || []).includes("dream-like") || story.discoveryStyle === "dream-like" :
state.mode === "temporal" ? (story.markers || []).includes("temporal") || story.discoveryStyle === "temporal" || (story.characters || []).includes("future z") :
state.mode === "journey" ? (story.markers || []).includes("emotional") || story.discoveryStyle === "gradual" :
true;
return hasVisibleNode
&& modeMatches
&& (!query || text.includes(query) || (story.nodes || []).some((id) => entryTextCorpus(entryById(id) || {}).includes(query)))
&& (!$("characterFilter").value || (story.characters || []).includes($("characterFilter").value) || (story.nodes || []).some((id) => (entryById(id)?.characters || []).includes($("characterFilter").value)))
&& (!$("toneFilter").value || story.tone === $("toneFilter").value)
&& (!$("rarityFilter").value || (story.markers || []).includes($("rarityFilter").value) || story.hidden && $("rarityFilter").value === "rare");
});
}
function storyEntries(story) {
return (story?.nodes || []).map(entryById).filter(Boolean);
}
function storySequencePairs(entries) {
const visible = new Set(entries.map((entry) => entry.id));
const pairs = [];
filteredStories().forEach((story) => {
const nodes = (story.nodes || []).filter((id) => visible.has(id));
nodes.slice(0, -1).forEach((source, index) => pairs.push({ source, target: nodes[index + 1], kind: "story-path", storyId: story.id }));
});
return pairs;
}
function relationshipPairs(entries) {
return storySequencePairs(entries).slice(0, 180);
}
function clusterKey(entry) {
const story = storiesForEntry(entry.id)[0];
if (story) return story.title;
if (state.mode === "character") return (entry.characters || [])[0] || "z";
return "Loose memories";
}
function clusterKindLabel() {
return { graph: "story constellation", journey: "emotional journey", hidden: "hidden route", character: "character story", dream: "dream path", temporal: "temporal story" }[state.mode] || "story";
}
function clusteredEntries(entries) {
const clusters = new Map();
filteredStories().forEach((story) => {
const storyNodes = storyEntries(story).filter((entry) => entries.some((item) => item.id === entry.id));
if (!storyNodes.length) return;
clusters.set(story.id, {
id: story.id,
label: story.title,
story,
entries: storyNodes,
depthTotal: storyNodes.reduce((total, entry) => total + depth(entry), 0),
tones: new Map([[story.tone || "warm", storyNodes.length]]),
characters: new Map((story.characters || []).map((name) => [name, 1])),
rarity: story.hidden || (story.markers || []).includes("rare") ? 2 : 0,
});
});
entries.forEach((entry) => {
if (storiesForEntry(entry.id).some((story) => clusters.has(story.id))) return;
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) {
return [];
}
function layoutEntries(entries) {
const cx = WORLD.cx;
const cy = WORLD.cy;
state.positions = new Map();
const stories = filteredStories().filter((story) => (story.nodes || []).some((id) => entries.some((entry) => entry.id === id)));
stories.forEach((story, storyIndex) => {
const route = storyEntries(story).filter((entry) => entries.some((item) => item.id === entry.id));
const angle = (Math.PI * 2 * storyIndex) / Math.max(1, stories.length) + hash(story.id) / 9000;
const baseRadius = 520 + (storyIndex % 4) * 250;
const anchorX = cx + Math.cos(angle) * baseRadius;
const anchorY = cy + Math.sin(angle) * baseRadius * 0.68;
route.forEach((entry, index) => {
const step = index - (route.length - 1) / 2;
const curve = Math.sin(index / Math.max(1, route.length - 1) * Math.PI) * 90;
const direction = angle + Math.PI / 2;
const x = anchorX + Math.cos(direction) * step * 150 + Math.cos(angle) * curve;
const y = anchorY + Math.sin(direction) * step * 110 + Math.sin(angle) * curve * 0.7;
state.positions.set(entry.id, { x, y });
});
});
const loose = entries.filter((entry) => !state.positions.has(entry.id));
const byLayer = new Map();
loose.forEach((entry) => {
const layer = state.mode === "character" ? characterBucket(entry) : depth(entry);
if (!byLayer.has(layer)) byLayer.set(layer, []);
byLayer.get(layer).push(entry);
});
[...byLayer.entries()].forEach(([layer, items]) => {
const layerNum = Number(layer);
const radius = 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 = ["journey","hidden","dream","temporal"].includes(state.mode) ? "" : [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 storyRegions = renderStoryRegions(entries);
const occupied = [];
const defs = [];
const clusterNodes = clusters.map((cluster) => {
const pos = state.clusterPositions.get(cluster.id);
const character = state.mode === "character" ? cluster.label : cluster.character || "z";
const color = characterColor(character) || 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;
const portraitRadius = Math.max(28, size * 0.46) * grow;
const clipId = `clip-cluster-${slugify(cluster.id)}`;
defs.push(`<clipPath id="${html(clipId)}"><circle r="${portraitRadius}"></circle></clipPath>`);
return `<g class="cluster-node${activeDrop ? " drop-target" : ""}" data-id="${html(cluster.id)}" tabindex="0" transform="translate(${pos.x} ${pos.y})" style="--node-glow:${html(avatarGlow(character))}">
<circle class="cluster-halo" r="${(size + 13) * grow}"></circle>
<circle r="${size * grow}" fill="${color}" opacity="0.58"></circle>
${state.zoom > 0 || state.mode === "character" ? svgAvatar(character, portraitRadius, clipId, "default", "node-breathe") : ""}
<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 character = primaryCharacterForEntry(entry);
const color = characterColor(character) || toneColors[entry.emotionalTone] || "#d3a64d";
const baseSize = 18 + Math.min(18, Number(entry.resonanceScore || 3) * 2.2) + (entry.rarity === "rare" || entry.rarity === "very rare" ? 8 : 0);
const zoomSize = state.zoom === 0 ? avatarSize(character, "tiny") : state.zoom === 1 ? avatarSize(character, "medium") : state.zoom === 2 ? avatarSize(character, "close") : avatarSize(character, "focus");
const size = Math.max(baseSize, zoomSize / 2);
const label = labelForEntry(entry, pos, size, occupied);
const dragClass = dragClassForEntry(entry.id);
const clipId = `clip-node-${slugify(entry.id)}`;
defs.push(`<clipPath id="${html(clipId)}"><circle r="${size}"></circle></clipPath>`);
const distant = state.zoom === 0;
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})" style="--node-glow:${html(avatarGlow(character))}">
${distant
? `<circle class="node-marker constellation-shimmer" r="${size}" fill="${color}" opacity="${entry.enabled === false ? 0.32 : 0.78}"></circle><text x="0" y="4" text-anchor="middle">${html(characterSymbol(character))}</text>`
: svgAvatar(character, size, clipId, avatarExpressionForEntry(entry), state.zoom >= 2 ? "node-breathe" : "")}
</g>${label}`;
}).join("");
graph.innerHTML = `<defs>${defs.join("")}</defs><g id="world" transform="${cameraTransform()}">${rings}${storyRegions}${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 renderStoryRegions(entries) {
if (!entries.length || state.camera.k < 0.22) return "";
return filteredStories().map((story) => {
const points = storyEntries(story).map((entry) => state.positions.get(entry.id)).filter(Boolean);
if (points.length < 2) return "";
const minX = Math.min(...points.map((p) => p.x)) - 120;
const maxX = Math.max(...points.map((p) => p.x)) + 120;
const minY = Math.min(...points.map((p) => p.y)) - 100;
const maxY = Math.max(...points.map((p) => p.y)) + 100;
const color = toneColors[story.tone] || "#d3a64d";
return `<ellipse class="story-region" cx="${(minX + maxX) / 2}" cy="${(minY + maxY) / 2}" rx="${Math.max(130, (maxX - minX) / 2)}" ry="${Math.max(90, (maxY - minY) / 2)}" stroke="${html(color)}"></ellipse>`;
}).join("");
}
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: ["Story Constellations", "Stories are the main territories. Memories glow along calm narrative routes."],
journey: ["Emotional Journeys", "Warm, gradual stories arranged as readable paths."],
hidden: ["Hidden Routes", "Stories that ask to be discovered patiently."],
character: ["Character Stories", "Routes gathered around lima, aphy, sensei chi, young z, future z, and z."],
dream: ["Dream Paths", "Dream-like stories and symbolic memory sequences."],
temporal: ["Temporal Stories", "Future echoes, dated memories, and time-softened routes."],
};
$("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 a story to reveal its memory flow. Use Story Builder to shape the route.`;
}
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 and stories reference canonical characters and valid memory lights."
: `needs attention: ${summary.unknownCharacterCount || 0} unknown characters, ${summary.orphanNodeCount || 0} orphan memories, ${summary.staleLinkCount || 0} legacy stale links, ${summary.staleStoryNodeCount || 0} stale story nodes. ${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");
if (storyById(clusterId)) fillStoryForm(storyById(clusterId));
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)}">${filter.character ? avatarImg(filter.character, "tiny") : ""}${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")}`, 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)}">${item.character ? avatarImg(item.character, "tiny") : ""}${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);
renderStoryBuilder();
render();
}
function relationForEvent(event) {
return { key: "stories", kind: "story-path", label: "Add to story route" };
}
function connectNodes(sourceId, targetId, relation = relationForEvent()) {
if (!sourceId || !targetId || sourceId === targetId) return;
snapshot();
let story = storyById();
if (!story) {
newStory(sourceId);
story = storyById();
}
[sourceId, targetId].forEach((id) => {
if (story && !story.nodes.includes(id)) story.nodes.push(id);
});
state.selectedId = sourceId;
rememberDraft();
fillForm(entryById(sourceId));
fillStoryForm(story);
render();
setStatus("those memories now sit together in the selected story route.");
}
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 addNodeToStory(sourceId);
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();
newStory(sourceId);
state.selectedId = sourceId;
rememberDraft();
fillForm(entry);
render();
setStatus("a new story route 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(storiesForEntry(id).flatMap((story) => story.nodes || []));
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 = [
["add-story", "Add to selected story"],
["new-story", "Create story from memory"],
["focus", "Focus story"],
["isolate", "Isolate character constellation"],
["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);
if (action === "add-story") addNodeToStory(id);
if (action === "new-story") newStory(id);
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 === "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() {
focusStory();
}
function focusStory() {
const story = storyById() || storiesForEntry(state.selectedId)[0];
if (!story) return;
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 = new Set(story.nodes || []);
state.focusLabel = `Story: ${story.title || story.id}`;
render();
fitCameraTo(boundsForEntries(visibleScope().entries), true);
setStatus("focus mode is showing this story route.");
}
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") {
state.mode = "hidden";
}
if (name === "connected") {
state.mode = "journey";
}
if (name === "hidden") {
state.mode = "hidden";
$("characterFilter").value = "";
$("search").value = "";
}
if (name === "dream") {
state.mode = "dream";
$("characterFilter").value = "";
$("search").value = "";
}
$("modebar").querySelectorAll("button").forEach((item) => item.classList.toggle("active", item.dataset.mode === state.mode));
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>");
const avatarPills = charactersForEntry(entry).map((character) => `<span class="avatar-pill pill" style="--avatar-glow:${html(avatarGlow(character))}">${avatarImg(character, "tiny", avatarExpressionForEntry(entry))}${html(characterLabel(character))}</span>`).join("");
$("preview").innerHTML = `${body || "<span class='subtle'>This memory is waiting for words.</span>"}<div class="pills" style="margin-top:10px">${avatarPills}<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 stories = storiesForEntry(entry.id);
$("connections").innerHTML = stories.length ? stories.map((story) => {
return `<button class="connection story-card${story.id === state.selectedStoryId ? " active" : ""}" type="button" data-story="${html(story.id)}"><strong>${html(story.title)}</strong><span class="subtle">${story.nodes.length} memories / ${html(story.discoveryStyle || "gradual")}</span></button>`;
}).join("") : "<div class='subtle'>This memory is not in a story yet. Add it from the Story Builder to give it an emotional route.</div>";
$("connections").querySelectorAll("[data-story]").forEach((button) => button.addEventListener("click", () => {
fillStoryForm(storyById(button.dataset.story));
focusStory();
}));
}
function renderStoryBuilder() {
const story = storyById();
renderStoryAtlas();
renderMemoryPool();
if (!story) {
$("storyCover").innerHTML = "<span class='subtle'>No story selected.</span>";
$("storyFlow").innerHTML = "";
return;
}
const characters = (story.characters || []).map((character) => `<span class="avatar-pill pill">${avatarImg(character, "tiny")}${html(characterLabel(character))}</span>`).join("");
$("storyCover").innerHTML = `<strong>${html(story.title)}</strong><p class="subtle">${html(story.description || "A quiet route waiting for a summary.")}</p><div class="pills">${characters}<span class="pill">${html(story.tone || "warm")}</span><span class="pill">${html(story.discoveryStyle || "gradual")}</span>${story.hidden ? "<span class='pill'>hidden</span>" : ""}${(story.markers || []).map((item) => `<span class="pill">${html(item)}</span>`).join("")}</div>`;
const nodes = storyEntries(story);
$("storyFlow").innerHTML = nodes.length ? nodes.map((entry, index) => {
const character = primaryCharacterForEntry(entry);
return `<div class="story-flow-item" draggable="true" data-node="${html(entry.id)}"><span class="story-step">${index + 1}</span><span><strong>${html(entry.title)}</strong><br><span class="subtle">${html(entry.emotionalTone || "warm")} / ${html(layerName(depth(entry)))}</span></span><button type="button" data-remove="${html(entry.id)}">Remove</button></div>`;
}).join("") : "<div class='subtle'>No memories in this story yet.</div>";
$("storyFlow").querySelectorAll("[data-remove]").forEach((button) => button.addEventListener("click", () => removeNodeFromStory(button.dataset.remove)));
$("storyFlow").querySelectorAll("[draggable='true']").forEach((item) => {
item.addEventListener("dragstart", (event) => event.dataTransfer.setData("text/story-node", item.dataset.node));
item.addEventListener("dragover", (event) => event.preventDefault());
item.addEventListener("drop", (event) => {
event.preventDefault();
const source = event.dataTransfer.getData("text/story-node") || event.dataTransfer.getData("text/memory-id");
if (source) moveNodeInStory(source, item.dataset.node);
});
});
}
function renderStoryAtlas() {
const stories = filteredStories().slice(0, 18);
$("storyAtlas").innerHTML = stories.length ? stories.map((story) => `<button type="button" class="story-card${story.id === state.selectedStoryId ? " active" : ""}" data-story="${html(story.id)}"><strong>${html(story.title)}</strong><span class="subtle">${story.nodes.length} memories / ${html(story.tone || "warm")}</span></button>`).join("") : "<div class='subtle'>No story routes match this view yet.</div>";
$("storyAtlas").querySelectorAll("[data-story]").forEach((button) => button.addEventListener("click", () => {
fillStoryForm(storyById(button.dataset.story));
openCluster(button.dataset.story);
}));
}
function renderMemoryPool() {
const query = $("search").value.toLowerCase();
const story = storyById();
const inStory = new Set(story?.nodes || []);
const pool = state.entries.filter((entry) => !inStory.has(entry.id) && (!query || entryTextCorpus(entry).includes(query))).slice(0, 24);
$("memoryPool").innerHTML = pool.map((entry) => {
const character = primaryCharacterForEntry(entry);
return `<button class="connection avatar-row" type="button" draggable="true" data-id="${html(entry.id)}" style="--avatar-glow:${html(avatarGlow(character))}"><span>${avatarImg(character, "tiny", avatarExpressionForEntry(entry))}</span><span><strong>${html(entry.title)}</strong><br><span class="subtle">${html(entry.emotionalTone || "warm")} / ${html(entry.type || "memory")}</span></span></button>`;
}).join("") || "<div class='subtle'>Every visible memory is already in this story.</div>";
$("memoryPool").querySelectorAll("[data-id]").forEach((button) => {
button.addEventListener("click", () => selectNode(button.dataset.id));
button.addEventListener("dragstart", (event) => event.dataTransfer.setData("text/memory-id", 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 newStory(seedId = "") {
snapshot();
const entry = seedId ? entryById(seedId) : currentEntry();
const now = new Date().toISOString().slice(0, 10);
const title = entry ? `${entry.title} route` : "Untitled story";
const story = {
id: `story-${slugify(title)}-${Date.now()}`,
title,
description: entry ? `A quiet path beginning with ${entry.title}.` : "",
tone: entry?.emotionalTone || "warm",
characters: entry?.characters?.length ? [...entry.characters] : ["z"],
symbols: entry?.symbols ? [...entry.symbols] : [],
nodes: entry ? [entry.id] : [],
discoveryStyle: "gradual",
layerAffinity: entry ? [depth(entry)] : [],
unlockConditions: [],
hidden: false,
markers: ["emotional"],
createdDate: now,
modifiedDate: now,
};
state.stories.unshift(story);
state.selectedStoryId = story.id;
fillStoryForm(story);
rememberDraft();
render();
setStatus("new story route opened.");
}
function deleteStory() {
const story = storyById();
if (!story || !confirm(`Let "${story.title}" rest outside the observatory? Memories stay intact.`)) return;
snapshot();
state.stories = state.stories.filter((item) => item.id !== story.id);
state.selectedStoryId = state.stories[0]?.id || "";
fillStoryForm(storyById());
rememberDraft();
render();
}
function addNodeToStory(nodeId) {
const story = storyById();
if (!story || !nodeId || story.nodes.includes(nodeId)) return;
snapshot();
story.nodes.push(nodeId);
const entry = entryById(nodeId);
if (entry) {
(entry.characters || []).forEach((character) => {
if (!story.characters.includes(character)) story.characters.push(character);
});
(entry.symbols || []).forEach((symbol) => {
if (!story.symbols.includes(symbol)) story.symbols.push(symbol);
});
}
rememberDraft();
fillStoryForm(story);
render();
}
function removeNodeFromStory(nodeId) {
const story = storyById();
if (!story) return;
snapshot();
story.nodes = (story.nodes || []).filter((id) => id !== nodeId);
rememberDraft();
fillStoryForm(story);
render();
}
function moveNodeInStory(sourceId, targetId) {
const story = storyById();
if (!story || !sourceId) return;
snapshot();
story.nodes = (story.nodes || []).filter((id) => id !== sourceId);
const targetIndex = story.nodes.indexOf(targetId);
if (targetIndex >= 0) story.nodes.splice(targetIndex, 0, sourceId);
else story.nodes.push(sourceId);
rememberDraft();
fillStoryForm(story);
render();
}
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.stories.forEach((story) => {
story.nodes = (story.nodes || []).filter((id) => 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("");
$("storyTone").innerHTML = state.meta.tones.map((value) => opt(value)).join("");
$("storyDiscovery").innerHTML = (state.meta.discoveryStyles || ["gradual"]).map((value) => opt(value)).join("");
$("storyMarkers").innerHTML = (state.meta.storyMarkers || []).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("");
$("characterAtlas").innerHTML = Object.keys(avatarRegistry()).map((character) => {
const config = characterConfig(character);
const territory = config.observatory?.territory || "memory territory";
return `<button class="connection avatar-row" type="button" data-character="${html(character)}" style="--avatar-glow:${html(avatarGlow(character))}"><span>${avatarImg(character, "medium")}</span><span><strong>${html(characterLabel(character))}</strong><br><span class="subtle">${html(territory)}</span></span></button>`;
}).join("");
$("characterAtlas").querySelectorAll("[data-character]").forEach((button) => button.addEventListener("click", () => {
snapshotExploration(`character territory: ${button.dataset.character}`);
$("characterFilter").value = button.dataset.character;
state.mode = "character";
state.focusCluster = "";
state.focusIds = null;
state.focusLabel = "";
$("modebar").querySelectorAll("button").forEach((item) => item.classList.toggle("active", item.dataset.mode === state.mode));
render();
window.setTimeout(() => fitCameraTo(boundsForEntries(visibleScope().entries), true), 0);
}));
renderStoryBuilder();
}
async function save() {
applyFormToState();
applyStoryFormToState();
setStatus("Saving the constellation.");
try {
const data = await api("/api/hidden", { method: "POST", body: JSON.stringify({ entries: state.entries, stories: state.stories }) });
state.entries = data.entries;
state.stories = data.stories || [];
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 || [];
state.stories = data.stories || [];
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.stories = draft.stories || state.stories;
state.selectedId = draft.selectedId || "";
state.selectedStoryId = draft.selectedStoryId || "";
}
populateControls();
renderValidation();
state.selectedStoryId = state.selectedStoryId || state.stories[0]?.id || "";
setStatus(data.migratedRelationshipsToStories ? "legacy relationships were gathered into calmer story routes." : data.migratedFromJs ? "Existing hidden details became a memory constellation." : "observatory open.");
selectNode(state.selectedId || state.entries[0]?.id);
fillStoryForm(storyById());
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(); });
});
storyFields.forEach((id) => {
$(id).addEventListener("input", applyStoryFormToState);
$(id).addEventListener("change", () => { snapshot(); applyStoryFormToState(); });
});
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();
$("newStoryBtn").onclick = () => newStory();
$("storyFromSelectionBtn").onclick = () => newStory(state.selectedId);
$("deleteStoryBtn").onclick = deleteStory;
$("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({ entries: state.entries, stories: state.stories, selectedStoryId: state.selectedStoryId })); restore(state.undo.pop()); };
$("redoBtn").onclick = () => { if (!state.redo.length) return; state.undo.push(JSON.stringify({ entries: state.entries, stories: state.stories, selectedStoryId: state.selectedStoryId })); 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)));
$("storyDropZone").addEventListener("dragover", (event) => event.preventDefault());
$("storyDropZone").addEventListener("drop", (event) => {
event.preventDefault();
const id = event.dataTransfer.getData("text/memory-id") || event.dataTransfer.getData("text/story-node");
if (id) addNodeToStory(id);
});
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
if parsed.path.startswith("/assets/avatars/"):
try:
relative = Path(parsed.path.lstrip("/"))
if relative.is_absolute() or ".." in relative.parts:
self.send_error(HTTPStatus.NOT_FOUND)
return
asset_path = (ROOT / relative).resolve()
avatars_root = (ROOT / "assets" / "avatars").resolve()
if not asset_path.is_file() or avatars_root not in asset_path.parents:
self.send_error(HTTPStatus.NOT_FOUND)
return
body = asset_path.read_bytes()
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", mimetypes.guess_type(asset_path.name)[0] or "application/octet-stream")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
except Exception:
self.send_error(HTTPStatus.NOT_FOUND)
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_CONTENT_ROOT/AUTHOR_ROOT and the launch directory.")
print("Press Ctrl-C to stop.")
try:
server.serve_forever()
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()