new UI changes
All checks were successful
Build Authoring Service / build (push) Successful in 10s

This commit is contained in:
2026-05-15 15:22:18 +01:00
parent 0cc588e9cc
commit 02018bdb50
2 changed files with 295 additions and 18 deletions

View File

@@ -30,12 +30,15 @@ HIDDEN_SURFACES = [
"poem",
"story",
"observatory",
"constellation route",
"hidden route",
"hidden interaction",
"search",
"keyboard",
"play",
"dream",
"temporal",
"future z",
"seasonal",
"loading",
"guestbook",

View File

@@ -1174,6 +1174,19 @@ HIDDEN_APP_HTML = r"""<!doctype html>
linear-gradient(90deg, rgba(255,255,255,0.035) 1px, transparent 1px);
background-size: 34px 34px;
}
body::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
opacity: 0.28;
background-image:
radial-gradient(circle, rgba(255, 239, 191, 0.9) 0 1px, transparent 1.8px),
radial-gradient(circle, rgba(151, 205, 221, 0.68) 0 1px, transparent 1.7px);
background-position: 12% 18%, 72% 34%;
background-size: 240px 190px, 310px 260px;
animation: sky-drift 28s ease-in-out infinite alternate;
}
button, input, textarea, select { font: inherit; }
button {
min-height: 36px;
@@ -1329,12 +1342,14 @@ HIDDEN_APP_HTML = r"""<!doctype html>
.memory-node { cursor: grab; transition: opacity 160ms ease, filter 180ms ease; }
.memory-node:active { cursor: grabbing; }
.memory-node text, .node-label text, .cluster-label text { fill: #f7ead3; paint-order: stroke; stroke: rgba(14, 11, 8, 0.82); stroke-width: 3px; stroke-linejoin: round; }
.memory-node .node-shell, .memory-node .node-portrait, .memory-node .node-marker {
.memory-node .node-shell, .memory-node .node-portrait, .memory-node .node-marker, .memory-node .fragment-star {
stroke: rgba(255, 246, 223, 0.82);
stroke-width: 1.4;
filter: drop-shadow(0 0 7px var(--node-glow, rgba(255, 232, 177, 0.18)));
transition: r 160ms ease, stroke 160ms ease, opacity 160ms ease, filter 180ms ease;
}
.memory-node .fragment-star { stroke-width: 1.1; }
.memory-node .fragment-ring { fill: none; stroke: rgba(255, 246, 223, 0.24); stroke-width: 1; stroke-dasharray: 2 7; }
.memory-node .node-breathe, .cluster-node .node-breathe { animation: avatar-breathe 8s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
.memory-node:hover .node-shell, .memory-node:hover .node-portrait { filter: drop-shadow(0 0 20px var(--node-glow, rgba(255, 232, 177, 0.4))); }
.memory-node.selected .node-shell, .memory-node.selected .node-portrait { stroke: #fff3c7; stroke-width: 3; }
@@ -1558,9 +1573,43 @@ HIDDEN_APP_HTML = r"""<!doctype html>
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-path { stroke: rgba(234, 195, 108, 0.7); stroke-width: 3; stroke-linecap: round; fill: none; filter: drop-shadow(0 0 8px rgba(234, 195, 108, 0.28)); }
.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; }
.story-region { fill: rgba(211, 166, 77, 0.026); stroke: rgba(211, 166, 77, 0.16); stroke-width: 1.2; stroke-dasharray: 2 12; }
.route-spark { fill: rgba(255, 236, 181, 0.82); filter: drop-shadow(0 0 9px rgba(255, 236, 181, 0.46)); animation: constellation-shimmer 8s ease-in-out infinite; }
.appearance-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; }
.appearance-toggle {
display: flex;
align-items: center;
gap: 7px;
min-height: 34px;
padding: 7px 8px;
border: 1px solid rgba(232, 202, 139, 0.16);
border-radius: 8px;
background: rgba(251, 241, 220, 0.055);
color: #ecd6ab;
font-size: 12px;
font-weight: 800;
}
.appearance-toggle input { width: auto; accent-color: var(--gold); }
.reuse-list { display: grid; gap: 8px; }
.reuse-item {
text-align: left;
width: 100%;
height: auto;
min-height: 0;
display: grid;
gap: 5px;
padding: 10px;
border: 1px solid rgba(232, 202, 139, 0.16);
border-radius: 8px;
background: rgba(251, 241, 220, 0.07);
}
.reuse-kind { color: #ffe2a6; font-size: 11px; font-weight: 850; text-transform: uppercase; }
.reuse-item.active { border-color: var(--gold); background: rgba(211, 166, 77, 0.15); }
.story-card, .story-flow-item, .reuse-item { transition: border-color 180ms ease, background 180ms ease, transform 180ms ease, box-shadow 180ms ease; }
.story-card:hover, .story-flow-item:hover, .reuse-item:hover { transform: translateY(-1px); box-shadow: 0 10px 34px rgba(0, 0, 0, 0.22); }
.story-flow-list { gap: 12px; padding-left: 10px; border-left: 1px solid rgba(232, 202, 139, 0.16); }
.hidden { display: none !important; }
@keyframes breathe {
0%, 100% { opacity: 0.72; }
@@ -1576,6 +1625,95 @@ HIDDEN_APP_HTML = r"""<!doctype html>
}
.constellation-shimmer { animation: constellation-shimmer 9s ease-in-out infinite; }
.core-glow { animation: breathe 7s ease-in-out infinite; }
@keyframes sky-drift {
from { transform: translate3d(0, 0, 0); opacity: 0.22; }
to { transform: translate3d(18px, -12px, 0); opacity: 0.34; }
}
.reader {
position: fixed;
inset: 0;
z-index: 50;
display: grid;
grid-template-rows: auto 1fr auto;
gap: 18px;
padding: min(6vw, 54px);
background:
radial-gradient(circle at 50% 18%, rgba(211, 166, 77, 0.18), transparent 24%),
radial-gradient(circle at 16% 82%, rgba(117, 169, 189, 0.14), transparent 26%),
rgba(10, 8, 7, 0.92);
backdrop-filter: blur(18px);
}
.reader-head, .reader-foot {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.reader-title { max-width: 780px; }
.reader-title h1 { margin: 0; font-size: clamp(28px, 5vw, 62px); }
.reader-stage {
align-self: center;
width: min(860px, 100%);
margin: 0 auto;
min-height: min(58vh, 620px);
display: grid;
align-content: center;
gap: 22px;
position: relative;
}
.reader-stage::before {
content: "";
position: absolute;
inset: -40px 8%;
border-top: 1px solid rgba(232, 202, 139, 0.16);
border-bottom: 1px solid rgba(117, 169, 189, 0.14);
border-radius: 50%;
opacity: 0.68;
pointer-events: none;
}
.reader-scene {
position: relative;
padding: clamp(22px, 4vw, 44px);
border: 1px solid rgba(232, 202, 139, 0.22);
border-radius: 8px;
background: linear-gradient(145deg, rgba(251, 241, 220, 0.1), rgba(18, 16, 13, 0.32));
box-shadow: 0 34px 110px rgba(0, 0, 0, 0.42);
animation: reader-rise 360ms ease both;
}
.reader-memory {
border-radius: 999px;
text-align: center;
padding: clamp(24px, 5vw, 54px);
background: radial-gradient(circle, rgba(255, 239, 191, 0.16), rgba(117, 169, 189, 0.06) 54%, transparent 72%);
border-color: rgba(255, 239, 191, 0.3);
}
.reader-kicker { color: #d9c28d; font-size: 12px; font-weight: 850; text-transform: uppercase; }
.reader-copy {
margin: 12px 0 0;
color: #fff0d2;
font-family: Georgia, "Times New Roman", serif;
font-size: clamp(22px, 3vw, 38px);
line-height: 1.48;
}
.reader-section .reader-copy { font-size: clamp(19px, 2.2vw, 30px); text-align: left; white-space: pre-wrap; }
.reader-route {
display: flex;
justify-content: center;
gap: 9px;
flex-wrap: wrap;
}
.reader-dot {
width: 10px;
height: 10px;
border-radius: 50%;
border: 1px solid rgba(255, 239, 191, 0.52);
background: rgba(251, 241, 220, 0.08);
}
.reader-dot.active { background: #f0cf83; box-shadow: 0 0 18px rgba(240, 207, 131, 0.62); }
@keyframes reader-rise {
from { opacity: 0; transform: translateY(12px) scale(0.99); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@media (max-width: 1120px) {
body { overflow: auto; }
.observatory { grid-template-columns: 1fr; height: auto; min-height: 100vh; }
@@ -1588,6 +1726,9 @@ HIDDEN_APP_HTML = r"""<!doctype html>
.map-head { position: static; padding: 16px; display: grid; }
#graph { top: 128px; height: calc(100% - 128px); }
.editor-grid { grid-template-columns: 1fr; }
.appearance-grid { grid-template-columns: 1fr; }
.reader { padding: 18px; }
.reader-head, .reader-foot { align-items: stretch; flex-direction: column; }
}
</style>
</head>
@@ -1665,6 +1806,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
<button id="homeBtn" type="button">Whole map</button>
<button id="exploreUndoBtn" type="button">Undo exploration</button>
<button id="focusBtn" type="button">Focus story</button>
<button id="readStoryBtn" type="button">Read story</button>
<button id="undoBtn" type="button">Undo</button>
<button id="redoBtn" type="button">Redo</button>
<button id="duplicateBtn" type="button">Duplicate</button>
@@ -1738,7 +1880,11 @@ HIDDEN_APP_HTML = r"""<!doctype html>
<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>Can appear as<input id="surfaces" placeholder="tooltip, story, observatory" /></label>
<label class="full hidden">Can appear as<input id="surfaces" placeholder="tooltip, story, observatory" /></label>
<div class="full">
<label>Appearance controls</label>
<div id="appearanceControls" class="appearance-grid"></div>
</div>
<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>Symbols<input id="symbols" placeholder="ring, tea, crayon sun" /></label>
<label>Story hint<input id="narrativeArcs" placeholder="optional theme note" /></label>
@@ -1764,7 +1910,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
<div id="autosave" class="subtle" style="margin-top:10px"></div>
</section>
<section class="panel">
<h2>This memory belongs to</h2>
<h2>This memory appears in</h2>
<div id="connections" class="connection-list"></div>
</section>
<section class="panel">
@@ -1779,17 +1925,54 @@ HIDDEN_APP_HTML = r"""<!doctype html>
</aside>
</div>
<div id="contextMenu" class="context-menu hidden"></div>
<div id="storyReader" class="reader hidden" role="dialog" aria-modal="true" aria-label="Story reading mode">
<div class="reader-head">
<div class="reader-title">
<div id="readerMeta" class="subtle"></div>
<h1 id="readerTitle"></h1>
</div>
<div class="actions">
<button id="readerCloseBtn" type="button">Close</button>
</div>
</div>
<div id="readerStage" class="reader-stage"></div>
<div class="reader-foot">
<button id="readerPrevBtn" type="button">Previous</button>
<div id="readerRoute" class="reader-route"></div>
<button id="readerNextBtn" type="button">Next</button>
</div>
</div>
<script>
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: "", 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 state = { entries: [], stories: [], meta: {}, mode: "graph", selectedId: "", selectedStoryId: "", editingSectionId: "", readerIndex: 0, 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);
const toneColors = { warm: "#d3a64d", funny: "#7fb089", nostalgic: "#d06b78", wise: "#75a9bd", strange: "#a58ac9", soft: "#e6bd8c", hopeful: "#9fcf9f", protective: "#d98f71", melancholy: "#8fa7c6" };
const defaultAvatarPath = "/assets/avatars/z.jpeg";
const appearanceLabels = {
observatory: "Observatory stars",
tooltip: "Soft tooltips",
quote: "Rotating quotes",
story: "Story routes",
"constellation route": "Constellation routes",
dream: "Dream paths",
temporal: "Future echoes",
"future z": "Future z systems",
search: "Search discoveries",
"hidden route": "Hidden routes",
"hidden interaction": "Hidden interactions",
keyboard: "Keyboard secrets",
play: "Play experiences",
seasonal: "Seasonal visits",
loading: "Loading whispers",
guestbook: "Guestbook traces",
terminal: "Terminal traces",
"layer guide": "Layer guide",
};
function html(value) {
return String(value || "").replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char]));
@@ -2229,9 +2412,23 @@ HIDDEN_APP_HTML = r"""<!doctype html>
$("animationTrigger").value = entry.animationTrigger || "";
$("notes").value = entry.notes || "";
renderCharacterPresence(entry);
renderAppearanceControls(entry);
renderPreview(entry);
renderConnections(entry);
}
function renderAppearanceControls(entry) {
const allowed = state.meta.surfaces || ["tooltip","quote","story","observatory","search","hidden route","keyboard","play","dream","temporal"];
const visible = ["observatory","story","constellation route","tooltip","quote","dream","temporal","future z","search","hidden route","hidden interaction","keyboard","play","seasonal","loading"].filter((surface) => allowed.includes(surface));
const selected = new Set(entry.surfaces || []);
$("appearanceControls").innerHTML = visible.map((surface) => `<label class="appearance-toggle"><input type="checkbox" value="${html(surface)}" ${selected.has(surface) ? "checked" : ""}>${html(appearanceLabels[surface] || surface)}</label>`).join("");
$("appearanceControls").querySelectorAll("input").forEach((input) => {
input.addEventListener("change", () => {
$("surfaces").value = Array.from($("appearanceControls").querySelectorAll("input:checked")).map((item) => item.value).join(", ");
snapshot();
applyFormToState();
});
});
}
function renderCharacterPresence(entry) {
const characters = charactersForEntry(entry);
$("drawerAvatars").innerHTML = characters.map((character, index) => `<span class="avatar-pill pill" style="--avatar-glow:${html(avatarGlow(character))}">${avatarImg(character, index === 0 ? "medium" : "tiny", avatarExpressionForEntry(entry))}${html(characterLabel(character))}</span>`).join("");
@@ -2505,12 +2702,18 @@ HIDDEN_APP_HTML = r"""<!doctype html>
if (!a || !b) return "";
return `<line class="edge theme" x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" opacity="${Math.min(0.55, 0.12 + pair.count * 0.06)}"></line>`;
}).join("");
const pairs = entries.length ? relationshipPairs(entries).slice(0, state.focusCluster ? 160 : 90) : [];
const pairs = entries.length ? relationshipPairs(entries).slice(0, state.focusCluster ? 120 : 70) : [];
const edges = pairs.map((pair) => {
const a = state.positions.get(pair.source);
const b = state.positions.get(pair.target);
if (!a || !b) return "";
return `<line class="edge ${html(pair.kind)}" x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}"></line>`;
const dx = b.x - a.x;
const dy = b.y - a.y;
const lift = Math.min(90, Math.max(28, Math.hypot(dx, dy) * 0.16));
const cx = (a.x + b.x) / 2 - dy / Math.max(1, Math.hypot(dx, dy)) * lift;
const cy = (a.y + b.y) / 2 + dx / Math.max(1, Math.hypot(dx, dy)) * lift;
const spark = pair.kind === "story-path" ? `<circle class="route-spark" cx="${cx}" cy="${cy}" r="4"></circle>` : "";
return `<path class="edge ${html(pair.kind)}" d="M ${a.x} ${a.y} Q ${cx} ${cy} ${b.x} ${b.y}"></path>${spark}`;
}).join("");
const storyRegions = renderStoryRegions(entries);
const occupied = [];
@@ -2538,16 +2741,19 @@ HIDDEN_APP_HTML = r"""<!doctype html>
if (!isWorldVisible(pos.x, pos.y, 220)) return "";
const character = primaryCharacterForEntry(entry);
const color = characterColor(character) || toneColors[entry.emotionalTone] || "#d3a64d";
const baseSize = 18 + Math.min(18, Number(entry.resonanceScore || 3) * 2.2) + (entry.rarity === "rare" || entry.rarity === "very rare" ? 8 : 0);
const isFragment = (entry.contentClass || "fragment") === "fragment" || (entry.observatoryRole || "") === "ambient";
const baseSize = (isFragment ? 10 : 18) + Math.min(isFragment ? 10 : 18, Number(entry.resonanceScore || 3) * (isFragment ? 1.6 : 2.2)) + (entry.rarity === "rare" || entry.rarity === "very rare" ? 8 : 0);
const zoomSize = state.zoom === 0 ? avatarSize(character, "tiny") : state.zoom === 1 ? avatarSize(character, "medium") : state.zoom === 2 ? avatarSize(character, "close") : avatarSize(character, "focus");
const size = Math.max(baseSize, zoomSize / 2);
const size = isFragment ? baseSize : Math.max(baseSize, zoomSize / 2);
const label = labelForEntry(entry, pos, size, occupied);
const dragClass = dragClassForEntry(entry.id);
const clipId = `clip-node-${slugify(entry.id)}`;
defs.push(`<clipPath id="${html(clipId)}"><circle r="${size}"></circle></clipPath>`);
const distant = state.zoom === 0;
return `<g class="memory-node${entry.id === state.selectedId ? " selected" : ""}${entry.enabled === false ? " resting" : ""}${dragClass}" data-id="${html(entry.id)}" tabindex="0" transform="translate(${pos.x} ${pos.y})" style="--node-glow:${html(avatarGlow(character))}">
${distant
${isFragment
? `<circle class="fragment-ring constellation-shimmer" r="${size + 9}" opacity="${entry.enabled === false ? 0.22 : 0.62}"></circle><circle class="fragment-star" r="${size}" fill="${color}" opacity="${entry.enabled === false ? 0.34 : 0.84}"></circle><text x="0" y="4" text-anchor="middle">${html(characterSymbol(character))}</text>`
: distant
? `<circle class="node-marker constellation-shimmer" r="${size}" fill="${color}" opacity="${entry.enabled === false ? 0.32 : 0.78}"></circle><text x="0" y="4" text-anchor="middle">${html(characterSymbol(character))}</text>`
: svgAvatar(character, size, clipId, avatarExpressionForEntry(entry), state.zoom >= 2 ? "node-breathe" : "")}
</g>${label}`;
@@ -3183,9 +3389,15 @@ HIDDEN_APP_HTML = r"""<!doctype html>
}
function renderConnections(entry) {
const stories = storiesForEntry(entry.id);
$("connections").innerHTML = stories.length ? stories.map((story) => {
return `<button class="connection story-card${story.id === state.selectedStoryId ? " active" : ""}" type="button" data-story="${html(story.id)}"><strong>${html(story.title)}</strong><span class="subtle">${story.nodes.length} memories / ${html(story.discoveryStyle || "gradual")}</span></button>`;
}).join("") : "<div class='subtle'>This memory is not in a story yet. Add it from the Story Builder to give it an emotional route.</div>";
const surfaces = (entry.surfaces || []).map((surface) => ({ kind: "appearance", title: appearanceLabels[surface] || surface, detail: "This is one place the memory may manifest." }));
const routes = [];
if (entry.pageLocation) routes.push({ kind: "route", title: entry.pageLocation, detail: "A page or hidden room connected to this memory." });
if (entry.triggerConditions) routes.push({ kind: "trigger", title: entry.triggerConditions, detail: "A discovery condition that can call this memory forward." });
const storyItemsHtml = stories.map((story) => `<button class="reuse-item${story.id === state.selectedStoryId ? " active" : ""}" type="button" data-story="${html(story.id)}"><span class="reuse-kind">Story</span><strong>${html(story.title)}</strong><span class="subtle">${story.nodes.length} memories / ${html(story.discoveryStyle || "gradual")} / ${html(story.tone || "warm")}</span></button>`);
const surfaceHtml = [...surfaces, ...routes].map((item) => `<div class="reuse-item"><span class="reuse-kind">${html(item.kind)}</span><strong>${html(item.title)}</strong><span class="subtle">${html(item.detail)}</span></div>`);
$("connections").innerHTML = storyItemsHtml.length || surfaceHtml.length
? `<div class="reuse-list">${[...storyItemsHtml, ...surfaceHtml].join("")}</div>`
: "<div class='subtle'>This memory has not been placed yet. Add it to a story or choose where it can appear.</div>";
$("connections").querySelectorAll("[data-story]").forEach((button) => button.addEventListener("click", () => {
fillStoryForm(storyById(button.dataset.story));
focusStory();
@@ -3201,15 +3413,17 @@ HIDDEN_APP_HTML = r"""<!doctype html>
return;
}
const characters = (story.characters || []).map((character) => `<span class="avatar-pill pill">${avatarImg(character, "tiny")}${html(characterLabel(character))}</span>`).join("");
$("storyCover").innerHTML = `<strong>${html(story.title)}</strong><p class="subtle">${html(story.description || "A quiet route waiting for a summary.")}</p><div class="pills">${characters}<span class="pill">${html(story.tone || "warm")}</span><span class="pill">${html(story.discoveryStyle || "gradual")}</span>${story.hidden ? "<span class='pill'>hidden</span>" : ""}${(story.markers || []).map((item) => `<span class="pill">${html(item)}</span>`).join("")}</div>`;
$("storyCover").innerHTML = `<strong>${html(story.title)}</strong><p class="subtle">${html(story.description || "A quiet route waiting for a summary.")}</p><div class="pills">${characters}<span class="pill">${html(story.tone || "warm")}</span><span class="pill">${html(story.discoveryStyle || "gradual")}</span>${story.hidden ? "<span class='pill'>hidden</span>" : ""}${(story.markers || []).map((item) => `<span class="pill">${html(item)}</span>`).join("")}</div><div class="actions" style="margin-top:10px"><button type="button" data-read-story="${html(story.id)}">Read this story</button></div>`;
$("storyCover").querySelector("[data-read-story]")?.addEventListener("click", () => openStoryReader(story.id));
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>`;
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 scene / ${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>`;
const kind = (entry.contentClass || "fragment") === "fragment" ? "reusable memory light" : "reusable memory";
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(kind)} / ${html(entry.emotionalTone || "warm")} / used in ${storiesForEntry(entry.id).length} ${storiesForEntry(entry.id).length === 1 ? "story" : "stories"}</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)));
@@ -3226,7 +3440,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
}
function renderStoryAtlas() {
const stories = filteredStories().slice(0, 18);
$("storyAtlas").innerHTML = stories.length ? stories.map((story) => `<button type="button" class="story-card${story.id === state.selectedStoryId ? " active" : ""}" data-story="${html(story.id)}"><strong>${html(story.title)}</strong><span class="subtle">${story.nodes.length} memories / ${html(story.tone || "warm")}</span></button>`).join("") : "<div class='subtle'>No story routes match this view yet.</div>";
$("storyAtlas").innerHTML = stories.length ? stories.map((story) => `<button type="button" class="story-card${story.id === state.selectedStoryId ? " active" : ""}" data-story="${html(story.id)}"><strong>${html(story.title)}</strong><span class="subtle">${story.nodes.length} memories / ${html(story.tone || "warm")} / ${html(story.discoveryStyle || "gradual")}</span></button>`).join("") : "<div class='subtle'>No story routes match this view yet.</div>";
$("storyAtlas").querySelectorAll("[data-story]").forEach((button) => button.addEventListener("click", () => {
fillStoryForm(storyById(button.dataset.story));
openCluster(button.dataset.story);
@@ -3246,6 +3460,56 @@ HIDDEN_APP_HTML = r"""<!doctype html>
button.addEventListener("dragstart", (event) => event.dataTransfer.setData("text/memory-id", button.dataset.id));
});
}
function openStoryReader(storyId = state.selectedStoryId) {
const story = storyById(storyId);
if (!story) return setStatus("select a story to read first.");
state.selectedStoryId = story.id;
state.readerIndex = 0;
fillStoryForm(story);
$("storyReader").classList.remove("hidden");
renderStoryReader();
}
function closeStoryReader() {
$("storyReader").classList.add("hidden");
}
function readerItems(story) {
return storyItems(story).filter((item) => item.kind === "section" || entryById(item.id));
}
function renderStoryReader() {
const story = storyById();
if (!story) return closeStoryReader();
const items = readerItems(story);
if (!items.length) {
$("readerTitle").textContent = story.title || "Untitled story";
$("readerMeta").textContent = "empty story route";
$("readerStage").innerHTML = `<div class="reader-scene reader-section"><div class="reader-kicker">Quiet route</div><p class="reader-copy">This story is waiting for memories or a written section.</p></div>`;
$("readerRoute").innerHTML = "";
return;
}
state.readerIndex = Math.max(0, Math.min(items.length - 1, state.readerIndex));
const item = items[state.readerIndex];
$("readerTitle").textContent = story.title || "Untitled story";
$("readerMeta").textContent = `${state.readerIndex + 1} of ${items.length} / ${story.tone || "warm"} / ${story.discoveryStyle || "gradual"}`;
if (item.kind === "section") {
$("readerStage").innerHTML = `<article class="reader-scene reader-section"><div class="reader-kicker">Story scene</div><h2>${html(item.title || "Story section")}</h2><p class="reader-copy">${html(item.content || "This scene is waiting for words.")}</p></article>`;
} else {
const entry = entryById(item.id);
const characters = charactersForEntry(entry).map((character) => `<span class="avatar-pill pill">${avatarImg(character, "tiny", avatarExpressionForEntry(entry))}${html(characterLabel(character))}</span>`).join("");
const kind = (entry.contentClass || "fragment") === "fragment" ? "Reusable memory light" : "Reusable memory";
$("readerStage").innerHTML = `<article class="reader-scene reader-memory"><div class="reader-kicker">${html(kind)}</div><h2>${html(entry.title || "Untitled memory")}</h2><p class="reader-copy">${html(entry.content || "This memory is waiting for words.")}</p><div class="pills" style="justify-content:center;margin-top:18px">${characters}<span class="pill">${html(entry.emotionalTone || "warm")}</span><span class="pill">appears in ${storiesForEntry(entry.id).length} ${storiesForEntry(entry.id).length === 1 ? "story" : "stories"}</span></div></article>`;
}
$("readerRoute").innerHTML = items.map((_, index) => `<button class="reader-dot${index === state.readerIndex ? " active" : ""}" type="button" aria-label="Read step ${index + 1}" data-reader-step="${index}"></button>`).join("");
$("readerRoute").querySelectorAll("[data-reader-step]").forEach((button) => button.addEventListener("click", () => {
state.readerIndex = Number(button.dataset.readerStep || 0);
renderStoryReader();
}));
$("readerPrevBtn").disabled = state.readerIndex <= 0;
$("readerNextBtn").disabled = state.readerIndex >= items.length - 1;
}
function stepStoryReader(delta) {
state.readerIndex += delta;
renderStoryReader();
}
function newEntry(type = "tooltip") {
snapshot();
const now = new Date().toISOString().slice(0, 10);
@@ -3654,6 +3918,10 @@ HIDDEN_APP_HTML = r"""<!doctype html>
$("fullResetBtn").onclick = fullReset;
$("exploreUndoBtn").onclick = undoExploration;
$("focusBtn").onclick = focusThread;
$("readStoryBtn").onclick = () => openStoryReader();
$("readerCloseBtn").onclick = closeStoryReader;
$("readerPrevBtn").onclick = () => stepStoryReader(-1);
$("readerNextBtn").onclick = () => stepStoryReader(1);
$("duplicateBtn").onclick = duplicateEntry;
$("deleteBtn").onclick = deleteEntry;
$("undoBtn").onclick = () => { if (!state.undo.length) return; state.redo.push(JSON.stringify({ entries: state.entries, stories: state.stories, selectedStoryId: state.selectedStoryId })); restore(state.undo.pop()); };
@@ -3680,6 +3948,10 @@ HIDDEN_APP_HTML = r"""<!doctype html>
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
if (!$("storyReader").classList.contains("hidden")) {
closeStoryReader();
return;
}
hideContextMenu();
if (state.drag) {
state.drag = null;
@@ -3687,6 +3959,8 @@ HIDDEN_APP_HTML = r"""<!doctype html>
setStatus("drag cancelled. nothing changed.");
}
}
if (!$("storyReader").classList.contains("hidden") && event.key === "ArrowRight") stepStoryReader(1);
if (!$("storyReader").classList.contains("hidden") && event.key === "ArrowLeft") stepStoryReader(-1);
if ((event.metaKey || event.ctrlKey) && event.key === "[") undoExploration();
});
setupCameraEvents();