Phase 21E.2 - Prototype Visual Polish

This commit is contained in:
Nick Beckley 2026-07-12 14:54:03 +00:00
parent 35208591fd
commit 1f984c8e1e
2 changed files with 174 additions and 82 deletions

View File

@ -496,33 +496,36 @@ input:focus-visible {
.story-exp-stage {
display: grid;
grid-template-rows: auto minmax(0, 1fr) 158px;
gap: 10px;
grid-template-rows: auto minmax(0, 1fr) 154px;
gap: 8px;
min-height: 0;
}
.story-exp-stage-copy {
display: grid;
grid-template-columns: 1fr minmax(240px, 0.72fr);
align-items: end;
gap: 22px;
min-height: 62px;
grid-template-columns: minmax(260px, 0.64fr) 1fr;
align-items: center;
gap: 18px;
min-height: 42px;
}
.story-exp-stage-copy h1 {
grid-column: 1;
margin: 0;
font-size: clamp(1.55rem, 2.35vw, 2.75rem);
line-height: 1.05;
overflow: hidden;
font-size: clamp(1rem, 1.2vw, 1.34rem);
line-height: 1.12;
letter-spacing: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.story-exp-stage-copy p:last-child {
grid-column: 2;
margin: 0;
color: var(--story-soft);
font-size: clamp(0.86rem, 1vw, 1rem);
line-height: 1.38;
font-size: clamp(0.78rem, 0.86vw, 0.92rem);
line-height: 1.28;
}
.story-exp-visual {
@ -565,51 +568,57 @@ input:focus-visible {
}
.story-exp-connection {
stroke: rgba(215, 168, 93, 0.5);
stroke: rgba(215, 168, 93, 0.48);
stroke-linecap: round;
stroke-width: 1.8;
stroke-dasharray: 1;
stroke-dashoffset: 1;
fill: none;
filter: drop-shadow(0 0 7px rgba(215, 168, 93, 0.22));
animation: drawLine 950ms cubic-bezier(.16, .84, .28, 1) forwards;
filter: drop-shadow(0 0 5px rgba(215, 168, 93, 0.18));
animation: drawLine 760ms 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);
stroke: rgba(125, 193, 255, 0.52);
}
.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;
stroke-dasharray: 0.16 0.12;
}
.story-exp-connection-group--support .story-exp-connection {
stroke: rgba(121, 210, 166, 0.64);
stroke: rgba(121, 210, 166, 0.58);
}
.story-exp-connection-group--conflict .story-exp-connection {
stroke: rgba(227, 140, 168, 0.76);
stroke-dasharray: 0.18 0.1;
stroke: rgba(227, 140, 168, 0.7);
stroke-dasharray: 0.2 0.12;
}
.story-exp-connection-group--uncertain .story-exp-connection {
stroke: rgba(182, 162, 255, 0.56);
stroke-dasharray: 0.08 0.09;
stroke: rgba(182, 162, 255, 0.48);
stroke-dasharray: 0.12 0.11;
}
.story-exp-connection-group--knowledge .story-exp-connection {
stroke: rgba(182, 162, 255, 0.5);
stroke: rgba(182, 162, 255, 0.44);
}
.story-exp-connection-label-bg {
fill: rgba(4, 10, 20, 0.72);
stroke: rgba(151, 185, 218, 0.12);
stroke-width: 1px;
}
.story-exp-connection-label {
paint-order: stroke;
stroke: rgba(4, 10, 20, 0.92);
stroke-width: 6px;
stroke: rgba(4, 10, 20, 0.68);
stroke-width: 3px;
fill: var(--story-soft);
font-size: 0.68rem;
font-size: 0.66rem;
font-weight: 800;
letter-spacing: 0.01em;
text-transform: lowercase;
@ -857,8 +866,8 @@ input:focus-visible {
position: absolute;
display: grid;
justify-items: center;
gap: 7px;
width: max(var(--node-size, 86px), 112px);
gap: 6px;
width: max(var(--node-size, 78px), 104px);
opacity: var(--opacity, 1);
transform: translate(var(--x, 0), var(--y, 0)) scale(var(--scale, 1));
z-index: var(--z, 3);
@ -867,21 +876,21 @@ input:focus-visible {
}
.story-asset__image {
width: var(--node-size, 86px);
height: var(--node-size, 86px);
width: var(--node-size, 78px);
height: var(--node-size, 78px);
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);
box-shadow: 0 0 0 4px rgba(215, 168, 93, 0.08), 0 18px 38px rgba(0, 0, 0, 0.28), 0 0 24px rgba(215, 168, 93, 0.13);
}
.story-asset__text {
display: grid;
justify-items: center;
gap: 2px;
width: min(148px, calc(var(--node-size, 86px) + 52px));
width: min(140px, calc(var(--node-size, 78px) + 52px));
border: 1px solid rgba(215, 168, 93, 0.11);
border-radius: 10px;
padding: 5px 7px;
padding: 4px 7px 5px;
background: rgba(4, 10, 20, 0.6);
box-shadow: 0 12px 26px rgba(0, 0, 0, 0.22);
backdrop-filter: blur(8px);
@ -899,12 +908,12 @@ input:focus-visible {
}
.story-asset strong {
font-size: 0.82rem;
font-size: 0.78rem;
}
.story-asset span {
color: var(--story-muted);
font-size: 0.68rem;
font-size: 0.64rem;
}
.story-exp-ribbon-wrap {

View File

@ -131,7 +131,7 @@
}
function updateSceneCopy(scene) {
text(dom.sceneTitle, scene.title);
text(dom.sceneTitle, `Scene ${scene.sceneNumber}${scene.title}`);
text(dom.sceneSummary, scene.summary);
}
@ -418,7 +418,7 @@
const stageRect = dom.visualStage.getBoundingClientRect();
dom.connections.innerHTML = "";
const drawn = [];
const occupiedRects = readableRects(stageRect);
const occupiedRects = layoutRects(stageRect);
scene.relationships.forEach((relationship, index) => {
const source = dom.visualStage.querySelector(`[data-character-id="${cssEscape(relationship.sourceId)}"]`);
@ -427,65 +427,69 @@
const start = connectionPoint(source, stageRect, target);
const end = connectionPoint(target, stageRect, source);
drawn.push(drawConnection(start, end, relationshipTone(relationship), relationship.weight, index < 2 ? inlineRelationshipLabel(relationship) : "", index, occupiedRects));
const obstacles = connectionObstacles(stageRect, source, target);
drawn.push(drawConnection(start, end, relationshipTone(relationship), relationship.weight, index < 2 ? inlineRelationshipLabel(relationship) : "", index, obstacles, occupiedRects));
});
const pov = dom.visualStage.querySelector(`[data-character-id="${cssEscape(scene.povCharacterId)}"]`);
const location = dom.visualStage.querySelector("[data-location-id]");
if (pov && location) {
drawn.push(drawConnection(connectionPoint(pov, stageRect, location), connectionPoint(location, stageRect, pov), "presence", 72, "", 0, occupiedRects));
drawn.push(drawConnection(connectionPoint(pov, stageRect, location), connectionPoint(location, stageRect, pov), "presence", 68, "", drawn.length, connectionObstacles(stageRect, pov, location), occupiedRects));
}
scene.characters
.filter((character) => character.id !== scene.povCharacterId && character.weight >= 65)
.slice(0, 2)
.slice(0, 1)
.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, "", drawn.length, occupiedRects));
drawn.push(drawConnection(connectionPoint(node, stageRect, location), connectionPoint(location, stageRect, node), "presence-soft", character.weight, "", drawn.length, connectionObstacles(stageRect, node, location), occupiedRects));
}
});
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) : "", drawn.length, occupiedRects));
if (pov && index < 2) {
drawn.push(drawConnection(connectionPoint(pov, stageRect, assetNode), connectionPoint(assetNode, stageRect, pov), "knowledge", Math.max(45, asset.weight - 18), "", drawn.length, occupiedRects));
}
drawn.push(drawConnection(connectionPoint(location, stageRect, assetNode), connectionPoint(assetNode, stageRect, location), "evidence", asset.weight, index === 0 ? assetConnectionLabel(asset) : "", drawn.length, connectionObstacles(stageRect, location, assetNode), occupiedRects));
});
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, "", drawn.length, occupiedRects));
}
}
}
function drawConnection(start, end, tone, weight, label, index, occupiedRects) {
function drawConnection(start, end, tone, weight, label, index, obstacles, occupiedRects) {
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 route = routeConnection(start, end, index, occupiedRects);
const d = `M ${start.x} ${start.y} Q ${route.control.x} ${route.control.y}, ${end.x} ${end.y}`;
const route = routeConnection(start, end, index, obstacles);
const d = `M ${start.x} ${start.y} C ${route.startControl.x} ${route.startControl.y}, ${route.endControl.x} ${route.endControl.y}, ${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);
path.style.strokeWidth = String(0.9 + Math.min(weight, 100) / 62);
group.append(path);
if (label) {
const point = pointOnQuadratic(start, route.control, end, 0.52);
const labelPosition = relationshipLabelPosition(start, route, end, occupiedRects);
if (!labelPosition) {
dom.connections.append(group);
return group;
}
const labelGroup = document.createElementNS("http://www.w3.org/2000/svg", "g");
labelGroup.setAttribute("class", "story-exp-connection-label-group");
const labelNode = document.createElementNS("http://www.w3.org/2000/svg", "text");
labelNode.setAttribute("class", "story-exp-connection-label");
labelNode.setAttribute("x", String(point.x + route.labelOffset.x));
labelNode.setAttribute("y", String(point.y + route.labelOffset.y));
labelNode.setAttribute("x", String(labelPosition.x));
labelNode.setAttribute("y", String(labelPosition.y));
labelNode.setAttribute("text-anchor", "middle");
labelNode.textContent = label;
group.append(labelNode);
labelGroup.append(labelNode);
group.append(labelGroup);
occupiedRects.push({
left: labelPosition.x - 78,
right: labelPosition.x + 78,
top: labelPosition.y - 22,
bottom: labelPosition.y + 10
});
window.requestAnimationFrame(() => fitConnectionLabelBackground(labelGroup));
}
dom.connections.append(group);
@ -651,7 +655,7 @@
{ 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));
const size = Math.min(104, Math.max(68, (64 + Math.min(asset.weight, 100) * 0.42) * 0.9));
return {
x: Math.round(width * anchor.x - size / 2),
y: Math.round(height * anchor.y - size / 2),
@ -676,8 +680,8 @@
const anchor = anchors[index] || anchors[anchors.length - 1];
const important = index === 0 || asset.weight >= 90;
const size = Math.round(important
? Math.min(116, Math.max(92, 70 + Math.min(asset.weight, 100) * 0.42))
: Math.min(98, Math.max(76, 58 + Math.min(asset.weight, 100) * 0.32)));
? Math.min(104, Math.max(83, (70 + Math.min(asset.weight, 100) * 0.42) * 0.9))
: Math.min(88, Math.max(68, (58 + Math.min(asset.weight, 100) * 0.32) * 0.9)));
const desired = {
x: width * anchor.x - size / 2,
y: height * anchor.y - size / 2
@ -741,7 +745,7 @@
const target = targetNode ? centrePoint(targetNode, parentRect) : centre;
const targetImage = targetNode?.querySelector?.(".story-character__image, .story-location__image, .story-asset__image");
const targetCentre = targetImage ? centrePoint(targetImage, parentRect) : target;
const radius = Math.min(rect.width, rect.height) * 0.5 + 3;
const radius = Math.min(rect.width, rect.height) * 0.5;
const angle = Math.atan2(targetCentre.y - centre.y, targetCentre.x - centre.x);
return {
x: centre.x + Math.cos(angle) * radius,
@ -749,43 +753,67 @@
};
}
function routeConnection(start, end, index, occupiedRects) {
function routeConnection(start, end, index, obstacles) {
const mid = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
const dx = end.x - start.x;
const dy = end.y - start.y;
const length = Math.max(1, Math.hypot(dx, dy));
const normal = { x: -dy / length, y: dx / length };
const base = Math.max(28, Math.min(118, length * 0.18));
const tangent = { x: dx / length, y: dy / length };
const base = Math.max(34, Math.min(122, length * 0.2));
const direction = index % 2 === 0 ? 1 : -1;
const offsets = [direction * base, -direction * base, direction * base * 1.55, -direction * base * 1.55, 0];
const offsets = [direction * base, -direction * base, direction * base * 1.45, -direction * base * 1.45, 0];
let best = offsets[0];
let bestScore = Number.POSITIVE_INFINITY;
offsets.forEach((offset) => {
const control = { x: mid.x + normal.x * offset, y: mid.y + normal.y * offset };
const samples = [0.28, 0.5, 0.72].map((t) => pointOnQuadratic(start, control, end, t));
const score = samples.reduce((total, point) => total + occupiedRects.reduce((sum, rect) => sum + pointRectPenalty(point, rect), 0), Math.abs(offset) * 0.05);
const controls = cubicControls(start, end, tangent, normal, offset, length);
const samples = [0.18, 0.33, 0.5, 0.67, 0.82].map((t) => pointOnCubic(start, controls.startControl, controls.endControl, end, t));
const score = samples.reduce((total, point) => total + obstacles.reduce((sum, rect) => sum + pointRectPenalty(point, rect), 0), Math.abs(offset) * 0.04);
if (score < bestScore) {
bestScore = score;
best = offset;
}
});
return cubicControls(start, end, tangent, normal, best, length);
}
function cubicControls(start, end, tangent, normal, offset, length) {
const pull = Math.max(52, Math.min(150, length * 0.34));
return {
control: { x: mid.x + normal.x * best, y: mid.y + normal.y * best },
labelOffset: { x: normal.x * Math.sign(best || direction) * 14, y: normal.y * Math.sign(best || direction) * 14 - 6 }
startControl: {
x: start.x + tangent.x * pull + normal.x * offset,
y: start.y + tangent.y * pull + normal.y * offset
},
endControl: {
x: end.x - tangent.x * pull + normal.x * offset,
y: end.y - tangent.y * pull + normal.y * offset
},
normal,
offset
};
}
function readableRects(parentRect) {
return [...dom.visualStage.querySelectorAll(".story-character__text, .story-location__text, .story-asset__text")]
.map((node) => {
const rect = node.getBoundingClientRect();
return {
left: rect.left - parentRect.left - 10,
right: rect.right - parentRect.left + 10,
top: rect.top - parentRect.top - 6,
bottom: rect.bottom - parentRect.top + 6
};
});
function layoutRects(parentRect) {
return [...dom.visualStage.querySelectorAll(".story-character__image, .story-location__image, .story-asset__image, .story-character__text, .story-location__text, .story-asset__text")]
.map((node) => rectRelativeTo(node, parentRect, node.className.includes("__text") ? 8 : 10));
}
function connectionObstacles(parentRect, source, target) {
const sourceImage = source.querySelector(".story-character__image, .story-location__image, .story-asset__image") || source;
const targetImage = target.querySelector(".story-character__image, .story-location__image, .story-asset__image") || target;
return [...dom.visualStage.querySelectorAll(".story-character__image, .story-location__image, .story-asset__image, .story-character__text, .story-location__text, .story-asset__text")]
.filter((node) => node !== sourceImage && node !== targetImage)
.map((node) => rectRelativeTo(node, parentRect, node.className.includes("__text") ? 8 : 12));
}
function rectRelativeTo(node, parentRect, padding) {
const rect = node.getBoundingClientRect();
return {
left: rect.left - parentRect.left - padding,
right: rect.right - parentRect.left + padding,
top: rect.top - parentRect.top - padding,
bottom: rect.bottom - parentRect.top + padding
};
}
function pointOnQuadratic(start, control, end, t) {
@ -796,6 +824,61 @@
};
}
function pointOnCubic(start, startControl, endControl, end, t) {
const inverse = 1 - t;
return {
x: inverse ** 3 * start.x + 3 * inverse ** 2 * t * startControl.x + 3 * inverse * t ** 2 * endControl.x + t ** 3 * end.x,
y: inverse ** 3 * start.y + 3 * inverse ** 2 * t * startControl.y + 3 * inverse * t ** 2 * endControl.y + t ** 3 * end.y
};
}
function relationshipLabelPosition(start, route, end, occupiedRects) {
const sign = Math.sign(route.offset || 1);
const candidates = [0.42, 0.5, 0.58, 0.34, 0.66].flatMap((t) => {
const point = pointOnCubic(start, route.startControl, route.endControl, end, t);
return [34, 54, 76, -42, -64].map((distance) => ({
x: point.x + route.normal.x * sign * distance,
y: point.y + route.normal.y * sign * distance - 5
}));
});
const best = candidates.reduce((current, candidate) => {
const score = occupiedRects.reduce((sum, rect) => sum + labelRectPenalty(candidate, rect), 0);
return score < current.score ? { point: candidate, score } : current;
}, { point: candidates[0], score: Number.POSITIVE_INFINITY });
return best.score >= 1000 ? null : best.point;
}
function labelRectPenalty(point, rect) {
const labelRect = {
left: point.x - 70,
right: point.x + 70,
top: point.y - 18,
bottom: point.y + 8
};
if (rectsIntersect(labelRect, rect)) return 1000;
const dx = Math.max(rect.left - labelRect.right, 0, labelRect.left - rect.right);
const dy = Math.max(rect.top - labelRect.bottom, 0, labelRect.top - rect.bottom);
return Math.max(0, 30 - Math.hypot(dx, dy));
}
function rectsIntersect(a, b) {
return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
}
function fitConnectionLabelBackground(group) {
const label = group.querySelector(".story-exp-connection-label");
if (!label || group.querySelector("rect")) return;
const box = label.getBBox();
const background = document.createElementNS("http://www.w3.org/2000/svg", "rect");
background.setAttribute("class", "story-exp-connection-label-bg");
background.setAttribute("x", String(box.x - 8));
background.setAttribute("y", String(box.y - 4));
background.setAttribute("width", String(box.width + 16));
background.setAttribute("height", String(box.height + 8));
background.setAttribute("rx", "7");
group.insertBefore(background, label);
}
function pointRectPenalty(point, rect) {
if (point.x >= rect.left && point.x <= rect.right && point.y >= rect.top && point.y <= rect.bottom) return 1000;
const dx = Math.max(rect.left - point.x, 0, point.x - rect.right);