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

This commit is contained in:
2026-05-15 15:37:42 +01:00
parent 02018bdb50
commit 1a0352fc3c

View File

@@ -1341,7 +1341,11 @@ HIDDEN_APP_HTML = r"""<!doctype html>
.edge.theme, .edge.symbol { stroke: rgba(127, 176, 137, 0.38); }
.memory-node { cursor: grab; transition: opacity 160ms ease, filter 180ms ease; }
.memory-node:active { cursor: grabbing; }
.memory-node.dimmed { opacity: 0.28; }
.memory-node.related-node { opacity: 0.82; }
.memory-node.selected { opacity: 1; filter: drop-shadow(0 0 18px rgba(255, 231, 173, 0.45)); }
.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; }
.hit-target { fill: transparent; stroke: transparent; pointer-events: all; }
.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;
@@ -1351,8 +1355,9 @@ HIDDEN_APP_HTML = r"""<!doctype html>
.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; }
.selection-halo { fill: rgba(255, 231, 173, 0.1); stroke: rgba(255, 243, 199, 0.95); stroke-width: 3; stroke-dasharray: 4 9; filter: drop-shadow(0 0 22px rgba(255, 231, 173, 0.62)); animation: selection-pulse 2.8s ease-in-out infinite; pointer-events: none; }
.memory-node:hover .node-shell, .memory-node:hover .node-portrait, .memory-node:hover .fragment-star { 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, .memory-node.selected .fragment-star { stroke: #fff3c7; stroke-width: 3; }
.memory-node.dragging .node-shell, .memory-node.dragging .node-portrait { stroke: #f3cd7a; stroke-width: 3; opacity: 0.72; }
.memory-node.compatible .node-shell, .memory-node.compatible .node-portrait { stroke: rgba(255, 243, 199, 0.92); stroke-width: 2.4; filter: drop-shadow(0 0 16px rgba(255, 232, 177, 0.46)); }
.memory-node.drop-target .node-shell, .memory-node.drop-target .node-portrait { stroke: #fff5c9; stroke-width: 4; filter: drop-shadow(0 0 24px rgba(255, 232, 177, 0.7)); }
@@ -1384,6 +1389,11 @@ HIDDEN_APP_HTML = r"""<!doctype html>
stroke: rgba(248, 234, 209, 0.12);
rx: 7;
}
.node-label.selected rect {
fill: rgba(39, 29, 17, 0.92);
stroke: rgba(255, 243, 199, 0.78);
filter: drop-shadow(0 0 20px rgba(255, 231, 173, 0.3));
}
.node-meta { fill: rgba(248, 234, 209, 0.72); stroke-width: 2px; }
.world-faded { opacity: 0.2; }
.breadcrumb, .minimap, .guide-card, .active-state, .context-menu {
@@ -1574,8 +1584,10 @@ HIDDEN_APP_HTML = r"""<!doctype html>
text-align: center;
}
.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-path.focused-path { stroke: rgba(255, 228, 166, 0.95); stroke-width: 5; filter: drop-shadow(0 0 18px rgba(255, 228, 166, 0.45)); }
.edge.story-echo { stroke: rgba(117, 169, 189, 0.28); stroke-dasharray: 3 10; }
.story-region { fill: rgba(211, 166, 77, 0.026); stroke: rgba(211, 166, 77, 0.16); stroke-width: 1.2; stroke-dasharray: 2 12; }
.story-region.focused-region { fill: rgba(211, 166, 77, 0.06); stroke: rgba(255, 228, 166, 0.42); stroke-width: 2; }
.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 {
@@ -1610,6 +1622,48 @@ HIDDEN_APP_HTML = r"""<!doctype html>
.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); }
.story-flow-item.active-edit {
border-color: rgba(255, 243, 199, 0.86);
background: rgba(211, 166, 77, 0.17);
box-shadow: 0 0 0 1px rgba(255, 243, 199, 0.16), 0 0 28px rgba(211, 166, 77, 0.2);
}
.editor-modebar {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 12px;
}
.editor-modebar button {
height: auto;
min-height: 46px;
text-align: left;
display: grid;
gap: 2px;
padding: 8px 10px;
}
.editor-modebar button.active[data-editor-mode="story"] { border-color: rgba(211, 166, 77, 0.82); background: rgba(211, 166, 77, 0.2); }
.editor-modebar button.active[data-editor-mode="memory"] { border-color: rgba(117, 169, 189, 0.78); background: rgba(117, 169, 189, 0.16); }
.editor-panel {
transition: opacity 180ms ease, border-color 180ms ease, background 180ms ease;
}
.editor-panel.inactive {
opacity: 0.56;
}
.editor-panel.inactive input, .editor-panel.inactive textarea, .editor-panel.inactive select {
opacity: 0.72;
}
.story-editor-panel.active-editor { border-color: rgba(211, 166, 77, 0.58); background: rgba(211, 166, 77, 0.08); }
.memory-editor-panel.active-editor { border-color: rgba(117, 169, 189, 0.52); background: rgba(117, 169, 189, 0.07); }
.editing-banner {
border: 1px solid rgba(255, 243, 199, 0.28);
border-radius: 8px;
padding: 9px 10px;
margin-bottom: 12px;
background: rgba(251, 241, 220, 0.075);
color: #f4dfb6;
font-size: 13px;
line-height: 1.4;
}
.hidden { display: none !important; }
@keyframes breathe {
0%, 100% { opacity: 0.72; }
@@ -1623,6 +1677,10 @@ HIDDEN_APP_HTML = r"""<!doctype html>
0%, 100% { opacity: 0.48; }
50% { opacity: 0.82; }
}
@keyframes selection-pulse {
0%, 100% { opacity: 0.72; transform: scale(1); }
50% { opacity: 1; transform: scale(1.045); }
}
.constellation-shimmer { animation: constellation-shimmer 9s ease-in-out infinite; }
.core-glow { animation: breathe 7s ease-in-out infinite; }
@keyframes sky-drift {
@@ -1822,13 +1880,18 @@ HIDDEN_APP_HTML = r"""<!doctype html>
</main>
<aside class="drawer">
<div class="editor-modebar" id="editorModebar">
<button type="button" data-editor-mode="story"><strong>Story Editing</strong><span class="subtle">Route, pacing, sections</span></button>
<button type="button" data-editor-mode="memory"><strong>Memory Editing</strong><span class="subtle">Reusable fragment, recurrence</span></button>
</div>
<div id="editingBanner" class="editing-banner">Choose a memory or story route to begin editing.</div>
<section class="panel">
<h2 id="drawerTitle">Choose a memory</h2>
<div id="drawerSub" class="subtle">The side panel opens from the constellation.</div>
<div id="drawerAvatars" class="pills" style="margin-top:10px"></div>
<div id="preview" class="preview" style="margin-top:10px"></div>
</section>
<section class="panel">
<section class="panel editor-panel story-editor-panel" id="storyEditorPanel">
<h2>Story Builder</h2>
<div id="storyCover" class="story-cover"></div>
<div class="editor-grid" style="margin-top:10px">
@@ -1862,7 +1925,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
<h3 style="margin-top:14px">Memory pool</h3>
<div id="memoryPool" class="memory-pool"></div>
</section>
<section class="panel">
<section class="panel editor-panel memory-editor-panel" id="memoryEditorPanel">
<h2>Memory editor</h2>
<div class="editor-grid">
<label class="full">Name<input id="title" /></label>
@@ -1947,7 +2010,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
const draftKey = "hiddenNarrativeObservatoryDraft:v1";
const WORLD = { width: 4600, height: 3300, cx: 2300, cy: 1650 };
const SCREEN = { width: 1000, height: 760 };
const state = { entries: [], stories: [], meta: {}, mode: "graph", selectedId: "", selectedStoryId: "", 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 state = { entries: [], stories: [], meta: {}, mode: "graph", editorMode: "memory", 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, pendingDrag: 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);
@@ -2111,6 +2174,19 @@ HIDDEN_APP_HTML = r"""<!doctype html>
$("status").textContent = message;
$("autosave").textContent = message;
}
function setEditorMode(mode, detail = "") {
state.editorMode = mode === "story" ? "story" : "memory";
$("editorModebar").querySelectorAll("[data-editor-mode]").forEach((button) => button.classList.toggle("active", button.dataset.editorMode === state.editorMode));
$("storyEditorPanel").classList.toggle("active-editor", state.editorMode === "story");
$("storyEditorPanel").classList.toggle("inactive", state.editorMode !== "story");
$("memoryEditorPanel").classList.toggle("active-editor", state.editorMode === "memory");
$("memoryEditorPanel").classList.toggle("inactive", state.editorMode !== "memory");
const story = storyById();
const entry = currentEntry();
$("editingBanner").innerHTML = state.editorMode === "story"
? `<strong>Editing Story:</strong> ${html(story?.title || "No story selected")}<br><span class="subtle">${html(detail || "Shape the route, pacing, sections, and emotional sequence.")}</span>`
: `<strong>Editing Memory:</strong> ${html(entry?.title || "No memory selected")}<br><span class="subtle">${html(detail || "Craft the reusable fragment and choose where it can recur.")}</span>`;
}
function cameraTransform() {
return `translate(${state.camera.x} ${state.camera.y}) scale(${state.camera.k})`;
}
@@ -2362,6 +2438,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
return;
}
state.selectedStoryId = story.id;
setEditorMode("story", "Shape the route, pacing, sections, and emotional sequence.");
$("storyTitle").value = story.title || "";
$("storyDescription").value = story.description || "";
$("storyTone").value = story.tone || "warm";
@@ -2376,6 +2453,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
}
function fillForm(entry) {
if (!entry) return;
setEditorMode("memory", "Craft the reusable fragment and choose where it can recur.");
$("drawerTitle").textContent = entry.title || "Untitled memory";
$("drawerSub").textContent = `${layerName(depth(entry))} / ${entry.contentClass || "fragment"} / ${entry.type || "entry"}`;
$("title").value = entry.title || "";
@@ -2564,12 +2642,17 @@ HIDDEN_APP_HTML = r"""<!doctype html>
const baseRadius = 520 + (storyIndex % 4) * 250;
const anchorX = cx + Math.cos(angle) * baseRadius;
const anchorY = cy + Math.sin(angle) * baseRadius * 0.68;
const denseRoute = route.length > 12;
const stepX = denseRoute ? 188 : 150;
const stepY = denseRoute ? 138 : 110;
const curveLift = denseRoute ? 150 : 90;
route.forEach((entry, index) => {
const step = index - (route.length - 1) / 2;
const curve = Math.sin(index / Math.max(1, route.length - 1) * Math.PI) * 90;
const curve = Math.sin(index / Math.max(1, route.length - 1) * Math.PI) * curveLift;
const direction = angle + Math.PI / 2;
const x = anchorX + Math.cos(direction) * step * 150 + Math.cos(angle) * curve;
const y = anchorY + Math.sin(direction) * step * 110 + Math.sin(angle) * curve * 0.7;
const lane = denseRoute ? ((index % 2) - 0.5) * 70 : 0;
const x = anchorX + Math.cos(direction) * step * stepX + Math.cos(angle) * curve + Math.cos(angle) * lane;
const y = anchorY + Math.sin(direction) * step * stepY + Math.sin(angle) * curve * 0.7 + Math.sin(angle) * lane * 0.55;
state.positions.set(entry.id, { x, y });
});
});
@@ -2646,8 +2729,8 @@ HIDDEN_APP_HTML = r"""<!doctype html>
}
function relaxPositions(entries) {
const ids = entries.map((entry) => entry.id);
const minDistance = state.focusCluster || state.focusIds ? 112 : 86;
for (let pass = 0; pass < 3; pass += 1) {
const minDistance = state.focusCluster || state.focusIds ? 138 : entries.length > 90 ? 104 : 92;
for (let pass = 0; pass < 5; pass += 1) {
for (let i = 0; i < ids.length; i += 1) {
for (let j = i + 1; j < ids.length; j += 1) {
const a = state.positions.get(ids[i]);
@@ -2703,6 +2786,9 @@ HIDDEN_APP_HTML = r"""<!doctype html>
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 ? 120 : 70) : [];
const activeStory = storyById() || storiesForEntry(state.selectedId)[0];
const activeStoryIds = new Set(storyItems(activeStory).filter((item) => item.kind !== "section").map((item) => item.id));
const selectedRelatedIds = new Set(storiesForEntry(state.selectedId).flatMap((story) => story.nodes || []));
const edges = pairs.map((pair) => {
const a = state.positions.get(pair.source);
const b = state.positions.get(pair.target);
@@ -2712,8 +2798,9 @@ HIDDEN_APP_HTML = r"""<!doctype html>
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}`;
const focusedPath = activeStory && pair.storyId === activeStory.id;
const spark = pair.kind === "story-path" ? `<circle class="route-spark" cx="${cx}" cy="${cy}" r="${focusedPath ? 5 : 4}"></circle>` : "";
return `<path class="edge ${html(pair.kind)}${focusedPath ? " focused-path" : ""}" d="M ${a.x} ${a.y} Q ${cx} ${cy} ${b.x} ${b.y}"></path>${spark}`;
}).join("");
const storyRegions = renderStoryRegions(entries);
const occupied = [];
@@ -2747,10 +2834,15 @@ HIDDEN_APP_HTML = r"""<!doctype html>
const size = isFragment ? baseSize : Math.max(baseSize, zoomSize / 2);
const label = labelForEntry(entry, pos, size, occupied);
const dragClass = dragClassForEntry(entry.id);
const isSelected = entry.id === state.selectedId;
const related = selectedRelatedIds.has(entry.id) || activeStoryIds.has(entry.id);
const dimmed = Boolean(state.selectedId && !isSelected && !related && (state.focusCluster || state.focusIds || state.camera.k > 0.45));
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))}">
return `<g class="memory-node${isSelected ? " selected" : ""}${related && !isSelected ? " related-node" : ""}${dimmed ? " dimmed" : ""}${entry.enabled === false ? " resting" : ""}${dragClass}" data-id="${html(entry.id)}" tabindex="0" transform="translate(${pos.x} ${pos.y})" style="--node-glow:${html(avatarGlow(character))}">
<circle class="hit-target" r="${Math.max(34, size + 16)}"></circle>
${isSelected ? `<circle class="selection-halo" r="${Math.max(38, size + 18)}"></circle>` : ""}
${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
@@ -2787,7 +2879,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
const minY = Math.min(...points.map((p) => p.y)) - 100;
const maxY = Math.max(...points.map((p) => p.y)) + 100;
const color = toneColors[story.tone] || "#d3a64d";
return `<ellipse class="story-region" cx="${(minX + maxX) / 2}" cy="${(minY + maxY) / 2}" rx="${Math.max(130, (maxX - minX) / 2)}" ry="${Math.max(90, (maxY - minY) / 2)}" stroke="${html(color)}"></ellipse>`;
return `<ellipse class="story-region${story.id === state.selectedStoryId ? " focused-region" : ""}" cx="${(minX + maxX) / 2}" cy="${(minY + maxY) / 2}" rx="${Math.max(130, (maxX - minX) / 2)}" ry="${Math.max(90, (maxY - minY) / 2)}" stroke="${html(color)}"></ellipse>`;
}).join("");
}
function dragClassForEntry(id) {
@@ -2895,7 +2987,8 @@ HIDDEN_APP_HTML = r"""<!doctype html>
const scope = visibleScope();
if (scope.entries.length) {
fitCameraTo(boundsForEntries(scope.entries), true);
selectNode(scope.entries[0].id);
const preferred = scope.entries.some((entry) => entry.id === state.selectedId) ? state.selectedId : scope.entries[0].id;
selectNode(preferred, { keepEditorMode: storyById(clusterId) ? "story" : "" });
}
}
@@ -2926,12 +3019,13 @@ HIDDEN_APP_HTML = r"""<!doctype html>
const focus = [];
if (scope.focusedCluster) focus.push({ id: "focus", label: `Island: ${scope.focusedCluster.label}`, help: "The observatory is focused on one island." });
if (state.focusIds) focus.push({ id: "focus", label: state.focusLabel || "Focused thread", help: "Only nearby thread memories are visible." });
if (state.selectedId) focus.push({ id: "selection", label: `Editing: ${currentEntry()?.title || state.selectedId}`, help: "This is the currently selected editing target." });
const active = [...filters, ...focus];
if (!active.length) {
$("activeState").innerHTML = `<span class="subtle">Whole sky visible. You can always return here.</span>`;
return;
}
$("activeState").innerHTML = active.map((item) => `<span class="filter-pill" title="${html(item.help)}">${item.character ? avatarImg(item.character, "tiny") : ""}${html(item.label)}<button type="button" aria-label="Remove ${html(item.label)}" data-clear="${html(item.id)}">x</button></span>`).join("") + `<button type="button" data-action="soft">Clear Focus</button><button type="button" data-action="full">Whole Sky</button>`;
$("activeState").innerHTML = active.map((item) => `<span class="filter-pill" title="${html(item.help)}">${item.character ? avatarImg(item.character, "tiny") : ""}${html(item.label)}${item.id === "selection" ? "" : `<button type="button" aria-label="Remove ${html(item.label)}" data-clear="${html(item.id)}">x</button>`}</span>`).join("") + `<button type="button" data-action="soft">Clear Focus</button><button type="button" data-action="full">Whole Sky</button>`;
$("activeState").querySelectorAll("[data-clear]").forEach((button) => button.addEventListener("click", () => clearExplorationPart(button.dataset.clear)));
$("activeState").querySelector("[data-action='soft']")?.addEventListener("click", softReset);
$("activeState").querySelector("[data-action='full']")?.addEventListener("click", fullReset);
@@ -2945,6 +3039,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
state.focusIds = null;
state.focusLabel = "";
}
if (part === "selection") return;
if (["search","layerFilter","characterFilter","toneFilter","rarityFilter"].includes(part)) $(part).value = "";
$("modebar").querySelectorAll("button").forEach((item) => item.classList.toggle("active", item.dataset.mode === state.mode));
render();
@@ -3030,11 +3125,12 @@ HIDDEN_APP_HTML = r"""<!doctype html>
if (!point) return;
animateCamera({ k: 1.75, x: SCREEN.width / 2 - point.x * 1.75, y: SCREEN.height / 2 - point.y * 1.75 });
}
function selectNode(id) {
function selectNode(id, options = {}) {
const entry = state.entries.find((item) => item.id === id) || state.entries[0];
if (!entry) return;
state.selectedId = entry.id;
fillForm(entry);
if (options.keepEditorMode === "story") setEditorMode("story", "Story route is active. The selected memory is highlighted inside the sequence.");
renderStoryBuilder();
render();
}
@@ -3101,27 +3197,39 @@ HIDDEN_APP_HTML = r"""<!doctype html>
event.stopPropagation();
hideContextMenu();
// First click/press only loads/selects the node.
// Dragging is only allowed when the node is already selected.
if (state.selectedId !== id) {
state.drag = null;
state.pendingDrag = null;
state.draggingId = "";
selectNode(id);
updateDragPreview();
setStatus("memory loaded. click and drag it again to create a connection.");
setStatus("memory selected. the editor is anchored to this light.");
return;
}
// Second click + drag starts the connection.
const point = pointerWorld(event);
const entry = state.entries.find((item) => item.id === id);
const related = new Set(storiesForEntry(id).flatMap((story) => story.nodes || []));
state.drag = {
state.pendingDrag = {
sourceId: id,
pointerId: event.pointerId,
startClientX: event.clientX,
startClientY: event.clientY,
x: point.x,
y: point.y,
startedAt: Date.now(),
};
$("graph").setPointerCapture(event.pointerId);
}
function startNodeDragFromPending(event) {
if (!state.pendingDrag || state.drag) return;
const pending = state.pendingDrag;
const related = new Set(storiesForEntry(pending.sourceId).flatMap((story) => story.nodes || []));
state.drag = {
sourceId: pending.sourceId,
pointerId: pending.pointerId,
x: pending.x,
y: pending.y,
targetId: "",
targetKind: "",
relation: relationForEvent(event),
@@ -3129,10 +3237,10 @@ HIDDEN_APP_HTML = r"""<!doctype html>
startedAt: Date.now(),
};
state.draggingId = id;
state.draggingId = pending.sourceId;
state.pendingDrag = null;
$("dragGuide").classList.remove("hidden");
$("graph").setPointerCapture(event.pointerId);
updateDragTarget(point, event);
updateDragTarget(pointerWorld(event), event);
render();
}
@@ -3215,6 +3323,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
if (!state.drag || state.drag.pointerId !== event.pointerId) return;
const drag = state.drag;
state.drag = null;
state.pendingDrag = null;
state.draggingId = "";
try { $("graph").releasePointerCapture(event.pointerId); } catch (err) {}
updateDragPreview();
@@ -3332,6 +3441,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
state.focusCluster = "";
state.focusIds = new Set(storyItems(story).filter((item) => item.kind !== "section").map((item) => item.id));
state.focusLabel = `Story: ${story.title || story.id}`;
setEditorMode("story", "Story focus is locked to this route. Surrounding memories are softened.");
render();
fitCameraTo(boundsForEntries(visibleScope().entries), true);
setStatus("focus mode is showing this story route.");
@@ -3418,16 +3528,24 @@ HIDDEN_APP_HTML = r"""<!doctype html>
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 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>`;
return `<div class="story-flow-item section${state.editingSectionId === item.id ? " active-edit" : ""}" draggable="true" data-item="${html(item.id)}" data-select-section="${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 "";
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>`;
return `<div class="story-flow-item${entry.id === state.selectedId ? " active-edit" : ""}" draggable="true" data-item="${html(item.id)}" data-select-memory="${html(entry.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)));
$("storyFlow").querySelectorAll("[data-remove-section]").forEach((button) => button.addEventListener("click", () => removeStoryItem(button.dataset.removeSection)));
$("storyFlow").querySelectorAll("[data-select-memory]").forEach((row) => row.addEventListener("click", (event) => {
if (event.target.closest("button")) return;
selectNode(row.dataset.selectMemory, { keepEditorMode: "story" });
}));
$("storyFlow").querySelectorAll("[data-select-section]").forEach((row) => row.addEventListener("click", (event) => {
if (event.target.closest("button")) return;
openSectionEditor(row.dataset.selectSection);
}));
$("storyFlow").querySelectorAll("[draggable='true']").forEach((item) => {
item.addEventListener("dragstart", (event) => event.dataTransfer.setData("text/story-item", item.dataset.item));
item.addEventListener("dragover", (event) => event.preventDefault());
@@ -3676,6 +3794,8 @@ HIDDEN_APP_HTML = r"""<!doctype html>
$("sectionTitle").value = section.title || "";
$("sectionContent").value = section.content || "";
$("sectionEditor").classList.remove("hidden");
setEditorMode("story", `Editing story section: ${section.title || "Story section"}.`);
renderStoryBuilder();
}
function saveStorySection() {
const story = storyById();
@@ -3693,6 +3813,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
function closeSectionEditor() {
state.editingSectionId = "";
$("sectionEditor")?.classList.add("hidden");
renderStoryBuilder();
}
function duplicateEntry() {
const entry = currentEntry();
@@ -3812,6 +3933,12 @@ HIDDEN_APP_HTML = r"""<!doctype html>
state.pan = { id: event.pointerId, x: event.clientX, y: event.clientY, vx: 0, vy: 0, last: performance.now() };
});
graph.addEventListener("pointermove", (event) => {
if (state.pendingDrag && state.pendingDrag.pointerId === event.pointerId && !state.drag) {
const moved = Math.hypot(event.clientX - state.pendingDrag.startClientX, event.clientY - state.pendingDrag.startClientY);
if (moved < 9) return;
startNodeDragFromPending(event);
return;
}
if (state.drag && state.drag.pointerId === event.pointerId) {
updateDragTarget(pointerWorld(event), event);
return;
@@ -3832,9 +3959,14 @@ HIDDEN_APP_HTML = r"""<!doctype html>
});
graph.addEventListener("pointerup", (event) => {
if (state.drag && state.drag.pointerId === event.pointerId) finishNodeDrag(event);
if (state.pendingDrag && state.pendingDrag.pointerId === event.pointerId) {
state.pendingDrag = null;
try { graph.releasePointerCapture(event.pointerId); } catch (err) {}
}
finishPan(event.pointerId);
});
graph.addEventListener("pointercancel", (event) => {
if (state.pendingDrag && state.pendingDrag.pointerId === event.pointerId) state.pendingDrag = null;
if (state.drag && state.drag.pointerId === event.pointerId) {
state.drag = null;
updateDragPreview();
@@ -3936,6 +4068,15 @@ HIDDEN_APP_HTML = r"""<!doctype html>
$("modebar").querySelectorAll("button").forEach((item) => item.classList.toggle("active", item === button));
render();
}));
$("editorModebar").querySelectorAll("[data-editor-mode]").forEach((button) => button.addEventListener("click", () => {
if (button.dataset.editorMode === "story") {
setEditorMode("story");
focusStory();
} else {
setEditorMode("memory");
if (state.selectedId) zoomToNode(state.selectedId);
}
}));
document.querySelectorAll("[data-tour]").forEach((button) => button.addEventListener("click", () => runTour(button.dataset.tour)));
$("storyDropZone").addEventListener("dragover", (event) => event.preventDefault());
$("storyDropZone").addEventListener("drop", (event) => {