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.
-
Shaping Threads
-
aphy: hold a memory and move it toward another light. the sky will show what kind of relationship you are about to make.
-
Drop on a memory for a continuation. Hold Shift for an echo, Ctrl/Command for a symbolic link, or Alt for a mirror. Drop on empty sky to begin a new thread.
+
Building Stories
+
aphy: drag a memory into a story or reorder the route in the builder. no more tangled relationship engineering.
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.