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

This commit is contained in:
2026-05-14 16:45:10 +01:00
parent fe0f11535c
commit 5e55a52fc5
5 changed files with 211 additions and 18 deletions

View File

@@ -30,3 +30,4 @@ Code layout:
- `authoring_server.py` is the compatibility executable used by systemd. - `authoring_server.py` is the compatibility executable used by systemd.
- `src/authoring_service/` contains the implementation modules. - `src/authoring_service/` contains the implementation modules.
- `src/tests/` contains the unit tests. - `src/tests/` contains the unit tests.
- `docs/hidden-memory-architecture.md` documents the Fragment/Memory/Story model used by the Hidden Memory Observatory.

View File

@@ -0,0 +1,82 @@
# Hidden Memory Architecture
The hidden ecosystem has one friendly source of truth:
- `assets/content/hidden-details.json` in `org_web`
The live site still consumes generated constants in:
- `assets/scripts/hidden-details.js` in `org_web`
## Canonical Content
The old `type` field is now treated as the runtime delivery kind. It answers: where does this need to go in the existing JavaScript arrays?
The newer `contentClass` field answers: what is this thing conceptually?
Use these classes:
- `fragment`: tiny reusable language, such as tooltips, quotes, whispers, loading lines, and small dialogue.
- `memory`: a richer emotional moment or observation that can stand as an observatory node.
- `story material`: dialogue or sequence material that is primarily useful inside a story route.
- `interaction`: a trigger, route, keyboard secret, search response, seasonal event, or play-system behavior.
- `lore`: world material such as dreams, terminal logs, guestbook entries, or hidden archive objects.
- `system layer`: structural observatory material, such as the layer guide.
## Stories
Stories are curated routes. They should reference entries by id through `nodes`.
Do not duplicate paragraphs inside a story when an existing fragment or memory can be referenced. A story gives order, title, tone, unlock conditions, and emotional shape.
## Surfaces
The `surfaces` field says where an entry is reusable: `tooltip`, `quote`, `story`, `observatory`, `search`, `keyboard`, `play`, `dream`, `temporal`, and similar surfaces.
A tooltip fragment can appear in the footer without becoming a full story node. A memory can appear in the observatory and in a story without being forced into a rotating quote. An interaction can trigger a hidden route without pretending to be a narrative scene.
## Observatory Roles
Use `observatoryRole` to keep rendering calm:
- `ambient`: small lights and flavor, usually fragments.
- `node`: substantial emotional points, usually memories or story material.
- `event`: triggered behavior, routes, search, keyboard, seasonal, and play content.
- `guide`: layer/system material.
The observatory should render stories as the main territories, then reveal entries inside them. Zoomed-out views should favor routes and clusters; detailed views can show individual nodes, ambient fragments, and triggered events.
## Examples
`lima left this page a little steadier than she found it.`
- Class: `fragment`
- Runtime kind: `hidden tooltip`
- Surfaces: `tooltip`
- Observatory role: `ambient`
`July 2022, on the way to uni induction, lima pops into my life`
- Class: `memory`
- Runtime kind: `journal entry`
- Surfaces: `story`, `observatory`
- Observatory role: `node`
`search query "lima" opens a hidden route`
- Class: `interaction`
- Runtime kind: `search route`
- Surfaces: `search`, `hidden route`
- Observatory role: `event`
`The story of love between two souls`
- Class: story record, not an entry class
- References: ordered entry ids in `nodes`
- Purpose: emotional route, not duplicated content
## Authoring Rule
Write the smallest canonical thing that is emotionally honest.
If it is one line, make it a fragment. If it is a moment with weight, make it a memory. If it happens because of a trigger, make it an interaction. If it is a route through existing things, make it a story.

View File

@@ -32,6 +32,64 @@ HIDDEN_CONTENT_TYPES = [
"keyboard secret", "keyboard secret",
] ]
HIDDEN_CONTENT_CLASSES = [
"fragment",
"memory",
"story material",
"interaction",
"lore",
"system layer",
]
HIDDEN_SURFACES = [
"tooltip",
"quote",
"poem",
"story",
"observatory",
"hidden route",
"search",
"keyboard",
"play",
"dream",
"temporal",
"seasonal",
"loading",
"guestbook",
"terminal",
"layer guide",
]
TYPE_ARCHITECTURE = {
"quote": {"contentClass": "fragment", "surfaces": ["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"},
}
CHARACTER_REGISTRY = { CHARACTER_REGISTRY = {
"young z": { "young z": {
"id": "young z", "id": "young z",

View File

@@ -12,12 +12,15 @@ from .config import HIDDEN_BACKUP_DIR, HIDDEN_CONTENT_JSON, HIDDEN_DETAILS_JS, R
from .constants import ( from .constants import (
CHARACTER_REGISTRY, CHARACTER_REGISTRY,
HIDDEN_CHARACTERS, HIDDEN_CHARACTERS,
HIDDEN_CONTENT_CLASSES,
HIDDEN_CONTENT_TYPES, HIDDEN_CONTENT_TYPES,
HIDDEN_DISCOVERY_STYLES, HIDDEN_DISCOVERY_STYLES,
HIDDEN_LAYER_DEPTHS, HIDDEN_LAYER_DEPTHS,
HIDDEN_RARITIES, HIDDEN_RARITIES,
HIDDEN_SURFACES,
HIDDEN_STORY_MARKERS, HIDDEN_STORY_MARKERS,
HIDDEN_TONES, HIDDEN_TONES,
TYPE_ARCHITECTURE,
) )
from .utils import normalise_tags, slugify from .utils import normalise_tags, slugify
@@ -147,9 +150,11 @@ def make_hidden_entry(content_type: str, content: str, index: int, **extra: Any)
title = str(extra.pop("title", "") or hidden_title(content_type, content, index)) title = str(extra.pop("title", "") or hidden_title(content_type, content, index))
characters = extra.pop("characters", None) or detect_hidden_characters(f"{title} {content}") characters = extra.pop("characters", None) or detect_hidden_characters(f"{title} {content}")
tags = extra.pop("tags", None) or [slugify(character) for character in characters] tags = extra.pop("tags", None) or [slugify(character) for character in characters]
architecture = TYPE_ARCHITECTURE.get(content_type, TYPE_ARCHITECTURE["quote"])
entry = { entry = {
"id": hidden_entry_id(content_type, index, title), "id": hidden_entry_id(content_type, index, title),
"type": content_type, "type": content_type,
"contentClass": extra.pop("contentClass", architecture["contentClass"]),
"title": title, "title": title,
"content": content, "content": content,
"characters": characters, "characters": characters,
@@ -182,6 +187,9 @@ def make_hidden_entry(content_type: str, content: str, index: int, **extra: Any)
"thematicLinks": extra.pop("thematicLinks", []), "thematicLinks": extra.pop("thematicLinks", []),
"symbolicLinks": extra.pop("symbolicLinks", []), "symbolicLinks": extra.pop("symbolicLinks", []),
"triggerLinks": extra.pop("triggerLinks", []), "triggerLinks": extra.pop("triggerLinks", []),
"surfaces": extra.pop("surfaces", architecture["surfaces"]),
"observatoryRole": extra.pop("observatoryRole", architecture["observatory"]),
"canonicalUse": extra.pop("canonicalUse", ""),
} }
entry.update(extra) entry.update(extra)
return entry return entry
@@ -251,6 +259,7 @@ def normalize_hidden_entry(raw: dict[str, Any], existing: dict[str, Any] | None
content_type = str(normalize_character_text_refs(content_type)) content_type = str(normalize_character_text_refs(content_type))
if content_type not in HIDDEN_CONTENT_TYPES: if content_type not in HIDDEN_CONTENT_TYPES:
raise ValueError(f"Unsupported hidden content type: {content_type}") raise ValueError(f"Unsupported hidden content type: {content_type}")
architecture = TYPE_ARCHITECTURE.get(content_type, TYPE_ARCHITECTURE["quote"])
if not title: if not title:
raise ValueError("Every hidden entry needs a title.") raise ValueError("Every hidden entry needs a title.")
if not content and content_type not in {"search route"}: if not content and content_type not in {"search route"}:
@@ -258,6 +267,10 @@ def normalize_hidden_entry(raw: dict[str, Any], existing: dict[str, Any] | None
entry["id"] = slugify(str(entry.get("id") or title)) entry["id"] = slugify(str(entry.get("id") or title))
entry["title"] = title entry["title"] = title
entry["type"] = content_type entry["type"] = content_type
content_class = str(entry.get("contentClass") or architecture["contentClass"]).strip()
if content_class not in HIDDEN_CONTENT_CLASSES:
content_class = architecture["contentClass"]
entry["contentClass"] = content_class
entry["content"] = content entry["content"] = content
detected_characters = detect_hidden_characters(" ".join([ detected_characters = detect_hidden_characters(" ".join([
title, title,
@@ -305,8 +318,15 @@ def normalize_hidden_entry(raw: dict[str, Any], existing: dict[str, Any] | None
"thematicLinks", "thematicLinks",
"symbolicLinks", "symbolicLinks",
"triggerLinks", "triggerLinks",
"surfaces",
]: ]:
entry[key] = [str(normalize_character_text_refs(item)).strip() for item in entry.get(key, []) if str(item).strip()] entry[key] = [str(normalize_character_text_refs(item)).strip() for item in entry.get(key, []) if str(item).strip()]
if not entry["surfaces"]:
entry["surfaces"] = list(architecture["surfaces"])
entry["surfaces"] = [surface for surface in entry["surfaces"] if surface in HIDDEN_SURFACES] or list(architecture["surfaces"])
observatory_role = str(entry.get("observatoryRole") or architecture["observatory"]).strip()
entry["observatoryRole"] = observatory_role if observatory_role in {"ambient", "node", "event", "guide"} else architecture["observatory"]
entry["canonicalUse"] = str(normalize_character_text_refs(entry.get("canonicalUse") or ""))
for key in ["dialogue", "keyboard"]: for key in ["dialogue", "keyboard"]:
if key in entry: if key in entry:
entry[key] = normalize_character_text_refs(entry[key]) entry[key] = normalize_character_text_refs(entry[key])
@@ -444,12 +464,15 @@ def load_hidden_store() -> dict[str, Any]:
normalized_stories = migrate_hidden_stories(normalized) normalized_stories = migrate_hidden_stories(normalized)
migrated_relationships = bool(normalized_stories) migrated_relationships = bool(normalized_stories)
return { return {
"schemaVersion": 2, "schemaVersion": 3,
"source": HIDDEN_DETAILS_JS.relative_to(ROOT).as_posix(), "source": HIDDEN_DETAILS_JS.relative_to(ROOT).as_posix(),
"contentPath": HIDDEN_CONTENT_JSON.relative_to(ROOT).as_posix(), "contentPath": HIDDEN_CONTENT_JSON.relative_to(ROOT).as_posix(),
"migratedFromJs": migrated, "migratedFromJs": migrated,
"migratedRelationshipsToStories": migrated_relationships, "migratedRelationshipsToStories": migrated_relationships,
"types": HIDDEN_CONTENT_TYPES, "types": HIDDEN_CONTENT_TYPES,
"contentClasses": HIDDEN_CONTENT_CLASSES,
"surfaces": HIDDEN_SURFACES,
"typeArchitecture": TYPE_ARCHITECTURE,
"characters": HIDDEN_CHARACTERS, "characters": HIDDEN_CHARACTERS,
"characterRegistry": CHARACTER_REGISTRY, "characterRegistry": CHARACTER_REGISTRY,
"validation": validate_hidden_integrity(normalized, normalized_stories), "validation": validate_hidden_integrity(normalized, normalized_stories),
@@ -506,11 +529,11 @@ def validate_hidden_integrity(entries: list[dict[str, Any]], stories: list[dict[
def hidden_architecture_recommendations() -> dict[str, Any]: def hidden_architecture_recommendations() -> dict[str, Any]:
return { return {
"storage": "Use assets/content/hidden-details.json as the friendly source of truth for memories 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 canonical fragments, memories, interactions, lore, 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/.", "backups": "Every save writes timestamped backups for both JSON and JS under backups/hidden-details/.",
"versioning": "Commit the JSON and generated JS together so the live site and authoring history stay aligned.", "versioning": "Commit the JSON and generated JS together so the live site and authoring history stay aligned.",
"collaboration": "For simultaneous editing, resolve conflicts by memory id and story id rather than by whole-file ownership.", "collaboration": "For simultaneous editing, resolve conflicts by entry id and story id rather than by whole-file ownership.",
"scalability": "Stories are the primary emotional routes. Legacy relationship fields remain readable for migration, but the observatory should stay story-first.", "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.",
} }
@@ -640,7 +663,7 @@ def save_hidden_store(payload: dict[str, Any]) -> dict[str, Any]:
HIDDEN_CONTENT_JSON.write_text( HIDDEN_CONTENT_JSON.write_text(
json.dumps( json.dumps(
{ {
"schemaVersion": 2, "schemaVersion": 3,
"generatedAt": datetime.now().isoformat(timespec="seconds"), "generatedAt": datetime.now().isoformat(timespec="seconds"),
"entries": entries, "entries": entries,
"stories": stories, "stories": stories,
@@ -656,5 +679,3 @@ def save_hidden_store(payload: dict[str, Any]) -> dict[str, Any]:
saved["message"] = "stored safely. future-you will probably smile at this one." saved["message"] = "stored safely. future-you will probably smile at this one."
saved["backupStamp"] = stamp saved["backupStamp"] = stamp
return saved return saved

View File

@@ -1609,7 +1609,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
<select id="rarityFilter"></select> <select id="rarityFilter"></select>
</div> </div>
<div class="actions"> <div class="actions">
<button id="newBtn" type="button">New memory</button> <button id="newBtn" type="button">New fragment</button>
<button id="saveBtn" class="primary" type="button">Save constellation</button> <button id="saveBtn" class="primary" type="button">Save constellation</button>
</div> </div>
<div class="actions" style="margin-top:8px"> <div class="actions" style="margin-top:8px">
@@ -1715,7 +1715,8 @@ HIDDEN_APP_HTML = r"""<!doctype html>
<h2>Memory editor</h2> <h2>Memory editor</h2>
<div class="editor-grid"> <div class="editor-grid">
<label class="full">Name<input id="title" /></label> <label class="full">Name<input id="title" /></label>
<label>Kind<select id="type"></select></label> <label>Runtime kind<select id="type"></select></label>
<label>Canonical class<select id="contentClass"></select></label>
<label>Depth<select id="familyLayer"></select></label> <label>Depth<select id="familyLayer"></select></label>
<label>Characters<input id="characters" placeholder="lima, aphy" /></label> <label>Characters<input id="characters" placeholder="lima, aphy" /></label>
<div id="characterTags" class="full pills"></div> <div id="characterTags" class="full pills"></div>
@@ -1727,7 +1728,10 @@ HIDDEN_APP_HTML = r"""<!doctype html>
<label>Resonance<input id="resonanceScore" type="number" min="1" max="10" /></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>Where it appears<input id="pageLocation" placeholder="/play/lima-note.html" /></label>
<label class="full">Trigger path<input id="triggerConditions" placeholder="search: lima, hover, idle 90 seconds" /></label> <label class="full">Trigger path<input id="triggerConditions" placeholder="search: lima, hover, idle 90 seconds" /></label>
<label class="full">Memory text<textarea id="content" spellcheck="true"></textarea></label> <label class="full">Content text<textarea id="content" spellcheck="true"></textarea></label>
<label>Reusable surfaces<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>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 legacy arc note" /></label>
<label>Tags<input id="tags" placeholder="warm, october" /></label> <label>Tags<input id="tags" placeholder="warm, october" /></label>
@@ -1757,7 +1761,8 @@ HIDDEN_APP_HTML = r"""<!doctype html>
<section class="panel"> <section class="panel">
<h2>Architecture</h2> <h2>Architecture</h2>
<div class="subtle"> <div class="subtle">
<p><strong>Story model:</strong> stories are first-class emotional routes. Memories can belong to multiple stories, but routes stay readable and authored in order.</p> <p><strong>Canonical model:</strong> fragments are tiny reusable lines, memories are richer observatory moments, interactions are triggers, lore is world material, and stories are curated routes through them.</p>
<p><strong>Story model:</strong> stories are first-class emotional routes. Entries can belong to multiple stories, but routes stay readable and authored in order.</p>
<p><strong>Legacy links:</strong> old continuations, echoes, and symbolic links are retained for migration only. New authoring happens through Story Builder.</p> <p><strong>Legacy links:</strong> old continuations, echoes, and symbolic links are retained for migration only. New authoring happens through Story Builder.</p>
<p><strong>Storage:</strong> the friendly graph lives in <code>assets/content/hidden-details.json</code>; the live site still receives generated constants in <code>assets/scripts/hidden-details.js</code>.</p> <p><strong>Storage:</strong> the friendly graph lives in <code>assets/content/hidden-details.json</code>; the live site still receives generated constants in <code>assets/scripts/hidden-details.js</code>.</p>
</div> </div>
@@ -1771,7 +1776,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
const WORLD = { width: 4600, height: 3300, cx: 2300, cy: 1650 }; const WORLD = { width: 4600, height: 3300, cx: 2300, cy: 1650 };
const SCREEN = { width: 1000, height: 760 }; const SCREEN = { width: 1000, height: 760 };
const state = { entries: [], stories: [], meta: {}, mode: "graph", selectedId: "", selectedStoryId: "", undo: [], redo: [], draggingId: "", positions: new Map(), clusterPositions: new Map(), zoom: 0, focusCluster: "", focusKind: "", focusIds: null, focusLabel: "", history: [], explorationUndo: [], explorationRedo: [], camera: { x: 0, y: 0, k: 0.22 }, pan: null, drag: null, renderTimer: 0 }; const state = { entries: [], stories: [], meta: {}, mode: "graph", selectedId: "", selectedStoryId: "", 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 fields = ["title","type","contentClass","familyLayer","characters","tone","rarity","discoveryDifficulty","mysteryLevel","resonanceScore","pageLocation","triggerConditions","content","surfaces","observatoryRole","canonicalUse","symbols","narrativeArcs","tags","emotionalRole","continuationLinks","echoes","thematicLinks","symbolicLinks","triggerLinks","parentLinks","childLinks","mirroredEntries","enabled","cssClassHooks","audioSettings","animationTrigger","notes"];
const storyFields = ["storyTitle","storyDescription","storyTone","storyDiscovery","storyCharacters","storySymbols","storyMarkers","storyLayers","storyUnlock","storyHidden"]; const storyFields = ["storyTitle","storyDescription","storyTone","storyDiscovery","storyCharacters","storySymbols","storyMarkers","storyLayers","storyUnlock","storyHidden"];
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
const toneColors = { warm: "#d3a64d", funny: "#7fb089", nostalgic: "#d06b78", wise: "#75a9bd", strange: "#a58ac9", soft: "#e6bd8c", hopeful: "#9fcf9f", protective: "#d98f71", melancholy: "#8fa7c6" }; const toneColors = { warm: "#d3a64d", funny: "#7fb089", nostalgic: "#d06b78", wise: "#75a9bd", strange: "#a58ac9", soft: "#e6bd8c", hopeful: "#9fcf9f", protective: "#d98f71", melancholy: "#8fa7c6" };
@@ -1838,11 +1843,14 @@ HIDDEN_APP_HTML = r"""<!doctype html>
entry.title, entry.title,
entry.content, entry.content,
entry.type, entry.type,
entry.contentClass,
entry.triggerConditions, entry.triggerConditions,
entry.pageLocation, entry.pageLocation,
entry.canonicalUse,
entry.emotionalRole, entry.emotionalRole,
entry.mysteryLevel, entry.mysteryLevel,
...(entry.tags || []), ...(entry.tags || []),
...(entry.surfaces || []),
...(entry.symbols || []), ...(entry.symbols || []),
...(entry.narrativeArcs || []), ...(entry.narrativeArcs || []),
].join(" ").toLowerCase(); ].join(" ").toLowerCase();
@@ -2065,8 +2073,9 @@ HIDDEN_APP_HTML = r"""<!doctype html>
return { return {
...current, ...current,
id: current.id || slugify($("title").value), id: current.id || slugify($("title").value),
title: $("title").value.trim() || "Untitled memory", title: $("title").value.trim() || "Untitled fragment",
type: $("type").value, type: $("type").value,
contentClass: $("contentClass").value,
familyLayer: $("familyLayer").value, familyLayer: $("familyLayer").value,
characters: normalizeCharacters($("characters").value), characters: normalizeCharacters($("characters").value),
emotionalTone: $("tone").value, emotionalTone: $("tone").value,
@@ -2077,6 +2086,9 @@ HIDDEN_APP_HTML = r"""<!doctype html>
pageLocation: $("pageLocation").value, pageLocation: $("pageLocation").value,
triggerConditions: $("triggerConditions").value, triggerConditions: $("triggerConditions").value,
content: $("content").value, content: $("content").value,
surfaces: splitList($("surfaces").value),
observatoryRole: $("observatoryRole").value,
canonicalUse: $("canonicalUse").value,
symbols: splitList($("symbols").value), symbols: splitList($("symbols").value),
narrativeArcs: splitList($("narrativeArcs").value), narrativeArcs: splitList($("narrativeArcs").value),
tags: splitList($("tags").value), tags: splitList($("tags").value),
@@ -2096,6 +2108,12 @@ HIDDEN_APP_HTML = r"""<!doctype html>
notes: $("notes").value, notes: $("notes").value,
}; };
} }
function applyTypeDefaults() {
const architecture = state.meta.typeArchitecture?.[$("type").value] || {};
if (architecture.contentClass) $("contentClass").value = architecture.contentClass;
if (architecture.surfaces) $("surfaces").value = architecture.surfaces.join(", ");
if (architecture.observatory) $("observatoryRole").value = architecture.observatory;
}
function applyFormToState() { function applyFormToState() {
if (!state.selectedId) return; if (!state.selectedId) return;
const index = state.entries.findIndex((entry) => entry.id === state.selectedId); const index = state.entries.findIndex((entry) => entry.id === state.selectedId);
@@ -2158,9 +2176,10 @@ HIDDEN_APP_HTML = r"""<!doctype html>
function fillForm(entry) { function fillForm(entry) {
if (!entry) return; if (!entry) return;
$("drawerTitle").textContent = entry.title || "Untitled memory"; $("drawerTitle").textContent = entry.title || "Untitled memory";
$("drawerSub").textContent = `${layerName(depth(entry))} / ${entry.type || "memory"}`; $("drawerSub").textContent = `${layerName(depth(entry))} / ${entry.contentClass || "fragment"} / ${entry.type || "entry"}`;
$("title").value = entry.title || ""; $("title").value = entry.title || "";
$("type").value = entry.type || "quote"; $("type").value = entry.type || "quote";
$("contentClass").value = entry.contentClass || state.meta.typeArchitecture?.[entry.type]?.contentClass || "fragment";
$("familyLayer").value = String(depth(entry)); $("familyLayer").value = String(depth(entry));
$("characters").value = (entry.characters || []).join(", "); $("characters").value = (entry.characters || []).join(", ");
$("tone").value = entry.emotionalTone || "warm"; $("tone").value = entry.emotionalTone || "warm";
@@ -2171,6 +2190,9 @@ HIDDEN_APP_HTML = r"""<!doctype html>
$("pageLocation").value = entry.pageLocation || ""; $("pageLocation").value = entry.pageLocation || "";
$("triggerConditions").value = entry.triggerConditions || ""; $("triggerConditions").value = entry.triggerConditions || "";
$("content").value = entry.content || ""; $("content").value = entry.content || "";
$("surfaces").value = (entry.surfaces || []).join(", ");
$("observatoryRole").value = entry.observatoryRole || state.meta.typeArchitecture?.[entry.type]?.observatory || "ambient";
$("canonicalUse").value = entry.canonicalUse || "";
$("symbols").value = (entry.symbols || []).join(", "); $("symbols").value = (entry.symbols || []).join(", ");
$("narrativeArcs").value = (entry.narrativeArcs || []).join(", "); $("narrativeArcs").value = (entry.narrativeArcs || []).join(", ");
$("tags").value = (entry.tags || []).join(", "); $("tags").value = (entry.tags || []).join(", ");
@@ -2206,7 +2228,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
function filteredEntries() { function filteredEntries() {
const query = $("search").value.toLowerCase(); const query = $("search").value.toLowerCase();
return state.entries.filter((entry) => { return state.entries.filter((entry) => {
const haystack = [entry.title, entry.content, entry.type, entry.triggerConditions, entry.pageLocation, entry.emotionalRole, entry.mysteryLevel, ...(entry.characters || []), ...(entry.tags || []), ...(entry.symbols || []), ...(entry.narrativeArcs || [])].join(" ").toLowerCase(); const haystack = [entry.title, entry.content, entry.type, entry.contentClass, entry.triggerConditions, entry.pageLocation, entry.canonicalUse, entry.emotionalRole, entry.mysteryLevel, ...(entry.surfaces || []), ...(entry.characters || []), ...(entry.tags || []), ...(entry.symbols || []), ...(entry.narrativeArcs || [])].join(" ").toLowerCase();
return (!query || haystack.includes(query)) return (!query || haystack.includes(query))
&& (!$("layerFilter").value || String(depth(entry)) === $("layerFilter").value) && (!$("layerFilter").value || String(depth(entry)) === $("layerFilter").value)
&& (!$("characterFilter").value || (entry.characters || []).includes($("characterFilter").value)) && (!$("characterFilter").value || (entry.characters || []).includes($("characterFilter").value))
@@ -2619,7 +2641,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
const scale = labelScale(); const scale = labelScale();
const deep = state.camera.k > 1.25 || selected; const deep = state.camera.k > 1.25 || selected;
const text = truncate(entry.title, deep ? 72 : state.camera.k > 0.85 ? 42 : 24); const text = truncate(entry.title, deep ? 72 : state.camera.k > 0.85 ? 42 : 24);
const meta = `${entry.type || "memory"} / ${entry.emotionalTone || "warm"}`; const meta = `${entry.contentClass || "fragment"} / ${entry.type || "entry"} / ${entry.emotionalTone || "warm"}`;
const width = Math.max(190, Math.min(420, text.length * 8 + 42)); const width = Math.max(190, Math.min(420, text.length * 8 + 42));
const height = deep ? 92 : 44; const height = deep ? 92 : 44;
const labelPos = { x: pos.x + size + 18, y: pos.y - height * scale / 2 }; const labelPos = { x: pos.x + size + 18, y: pos.y - height * scale / 2 };
@@ -3135,7 +3157,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
function renderPreview(entry) { function renderPreview(entry) {
const body = html(entry.content || "").replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/\*([^*]+)\*/g, "<em>$1</em>"); const body = html(entry.content || "").replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/\*([^*]+)\*/g, "<em>$1</em>");
const avatarPills = charactersForEntry(entry).map((character) => `<span class="avatar-pill pill" style="--avatar-glow:${html(avatarGlow(character))}">${avatarImg(character, "tiny", avatarExpressionForEntry(entry))}${html(characterLabel(character))}</span>`).join(""); const avatarPills = charactersForEntry(entry).map((character) => `<span class="avatar-pill pill" style="--avatar-glow:${html(avatarGlow(character))}">${avatarImg(character, "tiny", avatarExpressionForEntry(entry))}${html(characterLabel(character))}</span>`).join("");
$("preview").innerHTML = `${body || "<span class='subtle'>This memory is waiting for words.</span>"}<div class="pills" style="margin-top:10px">${avatarPills}<span class="pill">${html(layerName(depth(entry)))}</span><span class="pill">${html(entry.emotionalTone)}</span><span class="pill">${html(entry.rarity)}</span>${(entry.symbols || []).map((item) => `<span class="pill">${html(item)}</span>`).join("")}</div>`; $("preview").innerHTML = `${body || "<span class='subtle'>This entry is waiting for words.</span>"}<div class="pills" style="margin-top:10px">${avatarPills}<span class="pill">${html(entry.contentClass || "fragment")}</span><span class="pill">${html(entry.observatoryRole || "ambient")}</span><span class="pill">${html(layerName(depth(entry)))}</span><span class="pill">${html(entry.emotionalTone)}</span><span class="pill">${html(entry.rarity)}</span>${(entry.surfaces || []).map((item) => `<span class="pill">${html(item)}</span>`).join("")}${(entry.symbols || []).map((item) => `<span class="pill">${html(item)}</span>`).join("")}</div>`;
} }
function renderConnections(entry) { function renderConnections(entry) {
const stories = storiesForEntry(entry.id); const stories = storiesForEntry(entry.id);
@@ -3201,13 +3223,17 @@ HIDDEN_APP_HTML = r"""<!doctype html>
const now = new Date().toISOString().slice(0, 10); const now = new Date().toISOString().slice(0, 10);
const entry = { const entry = {
id: `memory-${Date.now()}`, id: `memory-${Date.now()}`,
title: "Untitled memory", title: "Untitled fragment",
type, type,
contentClass: state.meta.typeArchitecture?.[type]?.contentClass || "fragment",
content: "", content: "",
characters: [], characters: [],
emotionalTone: "warm", emotionalTone: "warm",
rarity: "common", rarity: "common",
triggerConditions: "", triggerConditions: "",
surfaces: state.meta.typeArchitecture?.[type]?.surfaces || ["quote"],
observatoryRole: state.meta.typeArchitecture?.[type]?.observatory || "ambient",
canonicalUse: "",
tags: [], tags: [],
category: type, category: type,
pageLocation: "", pageLocation: "",
@@ -3340,6 +3366,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
function populateControls() { function populateControls() {
const opt = (value, label = value) => `<option value="${html(value)}">${html(label || "All")}</option>`; const opt = (value, label = value) => `<option value="${html(value)}">${html(label || "All")}</option>`;
$("type").innerHTML = state.meta.types.map((value) => opt(value)).join(""); $("type").innerHTML = state.meta.types.map((value) => opt(value)).join("");
$("contentClass").innerHTML = (state.meta.contentClasses || ["fragment","memory"]).map((value) => opt(value)).join("");
$("tone").innerHTML = state.meta.tones.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(""); $("storyTone").innerHTML = state.meta.tones.map((value) => opt(value)).join("");
$("storyDiscovery").innerHTML = (state.meta.discoveryStyles || ["gradual"]).map((value) => opt(value)).join(""); $("storyDiscovery").innerHTML = (state.meta.discoveryStyles || ["gradual"]).map((value) => opt(value)).join("");
@@ -3491,6 +3518,10 @@ HIDDEN_APP_HTML = r"""<!doctype html>
$(id).addEventListener("input", applyFormToState); $(id).addEventListener("input", applyFormToState);
$(id).addEventListener("change", () => { snapshot(); applyFormToState(); }); $(id).addEventListener("change", () => { snapshot(); applyFormToState(); });
}); });
$("type").addEventListener("change", () => {
applyTypeDefaults();
applyFormToState();
});
storyFields.forEach((id) => { storyFields.forEach((id) => {
$(id).addEventListener("input", applyStoryFormToState); $(id).addEventListener("input", applyStoryFormToState);
$(id).addEventListener("change", () => { snapshot(); applyStoryFormToState(); }); $(id).addEventListener("change", () => { snapshot(); applyStoryFormToState(); });