Phase 21B.2 - Story Intelligence Experience Composition Refinement

This commit is contained in:
Nick Beckley 2026-07-11 20:49:15 +00:00
parent e3d18b4a6b
commit 57df5646e6
4 changed files with 607 additions and 103 deletions

View File

@ -21,6 +21,7 @@
<strong>PlotDirector</strong>
<span>Story Intelligence</span>
</div>
<ol class="story-exp-pipeline" data-analysis-stages aria-label="Analysis stages"></ol>
<div class="story-exp-context">
<span>@Model.ProjectName</span>
<span>@Model.BookTitle</span>

View File

@ -54,7 +54,7 @@ input:focus-visible {
.story-exp {
position: relative;
display: grid;
grid-template-rows: 70px minmax(0, 1fr);
grid-template-rows: 66px minmax(0, 1fr);
width: 100vw;
height: 100vh;
isolation: isolate;
@ -87,10 +87,10 @@ input:focus-visible {
.story-exp-header {
display: grid;
grid-template-columns: minmax(210px, 0.65fr) minmax(460px, 1.35fr) auto;
grid-template-columns: minmax(210px, 0.52fr) minmax(400px, 1.35fr) minmax(290px, 0.78fr) auto;
align-items: center;
gap: 22px;
padding: 14px 24px;
gap: 18px;
padding: 10px 24px;
border-bottom: 1px solid var(--story-line);
background: rgba(4, 10, 20, 0.68);
backdrop-filter: blur(18px);
@ -102,6 +102,139 @@ input:focus-visible {
gap: 12px;
}
.story-exp-pipeline {
position: relative;
display: grid;
grid-template-columns: repeat(11, minmax(38px, 1fr));
gap: 0;
min-width: 0;
margin: 0;
padding: 0;
list-style: none;
}
.story-exp-pipeline::before {
content: "";
position: absolute;
left: 4%;
right: 4%;
top: 10px;
height: 1px;
background: linear-gradient(90deg, rgba(109, 181, 255, 0.54), rgba(215, 168, 93, 0.24), rgba(255, 255, 255, 0.18));
}
.story-exp-pipeline-step {
position: relative;
display: grid;
justify-items: center;
gap: 6px;
color: rgba(196, 214, 235, 0.54);
font-size: 0.62rem;
font-weight: 700;
min-width: 0;
}
.story-exp-pipeline-step span {
z-index: 1;
width: 10px;
height: 10px;
border: 1px solid rgba(196, 214, 235, 0.36);
border-radius: 50%;
background: rgba(8, 18, 32, 0.98);
}
.story-exp-pipeline-step strong {
overflow: hidden;
max-width: 78px;
text-overflow: ellipsis;
white-space: nowrap;
}
.story-exp-pipeline-step.is-complete span {
border-color: rgba(109, 181, 255, 0.9);
background: var(--story-blue);
box-shadow: 0 0 14px rgba(109, 181, 255, 0.35);
}
.story-exp-pipeline-step.is-current {
color: var(--story-text);
}
.story-exp-pipeline-step.is-current span {
width: 14px;
height: 14px;
margin-top: -2px;
border-color: rgba(215, 168, 93, 0.9);
background: var(--story-gold);
box-shadow: 0 0 0 5px rgba(215, 168, 93, 0.12), 0 0 22px rgba(215, 168, 93, 0.5);
}
@media (max-width: 1500px) {
.story-exp-header {
grid-template-columns: minmax(188px, 0.44fr) minmax(360px, 1.2fr) minmax(230px, 0.66fr) auto;
gap: 12px;
}
.story-exp-pipeline-step {
font-size: 0.55rem;
}
.story-exp-pipeline-step strong {
max-width: 45px;
}
.story-exp-context {
gap: 8px;
}
.story-exp-context span {
max-width: 96px;
}
}
@media (max-height: 820px) and (min-width: 1181px) {
.story-exp-shell {
padding-top: 10px;
padding-bottom: 64px;
}
.story-exp-stage {
grid-template-rows: auto minmax(0, 1fr) 126px;
}
.story-exp-stage-copy {
min-height: 54px;
}
.story-exp-ribbon-wrap {
gap: 8px;
padding: 10px;
}
.story-exp-ribbon {
min-height: 70px;
}
.story-scene-card {
flex-basis: 126px;
padding: 8px 10px;
}
.story-scene-card.is-current {
flex-basis: 172px;
}
.story-exp-timeline {
min-height: 34px;
}
.story-exp-controls {
bottom: 10px;
padding-top: 7px;
padding-bottom: 7px;
}
}
.story-exp-brand strong {
font-size: 1.12rem;
letter-spacing: 0.02em;
@ -121,8 +254,8 @@ input:focus-visible {
.story-exp-context {
display: flex;
justify-content: center;
gap: 16px;
justify-content: flex-end;
gap: 12px;
min-width: 0;
}
@ -154,7 +287,7 @@ input:focus-visible {
grid-template-columns: minmax(206px, 0.54fr) minmax(760px, 2.44fr) minmax(214px, 0.56fr);
gap: 16px;
min-height: 0;
padding: 18px 22px 84px;
padding: 14px 22px 74px;
}
.story-exp-left,
@ -363,8 +496,8 @@ input:focus-visible {
.story-exp-stage {
display: grid;
grid-template-rows: auto minmax(0, 1fr) 166px;
gap: 14px;
grid-template-rows: auto minmax(0, 1fr) 158px;
gap: 10px;
min-height: 0;
}
@ -373,14 +506,14 @@ input:focus-visible {
grid-template-columns: 1fr minmax(240px, 0.72fr);
align-items: end;
gap: 22px;
min-height: 86px;
min-height: 62px;
}
.story-exp-stage-copy h1 {
grid-column: 1;
margin: 0;
font-size: clamp(1.8rem, 3vw, 3.8rem);
line-height: 1.02;
font-size: clamp(1.55rem, 2.35vw, 2.75rem);
line-height: 1.05;
letter-spacing: 0;
}
@ -388,38 +521,37 @@ input:focus-visible {
grid-column: 2;
margin: 0;
color: var(--story-soft);
font-size: clamp(0.9rem, 1.2vw, 1.05rem);
line-height: 1.45;
font-size: clamp(0.86rem, 1vw, 1rem);
line-height: 1.38;
}
.story-exp-visual {
position: relative;
display: grid;
grid-template-columns: minmax(470px, 1.22fr) minmax(320px, 0.9fr) minmax(238px, 0.58fr);
grid-template-rows: minmax(0, 1fr);
gap: 10px;
display: block;
min-height: 0;
overflow: hidden;
border: 1px solid rgba(151, 185, 218, 0.11);
border-radius: 30px;
padding: 22px;
border: 1px solid rgba(151, 185, 218, 0.045);
border-radius: 24px;
padding: 0;
background:
radial-gradient(circle at 22% 40%, rgba(109, 181, 255, 0.18), transparent 24rem),
radial-gradient(circle at 58% 49%, rgba(215, 168, 93, 0.15), transparent 20rem),
radial-gradient(circle at 86% 30%, rgba(227, 140, 168, 0.08), transparent 18rem),
linear-gradient(180deg, rgba(11, 24, 43, 0.66), rgba(4, 10, 20, 0.54));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05), var(--story-shadow);
radial-gradient(circle at 20% 22%, rgba(109, 181, 255, 0.14), transparent 16rem),
radial-gradient(circle at 58% 46%, rgba(109, 181, 255, 0.13), transparent 18rem),
radial-gradient(circle at 78% 55%, rgba(215, 168, 93, 0.11), transparent 18rem),
linear-gradient(180deg, rgba(11, 24, 43, 0.34), rgba(4, 10, 20, 0.18));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.025), 0 22px 72px rgba(0, 0, 0, 0.22);
}
.story-exp-visual::before {
content: "";
position: absolute;
inset: 28px 34px;
border-radius: 28px;
inset: 0;
border-radius: inherit;
background:
linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.05), transparent),
linear-gradient(180deg, transparent, rgba(109, 181, 255, 0.04), transparent);
opacity: 0.52;
radial-gradient(circle, rgba(255, 255, 255, 0.28) 0 1px, transparent 1.6px),
linear-gradient(122deg, transparent 0 34%, rgba(109, 181, 255, 0.08) 34.05% 34.18%, transparent 34.28%),
linear-gradient(28deg, transparent 0 62%, rgba(215, 168, 93, 0.08) 62.05% 62.18%, transparent 62.28%);
background-size: 112px 112px, auto, auto;
opacity: 0.34;
pointer-events: none;
}
@ -433,30 +565,93 @@ input:focus-visible {
}
.story-exp-connection {
stroke: rgba(215, 168, 93, 0.48);
stroke: rgba(215, 168, 93, 0.5);
stroke-linecap: round;
stroke-width: 2;
stroke-width: 1.8;
stroke-dasharray: 1;
stroke-dashoffset: 1;
filter: drop-shadow(0 0 8px rgba(215, 168, 93, 0.28));
animation: drawLine 900ms ease forwards;
fill: none;
filter: drop-shadow(0 0 7px rgba(215, 168, 93, 0.22));
animation: drawLine 950ms cubic-bezier(.16, .84, .28, 1) forwards;
}
.story-exp-connection-group--presence .story-exp-connection,
.story-exp-connection-group--presence-soft .story-exp-connection {
stroke: rgba(125, 193, 255, 0.58);
}
.story-exp-connection-group--presence-soft .story-exp-connection,
.story-exp-connection-group--knowledge .story-exp-connection,
.story-exp-connection-group--asset-link .story-exp-connection {
stroke-dasharray: 0.12 0.08;
}
.story-exp-connection-group--support .story-exp-connection {
stroke: rgba(121, 210, 166, 0.64);
}
.story-exp-connection-group--conflict .story-exp-connection {
stroke: rgba(227, 140, 168, 0.76);
stroke-dasharray: 0.18 0.1;
}
.story-exp-connection-group--uncertain .story-exp-connection {
stroke: rgba(182, 162, 255, 0.56);
stroke-dasharray: 0.08 0.09;
}
.story-exp-connection-group--knowledge .story-exp-connection {
stroke: rgba(182, 162, 255, 0.5);
}
.story-exp-connection-label {
paint-order: stroke;
stroke: rgba(4, 10, 20, 0.86);
stroke-width: 5px;
fill: var(--story-soft);
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.01em;
text-transform: lowercase;
filter: drop-shadow(0 0 9px rgba(0, 0, 0, 0.5));
}
.story-exp-zone {
position: relative;
position: absolute;
inset: 0;
z-index: 2;
min-width: 0;
min-height: 0;
pointer-events: none;
}
.story-exp-zone-label {
margin: 0 0 8px;
opacity: 0.72;
position: absolute;
margin: 0;
color: rgba(158, 177, 199, 0.54);
font-size: 0.64rem;
opacity: 0.56;
}
.story-exp-zone--characters .story-exp-zone-label {
left: 24px;
top: 22px;
}
.story-exp-zone--location .story-exp-zone-label {
left: 55%;
top: 22px;
}
.story-exp-zone--assets .story-exp-zone-label {
right: 8%;
top: 22px;
}
.story-exp-characters {
position: relative;
min-height: 100%;
width: 100%;
height: 100%;
}
.story-character {
@ -466,6 +661,7 @@ input:focus-visible {
width: var(--node-size, 150px);
transform: translate(var(--x, 0), var(--y, 0)) scale(var(--scale, 1));
opacity: var(--opacity, 1);
pointer-events: auto;
transition: transform 1050ms cubic-bezier(.16, .84, .28, 1), opacity 780ms ease;
}
@ -478,9 +674,9 @@ input:focus-visible {
.story-asset__image {
display: block;
overflow: hidden;
border: 1px solid rgba(169, 205, 240, 0.2);
border: 1px solid rgba(169, 205, 240, 0.36);
background: rgba(255, 255, 255, 0.045);
box-shadow: 0 24px 54px rgba(0, 0, 0, 0.28);
box-shadow: 0 24px 54px rgba(0, 0, 0, 0.32), 0 0 28px rgba(109, 181, 255, 0.08);
}
.story-character__image {
@ -499,7 +695,7 @@ input:focus-visible {
.story-character.is-pov .story-character__image {
border-color: rgba(215, 168, 93, 0.82);
box-shadow: 0 0 0 7px rgba(215, 168, 93, 0.1), 0 34px 78px rgba(215, 168, 93, 0.2);
box-shadow: 0 0 0 7px rgba(215, 168, 93, 0.1), 0 34px 78px rgba(215, 168, 93, 0.22), 0 0 36px rgba(215, 168, 93, 0.22);
}
.story-character__text {
@ -521,12 +717,14 @@ input:focus-visible {
}
.story-location {
position: absolute;
display: grid;
align-content: center;
gap: 14px;
height: calc(100% - 28px);
padding: 8px 0;
transition: opacity 560ms ease, transform 800ms cubic-bezier(.16, .84, .28, 1);
justify-items: center;
gap: 10px;
width: var(--node-size, 190px);
transform: translate(var(--x, 0), var(--y, 0));
pointer-events: auto;
transition: opacity 560ms ease, transform 850ms cubic-bezier(.16, .84, .28, 1);
}
.story-location.is-changing {
@ -535,11 +733,11 @@ input:focus-visible {
}
.story-location__image {
width: 100%;
max-height: 360px;
aspect-ratio: 4 / 5;
border-radius: 32px;
box-shadow: 0 28px 78px rgba(0, 0, 0, 0.34), 0 0 54px rgba(109, 181, 255, 0.1);
width: var(--node-size, 190px);
height: var(--node-size, 190px);
border-radius: 50%;
border-color: rgba(125, 193, 255, 0.82);
box-shadow: 0 0 0 7px rgba(109, 181, 255, 0.1), 0 30px 82px rgba(0, 0, 0, 0.38), 0 0 58px rgba(109, 181, 255, 0.22);
}
.story-location h2,
@ -548,35 +746,47 @@ input:focus-visible {
}
.story-location h2 {
font-size: clamp(1.45rem, 2vw, 2.1rem);
font-size: clamp(1.12rem, 1.55vw, 1.55rem);
text-align: center;
}
.story-location p {
color: var(--story-muted);
font-size: 0.86rem;
text-align: center;
}
.story-exp-assets {
display: grid;
align-content: center;
gap: 14px;
height: calc(100% - 28px);
padding-top: 24px;
position: relative;
width: 100%;
height: 100%;
}
.story-asset {
position: absolute;
display: grid;
grid-template-columns: 66px 1fr;
align-items: center;
gap: 11px;
justify-items: center;
gap: 7px;
width: max(var(--node-size, 86px), 112px);
opacity: var(--opacity, 1);
transform: translateY(var(--y, 0)) scale(var(--scale, 1));
transform: translate(var(--x, 0), var(--y, 0)) scale(var(--scale, 1));
pointer-events: auto;
transition: transform 860ms cubic-bezier(.16, .84, .28, 1), opacity 620ms ease;
}
.story-asset__image {
width: 66px;
height: 66px;
border-radius: 17px;
width: var(--node-size, 86px);
height: var(--node-size, 86px);
border-radius: 50%;
border-color: rgba(215, 168, 93, 0.68);
box-shadow: 0 0 0 5px rgba(215, 168, 93, 0.08), 0 22px 48px rgba(0, 0, 0, 0.3), 0 0 28px rgba(215, 168, 93, 0.14);
}
.story-asset__text {
display: grid;
justify-items: center;
gap: 2px;
text-align: center;
}
.story-asset strong,
@ -586,7 +796,7 @@ input:focus-visible {
.story-asset span {
color: var(--story-muted);
font-size: 0.82rem;
font-size: 0.76rem;
}
.story-exp-ribbon-wrap {
@ -781,13 +991,29 @@ input:focus-visible {
min-height: 100vh;
}
.story-exp-header,
.story-exp-shell,
.story-exp-stage-copy,
.story-exp-visual {
grid-template-columns: 1fr;
}
.story-exp-header {
grid-template-columns: 1fr auto;
}
.story-exp-pipeline {
grid-column: 1 / -1;
grid-row: 2;
overflow-x: auto;
padding-bottom: 4px;
}
.story-exp-context {
grid-column: 1 / -1;
grid-row: 3;
justify-content: start;
}
.story-exp-shell {
padding-bottom: 110px;
}

View File

@ -14,9 +14,23 @@
intervalMs: 6200,
timer: null
};
const analysisStages = [
{ key: "reading", label: "Reading" },
{ key: "chapters", label: "Chapters" },
{ key: "scenes", label: "Scenes" },
{ key: "content", label: "Content" },
{ key: "characters", label: "Characters" },
{ key: "locations", label: "Locations" },
{ key: "assets", label: "Assets" },
{ key: "relationships", label: "Relationships" },
{ key: "knowledge", label: "Knowledge" },
{ key: "continuity", label: "Continuity" },
{ key: "finalising", label: "Finalising" }
];
const dom = {
stageLabel: root.querySelector("[data-stage-label]"),
stageRail: root.querySelector("[data-analysis-stages]"),
currentScene: root.querySelector("[data-current-scene]"),
progressPercent: root.querySelector("[data-progress-percent]"),
elapsedTime: root.querySelector("[data-elapsed-time]"),
@ -53,6 +67,7 @@
const current = loadSceneState(state.index);
updateProgress(current);
updateAnalysisStages(current);
updateSceneCopy(current);
reconcileCharacters(current, previous);
reconcileLocation(current, previous);
@ -77,6 +92,37 @@
if (dom.progressRing) dom.progressRing.style.setProperty("--progress-value", `${scene.progressPercent * 3.6}deg`);
}
function updateAnalysisStages(scene) {
if (!dom.stageRail) return;
const currentIndex = stageIndex(scene);
dom.stageRail.innerHTML = "";
analysisStages.forEach((stage, index) => {
const node = document.createElement("li");
node.className = "story-exp-pipeline-step";
node.classList.toggle("is-complete", index < currentIndex || scene.progressPercent >= 100);
node.classList.toggle("is-current", index === currentIndex && scene.progressPercent < 100);
node.classList.toggle("is-future", index > currentIndex && scene.progressPercent < 100);
node.innerHTML = "<span></span><strong></strong>";
text(node.querySelector("strong"), stage.label);
dom.stageRail.append(node);
});
}
function stageIndex(scene) {
const stage = String(scene.stage || "").toLowerCase();
if (scene.progressPercent >= 100 || stage.includes("review")) return analysisStages.length - 1;
if (stage.includes("continuity")) return 9;
if (stage.includes("knowledge")) return 8;
if (stage.includes("relationship")) return 7;
if (stage.includes("asset")) return 6;
if (stage.includes("location")) return 5;
if (stage.includes("character")) return 4;
if (stage.includes("content") || stage.includes("inferring")) return 3;
if (stage.includes("scene")) return 2;
if (stage.includes("chapter")) return 1;
return Math.max(0, Math.min(analysisStages.length - 1, Math.floor(scene.progressPercent / 10)));
}
function updateSceneCopy(scene) {
text(dom.sceneTitle, scene.title);
text(dom.sceneSummary, scene.summary);
@ -108,7 +154,10 @@
if (!activeIds.has(node.dataset.characterId)) {
node.style.setProperty("--opacity", "0");
node.style.setProperty("--scale", "0.86");
window.setTimeout(() => node.remove(), reducedMotion ? 0 : 620);
window.setTimeout(() => {
const currentIds = new Set(loadSceneState(state.index).characters.map((character) => character.id));
if (!currentIds.has(node.dataset.characterId)) node.remove();
}, reducedMotion ? 0 : 620);
}
});
}
@ -126,14 +175,19 @@
node.dataset.locationId = scene.location.id;
node.innerHTML = `
<div class="story-location__image"><img alt="${escapeHtml(scene.location.name)} prototype location" /></div>
<div>
<div class="story-location__text">
<h2></h2>
<p></p>
</div>`;
const position = locationPosition();
node.style.setProperty("--node-size", `${position.size}px`);
node.style.setProperty("--x", `${position.x}px`);
node.style.setProperty("--y", `${position.y}px`);
node.querySelector("img").src = scene.location.imagePath;
text(node.querySelector("h2"), scene.location.name);
text(node.querySelector("p"), scene.location.label);
dom.location.append(node);
scheduleConnectionDraw();
}, changed && !reducedMotion ? 260 : 0);
}
@ -146,8 +200,11 @@
dom.assets.append(node);
}
node.style.setProperty("--y", `${(index - 1) * 2}px`);
node.style.setProperty("--scale", String(0.92 + Math.min(asset.weight, 100) / 500));
const position = assetPosition(asset, index, scene.assets.length);
node.style.setProperty("--node-size", `${position.size}px`);
node.style.setProperty("--x", `${position.x}px`);
node.style.setProperty("--y", `${position.y}px`);
node.style.setProperty("--scale", String(position.scale));
node.style.setProperty("--opacity", "1");
node.querySelector("img").src = asset.imagePath;
node.querySelector("img").alt = `${asset.name} prototype asset`;
@ -159,7 +216,10 @@
if (!activeIds.has(node.dataset.assetId)) {
node.style.setProperty("--opacity", "0");
node.style.setProperty("--scale", "0.8");
window.setTimeout(() => node.remove(), reducedMotion ? 0 : 520);
window.setTimeout(() => {
const currentIds = new Set(loadSceneState(state.index).assets.map((asset) => asset.id));
if (!currentIds.has(node.dataset.assetId)) node.remove();
}, reducedMotion ? 0 : 520);
}
});
}
@ -272,35 +332,80 @@
if (!dom.connections || !dom.visualStage) return;
const stageRect = dom.visualStage.getBoundingClientRect();
dom.connections.innerHTML = "";
const drawn = [];
scene.relationships.forEach((relationship) => {
scene.relationships.forEach((relationship, index) => {
const source = dom.visualStage.querySelector(`[data-character-id="${cssEscape(relationship.sourceId)}"]`);
const target = dom.visualStage.querySelector(`[data-character-id="${cssEscape(relationship.targetId)}"]`);
if (!source || !target) return;
const start = centrePoint(source, stageRect);
const end = centrePoint(target, stageRect);
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
const midY = Math.min(start.y, end.y) - 36;
path.setAttribute("d", `M ${start.x} ${start.y} C ${start.x} ${midY}, ${end.x} ${midY}, ${end.x} ${end.y}`);
path.setAttribute("pathLength", "1");
path.setAttribute("class", "story-exp-connection");
path.style.strokeWidth = String(1.5 + Math.min(relationship.weight, 100) / 28);
dom.connections.append(path);
const start = connectionPoint(source, stageRect, target);
const end = connectionPoint(target, stageRect, source);
drawn.push(drawConnection(start, end, relationshipTone(relationship), relationship.weight, index === 0 ? inlineRelationshipLabel(relationship) : ""));
});
const pov = dom.visualStage.querySelector(`[data-character-id="${cssEscape(scene.povCharacterId)}"]`);
const location = dom.visualStage.querySelector("[data-location-id]");
if (pov && location) {
const start = centrePoint(pov, stageRect);
const end = centrePoint(location, stageRect);
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", `M ${start.x} ${start.y} C ${start.x + 120} ${start.y + 30}, ${end.x - 110} ${end.y - 30}, ${end.x} ${end.y}`);
path.setAttribute("pathLength", "1");
path.setAttribute("class", "story-exp-connection");
path.style.stroke = "rgba(109, 181, 255, 0.42)";
dom.connections.append(path);
drawn.push(drawConnection(connectionPoint(pov, stageRect, location), connectionPoint(location, stageRect, pov), "presence", 72, ""));
}
scene.characters
.filter((character) => character.id !== scene.povCharacterId && character.weight >= 65)
.slice(0, 2)
.forEach((character) => {
const node = dom.visualStage.querySelector(`[data-character-id="${cssEscape(character.id)}"]`);
if (node && location) {
drawn.push(drawConnection(connectionPoint(node, stageRect, location), connectionPoint(location, stageRect, node), "presence-soft", character.weight, ""));
}
});
scene.assets.forEach((asset, index) => {
const assetNode = dom.visualStage.querySelector(`[data-asset-id="${cssEscape(asset.id)}"]`);
if (!assetNode || !location) return;
drawn.push(drawConnection(connectionPoint(location, stageRect, assetNode), connectionPoint(assetNode, stageRect, location), "evidence", asset.weight, index === 0 ? assetConnectionLabel(asset) : ""));
if (pov && index < 2) {
drawn.push(drawConnection(connectionPoint(pov, stageRect, assetNode), connectionPoint(assetNode, stageRect, pov), "knowledge", Math.max(45, asset.weight - 18), ""));
}
});
if (scene.assets.length > 1) {
const first = dom.visualStage.querySelector(`[data-asset-id="${cssEscape(scene.assets[0].id)}"]`);
const second = dom.visualStage.querySelector(`[data-asset-id="${cssEscape(scene.assets[1].id)}"]`);
if (first && second) {
drawn.push(drawConnection(connectionPoint(first, stageRect, second), connectionPoint(second, stageRect, first), "asset-link", 48, ""));
}
}
}
function drawConnection(start, end, tone, weight, label) {
const group = document.createElementNS("http://www.w3.org/2000/svg", "g");
group.setAttribute("class", `story-exp-connection-group story-exp-connection-group--${tone}`);
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
const dx = end.x - start.x;
const dy = end.y - start.y;
const curve = Math.max(34, Math.min(126, Math.abs(dx) * 0.24 + Math.abs(dy) * 0.1));
const bend = start.x < end.x ? curve : -curve;
const d = `M ${start.x} ${start.y} C ${start.x + bend} ${start.y - curve * 0.28}, ${end.x - bend} ${end.y + curve * 0.28}, ${end.x} ${end.y}`;
path.setAttribute("d", d);
path.setAttribute("pathLength", "1");
path.setAttribute("class", "story-exp-connection");
path.style.strokeWidth = String(1.1 + Math.min(weight, 100) / 56);
group.append(path);
if (label) {
const labelNode = document.createElementNS("http://www.w3.org/2000/svg", "text");
labelNode.setAttribute("class", "story-exp-connection-label");
labelNode.setAttribute("x", String((start.x + end.x) / 2));
labelNode.setAttribute("y", String((start.y + end.y) / 2 - 8));
labelNode.setAttribute("text-anchor", "middle");
labelNode.textContent = label;
group.append(labelNode);
}
dom.connections.append(group);
return group;
}
function createCharacterNode(character) {
@ -322,7 +427,7 @@
node.dataset.assetId = asset.id;
node.innerHTML = `
<div class="story-asset__image"><img loading="eager" /></div>
<div>
<div class="story-asset__text">
<strong data-asset-name></strong>
<span data-asset-label></span>
</div>`;
@ -330,15 +435,15 @@
}
function characterPosition(character, index, povId) {
const bounds = dom.characters?.getBoundingClientRect();
const width = Math.max(520, bounds?.width || 620);
const height = Math.max(520, bounds?.height || 620);
const povSize = Math.min(238, Math.max(206, width * 0.34));
const bounds = dom.visualStage?.getBoundingClientRect();
const width = Math.max(820, bounds?.width || 1040);
const height = Math.max(500, bounds?.height || 560);
const povSize = Math.min(210, Math.max(166, width * 0.16));
if (character.id === povId) {
return {
x: Math.round(width * 0.36 - povSize / 2),
y: Math.round(height * 0.46 - povSize / 2),
x: Math.round(width * 0.25 - povSize / 2),
y: Math.round(height * 0.25 - povSize / 2),
size: Math.round(povSize),
scale: 1,
opacity: 1
@ -348,14 +453,14 @@
const important = character.weight >= 70;
const positions = important
? [
{ x: width * 0.08, y: height * 0.18 },
{ x: width * 0.62, y: height * 0.15 },
{ x: width * 0.58, y: height * 0.58 }
{ x: width * 0.12, y: height * 0.16 },
{ x: width * 0.38, y: height * 0.38 },
{ x: width * 0.16, y: height * 0.56 }
]
: [
{ x: width * 0.12, y: height * 0.62 },
{ x: width * 0.73, y: height * 0.48 },
{ x: width * 0.06, y: height * 0.42 }
{ x: width * 0.08, y: height * 0.45 },
{ x: width * 0.39, y: height * 0.66 },
{ x: width * 0.18, y: height * 0.34 }
];
const slot = positions[index % positions.length];
const size = important
@ -370,6 +475,38 @@
};
}
function locationPosition() {
const bounds = dom.visualStage?.getBoundingClientRect();
const width = Math.max(820, bounds?.width || 1040);
const height = Math.max(500, bounds?.height || 560);
const size = Math.min(220, Math.max(176, width * 0.18));
return {
x: Math.round(width * 0.62 - size / 2),
y: Math.round(height * 0.46 - size / 2),
size
};
}
function assetPosition(asset, index, total) {
const bounds = dom.visualStage?.getBoundingClientRect();
const width = Math.max(820, bounds?.width || 1040);
const height = Math.max(500, bounds?.height || 560);
const anchors = [
{ x: 0.83, y: 0.26 },
{ x: 0.84, y: 0.51 },
{ x: 0.82, y: 0.74 },
{ x: 0.72, y: 0.19 }
];
const anchor = anchors[index % anchors.length];
const size = Math.min(116, Math.max(76, 64 + Math.min(asset.weight, 100) * 0.42));
return {
x: Math.round(width * anchor.x - size / 2),
y: Math.round(height * anchor.y - size / 2),
size: Math.round(size),
scale: 1
};
}
function centrePoint(node, parentRect) {
const rect = node.getBoundingClientRect();
return {
@ -378,6 +515,45 @@
};
}
function connectionPoint(node, parentRect, targetNode) {
const centre = centrePoint(node, parentRect);
const rect = node.getBoundingClientRect();
const target = targetNode ? centrePoint(targetNode, parentRect) : centre;
const radius = Math.min(rect.width, rect.height) * 0.44;
const angle = Math.atan2(target.y - centre.y, target.x - centre.x);
return {
x: centre.x + Math.cos(angle) * radius,
y: centre.y + Math.sin(angle) * radius
};
}
function relationshipTone(relationship) {
const stateText = `${relationship.label} ${relationship.state}`.toLowerCase();
if (stateText.includes("conflict") || stateText.includes("threat") || stateText.includes("obstruction") || stateText.includes("suspect")) return "conflict";
if (stateText.includes("trust") || stateText.includes("ally") || stateText.includes("protect")) return "support";
return relationship.weight >= 80 ? "strong" : "uncertain";
}
function inlineRelationshipLabel(relationship) {
const stateText = `${relationship.label} ${relationship.state}`.toLowerCase();
if (stateText.includes("trust")) return "trust strengthened";
if (stateText.includes("ally") || stateText.includes("alliance")) return "ally";
if (stateText.includes("conflict") || stateText.includes("threat")) return "tension increasing";
if (stateText.includes("suspect") || stateText.includes("question")) return "questions";
if (stateText.includes("family") || stateText.includes("grief")) return "family";
if (stateText.includes("motive")) return "motive exposed";
return relationship.state || relationship.label;
}
function assetConnectionLabel(asset) {
const value = `${asset.name} ${asset.label}`.toLowerCase();
if (value.includes("letter")) return "evidence";
if (value.includes("key")) return "access";
if (value.includes("tr6") || value.includes("car")) return "blocks escape";
if (value.includes("phone")) return "recorded";
return "found here";
}
function startTimer() {
stopTimer();
if (!state.playing) return;
@ -441,6 +617,8 @@
window.addEventListener("resize", () => {
const current = loadSceneState(state.index);
reconcileCharacters(current);
reconcileLocation(current, current);
reconcileAssets(current);
scheduleConnectionDraw();
});

View File

@ -0,0 +1,99 @@
# Phase 21B.2 - Story Intelligence Experience Composition Refinement
## Purpose
Phase 21B.2 refines the development-only Story Intelligence Experience prototype so the Living Story View reads as one connected narrative map rather than separate character, location and asset regions.
This phase remains visual-only. It does not connect real Story Intelligence data, add SignalR, alter the import workflow, expose production navigation, create SQL, or modify the database.
## Composition Changes
- The central map container border and background were greatly softened.
- Characters, the active location and significant assets now share a single visual canvas.
- The scene title was reduced so the map has more vertical room.
- Category labels remain only as faint positional guides.
- The background now carries a subtler star/network texture behind the entities.
## Circular Location
The active location is now rendered as a circular node with the same broad visual language as character and asset nodes:
- Circular image mask
- Soft illuminated border
- Location name and secondary description below the node
- Stable `data-location-id` connection target
The existing prototype SVG location artwork is still used and masked with `object-fit: cover`, so future curated images can replace it without changing the layout contract.
## Circular Assets
Assets in the main Living Story View now render as circular image-first nodes:
- Circular image/avatar
- Label and state text underneath
- Size influenced by simulated narrative weight
- Stable `data-asset-id` connection targets
The compact discovery feed indicators were left unchanged because they serve a different scanning purpose in the left panel.
## Connection Lines
The SVG connection layer now draws multiple current-scene connection categories:
- Character relationship
- Character present at location
- Asset found at location
- Character knowledge or evidence connection to assets
- Asset-to-asset supporting evidence link
Connections use curved paths and category-specific styling. Supportive, uncertain, conflict, presence, evidence and knowledge lines are distinguishable by colour and line style rather than colour alone.
## Inline Relationship Labels
The central map now renders one or two inline relationship labels per scene on selected character-to-character connections. These labels summarize the active relationship state, while the right-hand Relationship Changes panel continues to show the latest detected changes.
Examples include:
- `trust strengthened`
- `tension increasing`
- `family`
- `questions`
- `motive exposed`
## Analysis Stage Progress
A compact analysis-stage rail was added to the header. It advances with simulated scene state and uses live-analysis stage labels:
- Reading
- Chapters
- Scenes
- Content
- Characters
- Locations
- Assets
- Relationships
- Knowledge
- Continuity
- Finalising
The real Story Intelligence pipeline classes were inspected, but not changed. This rail is prototype-only and maps the existing simulated scene `stage` and `progressPercent` values onto a compact display.
## Responsive Behaviour
The main desktop target remains 1920 x 1080 through 1366 x 768. At narrower widths the stage rail can scroll horizontally and the existing stacked layout is retained. Full mobile optimisation remains deferred.
## Limitations
- The map positions are still authored prototype positions, not graph-solved layout.
- SVG connection labels are intentionally sparse to avoid turning the experience into a dense generic graph.
- Location and asset artwork is still prototype SVG artwork, not final curated imagery.
- No historical replay/event store exists yet.
## Deferred
- Real data binding
- Durable Story Intelligence snapshot contract
- Production route/navigation
- Final image library
- Mobile-specific composition pass
- Phase 21C real-data integration