memory observatory redesign x2
All checks were successful
Build Authoring Service / build (push) Successful in 9s
All checks were successful
Build Authoring Service / build (push) Successful in 9s
This commit is contained in:
0
docs/hidden-memory-architecture.md
Normal file → Executable file
0
docs/hidden-memory-architecture.md
Normal file → Executable file
@@ -3,33 +3,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
HIDDEN_CONTENT_TYPES = [
|
||||
"tooltip",
|
||||
"quote",
|
||||
"whisper",
|
||||
"poem",
|
||||
"hidden dialogue",
|
||||
"journal entry",
|
||||
"rare event",
|
||||
"loading screen message",
|
||||
"secret interaction",
|
||||
"hidden tooltip",
|
||||
"future z message",
|
||||
"young z memory fragment",
|
||||
"sensei chi wisdom entry",
|
||||
"aphy system message",
|
||||
"lima note/message",
|
||||
"dream sequence",
|
||||
"terminal log",
|
||||
"fake error message",
|
||||
"recurring joke",
|
||||
"seasonal event",
|
||||
"weather-based event",
|
||||
"hover message",
|
||||
"hidden achievement",
|
||||
"guestbook entry",
|
||||
"hidden conversation",
|
||||
"family layer",
|
||||
"search toast",
|
||||
"search route",
|
||||
"keyboard secret",
|
||||
"observation",
|
||||
"dialogue",
|
||||
"secret search",
|
||||
"symbolic fragment",
|
||||
"ambient memory",
|
||||
"hidden interaction",
|
||||
]
|
||||
|
||||
HIDDEN_CONTENT_CLASSES = [
|
||||
@@ -61,33 +44,44 @@ HIDDEN_SURFACES = [
|
||||
]
|
||||
|
||||
TYPE_ARCHITECTURE = {
|
||||
"tooltip": {"contentClass": "fragment", "surfaces": ["tooltip"], "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"},
|
||||
"hidden dialogue": {"contentClass": "fragment", "surfaces": ["tooltip", "story", "observatory"], "observatory": "node"},
|
||||
"journal entry": {"contentClass": "memory", "surfaces": ["story", "observatory"], "observatory": "node"},
|
||||
"rare event": {"contentClass": "interaction", "surfaces": ["hidden route", "play", "observatory"], "observatory": "event"},
|
||||
"loading screen message": {"contentClass": "fragment", "surfaces": ["loading", "quote"], "observatory": "ambient"},
|
||||
"secret interaction": {"contentClass": "interaction", "surfaces": ["hidden route", "play"], "observatory": "event"},
|
||||
"hidden tooltip": {"contentClass": "fragment", "surfaces": ["tooltip"], "observatory": "ambient"},
|
||||
"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"},
|
||||
"aphy system message": {"contentClass": "fragment", "surfaces": ["quote", "terminal"], "observatory": "ambient"},
|
||||
"lima note/message": {"contentClass": "fragment", "surfaces": ["quote", "story"], "observatory": "ambient"},
|
||||
"dream sequence": {"contentClass": "lore", "surfaces": ["dream", "play", "story", "observatory"], "observatory": "node"},
|
||||
"terminal log": {"contentClass": "lore", "surfaces": ["terminal", "play"], "observatory": "event"},
|
||||
"fake error message": {"contentClass": "interaction", "surfaces": ["play", "hidden route"], "observatory": "event"},
|
||||
"recurring joke": {"contentClass": "fragment", "surfaces": ["tooltip", "quote"], "observatory": "ambient"},
|
||||
"seasonal event": {"contentClass": "interaction", "surfaces": ["seasonal", "hidden route"], "observatory": "event"},
|
||||
"weather-based event": {"contentClass": "interaction", "surfaces": ["hidden route"], "observatory": "event"},
|
||||
"hover message": {"contentClass": "fragment", "surfaces": ["tooltip"], "observatory": "ambient"},
|
||||
"hidden achievement": {"contentClass": "interaction", "surfaces": ["play", "hidden route"], "observatory": "event"},
|
||||
"guestbook entry": {"contentClass": "lore", "surfaces": ["guestbook", "observatory"], "observatory": "ambient"},
|
||||
"hidden conversation": {"contentClass": "story material", "surfaces": ["story", "observatory"], "observatory": "node"},
|
||||
"family layer": {"contentClass": "system layer", "surfaces": ["layer guide"], "observatory": "guide"},
|
||||
"search toast": {"contentClass": "interaction", "surfaces": ["search"], "observatory": "event"},
|
||||
"search route": {"contentClass": "interaction", "surfaces": ["search", "hidden route"], "observatory": "event"},
|
||||
"keyboard secret": {"contentClass": "interaction", "surfaces": ["keyboard", "hidden route"], "observatory": "event"},
|
||||
"observation": {"contentClass": "memory", "surfaces": ["story", "observatory"], "observatory": "node"},
|
||||
"dialogue": {"contentClass": "memory", "surfaces": ["story", "observatory"], "observatory": "node"},
|
||||
"secret search": {"contentClass": "interaction", "surfaces": ["search", "hidden route"], "observatory": "event"},
|
||||
"symbolic fragment": {"contentClass": "fragment", "surfaces": ["story", "observatory"], "observatory": "ambient"},
|
||||
"ambient memory": {"contentClass": "memory", "surfaces": ["story", "observatory"], "observatory": "node"},
|
||||
"hidden interaction": {"contentClass": "interaction", "surfaces": ["hidden route", "keyboard", "play"], "observatory": "event"},
|
||||
}
|
||||
|
||||
LEGACY_CONTENT_TYPE_MAP = {
|
||||
"hidden tooltip": "tooltip",
|
||||
"hover message": "tooltip",
|
||||
"loading screen message": "whisper",
|
||||
"hidden dialogue": "dialogue",
|
||||
"hidden conversation": "dialogue",
|
||||
"journal entry": "observation",
|
||||
"future z message": "ambient memory",
|
||||
"young z memory fragment": "ambient memory",
|
||||
"sensei chi wisdom entry": "quote",
|
||||
"aphy system message": "whisper",
|
||||
"lima note/message": "whisper",
|
||||
"dream sequence": "symbolic fragment",
|
||||
"guestbook entry": "observation",
|
||||
"terminal log": "hidden interaction",
|
||||
"fake error message": "hidden interaction",
|
||||
"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 = {
|
||||
|
||||
@@ -20,6 +20,7 @@ from .constants import (
|
||||
HIDDEN_SURFACES,
|
||||
HIDDEN_STORY_MARKERS,
|
||||
HIDDEN_TONES,
|
||||
LEGACY_CONTENT_TYPE_MAP,
|
||||
TYPE_ARCHITECTURE,
|
||||
)
|
||||
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_type = str(entry.get("type") or "quote").strip()
|
||||
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:
|
||||
raise ValueError(f"Unsupported hidden content type: {content_type}")
|
||||
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:
|
||||
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)
|
||||
raw_items = story.get("items")
|
||||
if not isinstance(raw_items, list):
|
||||
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 = []
|
||||
for item in raw_nodes:
|
||||
node_id = str(item).strip()
|
||||
for index, item in enumerate(raw_items):
|
||||
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):
|
||||
nodes.append(node_id)
|
||||
items.append({"kind": "memory", "id": 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()]
|
||||
@@ -374,6 +398,7 @@ def normalize_hidden_story(raw: dict[str, Any], entries: list[dict[str, Any]] |
|
||||
"tone": str(story.get("tone") or "warm"),
|
||||
"characters": characters,
|
||||
"symbols": symbols,
|
||||
"items": items,
|
||||
"nodes": nodes,
|
||||
"discoveryStyle": str(story.get("discoveryStyle") or "gradual"),
|
||||
"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]:
|
||||
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/.",
|
||||
"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.",
|
||||
"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:
|
||||
family_layers = hidden_contents(entries, "family layer")
|
||||
details = hidden_contents(entries, "hidden tooltip") + hidden_contents(entries, "hover message")
|
||||
family_layers = [
|
||||
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")
|
||||
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 = [
|
||||
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()
|
||||
]
|
||||
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 = {
|
||||
"quotes": quote_like,
|
||||
"conversations": [
|
||||
entry.get("dialogue") if isinstance(entry.get("dialogue"), list) else [line for line in str(entry.get("content", "")).splitlines() if line.strip()]
|
||||
for entry in hidden_entries_by_type(entries, "hidden conversation")
|
||||
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": {
|
||||
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": [
|
||||
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()
|
||||
],
|
||||
"roomLinks": [
|
||||
[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")
|
||||
],
|
||||
}
|
||||
search_toasts = {
|
||||
entry.get("query") or entry.get("triggerConditions") or entry.get("title", ""): entry.get("content", "")
|
||||
for entry in hidden_entries_by_type(entries, "search toast")
|
||||
for entry in hidden_entries_by_type(entries, "secret search")
|
||||
if not entry.get("pageLocation")
|
||||
}
|
||||
search_routes = {
|
||||
entry.get("query") or entry.get("triggerConditions") or entry.get("title", ""): entry.get("pageLocation") or entry.get("content", "")
|
||||
for entry in hidden_entries_by_type(entries, "search route")
|
||||
for entry in hidden_entries_by_type(entries, "secret search")
|
||||
if entry.get("pageLocation")
|
||||
}
|
||||
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["phrase"] = secret.get("phrase") or entry.get("triggerConditions") or entry.get("title", "")
|
||||
if entry.get("pageLocation"):
|
||||
|
||||
@@ -1201,7 +1201,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
|
||||
// h1 { margin: 0; font-size: clamp(24px, 3vw, 42px); }
|
||||
h2 { margin: 0 0 10px; font-size: 18px; }
|
||||
h3 { margin: 0; font-size: 16px; }
|
||||
.observatory { display: grid; grid-template-columns: minmax(260px, 300px) minmax(620px, 1fr) minmax(390px, 460px); height: 100vh; min-width: 0; }
|
||||
.observatory { display: grid; grid-template-columns: minmax(250px, 290px) minmax(680px, 1fr) minmax(400px, 440px); height: 100vh; min-width: 0; }
|
||||
.left, .drawer {
|
||||
z-index: 3;
|
||||
overflow: auto;
|
||||
@@ -1528,6 +1528,10 @@ HIDDEN_APP_HTML = r"""<!doctype html>
|
||||
border-radius: 8px;
|
||||
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-step {
|
||||
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 class="modebar" id="modebar">
|
||||
<button data-mode="graph" class="active">Story constellations</button>
|
||||
<button data-mode="journey">Emotional journeys</button>
|
||||
<button data-mode="hidden">Hidden routes</button>
|
||||
<button data-mode="character">Character stories</button>
|
||||
<button data-mode="dream">Dream paths</button>
|
||||
<button data-mode="temporal">Temporal stories</button>
|
||||
</div>
|
||||
<div class="filters">
|
||||
<input id="search" type="search" placeholder="Search memories, symbols, triggers" />
|
||||
<input id="search" type="search" placeholder="Search memories and stories" />
|
||||
<select id="layerFilter"></select>
|
||||
<select id="characterFilter"></select>
|
||||
<select id="toneFilter"></select>
|
||||
<select id="rarityFilter"></select>
|
||||
<select id="rarityFilter" class="hidden"></select>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button id="newBtn" type="button">New fragment</button>
|
||||
<button id="saveBtn" class="primary" type="button">Save constellation</button>
|
||||
<button id="newBtn" type="button">New memory</button>
|
||||
<button id="newStoryBtn" type="button">New story</button>
|
||||
<button id="saveBtn" class="primary" type="button">Save</button>
|
||||
</div>
|
||||
<div class="actions" style="margin-top:8px">
|
||||
<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="future" type="button">show discoveries tied to future z</button>
|
||||
<button data-tour="hidden">show hidden routes</button>
|
||||
<button data-tour="dream">show dream paths</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="guide-card">
|
||||
@@ -1695,19 +1696,27 @@ HIDDEN_APP_HTML = r"""<!doctype html>
|
||||
<label>Discovery<select id="storyDiscovery"></select></label>
|
||||
<label>Characters<input id="storyCharacters" placeholder="lima, z" /></label>
|
||||
<label>Symbols<input id="storySymbols" placeholder="ring, kitchen light" /></label>
|
||||
<label>Markers<select id="storyMarkers" multiple size="4"></select></label>
|
||||
<label>Layers<input id="storyLayers" placeholder="1, 2, 3" /></label>
|
||||
<label>Markers<select id="storyMarkers" multiple size="3"></select></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>Visibility<select id="storyHidden"><option value="false">Public</option><option value="true">Hidden</option></select></label>
|
||||
</div>
|
||||
<div class="actions" style="margin-top:10px">
|
||||
<button id="newStoryBtn" type="button">New story</button>
|
||||
<button id="storyFromSelectionBtn" type="button">Create story from memory</button>
|
||||
<button id="addSectionBtn" type="button">Add section</button>
|
||||
<button id="deleteStoryBtn" class="danger" type="button">Delete story</button>
|
||||
</div>
|
||||
<h3 style="margin-top:14px">Story flow</h3>
|
||||
<div id="storyFlow" class="story-flow-list"></div>
|
||||
<div id="storyDropZone" class="story-drop-zone">Drag memories here to add them to this story.</div>
|
||||
<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>
|
||||
<div id="memoryPool" class="memory-pool"></div>
|
||||
</section>
|
||||
@@ -1715,8 +1724,8 @@ HIDDEN_APP_HTML = r"""<!doctype html>
|
||||
<h2>Memory editor</h2>
|
||||
<div class="editor-grid">
|
||||
<label class="full">Name<input id="title" /></label>
|
||||
<label>Runtime kind<select id="type"></select></label>
|
||||
<label>Canonical class<select id="contentClass"></select></label>
|
||||
<label>Memory type<select id="type"></select></label>
|
||||
<label>Behavior<input id="canonicalUse" placeholder="where and how this memory appears" /></label>
|
||||
<label>Depth<select id="familyLayer"></select></label>
|
||||
<label>Characters<input id="characters" placeholder="lima, aphy" /></label>
|
||||
<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>Discovery<select id="discoveryDifficulty" placeholder="gentle, patient, hidden" /></label>
|
||||
<label>Mystery<input id="mysteryLevel" placeholder="quiet, strange, deep" /></label>
|
||||
<label>Resonance<input id="resonanceScore" type="number" min="1" max="10" /></label>
|
||||
<label>Where it appears<input id="pageLocation" placeholder="/play/lima-note.html" /></label>
|
||||
<label>Glow<input id="resonanceScore" type="number" min="1" max="10" /></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">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 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>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>Role<input id="emotionalRole" placeholder="reassurance, invitation, warning" /></label>
|
||||
<label>Enabled<select id="enabled"><option value="true">Awake</option><option value="false">Resting</option></select></label>
|
||||
<label>CSS hooks<input id="cssClassHooks" /></label>
|
||||
<label>Audio<input id="audioSettings" /></label>
|
||||
<label>Animation<input id="animationTrigger" /></label>
|
||||
<label class="full">Private notes<textarea id="notes"></textarea></label>
|
||||
</div>
|
||||
<div class="hidden" aria-hidden="true">
|
||||
<select id="contentClass"></select>
|
||||
<input id="cssClassHooks" />
|
||||
<input id="audioSettings" />
|
||||
<input id="animationTrigger" />
|
||||
<input id="continuationLinks" />
|
||||
<input id="echoes" />
|
||||
<input id="thematicLinks" />
|
||||
@@ -1775,7 +1784,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
|
||||
const draftKey = "hiddenNarrativeObservatoryDraft:v1";
|
||||
const WORLD = { width: 4600, height: 3300, cx: 2300, cy: 1650 };
|
||||
const SCREEN = { width: 1000, height: 760 };
|
||||
const state = { entries: [], stories: [], meta: {}, mode: "graph", selectedId: "", selectedStoryId: "", undo: [], redo: [], draggingId: "", positions: new Map(), clusterPositions: new Map(), zoom: 0, focusCluster: "", focusKind: "", focusIds: null, focusLabel: "", history: [], explorationUndo: [], explorationRedo: [], camera: { x: 0, y: 0, k: 0.22 }, pan: null, drag: null, renderTimer: 0 };
|
||||
const 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 storyFields = ["storyTitle","storyDescription","storyTone","storyDiscovery","storyCharacters","storySymbols","storyMarkers","storyLayers","storyUnlock","storyHidden"];
|
||||
const $ = (id) => document.getElementById(id);
|
||||
@@ -1797,6 +1806,11 @@ HIDDEN_APP_HTML = r"""<!doctype html>
|
||||
function storiesForEntry(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) {
|
||||
return state.entries.find((entry) => entry.id === id);
|
||||
}
|
||||
@@ -2126,6 +2140,8 @@ HIDDEN_APP_HTML = r"""<!doctype html>
|
||||
}
|
||||
function storyFromForm() {
|
||||
const current = storyById() || {};
|
||||
const items = storyItems(current);
|
||||
const nodes = items.filter((item) => item.kind !== "section").map((item) => item.id).filter(Boolean);
|
||||
return {
|
||||
...current,
|
||||
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",
|
||||
characters: normalizeCharacters($("storyCharacters").value),
|
||||
symbols: splitList($("storySymbols").value),
|
||||
nodes: current.nodes || [],
|
||||
items,
|
||||
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),
|
||||
@@ -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>";
|
||||
storyFields.forEach((id) => { if ($(id)) $(id).value = ""; });
|
||||
$("storyFlow").innerHTML = "";
|
||||
closeSectionEditor();
|
||||
renderMemoryPool();
|
||||
return;
|
||||
}
|
||||
@@ -2257,13 +2275,13 @@ HIDDEN_APP_HTML = r"""<!doctype html>
|
||||
});
|
||||
}
|
||||
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) {
|
||||
const visible = new Set(entries.map((entry) => entry.id));
|
||||
const pairs = [];
|
||||
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 }));
|
||||
});
|
||||
return pairs;
|
||||
@@ -2826,7 +2844,11 @@ HIDDEN_APP_HTML = r"""<!doctype html>
|
||||
story = storyById();
|
||||
}
|
||||
[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;
|
||||
rememberDraft();
|
||||
@@ -3102,7 +3124,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
|
||||
snapshotExploration("focus thread");
|
||||
state.history.push({ focusCluster: state.focusCluster, focusIds: state.focusIds ? [...state.focusIds] : null, focusLabel: state.focusLabel, filters: filterState(), camera: { ...state.camera }, zoom: state.zoom, selectedId: state.selectedId });
|
||||
state.focusCluster = "";
|
||||
state.focusIds = new Set(story.nodes || []);
|
||||
state.focusIds = new Set(storyItems(story).filter((item) => item.kind !== "section").map((item) => item.id));
|
||||
state.focusLabel = `Story: ${story.title || story.id}`;
|
||||
render();
|
||||
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("");
|
||||
$("storyCover").innerHTML = `<strong>${html(story.title)}</strong><p class="subtle">${html(story.description || "A quiet route waiting for a summary.")}</p><div class="pills">${characters}<span class="pill">${html(story.tone || "warm")}</span><span class="pill">${html(story.discoveryStyle || "gradual")}</span>${story.hidden ? "<span class='pill'>hidden</span>" : ""}${(story.markers || []).map((item) => `<span class="pill">${html(item)}</span>`).join("")}</div>`;
|
||||
const nodes = storyEntries(story);
|
||||
$("storyFlow").innerHTML = nodes.length ? nodes.map((entry, index) => {
|
||||
const character = primaryCharacterForEntry(entry);
|
||||
return `<div class="story-flow-item" draggable="true" data-node="${html(entry.id)}"><span class="story-step">${index + 1}</span><span><strong>${html(entry.title)}</strong><br><span class="subtle">${html(entry.emotionalTone || "warm")} / ${html(layerName(depth(entry)))}</span></span><button type="button" data-remove="${html(entry.id)}">Remove</button></div>`;
|
||||
}).join("") : "<div class='subtle'>No memories in this story yet.</div>";
|
||||
const items = storyItems(story);
|
||||
$("storyFlow").innerHTML = items.length ? items.map((item, index) => {
|
||||
if (item.kind === "section") {
|
||||
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>`;
|
||||
}
|
||||
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-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) => {
|
||||
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("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);
|
||||
const source = event.dataTransfer.getData("text/story-item") || event.dataTransfer.getData("text/story-node") || event.dataTransfer.getData("text/memory-id");
|
||||
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));
|
||||
});
|
||||
}
|
||||
function newEntry(type = "quote") {
|
||||
function newEntry(type = "tooltip") {
|
||||
snapshot();
|
||||
const now = new Date().toISOString().slice(0, 10);
|
||||
const entry = {
|
||||
id: `memory-${Date.now()}`,
|
||||
title: "Untitled fragment",
|
||||
title: "Untitled memory",
|
||||
type,
|
||||
contentClass: state.meta.typeArchitecture?.[type]?.contentClass || "fragment",
|
||||
content: "",
|
||||
@@ -3273,6 +3301,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
|
||||
tone: entry?.emotionalTone || "warm",
|
||||
characters: entry?.characters?.length ? [...entry.characters] : ["z"],
|
||||
symbols: entry?.symbols ? [...entry.symbols] : [],
|
||||
items: entry ? [{ kind: "memory", id: entry.id }] : [],
|
||||
nodes: entry ? [entry.id] : [],
|
||||
discoveryStyle: "gradual",
|
||||
layerAffinity: entry ? [depth(entry)] : [],
|
||||
@@ -3301,8 +3330,11 @@ HIDDEN_APP_HTML = r"""<!doctype html>
|
||||
}
|
||||
function addNodeToStory(nodeId) {
|
||||
const story = storyById();
|
||||
if (!story || !nodeId || story.nodes.includes(nodeId)) return;
|
||||
if (!story || !nodeId || (story.nodes || []).includes(nodeId)) return;
|
||||
snapshot();
|
||||
story.items = storyItems(story);
|
||||
story.items.push({ kind: "memory", id: nodeId });
|
||||
story.nodes = story.nodes || [];
|
||||
story.nodes.push(nodeId);
|
||||
const entry = entryById(nodeId);
|
||||
if (entry) {
|
||||
@@ -3321,23 +3353,83 @@ HIDDEN_APP_HTML = r"""<!doctype html>
|
||||
const story = storyById();
|
||||
if (!story) return;
|
||||
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();
|
||||
fillStoryForm(story);
|
||||
render();
|
||||
}
|
||||
function moveNodeInStory(sourceId, targetId) {
|
||||
moveStoryItem(sourceId, targetId);
|
||||
}
|
||||
function moveStoryItem(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);
|
||||
const sourceIsExisting = storyItems(story).some((item) => item.id === sourceId);
|
||||
const sourceItem = sourceIsExisting ? storyItems(story).find((item) => item.id === sourceId) : { kind: "memory", id: sourceId };
|
||||
story.items = storyItems(story).filter((item) => item.id !== 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();
|
||||
fillStoryForm(story);
|
||||
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() {
|
||||
const entry = currentEntry();
|
||||
if (!entry) return;
|
||||
@@ -3354,6 +3446,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
|
||||
state.entries = state.entries.filter((item) => item.id !== entry.id);
|
||||
state.stories.forEach((story) => {
|
||||
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) => {
|
||||
["continuationLinks","echoes","thematicLinks","symbolicLinks","triggerLinks","parentLinks","childLinks","mirroredEntries"].forEach((key) => {
|
||||
@@ -3548,6 +3641,9 @@ HIDDEN_APP_HTML = r"""<!doctype html>
|
||||
$("newBtn").onclick = () => newEntry();
|
||||
$("newStoryBtn").onclick = () => newStory();
|
||||
$("storyFromSelectionBtn").onclick = () => newStory(state.selectedId);
|
||||
$("addSectionBtn").onclick = addStorySection;
|
||||
$("saveSectionBtn").onclick = saveStorySection;
|
||||
$("closeSectionBtn").onclick = closeSectionEditor;
|
||||
$("deleteStoryBtn").onclick = deleteStory;
|
||||
$("saveBtn").onclick = save;
|
||||
$("zoomOutBtn").onclick = () => zoomBy(-1);
|
||||
@@ -3576,7 +3672,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
|
||||
$("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");
|
||||
const id = event.dataTransfer.getData("text/memory-id") || event.dataTransfer.getData("text/story-node") || event.dataTransfer.getData("text/story-item");
|
||||
if (id) addNodeToStory(id);
|
||||
});
|
||||
document.addEventListener("click", (event) => {
|
||||
|
||||
Reference in New Issue
Block a user