memory observatory redesign x2
All checks were successful
Build Authoring Service / build (push) Successful in 9s

This commit is contained in:
2026-05-15 12:36:03 +01:00
parent 5e55a52fc5
commit 0cc588e9cc
4 changed files with 254 additions and 119 deletions

0
docs/hidden-memory-architecture.md Normal file → Executable file
View File

View File

@@ -3,33 +3,16 @@
from __future__ import annotations from __future__ import annotations
HIDDEN_CONTENT_TYPES = [ HIDDEN_CONTENT_TYPES = [
"tooltip",
"quote", "quote",
"whisper",
"poem", "poem",
"hidden dialogue", "observation",
"journal entry", "dialogue",
"rare event", "secret search",
"loading screen message", "symbolic fragment",
"secret interaction", "ambient memory",
"hidden tooltip", "hidden interaction",
"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",
] ]
HIDDEN_CONTENT_CLASSES = [ HIDDEN_CONTENT_CLASSES = [
@@ -61,33 +44,44 @@ HIDDEN_SURFACES = [
] ]
TYPE_ARCHITECTURE = { TYPE_ARCHITECTURE = {
"tooltip": {"contentClass": "fragment", "surfaces": ["tooltip"], "observatory": "ambient"},
"quote": {"contentClass": "fragment", "surfaces": ["quote", "observatory"], "observatory": "ambient"}, "quote": {"contentClass": "fragment", "surfaces": ["quote", "observatory"], "observatory": "ambient"},
"whisper": {"contentClass": "fragment", "surfaces": ["tooltip", "quote", "observatory"], "observatory": "ambient"},
"poem": {"contentClass": "fragment", "surfaces": ["poem", "observatory"], "observatory": "ambient"}, "poem": {"contentClass": "fragment", "surfaces": ["poem", "observatory"], "observatory": "ambient"},
"hidden dialogue": {"contentClass": "fragment", "surfaces": ["tooltip", "story", "observatory"], "observatory": "node"}, "observation": {"contentClass": "memory", "surfaces": ["story", "observatory"], "observatory": "node"},
"journal entry": {"contentClass": "memory", "surfaces": ["story", "observatory"], "observatory": "node"}, "dialogue": {"contentClass": "memory", "surfaces": ["story", "observatory"], "observatory": "node"},
"rare event": {"contentClass": "interaction", "surfaces": ["hidden route", "play", "observatory"], "observatory": "event"}, "secret search": {"contentClass": "interaction", "surfaces": ["search", "hidden route"], "observatory": "event"},
"loading screen message": {"contentClass": "fragment", "surfaces": ["loading", "quote"], "observatory": "ambient"}, "symbolic fragment": {"contentClass": "fragment", "surfaces": ["story", "observatory"], "observatory": "ambient"},
"secret interaction": {"contentClass": "interaction", "surfaces": ["hidden route", "play"], "observatory": "event"}, "ambient memory": {"contentClass": "memory", "surfaces": ["story", "observatory"], "observatory": "node"},
"hidden tooltip": {"contentClass": "fragment", "surfaces": ["tooltip"], "observatory": "ambient"}, "hidden interaction": {"contentClass": "interaction", "surfaces": ["hidden route", "keyboard", "play"], "observatory": "event"},
"future z message": {"contentClass": "memory", "surfaces": ["temporal", "story", "observatory"], "observatory": "node"}, }
"young z memory fragment": {"contentClass": "memory", "surfaces": ["story", "observatory"], "observatory": "node"},
"sensei chi wisdom entry": {"contentClass": "fragment", "surfaces": ["quote", "story", "observatory"], "observatory": "ambient"}, LEGACY_CONTENT_TYPE_MAP = {
"aphy system message": {"contentClass": "fragment", "surfaces": ["quote", "terminal"], "observatory": "ambient"}, "hidden tooltip": "tooltip",
"lima note/message": {"contentClass": "fragment", "surfaces": ["quote", "story"], "observatory": "ambient"}, "hover message": "tooltip",
"dream sequence": {"contentClass": "lore", "surfaces": ["dream", "play", "story", "observatory"], "observatory": "node"}, "loading screen message": "whisper",
"terminal log": {"contentClass": "lore", "surfaces": ["terminal", "play"], "observatory": "event"}, "hidden dialogue": "dialogue",
"fake error message": {"contentClass": "interaction", "surfaces": ["play", "hidden route"], "observatory": "event"}, "hidden conversation": "dialogue",
"recurring joke": {"contentClass": "fragment", "surfaces": ["tooltip", "quote"], "observatory": "ambient"}, "journal entry": "observation",
"seasonal event": {"contentClass": "interaction", "surfaces": ["seasonal", "hidden route"], "observatory": "event"}, "future z message": "ambient memory",
"weather-based event": {"contentClass": "interaction", "surfaces": ["hidden route"], "observatory": "event"}, "young z memory fragment": "ambient memory",
"hover message": {"contentClass": "fragment", "surfaces": ["tooltip"], "observatory": "ambient"}, "sensei chi wisdom entry": "quote",
"hidden achievement": {"contentClass": "interaction", "surfaces": ["play", "hidden route"], "observatory": "event"}, "aphy system message": "whisper",
"guestbook entry": {"contentClass": "lore", "surfaces": ["guestbook", "observatory"], "observatory": "ambient"}, "lima note/message": "whisper",
"hidden conversation": {"contentClass": "story material", "surfaces": ["story", "observatory"], "observatory": "node"}, "dream sequence": "symbolic fragment",
"family layer": {"contentClass": "system layer", "surfaces": ["layer guide"], "observatory": "guide"}, "guestbook entry": "observation",
"search toast": {"contentClass": "interaction", "surfaces": ["search"], "observatory": "event"}, "terminal log": "hidden interaction",
"search route": {"contentClass": "interaction", "surfaces": ["search", "hidden route"], "observatory": "event"}, "fake error message": "hidden interaction",
"keyboard secret": {"contentClass": "interaction", "surfaces": ["keyboard", "hidden route"], "observatory": "event"}, "recurring joke": "whisper",
"rare event": "hidden interaction",
"secret interaction": "hidden interaction",
"seasonal event": "hidden interaction",
"weather-based event": "hidden interaction",
"hidden achievement": "hidden interaction",
"search toast": "secret search",
"search route": "secret search",
"keyboard secret": "hidden interaction",
"family layer": "observation",
} }
CHARACTER_REGISTRY = { CHARACTER_REGISTRY = {

View File

@@ -20,6 +20,7 @@ from .constants import (
HIDDEN_SURFACES, HIDDEN_SURFACES,
HIDDEN_STORY_MARKERS, HIDDEN_STORY_MARKERS,
HIDDEN_TONES, HIDDEN_TONES,
LEGACY_CONTENT_TYPE_MAP,
TYPE_ARCHITECTURE, TYPE_ARCHITECTURE,
) )
from .utils import normalise_tags, slugify from .utils import normalise_tags, slugify
@@ -257,6 +258,7 @@ def normalize_hidden_entry(raw: dict[str, Any], existing: dict[str, Any] | None
content = str(normalize_character_text_refs(entry.get("content") or "")).replace("\r\n", "\n") 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(entry.get("type") or "quote").strip()
content_type = str(normalize_character_text_refs(content_type)) content_type = str(normalize_character_text_refs(content_type))
content_type = LEGACY_CONTENT_TYPE_MAP.get(content_type, content_type)
if content_type not in HIDDEN_CONTENT_TYPES: if content_type not in HIDDEN_CONTENT_TYPES:
raise ValueError(f"Unsupported hidden content type: {content_type}") raise ValueError(f"Unsupported hidden content type: {content_type}")
architecture = TYPE_ARCHITECTURE.get(content_type, TYPE_ARCHITECTURE["quote"]) architecture = TYPE_ARCHITECTURE.get(content_type, TYPE_ARCHITECTURE["quote"])
@@ -341,14 +343,36 @@ def normalize_hidden_story(raw: dict[str, Any], entries: list[dict[str, Any]] |
if not title: if not title:
raise ValueError("Every story needs a title.") raise ValueError("Every story needs a title.")
ids = {entry["id"] for entry in entries or []} ids = {entry["id"] for entry in entries or []}
raw_nodes = story.get("nodes", []) raw_items = story.get("items")
if isinstance(raw_nodes, str): if not isinstance(raw_items, list):
raw_nodes = re.split(r"[,:\s]+", raw_nodes) raw_nodes = story.get("nodes", [])
if isinstance(raw_nodes, str):
raw_nodes = re.split(r"[,:\s]+", raw_nodes)
raw_items = [{"kind": "memory", "id": str(item).strip()} for item in raw_nodes if str(item).strip()]
items = []
nodes = [] nodes = []
for item in raw_nodes: for index, item in enumerate(raw_items):
node_id = str(item).strip() if isinstance(item, str):
item = {"kind": "memory", "id": item}
if not isinstance(item, dict):
continue
kind = str(item.get("kind") or "memory").strip().lower()
if kind == "section":
section_id = slugify(str(item.get("id") or f"section-{title}-{index + 1}"))
if not section_id.startswith("section-"):
section_id = f"section-{section_id}"
items.append({
"kind": "section",
"id": section_id,
"title": str(normalize_character_text_refs(item.get("title") or f"Section {index + 1}")).strip(),
"content": str(normalize_character_text_refs(item.get("content") or "")).replace("\r\n", "\n"),
"tone": str(item.get("tone") or story.get("tone") or "warm"),
})
continue
node_id = str(item.get("id") or item.get("memoryId") or "").strip()
if node_id and node_id not in nodes and (not ids or node_id in ids): if node_id and node_id not in nodes and (not ids or node_id in ids):
nodes.append(node_id) nodes.append(node_id)
items.append({"kind": "memory", "id": node_id})
characters = normalize_character_list(story.get("characters", [])) 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()] 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()] markers = [slugify(str(item)) for item in story.get("markers", []) if str(item).strip()]
@@ -374,6 +398,7 @@ def normalize_hidden_story(raw: dict[str, Any], entries: list[dict[str, Any]] |
"tone": str(story.get("tone") or "warm"), "tone": str(story.get("tone") or "warm"),
"characters": characters, "characters": characters,
"symbols": symbols, "symbols": symbols,
"items": items,
"nodes": nodes, "nodes": nodes,
"discoveryStyle": str(story.get("discoveryStyle") or "gradual"), "discoveryStyle": str(story.get("discoveryStyle") or "gradual"),
"layerAffinity": layer_affinity, "layerAffinity": layer_affinity,
@@ -529,11 +554,11 @@ def validate_hidden_integrity(entries: list[dict[str, Any]], stories: list[dict[
def hidden_architecture_recommendations() -> dict[str, Any]: def hidden_architecture_recommendations() -> dict[str, Any]:
return { return {
"storage": "Use assets/content/hidden-details.json as the friendly source of truth for canonical fragments, memories, interactions, lore, and first-class stories, then regenerate the editable constants in assets/scripts/hidden-details.js.", "storage": "Use assets/content/hidden-details.json as the friendly source of truth for reusable memories, first-class stories, and story-only sections, 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/.", "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.", "versioning": "Commit the JSON and generated JS together so the live site and authoring history stay aligned.",
"collaboration": "For simultaneous editing, resolve conflicts by entry id and story id rather than by whole-file ownership.", "collaboration": "For simultaneous editing, resolve conflicts by entry id and story id rather than by whole-file ownership.",
"scalability": "Stories are the primary emotional routes. Fragments remain reusable ambient language, memories become observatory nodes, and interactions describe triggers without pretending to be narrative scenes.", "scalability": "Stories are the primary emotional routes. Memories remain reusable artifacts, story sections hold longer one-off narrative writing, and interactions describe triggers without pretending to be narrative chapters.",
} }
@@ -546,51 +571,71 @@ def hidden_contents(entries: list[dict[str, Any]], content_type: str) -> list[st
def generated_hidden_content_block(entries: list[dict[str, Any]]) -> str: def generated_hidden_content_block(entries: list[dict[str, Any]]) -> str:
family_layers = hidden_contents(entries, "family layer") family_layers = [
details = hidden_contents(entries, "hidden tooltip") + hidden_contents(entries, "hover message") entry["content"] for entry in entries
if entry.get("enabled", True) and "Family Layer Index" in str(entry.get("category", ""))
]
details = hidden_contents(entries, "tooltip")
poems = hidden_contents(entries, "poem") poems = hidden_contents(entries, "poem")
greetings = hidden_contents(entries, "loading screen message") greetings = [
entry["content"] for entry in hidden_entries_by_type(entries, "whisper")
if "homepage" in f"{entry.get('category', '')} {entry.get('surfaces', '')}".lower() or "loading" in entry.get("surfaces", [])
]
night_messages = [ night_messages = [
entry["content"] for entry in hidden_entries_by_type(entries, "rare event") entry["content"] for entry in hidden_entries_by_type(entries, "hidden interaction")
if "night" in f"{entry.get('category', '')} {entry.get('triggerConditions', '')}".lower() 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") quote_like = hidden_contents(entries, "quote") + hidden_contents(entries, "whisper") + hidden_contents(entries, "ambient memory")
lore = { lore = {
"quotes": quote_like, "quotes": quote_like,
"conversations": [ "conversations": [
entry.get("dialogue") if isinstance(entry.get("dialogue"), list) else [line for line in str(entry.get("content", "")).splitlines() if line.strip()] 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") for entry in hidden_entries_by_type(entries, "dialogue")
],
"journals": hidden_contents(entries, "observation"),
"warnings": [
entry["content"] for entry in hidden_entries_by_type(entries, "hidden interaction")
if "warning" in f"{entry.get('category', '')} {entry.get('canonicalUse', '')}".lower()
],
"dreams": hidden_contents(entries, "symbolic fragment"),
"cassettes": [
entry["content"] for entry in hidden_entries_by_type(entries, "hidden interaction")
if "terminal" in entry.get("surfaces", []) or "cassette" in str(entry.get("category", "")).lower()
],
"fakeUsers": [
entry["content"] for entry in hidden_entries_by_type(entries, "observation")
if "guestbook" in f"{entry.get('category', '')} {entry.get('surfaces', '')}".lower()
], ],
"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": { "seasonal": {
str(entry.get("season") or entry.get("triggerConditions") or entry.get("title", "")).lower().replace("season is ", ""): entry.get("content", "") 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") for entry in hidden_entries_by_type(entries, "hidden interaction")
if "seasonal" in entry.get("surfaces", []) or "season" in str(entry.get("triggerConditions", "")).lower()
}, },
"homepageTakeovers": [ "homepageTakeovers": [
entry["content"] for entry in hidden_entries_by_type(entries, "rare event") entry["content"] for entry in hidden_entries_by_type(entries, "hidden interaction")
if "homepage" in f"{entry.get('category', '')} {entry.get('triggerConditions', '')}".lower() if "homepage" in f"{entry.get('category', '')} {entry.get('triggerConditions', '')}".lower()
], ],
"roomLinks": [ "roomLinks": [
[entry.get("pageLocation", ""), entry.get("content", "") or entry.get("title", "")] [entry.get("pageLocation", ""), entry.get("content", "") or entry.get("title", "")]
for entry in hidden_entries_by_type(entries, "secret interaction") for entry in hidden_entries_by_type(entries, "hidden interaction")
if entry.get("pageLocation") if entry.get("pageLocation")
], ],
} }
search_toasts = { search_toasts = {
entry.get("query") or entry.get("triggerConditions") or entry.get("title", ""): entry.get("content", "") entry.get("query") or entry.get("triggerConditions") or entry.get("title", ""): entry.get("content", "")
for entry in hidden_entries_by_type(entries, "search toast") for entry in hidden_entries_by_type(entries, "secret search")
if not entry.get("pageLocation")
} }
search_routes = { search_routes = {
entry.get("query") or entry.get("triggerConditions") or entry.get("title", ""): entry.get("pageLocation") or entry.get("content", "") 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") for entry in hidden_entries_by_type(entries, "secret search")
if entry.get("pageLocation")
} }
keyboard_secrets = [] keyboard_secrets = []
long_keyboard_secrets = [] long_keyboard_secrets = []
for entry in hidden_entries_by_type(entries, "keyboard secret"): for entry in hidden_entries_by_type(entries, "hidden interaction"):
if "keyboard" not in entry.get("surfaces", []) and not entry.get("keyboard"):
continue
secret = dict(entry.get("keyboard") or {}) secret = dict(entry.get("keyboard") or {})
secret["phrase"] = secret.get("phrase") or entry.get("triggerConditions") or entry.get("title", "") secret["phrase"] = secret.get("phrase") or entry.get("triggerConditions") or entry.get("title", "")
if entry.get("pageLocation"): if entry.get("pageLocation"):

View File

@@ -1201,7 +1201,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
// h1 { margin: 0; font-size: clamp(24px, 3vw, 42px); } // h1 { margin: 0; font-size: clamp(24px, 3vw, 42px); }
h2 { margin: 0 0 10px; font-size: 18px; } h2 { margin: 0 0 10px; font-size: 18px; }
h3 { margin: 0; font-size: 16px; } 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; } .observatory { display: grid; grid-template-columns: minmax(250px, 290px) minmax(680px, 1fr) minmax(400px, 440px); height: 100vh; min-width: 0; }
.left, .drawer { .left, .drawer {
z-index: 3; z-index: 3;
overflow: auto; overflow: auto;
@@ -1528,6 +1528,10 @@ HIDDEN_APP_HTML = r"""<!doctype html>
border-radius: 8px; border-radius: 8px;
background: rgba(251, 241, 220, 0.055); background: rgba(251, 241, 220, 0.055);
} }
.story-flow-item.section {
border-color: rgba(117, 169, 189, 0.3);
background: rgba(117, 169, 189, 0.09);
}
.story-flow-item strong, .story-card strong { overflow-wrap: anywhere; } .story-flow-item strong, .story-card strong { overflow-wrap: anywhere; }
.story-step { .story-step {
width: 26px; width: 26px;
@@ -1595,22 +1599,20 @@ HIDDEN_APP_HTML = r"""<!doctype html>
<div id="status" class="status">aphy is dimming the room lights.</div> <div id="status" class="status">aphy is dimming the room lights.</div>
<div class="modebar" id="modebar"> <div class="modebar" id="modebar">
<button data-mode="graph" class="active">Story constellations</button> <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="hidden">Hidden routes</button>
<button data-mode="character">Character stories</button> <button data-mode="character">Character stories</button>
<button data-mode="dream">Dream paths</button>
<button data-mode="temporal">Temporal stories</button>
</div> </div>
<div class="filters"> <div class="filters">
<input id="search" type="search" placeholder="Search memories, symbols, triggers" /> <input id="search" type="search" placeholder="Search memories and stories" />
<select id="layerFilter"></select> <select id="layerFilter"></select>
<select id="characterFilter"></select> <select id="characterFilter"></select>
<select id="toneFilter"></select> <select id="toneFilter"></select>
<select id="rarityFilter"></select> <select id="rarityFilter" class="hidden"></select>
</div> </div>
<div class="actions"> <div class="actions">
<button id="newBtn" type="button">New fragment</button> <button id="newBtn" type="button">New memory</button>
<button id="saveBtn" class="primary" type="button">Save constellation</button> <button id="newStoryBtn" type="button">New story</button>
<button id="saveBtn" class="primary" type="button">Save</button>
</div> </div>
<div class="actions" style="margin-top:8px"> <div class="actions" style="margin-top:8px">
<button id="softResetBtn" type="button">Clear Focus</button> <button id="softResetBtn" type="button">Clear Focus</button>
@@ -1633,7 +1635,6 @@ HIDDEN_APP_HTML = r"""<!doctype html>
<button data-tour="rare" type="button">show the rarest 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="future" type="button">show discoveries tied to future z</button>
<button data-tour="hidden">show hidden routes</button> <button data-tour="hidden">show hidden routes</button>
<button data-tour="dream">show dream paths</button>
</div> </div>
</div> </div>
<div class="guide-card"> <div class="guide-card">
@@ -1695,19 +1696,27 @@ HIDDEN_APP_HTML = r"""<!doctype html>
<label>Discovery<select id="storyDiscovery"></select></label> <label>Discovery<select id="storyDiscovery"></select></label>
<label>Characters<input id="storyCharacters" placeholder="lima, z" /></label> <label>Characters<input id="storyCharacters" placeholder="lima, z" /></label>
<label>Symbols<input id="storySymbols" placeholder="ring, kitchen light" /></label> <label>Symbols<input id="storySymbols" placeholder="ring, kitchen light" /></label>
<label>Markers<select id="storyMarkers" multiple size="4"></select></label> <label>Markers<select id="storyMarkers" multiple size="3"></select></label>
<label>Layers<input id="storyLayers" placeholder="1, 2, 3" /></label> <label>Depth<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 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> <label>Visibility<select id="storyHidden"><option value="false">Public</option><option value="true">Hidden</option></select></label>
</div> </div>
<div class="actions" style="margin-top:10px"> <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="storyFromSelectionBtn" type="button">Create story from memory</button>
<button id="addSectionBtn" type="button">Add section</button>
<button id="deleteStoryBtn" class="danger" type="button">Delete story</button> <button id="deleteStoryBtn" class="danger" type="button">Delete story</button>
</div> </div>
<h3 style="margin-top:14px">Story flow</h3> <h3 style="margin-top:14px">Story flow</h3>
<div id="storyFlow" class="story-flow-list"></div> <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> <div id="storyDropZone" class="story-drop-zone">Drag memories here to add them to this story.</div>
<div id="sectionEditor" class="story-cover hidden" style="margin-top:10px">
<label>Section title<input id="sectionTitle" /></label>
<label style="margin-top:8px">Section writing<textarea id="sectionContent" spellcheck="true"></textarea></label>
<div class="actions" style="margin-top:8px">
<button id="saveSectionBtn" type="button">Save section</button>
<button id="closeSectionBtn" type="button">Close</button>
</div>
</div>
<h3 style="margin-top:14px">Memory pool</h3> <h3 style="margin-top:14px">Memory pool</h3>
<div id="memoryPool" class="memory-pool"></div> <div id="memoryPool" class="memory-pool"></div>
</section> </section>
@@ -1715,8 +1724,8 @@ HIDDEN_APP_HTML = r"""<!doctype html>
<h2>Memory editor</h2> <h2>Memory editor</h2>
<div class="editor-grid"> <div class="editor-grid">
<label class="full">Name<input id="title" /></label> <label class="full">Name<input id="title" /></label>
<label>Runtime kind<select id="type"></select></label> <label>Memory type<select id="type"></select></label>
<label>Canonical class<select id="contentClass"></select></label> <label>Behavior<input id="canonicalUse" placeholder="where and how this memory appears" /></label>
<label>Depth<select id="familyLayer"></select></label> <label>Depth<select id="familyLayer"></select></label>
<label>Characters<input id="characters" placeholder="lima, aphy" /></label> <label>Characters<input id="characters" placeholder="lima, aphy" /></label>
<div id="characterTags" class="full pills"></div> <div id="characterTags" class="full pills"></div>
@@ -1725,24 +1734,24 @@ HIDDEN_APP_HTML = r"""<!doctype html>
<label>Rarity<select id="rarity"></select></label> <label>Rarity<select id="rarity"></select></label>
<label>Discovery<select id="discoveryDifficulty" placeholder="gentle, patient, hidden" /></label> <label>Discovery<select id="discoveryDifficulty" placeholder="gentle, patient, hidden" /></label>
<label>Mystery<input id="mysteryLevel" placeholder="quiet, strange, deep" /></label> <label>Mystery<input id="mysteryLevel" placeholder="quiet, strange, deep" /></label>
<label>Resonance<input id="resonanceScore" type="number" min="1" max="10" /></label> <label>Glow<input id="resonanceScore" type="number" min="1" max="10" /></label>
<label>Where it appears<input id="pageLocation" placeholder="/play/lima-note.html" /></label> <label>Optional route<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">Trigger path<input id="triggerConditions" placeholder="search: lima, hover, idle 90 seconds" /></label>
<label class="full">Content text<textarea id="content" spellcheck="true"></textarea></label> <label class="full">Content text<textarea id="content" spellcheck="true"></textarea></label>
<label>Reusable surfaces<input id="surfaces" placeholder="tooltip, story, observatory" /></label> <label>Can appear as<input id="surfaces" placeholder="tooltip, story, observatory" /></label>
<label>Observatory role<select id="observatoryRole"><option value="ambient">Ambient light</option><option value="node">Node</option><option value="event">Triggered event</option><option value="guide">Layer guide</option></select></label> <label>Observatory role<select id="observatoryRole"><option value="ambient">Ambient light</option><option value="node">Node</option><option value="event">Triggered event</option><option value="guide">Layer guide</option></select></label>
<label class="full">Canonical use<input id="canonicalUse" placeholder="short reusable whisper, observatory scene, search trigger..." /></label>
<label>Symbols<input id="symbols" placeholder="ring, tea, crayon sun" /></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>Story hint<input id="narrativeArcs" placeholder="optional theme note" /></label>
<label>Tags<input id="tags" placeholder="warm, october" /></label> <label>Tags<input id="tags" placeholder="warm, october" /></label>
<label>Role<input id="emotionalRole" placeholder="reassurance, invitation, warning" /></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>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> <label class="full">Private notes<textarea id="notes"></textarea></label>
</div> </div>
<div class="hidden" aria-hidden="true"> <div class="hidden" aria-hidden="true">
<select id="contentClass"></select>
<input id="cssClassHooks" />
<input id="audioSettings" />
<input id="animationTrigger" />
<input id="continuationLinks" /> <input id="continuationLinks" />
<input id="echoes" /> <input id="echoes" />
<input id="thematicLinks" /> <input id="thematicLinks" />
@@ -1775,7 +1784,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
const draftKey = "hiddenNarrativeObservatoryDraft:v1"; const draftKey = "hiddenNarrativeObservatoryDraft:v1";
const WORLD = { width: 4600, height: 3300, cx: 2300, cy: 1650 }; const WORLD = { width: 4600, height: 3300, cx: 2300, cy: 1650 };
const SCREEN = { width: 1000, height: 760 }; 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 state = { entries: [], stories: [], meta: {}, mode: "graph", selectedId: "", selectedStoryId: "", editingSectionId: "", 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","contentClass","familyLayer","characters","tone","rarity","discoveryDifficulty","mysteryLevel","resonanceScore","pageLocation","triggerConditions","content","surfaces","observatoryRole","canonicalUse","symbols","narrativeArcs","tags","emotionalRole","continuationLinks","echoes","thematicLinks","symbolicLinks","triggerLinks","parentLinks","childLinks","mirroredEntries","enabled","cssClassHooks","audioSettings","animationTrigger","notes"]; const fields = ["title","type","contentClass","familyLayer","characters","tone","rarity","discoveryDifficulty","mysteryLevel","resonanceScore","pageLocation","triggerConditions","content","surfaces","observatoryRole","canonicalUse","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 storyFields = ["storyTitle","storyDescription","storyTone","storyDiscovery","storyCharacters","storySymbols","storyMarkers","storyLayers","storyUnlock","storyHidden"];
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
@@ -1797,6 +1806,11 @@ HIDDEN_APP_HTML = r"""<!doctype html>
function storiesForEntry(entryId) { function storiesForEntry(entryId) {
return state.stories.filter((story) => (story.nodes || []).includes(entryId)); return state.stories.filter((story) => (story.nodes || []).includes(entryId));
} }
function storyItems(story) {
if (!story) return [];
if (Array.isArray(story.items) && story.items.length) return story.items;
return (story.nodes || []).map((id) => ({ kind: "memory", id }));
}
function entryById(id) { function entryById(id) {
return state.entries.find((entry) => entry.id === id); return state.entries.find((entry) => entry.id === id);
} }
@@ -2126,6 +2140,8 @@ HIDDEN_APP_HTML = r"""<!doctype html>
} }
function storyFromForm() { function storyFromForm() {
const current = storyById() || {}; const current = storyById() || {};
const items = storyItems(current);
const nodes = items.filter((item) => item.kind !== "section").map((item) => item.id).filter(Boolean);
return { return {
...current, ...current,
id: current.id || `story-${slugify($("storyTitle").value || "untitled-story")}-${Date.now()}`, id: current.id || `story-${slugify($("storyTitle").value || "untitled-story")}-${Date.now()}`,
@@ -2134,7 +2150,8 @@ HIDDEN_APP_HTML = r"""<!doctype html>
tone: $("storyTone").value || "warm", tone: $("storyTone").value || "warm",
characters: normalizeCharacters($("storyCharacters").value), characters: normalizeCharacters($("storyCharacters").value),
symbols: splitList($("storySymbols").value), symbols: splitList($("storySymbols").value),
nodes: current.nodes || [], items,
nodes,
discoveryStyle: $("storyDiscovery").value || "gradual", discoveryStyle: $("storyDiscovery").value || "gradual",
layerAffinity: splitList($("storyLayers").value).map((item) => Number(item)).filter((item) => Number.isInteger(item) && item >= 0 && item <= 5), layerAffinity: splitList($("storyLayers").value).map((item) => Number(item)).filter((item) => Number.isInteger(item) && item >= 0 && item <= 5),
unlockConditions: splitList($("storyUnlock").value), unlockConditions: splitList($("storyUnlock").value),
@@ -2157,6 +2174,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
$("storyCover").innerHTML = "<span class='subtle'>Create a story to shape a route through the observatory.</span>"; $("storyCover").innerHTML = "<span class='subtle'>Create a story to shape a route through the observatory.</span>";
storyFields.forEach((id) => { if ($(id)) $(id).value = ""; }); storyFields.forEach((id) => { if ($(id)) $(id).value = ""; });
$("storyFlow").innerHTML = ""; $("storyFlow").innerHTML = "";
closeSectionEditor();
renderMemoryPool(); renderMemoryPool();
return; return;
} }
@@ -2257,13 +2275,13 @@ HIDDEN_APP_HTML = r"""<!doctype html>
}); });
} }
function storyEntries(story) { function storyEntries(story) {
return (story?.nodes || []).map(entryById).filter(Boolean); return storyItems(story).filter((item) => item.kind !== "section").map((item) => entryById(item.id)).filter(Boolean);
} }
function storySequencePairs(entries) { function storySequencePairs(entries) {
const visible = new Set(entries.map((entry) => entry.id)); const visible = new Set(entries.map((entry) => entry.id));
const pairs = []; const pairs = [];
filteredStories().forEach((story) => { filteredStories().forEach((story) => {
const nodes = (story.nodes || []).filter((id) => visible.has(id)); const nodes = storyItems(story).filter((item) => item.kind !== "section").map((item) => item.id).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 })); nodes.slice(0, -1).forEach((source, index) => pairs.push({ source, target: nodes[index + 1], kind: "story-path", storyId: story.id }));
}); });
return pairs; return pairs;
@@ -2826,7 +2844,11 @@ HIDDEN_APP_HTML = r"""<!doctype html>
story = storyById(); story = storyById();
} }
[sourceId, targetId].forEach((id) => { [sourceId, targetId].forEach((id) => {
if (story && !story.nodes.includes(id)) story.nodes.push(id); if (story && !(story.nodes || []).includes(id)) {
story.items = storyItems(story);
story.items.push({ kind: "memory", id });
story.nodes = story.items.filter((item) => item.kind !== "section").map((item) => item.id);
}
}); });
state.selectedId = sourceId; state.selectedId = sourceId;
rememberDraft(); rememberDraft();
@@ -3102,7 +3124,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
snapshotExploration("focus thread"); 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.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.focusCluster = "";
state.focusIds = new Set(story.nodes || []); state.focusIds = new Set(storyItems(story).filter((item) => item.kind !== "section").map((item) => item.id));
state.focusLabel = `Story: ${story.title || story.id}`; state.focusLabel = `Story: ${story.title || story.id}`;
render(); render();
fitCameraTo(boundsForEntries(visibleScope().entries), true); fitCameraTo(boundsForEntries(visibleScope().entries), true);
@@ -3180,19 +3202,25 @@ HIDDEN_APP_HTML = r"""<!doctype html>
} }
const characters = (story.characters || []).map((character) => `<span class="avatar-pill pill">${avatarImg(character, "tiny")}${html(characterLabel(character))}</span>`).join(""); 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>`; $("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); const items = storyItems(story);
$("storyFlow").innerHTML = nodes.length ? nodes.map((entry, index) => { $("storyFlow").innerHTML = items.length ? items.map((item, index) => {
const character = primaryCharacterForEntry(entry); if (item.kind === "section") {
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>`; return `<div class="story-flow-item section" draggable="true" data-item="${html(item.id)}"><span class="story-step">${index + 1}</span><span><strong>${html(item.title || "Story section")}</strong><br><span class="subtle">story-only section / ${html(item.tone || story.tone || "warm")}</span></span><span class="actions"><button type="button" data-edit-section="${html(item.id)}">Edit</button><button type="button" data-remove-section="${html(item.id)}">Remove</button></span></div>`;
}).join("") : "<div class='subtle'>No memories in this story yet.</div>"; }
const entry = entryById(item.id);
if (!entry) return "";
return `<div class="story-flow-item" draggable="true" data-item="${html(item.id)}"><span class="story-step">${index + 1}</span><span><strong>${html(entry.title)}</strong><br><span class="subtle">${html(entry.type || "memory")} / ${html(entry.emotionalTone || "warm")}</span></span><button type="button" data-remove="${html(entry.id)}">Remove</button></div>`;
}).join("") : "<div class='subtle'>No memories or sections in this story yet.</div>";
$("storyFlow").querySelectorAll("[data-remove]").forEach((button) => button.addEventListener("click", () => removeNodeFromStory(button.dataset.remove))); $("storyFlow").querySelectorAll("[data-remove]").forEach((button) => button.addEventListener("click", () => removeNodeFromStory(button.dataset.remove)));
$("storyFlow").querySelectorAll("[data-edit-section]").forEach((button) => button.addEventListener("click", () => editStorySection(button.dataset.editSection)));
$("storyFlow").querySelectorAll("[data-remove-section]").forEach((button) => button.addEventListener("click", () => removeStoryItem(button.dataset.removeSection)));
$("storyFlow").querySelectorAll("[draggable='true']").forEach((item) => { $("storyFlow").querySelectorAll("[draggable='true']").forEach((item) => {
item.addEventListener("dragstart", (event) => event.dataTransfer.setData("text/story-node", item.dataset.node)); item.addEventListener("dragstart", (event) => event.dataTransfer.setData("text/story-item", item.dataset.item));
item.addEventListener("dragover", (event) => event.preventDefault()); item.addEventListener("dragover", (event) => event.preventDefault());
item.addEventListener("drop", (event) => { item.addEventListener("drop", (event) => {
event.preventDefault(); event.preventDefault();
const source = event.dataTransfer.getData("text/story-node") || event.dataTransfer.getData("text/memory-id"); const source = event.dataTransfer.getData("text/story-item") || event.dataTransfer.getData("text/story-node") || event.dataTransfer.getData("text/memory-id");
if (source) moveNodeInStory(source, item.dataset.node); if (source) moveStoryItem(source, item.dataset.item);
}); });
}); });
} }
@@ -3218,12 +3246,12 @@ HIDDEN_APP_HTML = r"""<!doctype html>
button.addEventListener("dragstart", (event) => event.dataTransfer.setData("text/memory-id", button.dataset.id)); button.addEventListener("dragstart", (event) => event.dataTransfer.setData("text/memory-id", button.dataset.id));
}); });
} }
function newEntry(type = "quote") { function newEntry(type = "tooltip") {
snapshot(); snapshot();
const now = new Date().toISOString().slice(0, 10); const now = new Date().toISOString().slice(0, 10);
const entry = { const entry = {
id: `memory-${Date.now()}`, id: `memory-${Date.now()}`,
title: "Untitled fragment", title: "Untitled memory",
type, type,
contentClass: state.meta.typeArchitecture?.[type]?.contentClass || "fragment", contentClass: state.meta.typeArchitecture?.[type]?.contentClass || "fragment",
content: "", content: "",
@@ -3273,6 +3301,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
tone: entry?.emotionalTone || "warm", tone: entry?.emotionalTone || "warm",
characters: entry?.characters?.length ? [...entry.characters] : ["z"], characters: entry?.characters?.length ? [...entry.characters] : ["z"],
symbols: entry?.symbols ? [...entry.symbols] : [], symbols: entry?.symbols ? [...entry.symbols] : [],
items: entry ? [{ kind: "memory", id: entry.id }] : [],
nodes: entry ? [entry.id] : [], nodes: entry ? [entry.id] : [],
discoveryStyle: "gradual", discoveryStyle: "gradual",
layerAffinity: entry ? [depth(entry)] : [], layerAffinity: entry ? [depth(entry)] : [],
@@ -3301,8 +3330,11 @@ HIDDEN_APP_HTML = r"""<!doctype html>
} }
function addNodeToStory(nodeId) { function addNodeToStory(nodeId) {
const story = storyById(); const story = storyById();
if (!story || !nodeId || story.nodes.includes(nodeId)) return; if (!story || !nodeId || (story.nodes || []).includes(nodeId)) return;
snapshot(); snapshot();
story.items = storyItems(story);
story.items.push({ kind: "memory", id: nodeId });
story.nodes = story.nodes || [];
story.nodes.push(nodeId); story.nodes.push(nodeId);
const entry = entryById(nodeId); const entry = entryById(nodeId);
if (entry) { if (entry) {
@@ -3321,23 +3353,83 @@ HIDDEN_APP_HTML = r"""<!doctype html>
const story = storyById(); const story = storyById();
if (!story) return; if (!story) return;
snapshot(); snapshot();
story.nodes = (story.nodes || []).filter((id) => id !== nodeId); story.items = storyItems(story).filter((item) => !(item.kind !== "section" && item.id === nodeId));
story.nodes = story.items.filter((item) => item.kind !== "section").map((item) => item.id);
rememberDraft();
fillStoryForm(story);
render();
}
function removeStoryItem(itemId) {
const story = storyById();
if (!story) return;
snapshot();
story.items = storyItems(story).filter((item) => item.id !== itemId);
story.nodes = story.items.filter((item) => item.kind !== "section").map((item) => item.id);
rememberDraft(); rememberDraft();
fillStoryForm(story); fillStoryForm(story);
render(); render();
} }
function moveNodeInStory(sourceId, targetId) { function moveNodeInStory(sourceId, targetId) {
moveStoryItem(sourceId, targetId);
}
function moveStoryItem(sourceId, targetId) {
const story = storyById(); const story = storyById();
if (!story || !sourceId) return; if (!story || !sourceId) return;
snapshot(); snapshot();
story.nodes = (story.nodes || []).filter((id) => id !== sourceId); const sourceIsExisting = storyItems(story).some((item) => item.id === sourceId);
const targetIndex = story.nodes.indexOf(targetId); const sourceItem = sourceIsExisting ? storyItems(story).find((item) => item.id === sourceId) : { kind: "memory", id: sourceId };
if (targetIndex >= 0) story.nodes.splice(targetIndex, 0, sourceId); story.items = storyItems(story).filter((item) => item.id !== sourceId);
else story.nodes.push(sourceId); const targetIndex = story.items.findIndex((item) => item.id === targetId);
if (targetIndex >= 0) story.items.splice(targetIndex, 0, sourceItem);
else story.items.push(sourceItem);
story.nodes = story.items.filter((item) => item.kind !== "section").map((item) => item.id);
rememberDraft(); rememberDraft();
fillStoryForm(story); fillStoryForm(story);
render(); render();
} }
function addStorySection() {
const story = storyById();
if (!story) return setStatus("create or select a story first.");
snapshot();
story.items = storyItems(story);
const section = { kind: "section", id: `section-${Date.now()}`, title: "Quiet section", content: "", tone: story.tone || "warm" };
story.items.push(section);
story.nodes = story.items.filter((item) => item.kind !== "section").map((item) => item.id);
state.editingSectionId = section.id;
rememberDraft();
fillStoryForm(story);
openSectionEditor(section.id);
render();
}
function editStorySection(sectionId) {
openSectionEditor(sectionId);
}
function openSectionEditor(sectionId) {
const story = storyById();
const section = storyItems(story).find((item) => item.id === sectionId && item.kind === "section");
if (!story || !section) return;
state.editingSectionId = section.id;
$("sectionTitle").value = section.title || "";
$("sectionContent").value = section.content || "";
$("sectionEditor").classList.remove("hidden");
}
function saveStorySection() {
const story = storyById();
const section = storyItems(story).find((item) => item.id === state.editingSectionId && item.kind === "section");
if (!story || !section) return closeSectionEditor();
snapshot();
section.title = $("sectionTitle").value.trim() || "Story section";
section.content = $("sectionContent").value;
section.tone = story.tone || section.tone || "warm";
story.items = storyItems(story);
rememberDraft();
fillStoryForm(story);
render();
}
function closeSectionEditor() {
state.editingSectionId = "";
$("sectionEditor")?.classList.add("hidden");
}
function duplicateEntry() { function duplicateEntry() {
const entry = currentEntry(); const entry = currentEntry();
if (!entry) return; if (!entry) return;
@@ -3354,6 +3446,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
state.entries = state.entries.filter((item) => item.id !== entry.id); state.entries = state.entries.filter((item) => item.id !== entry.id);
state.stories.forEach((story) => { state.stories.forEach((story) => {
story.nodes = (story.nodes || []).filter((id) => id !== entry.id); story.nodes = (story.nodes || []).filter((id) => id !== entry.id);
story.items = storyItems(story).filter((item) => item.kind === "section" || item.id !== entry.id);
}); });
state.entries.forEach((item) => { state.entries.forEach((item) => {
["continuationLinks","echoes","thematicLinks","symbolicLinks","triggerLinks","parentLinks","childLinks","mirroredEntries"].forEach((key) => { ["continuationLinks","echoes","thematicLinks","symbolicLinks","triggerLinks","parentLinks","childLinks","mirroredEntries"].forEach((key) => {
@@ -3548,6 +3641,9 @@ HIDDEN_APP_HTML = r"""<!doctype html>
$("newBtn").onclick = () => newEntry(); $("newBtn").onclick = () => newEntry();
$("newStoryBtn").onclick = () => newStory(); $("newStoryBtn").onclick = () => newStory();
$("storyFromSelectionBtn").onclick = () => newStory(state.selectedId); $("storyFromSelectionBtn").onclick = () => newStory(state.selectedId);
$("addSectionBtn").onclick = addStorySection;
$("saveSectionBtn").onclick = saveStorySection;
$("closeSectionBtn").onclick = closeSectionEditor;
$("deleteStoryBtn").onclick = deleteStory; $("deleteStoryBtn").onclick = deleteStory;
$("saveBtn").onclick = save; $("saveBtn").onclick = save;
$("zoomOutBtn").onclick = () => zoomBy(-1); $("zoomOutBtn").onclick = () => zoomBy(-1);
@@ -3576,7 +3672,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
$("storyDropZone").addEventListener("dragover", (event) => event.preventDefault()); $("storyDropZone").addEventListener("dragover", (event) => event.preventDefault());
$("storyDropZone").addEventListener("drop", (event) => { $("storyDropZone").addEventListener("drop", (event) => {
event.preventDefault(); event.preventDefault();
const id = event.dataTransfer.getData("text/memory-id") || event.dataTransfer.getData("text/story-node"); const id = event.dataTransfer.getData("text/memory-id") || event.dataTransfer.getData("text/story-node") || event.dataTransfer.getData("text/story-item");
if (id) addNodeToStory(id); if (id) addNodeToStory(id);
}); });
document.addEventListener("click", (event) => { document.addEventListener("click", (event) => {