This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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();
|
||||
|
||||
Reference in New Issue
Block a user