From 9270f1e6adb1a2e3f3dd574de6396f563baa0943 Mon Sep 17 00:00:00 2001 From: Zaine Date: Wed, 13 May 2026 14:45:49 +0100 Subject: [PATCH] stories --- authoring_server.py | 844 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 656 insertions(+), 188 deletions(-) diff --git a/authoring_server.py b/authoring_server.py index 6a05344..b10f357 100755 --- a/authoring_server.py +++ b/authoring_server.py @@ -277,6 +277,8 @@ CHARACTER_REGISTRY = { 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", @@ -1152,43 +1154,163 @@ def normalize_hidden_entry(raw: dict[str, Any], existing: dict[str, Any] | None 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": 1, + "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": 1, + "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), + "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]]) -> dict[str, Any]: +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: @@ -1200,27 +1322,36 @@ def validate_hidden_integrity(entries: list[dict[str, Any]]) -> dict[str, Any]: 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, + "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 so every memory has a territory.", + "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 narrative nodes, 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 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 narrative node id and relationship fields, not by whole-file ownership.", - "scalability": "The graph model can grow into arc folders, richer discovery flows, symbolic indexes, and per-character constellation views without changing the live hidden-details runtime.", + "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.", } @@ -1332,11 +1463,17 @@ def replace_hidden_editable_block(source: str, entries: list[dict[str, Any]]) -> def save_hidden_store(payload: dict[str, Any]) -> dict[str, Any]: - current = {entry["id"]: entry for entry in load_hidden_store()["entries"]} + 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) @@ -1344,9 +1481,10 @@ def save_hidden_store(payload: dict[str, Any]) -> dict[str, Any]: HIDDEN_CONTENT_JSON.write_text( json.dumps( { - "schemaVersion": 1, + "schemaVersion": 2, "generatedAt": datetime.now().isoformat(timespec="seconds"), "entries": entries, + "stories": stories, }, ensure_ascii=False, indent=2, @@ -2556,7 +2694,7 @@ HIDDEN_APP_HTML = r""" // h1 { margin: 0; font-size: clamp(24px, 3vw, 42px); } h2 { margin: 0 0 10px; font-size: 18px; } h3 { margin: 0; font-size: 16px; } - .observatory { display: grid; grid-template-columns: 280px minmax(0, 1fr) 390px; height: 100vh; } + .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; @@ -2573,7 +2711,8 @@ HIDDEN_APP_HTML = r""" top: 18px; left: 20px; right: 20px; - display: flex; + display: grid; + grid-template-columns: minmax(220px, 1fr) minmax(280px, auto); align-items: flex-start; justify-content: space-between; gap: 12px; @@ -2593,7 +2732,8 @@ HIDDEN_APP_HTML = r""" } .stack { display: grid; gap: 10px; } .filters { display: grid; gap: 9px; margin: 14px 0; } - .modebar, .actions, .pills { display: flex; flex-wrap: wrap; gap: 7px; align-items: center; } + .modebar, .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); @@ -2833,7 +2973,8 @@ HIDDEN_APP_HTML = r""" background: rgba(251, 241, 220, 0.07); box-shadow: var(--shadow); } - .editor-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; } + .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; @@ -2846,7 +2987,7 @@ HIDDEN_APP_HTML = r""" padding: 12px; font-family: Georgia, "Times New Roman", serif; } - .connection-list, .timeline-list { display: grid; gap: 8px; } + .connection-list, .timeline-list, .story-list, .story-flow-list, .memory-pool { display: grid; gap: 8px; } .connection { text-align: left; display: block; @@ -2856,6 +2997,63 @@ HIDDEN_APP_HTML = r""" 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; } @@ -2876,6 +3074,8 @@ HIDDEN_APP_HTML = r""" .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; } @@ -2891,11 +3091,12 @@ HIDDEN_APP_HTML = r"""
A constellation map for the hidden soul of the website.
aphy is dimming the room lights.
- - - - - + + + + + +
@@ -2914,14 +3115,13 @@ HIDDEN_APP_HTML = r"""

How to Read the Observatory

-

This is not a database. It is a map of emotional relationships. Layers are depths, nodes are memories and hidden interactions, and threads show meaning, echoes, symbols, or continuation.

-

aphy: zoomed out, I show islands. zoom in, I show memories. this prevents the sky from becoming noise.

+

This is a story constellation library. Stories are emotional routes, and memories are lights arranged along those routes.

+

aphy: zoomed out, I show stories. zoom in, I show the memories inside them. the sky stays quieter this way.

sensei chi: depth is not importance. Depth is how quietly a thing asks to be approached.

Guided Exploration

@@ -2929,10 +3129,14 @@ HIDDEN_APP_HTML = r""" - - + +
+
+

Story Covers

+
+

Character Territories

@@ -2956,7 +3160,7 @@ HIDDEN_APP_HTML = r""" - + @@ -2966,7 +3170,7 @@ HIDDEN_APP_HTML = r"""
-
Click a memory to open it. Drag one memory onto another to weave a continuation thread.
+
Click a story to open its route. Drag memories in the Story Builder to shape the path.
Atlas
@@ -2978,7 +3182,33 @@ HIDDEN_APP_HTML = r"""
-
+
+

Story Builder

+
+
+ + + + + + + + + + +
+
+ + + +
+

Story flow

+
+
Drag memories here to add them to this story.
+

Memory pool

+
+
+

Memory editor

@@ -2996,34 +3226,36 @@ HIDDEN_APP_HTML = r""" - + - - - - - - - -
+
-
-

Nearby memories

+
+

This memory belongs to

-
+

Architecture

-

Updated model: each hidden piece is a narrative node. The old Family Layer Index is now a depth zone, not a type.

-

Relationship engine: links come from continuations, echoes, themes, symbols, triggers, page locations, characters, and arcs.

+

Story model: stories are first-class emotional routes. Memories can belong to multiple stories, but routes stay readable and authored in order.

+

Legacy links: old continuations, echoes, and symbolic links are retained for migration only. New authoring happens through Story Builder.

Storage: the friendly graph lives in assets/content/hidden-details.json; the live site still receives generated constants in assets/scripts/hidden-details.js.

@@ -3035,8 +3267,9 @@ HIDDEN_APP_HTML = r""" const draftKey = "hiddenNarrativeObservatoryDraft:v1"; const WORLD = { width: 4600, height: 3300, cx: 2300, cy: 1650 }; const SCREEN = { width: 1000, height: 760 }; - const state = { entries: [], meta: {}, mode: "graph", selectedId: "", undo: [], redo: [], draggingId: "", positions: new Map(), clusterPositions: new Map(), zoom: 0, focusCluster: "", focusKind: "", focusIds: null, focusLabel: "", history: [], explorationUndo: [], explorationRedo: [], camera: { x: 0, y: 0, k: 0.22 }, pan: null, drag: null, renderTimer: 0 }; + const 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"; @@ -3047,6 +3280,18 @@ HIDDEN_APP_HTML = r""" 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 || {}; } @@ -3221,15 +3466,19 @@ HIDDEN_APP_HTML = r""" state.renderTimer = window.setTimeout(render, 90); } function snapshot() { - state.undo.push(JSON.stringify(state.entries)); + 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) { - state.entries = JSON.parse(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 { @@ -3294,7 +3543,7 @@ HIDDEN_APP_HTML = r""" setStatus("one exploration step undone."); } function rememberDraft() { - localStorage.setItem(draftKey, JSON.stringify({ entries: state.entries, selectedId: state.selectedId, savedAt: Date.now() })); + 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) { @@ -3352,6 +3601,56 @@ HIDDEN_APP_HTML = r""" 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 = "Create a story to shape a route through the observatory."; + 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; @@ -3412,51 +3711,71 @@ HIDDEN_APP_HTML = r""" && (!$("rarityFilter").value || entry.rarity === $("rarityFilter").value); }); } - function relationshipPairs(entries) { - const byId = new Map(state.entries.map((entry) => [entry.id, entry])); + 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 = []; - const add = (source, target, kind) => { - if (!target || !byId.has(target) || !visible.has(source.id) || !visible.has(target)) return; - pairs.push({ source: source.id, target, kind }); - }; - entries.forEach((entry) => { - (entry.continuationLinks || []).forEach((id) => add(entry, id, "continuation")); - (entry.chainReferences || []).forEach((id) => add(entry, id, "continuation")); - (entry.echoes || []).forEach((id) => add(entry, id, "echo")); - (entry.thematicLinks || []).forEach((id) => add(entry, id, "theme")); - (entry.symbolicLinks || []).forEach((id) => add(entry, id, "symbol")); - (entry.triggerLinks || []).forEach((id) => add(entry, id, "trigger")); - (entry.parentLinks || []).forEach((id) => add(entry, id, "parent")); - (entry.childLinks || []).forEach((id) => add(entry, id, "child")); - (entry.mirroredEntries || []).forEach((id) => add(entry, id, "echo")); + 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 })); }); - if (state.mode === "character" || state.mode === "graph") { - entries.forEach((entry, index) => { - entries.slice(index + 1).forEach((other) => { - if ((entry.characters || []).some((name) => (other.characters || []).includes(name))) pairs.push({ source: entry.id, target: other.id, kind: "character" }); - if ((entry.symbols || []).some((name) => (other.symbols || []).includes(name))) pairs.push({ source: entry.id, target: other.id, kind: "symbol" }); - }); - }); - } - return pairs.slice(0, 520); + return pairs; + } + function relationshipPairs(entries) { + return storySequencePairs(entries).slice(0, 180); } function clusterKey(entry) { - if (state.mode === "timeline") return `${entry.modifiedDate || entry.createdDate || "undated"}`.slice(0, 7); + const story = storiesForEntry(entry.id)[0]; + if (story) return story.title; if (state.mode === "character") return (entry.characters || [])[0] || "z"; - if (state.mode === "layer") return `Layer ${depth(entry)} - ${layerName(depth(entry))}`; - if (state.mode === "flow") return entry.triggerConditions ? `Trigger: ${entry.triggerConditions.split(/[,:]/)[0]}` : `Layer ${depth(entry)} discovery`; - return (entry.narrativeArcs || [])[0] || (entry.symbols || [])[0] || entry.emotionalTone || "Loose memories"; + return "Loose memories"; } function clusterKindLabel() { - return { graph: "arc/tone island", timeline: "era", character: "territory", layer: "depth zone", flow: "discovery gate" }[state.mode] || "island"; + 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); @@ -3492,39 +3811,38 @@ HIDDEN_APP_HTML = r""" } function relationshipPairsForClusters(clusters) { - const index = new Map(); - clusters.forEach((cluster) => cluster.entries.forEach((entry) => index.set(entry.id, cluster.id))); - const counts = new Map(); - clusters.forEach((cluster) => { - cluster.entries.forEach((entry) => { - ["continuationLinks","chainReferences","echoes","thematicLinks","symbolicLinks","triggerLinks","parentLinks","childLinks","mirroredEntries"].forEach((key) => { - (entry[key] || []).forEach((id) => { - const target = index.get(id); - if (!target || target === cluster.id) return; - const edge = [cluster.id, target].sort().join("::"); - counts.set(edge, (counts.get(edge) || 0) + 1); - }); - }); - }); - }); - return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 80).map(([edge, count]) => { - const [source, target] = edge.split("::"); - return { source, target, count }; - }); + 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(); - entries.forEach((entry) => { - const layer = state.mode === "layer" ? depth(entry) : state.mode === "character" ? characterBucket(entry) : depth(entry); + loose.forEach((entry) => { + const layer = state.mode === "character" ? characterBucket(entry) : depth(entry); if (!byLayer.has(layer)) byLayer.set(layer, []); byLayer.get(layer).push(entry); }); - state.positions = new Map(); [...byLayer.entries()].forEach(([layer, items]) => { const layerNum = Number(layer); - const radius = state.mode === "layer" ? 210 + (5 - layerNum) * 250 : 280 + (5 - depth({ familyLayer: String(layerNum) })) * 235; + const 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; @@ -3634,7 +3952,7 @@ HIDDEN_APP_HTML = r""" if (clusters.length) layoutClusters(clusters); if (entries.length) layoutEntries(entries); const graph = $("graph"); - const rings = state.mode === "timeline" || state.mode === "flow" ? "" : [0,1,2,3,4,5].map((layer) => { + const rings = ["journey","hidden","dream","temporal"].includes(state.mode) ? "" : [0,1,2,3,4,5].map((layer) => { const radius = 280 + (5 - layer) * 235; return `Layer ${layer} - ${html(layerName(layer))}`; }).join(""); @@ -3651,6 +3969,7 @@ HIDDEN_APP_HTML = r""" if (!a || !b) return ""; return ``; }).join(""); + const storyRegions = renderStoryRegions(entries); const occupied = []; const defs = []; const territoryAnchors = renderTerritoryAnchors(defs); @@ -3691,7 +4010,7 @@ HIDDEN_APP_HTML = r""" : svgAvatar(character, size, clipId, avatarExpressionForEntry(entry), state.zoom >= 2 ? "node-breathe" : "")} ${label}`; }).join(""); - graph.innerHTML = `${defs.join("")}${rings}${territoryAnchors}${clusterEdges}${edges}${clusterNodes}${nodes}`; + graph.innerHTML = `${defs.join("")}${rings}${territoryAnchors}${storyRegions}${clusterEdges}${edges}${clusterNodes}${nodes}`; graph.querySelectorAll(".cluster-node").forEach((node) => { node.addEventListener("click", () => openCluster(node.dataset.id)); node.addEventListener("dblclick", () => openCluster(node.dataset.id)); @@ -3711,7 +4030,7 @@ HIDDEN_APP_HTML = r""" updateDragPreview(); } function renderTerritoryAnchors(defs) { - if (state.camera.k < 0.18 || state.mode === "timeline" || state.mode === "flow") return ""; + if (state.camera.k < 0.18 || ["journey","hidden","dream","temporal"].includes(state.mode)) return ""; return Object.entries(avatarRegistry()).map(([character, config]) => { const anchor = config.observatory?.anchor || { x: 0.5, y: 0.5 }; const x = Math.max(260, Math.min(WORLD.width - 260, Number(anchor.x || 0.5) * WORLD.width)); @@ -3727,6 +4046,19 @@ HIDDEN_APP_HTML = r""" `; }).join(""); } + 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 ``; + }).join(""); + } function dragClassForEntry(id) { if (!state.drag) return ""; if (id === state.drag.sourceId) return " dragging"; @@ -3735,16 +4067,17 @@ HIDDEN_APP_HTML = r""" } function renderModeSupport(entries, clusters = []) { const titles = { - graph: ["Emotional Graph", "Relationship web: continuations, echoes, symbols, characters, triggers."], - timeline: ["Timeline View", "Memories arranged by modified date so the constellation becomes a diary."], - character: ["Character View", "Nodes gather around lima, aphy, sensei chi, young z, future z, and z."], - layer: ["Layer Dive", "Depth zones show the emotional pressure from surface reality to the core."], - flow: ["Discovery Flow", "A possible path from ordinary encounter to patient secret."], + 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 an island to reveal detail. Drag memories to weave continuation threads.`; + $("mapNote").textContent = `${scopeText} Zoom in or open a story to reveal its memory flow. Use Story Builder to shape the route.`; } function renderValidation() { @@ -3752,8 +4085,8 @@ HIDDEN_APP_HTML = r""" const summary = validation.summary || {}; const ok = validation.ok && !summary.unknownCharacterCount && !summary.orphanNodeCount && !summary.staleLinkCount; $("integrityStatus").innerHTML = ok - ? "all memories belong to canonical character territories. no orphan nodes, unknown aliases, or stale relationship links found." - : `needs attention: ${summary.unknownCharacterCount || 0} unknown characters, ${summary.orphanNodeCount || 0} orphan memories, ${summary.staleLinkCount || 0} stale links. ${html(validation.repairPolicy || "")}`; + ? "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) { @@ -3822,6 +4155,7 @@ HIDDEN_APP_HTML = r""" 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; @@ -3970,25 +4304,29 @@ HIDDEN_APP_HTML = r""" if (!entry) return; state.selectedId = entry.id; fillForm(entry); + renderStoryBuilder(); render(); } function relationForEvent(event) { - if (event?.altKey) return { key: "mirroredEntries", kind: "mirror", label: "Create mirror" }; - if (event?.shiftKey) return { key: "echoes", kind: "echo", label: "Add symbolic echo" }; - if (event?.ctrlKey || event?.metaKey) return { key: "symbolicLinks", kind: "symbolic", label: "Create symbolic link" }; - return { key: "continuationLinks", kind: "continuation", label: "Create continuation" }; + return { key: "stories", kind: "story-path", label: "Add to story route" }; } function connectNodes(sourceId, targetId, relation = relationForEvent()) { if (!sourceId || !targetId || sourceId === targetId) return; snapshot(); - const entry = state.entries.find((item) => item.id === sourceId); - if (!entry) return; - entry[relation.key] = Array.from(new Set([...(entry[relation.key] || []), targetId])); + 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(entry); + fillForm(entryById(sourceId)); + fillStoryForm(story); render(); - setStatus(`${relation.label.toLowerCase()} woven between those memories.`); + setStatus("those memories now sit together in the selected story route."); } function addToCluster(sourceId, clusterId) { @@ -3998,7 +4336,7 @@ HIDDEN_APP_HTML = r""" snapshot(); if (state.mode === "character" && cluster.label) entry.characters = Array.from(new Set([...(entry.characters || []), cluster.label])); else if (state.mode === "layer") entry.familyLayer = String(cluster.depth); - else entry.narrativeArcs = Array.from(new Set([...(entry.narrativeArcs || []), cluster.label])); + else addNodeToStory(sourceId); state.focusCluster = ""; state.focusIds = null; state.focusLabel = ""; @@ -4013,13 +4351,12 @@ HIDDEN_APP_HTML = r""" const entry = state.entries.find((item) => item.id === sourceId); if (!entry) return; snapshot(); - const name = `hand-shaped thread ${new Date().toISOString().slice(0, 10)}`; - entry.narrativeArcs = Array.from(new Set([...(entry.narrativeArcs || []), name])); + newStory(sourceId); state.selectedId = sourceId; rememberDraft(); fillForm(entry); render(); - setStatus("a new thread has begun around that memory."); + setStatus("a new story route has begun around that memory."); } function beginNodeDrag(event, id) { @@ -4043,13 +4380,7 @@ HIDDEN_APP_HTML = r""" // Second click + drag starts the connection. const point = pointerWorld(event); const entry = state.entries.find((item) => item.id === id); - const related = new Set([ - ...(entry?.continuationLinks || []), - ...(entry?.echoes || []), - ...(entry?.thematicLinks || []), - ...(entry?.symbolicLinks || []), - ...(entry?.mirroredEntries || []), - ]); + const related = new Set(storiesForEntry(id).flatMap((story) => story.nodes || [])); state.drag = { sourceId: id, @@ -4171,13 +4502,10 @@ HIDDEN_APP_HTML = r""" fillForm(entry); const menu = $("contextMenu"); menu.innerHTML = [ - ["continuation", "Create continuation"], - ["echo", "Create echo"], - ["mirror", "Create mirror"], - ["symbolic", "Create symbolic link"], - ["focus", "Focus thread"], - ["isolate", "Isolate constellation"], - ["arc", "Add to arc"], + ["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"], @@ -4202,17 +4530,8 @@ HIDDEN_APP_HTML = r""" function runContextAction(action, id) { hideContextMenu(); selectNode(id); - const relationMap = { - continuation: { key: "continuationLinks", label: "Create continuation" }, - echo: { key: "echoes", label: "Create echo" }, - mirror: { key: "mirroredEntries", label: "Create mirror" }, - symbolic: { key: "symbolicLinks", label: "Create symbolic link" }, - }; - if (relationMap[action]) { - const targetId = chooseTarget(id, "Connect to which memory? Type an id or exact title."); - if (targetId) connectNodes(id, targetId, relationMap[action]); - else setStatus("no matching memory found for that connection."); - } + if (action === "add-story") addNodeToStory(id); + if (action === "new-story") newStory(id); if (action === "focus") focusThread(); if (action === "isolate") { const entry = currentEntry(); @@ -4224,16 +4543,6 @@ HIDDEN_APP_HTML = r""" render(); fitCameraTo(boundsForEntries(visibleScope().entries), true); } - if (action === "arc") { - const entry = currentEntry(); - const arc = prompt("Add this memory to which arc?", (entry?.narrativeArcs || [])[0] || ""); - if (!entry || !arc) return; - snapshot(); - entry.narrativeArcs = Array.from(new Set([...(entry.narrativeArcs || []), arc.trim()])); - rememberDraft(); - fillForm(entry); - render(); - } if (action === "duplicate") duplicateEntry(); if (action === "layer") { const entry = currentEntry(); @@ -4278,17 +4587,19 @@ HIDDEN_APP_HTML = r""" } function focusThread() { - const entry = currentEntry(); - if (!entry) return; - const ids = new Set([entry.id, ...(entry.continuationLinks || []), ...(entry.echoes || []), ...(entry.thematicLinks || []), ...(entry.symbolicLinks || []), ...(entry.triggerLinks || []), ...(entry.parentLinks || []), ...(entry.childLinks || []), ...(entry.mirroredEntries || [])]); + 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 = ids; - state.focusLabel = `Thread: ${entry.title || entry.id}`; + 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 memory's nearest thread first."); + setStatus("focus mode is showing this story route."); } function runTour(name) { @@ -4316,17 +4627,22 @@ HIDDEN_APP_HTML = r""" $("search").value = ""; } if (name === "unresolved") { - $("search").value = "untitled unresolved maybe hidden"; - $("characterFilter").value = ""; - $("rarityFilter").value = ""; - $("toneFilter").value = ""; + state.mode = "hidden"; } if (name === "connected") { - $("search").value = "echo memory warm"; - $("characterFilter").value = ""; - $("rarityFilter").value = ""; - $("toneFilter").value = ""; + 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."); @@ -4337,15 +4653,63 @@ HIDDEN_APP_HTML = r""" $("preview").innerHTML = `${body || "This memory is waiting for words."}
${avatarPills}${html(layerName(depth(entry)))}${html(entry.emotionalTone)}${html(entry.rarity)}${(entry.symbols || []).map((item) => `${html(item)}`).join("")}
`; } function renderConnections(entry) { - const ids = new Set([...(entry.continuationLinks || []), ...(entry.echoes || []), ...(entry.thematicLinks || []), ...(entry.symbolicLinks || []), ...(entry.triggerLinks || []), ...(entry.parentLinks || []), ...(entry.childLinks || []), ...(entry.mirroredEntries || [])]); - const sameCharacter = state.entries.filter((other) => other.id !== entry.id && (entry.characters || []).some((name) => (other.characters || []).includes(name))).slice(0, 4); - const linked = [...ids].map((id) => state.entries.find((item) => item.id === id)).filter(Boolean); - const items = [...linked, ...sameCharacter].slice(0, 10); - $("connections").innerHTML = items.length ? items.map((item) => { - const character = primaryCharacterForEntry(item); - return ``; - }).join("") : "
No nearby memories yet. Drag from this node to another to begin a thread.
"; - $("connections").querySelectorAll("button").forEach((button) => button.addEventListener("click", () => selectNode(button.dataset.id))); + const stories = storiesForEntry(entry.id); + $("connections").innerHTML = stories.length ? stories.map((story) => { + return ``; + }).join("") : "
This memory is not in a story yet. Add it from the Story Builder to give it an emotional route.
"; + $("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 = "No story selected."; + $("storyFlow").innerHTML = ""; + return; + } + const characters = (story.characters || []).map((character) => `${avatarImg(character, "tiny")}${html(characterLabel(character))}`).join(""); + $("storyCover").innerHTML = `${html(story.title)}

${html(story.description || "A quiet route waiting for a summary.")}

${characters}${html(story.tone || "warm")}${html(story.discoveryStyle || "gradual")}${story.hidden ? "hidden" : ""}${(story.markers || []).map((item) => `${html(item)}`).join("")}
`; + const nodes = storyEntries(story); + $("storyFlow").innerHTML = nodes.length ? nodes.map((entry, index) => { + const character = primaryCharacterForEntry(entry); + return `
${index + 1}${html(entry.title)}
${html(entry.emotionalTone || "warm")} / ${html(layerName(depth(entry)))}
`; + }).join("") : "
No memories in this story yet.
"; + $("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) => ``).join("") : "
No story routes match this view yet.
"; + $("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 ``; + }).join("") || "
Every visible memory is already in this story.
"; + $("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(); @@ -4386,6 +4750,83 @@ HIDDEN_APP_HTML = r""" 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; @@ -4400,6 +4841,9 @@ HIDDEN_APP_HTML = r""" 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); @@ -4412,6 +4856,9 @@ HIDDEN_APP_HTML = r""" const opt = (value, label = value) => ``; $("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(""); @@ -4435,13 +4882,16 @@ HIDDEN_APP_HTML = r""" 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 }) }); + 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."); @@ -4456,15 +4906,20 @@ HIDDEN_APP_HTML = r""" 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(); - setStatus(data.migratedFromJs ? "Existing hidden details became a memory constellation." : "observatory open."); + 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) { @@ -4551,6 +5006,10 @@ HIDDEN_APP_HTML = r""" $(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", () => { @@ -4571,6 +5030,9 @@ HIDDEN_APP_HTML = r""" }); }); $("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); @@ -4582,8 +5044,8 @@ HIDDEN_APP_HTML = r""" $("focusBtn").onclick = focusThread; $("duplicateBtn").onclick = duplicateEntry; $("deleteBtn").onclick = deleteEntry; - $("undoBtn").onclick = () => { if (!state.undo.length) return; state.redo.push(JSON.stringify(state.entries)); restore(state.undo.pop()); }; - $("redoBtn").onclick = () => { if (!state.redo.length) return; state.undo.push(JSON.stringify(state.entries)); restore(state.redo.pop()); }; + $("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; @@ -4595,6 +5057,12 @@ HIDDEN_APP_HTML = r""" 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(); });