avatar support next to messages
All checks were successful
Build Org Website / build (push) Successful in 50s

This commit is contained in:
2026-05-11 16:21:21 +01:00
parent ad8cd407e2
commit 92093b9351

View File

@@ -12,6 +12,7 @@ import subprocess
import sys import sys
import threading import threading
import time import time
import mimetypes
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from email.parser import BytesParser from email.parser import BytesParser
@@ -148,6 +149,18 @@ CHARACTER_REGISTRY = {
"displayLabel": "young z", "displayLabel": "young z",
"aliases": ["Young Z", "young z", "young-z", "young_z", "young"], "aliases": ["Young Z", "young z", "young-z", "young_z", "young"],
"territoryColor": "#d8a95a", "territoryColor": "#d8a95a",
"glow": "#f0b85c",
"avatar": "/assets/avatars/young-z.jpeg",
"fallbackAvatar": "/assets/avatars/z.jpeg",
"expressions": {
"default": "/assets/avatars/young-z.jpeg",
"nostalgic": "/assets/avatars/young-z.jpeg",
"playful": "/assets/avatars/young-z.jpeg",
},
"sizes": {"tiny": 18, "medium": 42, "close": 56, "focus": 82},
"observatory": {"territory": "nostalgic/playful memories", "anchor": {"x": 0.23, "y": 0.7}, "shimmer": "soft"},
"presenceTones": ["nostalgic", "funny", "hopeful"],
"presenceKeywords": ["childhood", "play", "desk", "crayon", "young", "small", "blanket", "memory"],
"symbol": "Y", "symbol": "Y",
"motifs": ["crayon sun", "blanket cape", "childhood desk"], "motifs": ["crayon sun", "blanket cape", "childhood desk"],
"themes": ["childhood", "play", "memory", "safety"], "themes": ["childhood", "play", "memory", "safety"],
@@ -158,6 +171,14 @@ CHARACTER_REGISTRY = {
"displayLabel": "z", "displayLabel": "z",
"aliases": ["Z", "z", "zaine"], "aliases": ["Z", "z", "zaine"],
"territoryColor": "#d6c38a", "territoryColor": "#d6c38a",
"glow": "#ead68e",
"avatar": "/assets/avatars/z.jpeg",
"fallbackAvatar": "/assets/avatars/z.jpeg",
"expressions": {"default": "/assets/avatars/z.jpeg", "calm": "/assets/avatars/z.jpeg"},
"sizes": {"tiny": 18, "medium": 42, "close": 56, "focus": 82},
"observatory": {"territory": "self/archive memories", "anchor": {"x": 0.5, "y": 0.52}, "shimmer": "steady"},
"presenceTones": ["warm", "soft", "hopeful"],
"presenceKeywords": ["website", "archive", "home", "self", "making", "return", "zaine"],
"symbol": "Z", "symbol": "Z",
"motifs": ["archive", "website", "home"], "motifs": ["archive", "website", "home"],
"themes": ["selfhood", "return", "making"], "themes": ["selfhood", "return", "making"],
@@ -168,6 +189,18 @@ CHARACTER_REGISTRY = {
"displayLabel": "aphy", "displayLabel": "aphy",
"aliases": ["Aphy", "aphy", "aphy_bot", "aphy bot"], "aliases": ["Aphy", "aphy", "aphy_bot", "aphy bot"],
"territoryColor": "#7fb089", "territoryColor": "#7fb089",
"glow": "#9dd3a6",
"avatar": "/assets/avatars/aphy.jpeg",
"fallbackAvatar": "/assets/avatars/z.jpeg",
"expressions": {
"default": "/assets/avatars/aphy.jpeg",
"system": "/assets/avatars/aphy.jpeg",
"amused": "/assets/avatars/aphy.jpeg",
},
"sizes": {"tiny": 18, "medium": 42, "close": 56, "focus": 82},
"observatory": {"territory": "system/symbolic areas", "anchor": {"x": 0.77, "y": 0.35}, "shimmer": "diagnostic"},
"presenceTones": ["funny", "strange", "hopeful"],
"presenceKeywords": ["system", "console", "diagnostic", "keyboard", "search", "terminal", "backup", "symbolic", "query"],
"symbol": "A", "symbol": "A",
"motifs": ["console", "diagnostic", "backup"], "motifs": ["console", "diagnostic", "backup"],
"themes": ["humor", "systems", "care through tools"], "themes": ["humor", "systems", "care through tools"],
@@ -178,6 +211,19 @@ CHARACTER_REGISTRY = {
"displayLabel": "lima", "displayLabel": "lima",
"aliases": ["Lima", "lima"], "aliases": ["Lima", "lima"],
"territoryColor": "#d06b78", "territoryColor": "#d06b78",
"glow": "#f2c58b",
"avatar": "/assets/avatars/lima.jpg",
"fallbackAvatar": "/assets/avatars/z.jpeg",
"expressions": {
"default": "/assets/avatars/lima.jpg",
"calm": "/assets/avatars/lima.jpg",
"warm": "/assets/avatars/lima.jpg",
"protective": "/assets/avatars/lima.jpg",
},
"sizes": {"tiny": 18, "medium": 42, "close": 56, "focus": 82},
"observatory": {"territory": "warm/protective arcs", "anchor": {"x": 0.29, "y": 0.36}, "shimmer": "warm"},
"presenceTones": ["warm", "protective", "soft"],
"presenceKeywords": ["lima", "love", "warm", "kitchen", "light", "ring", "home", "protect", "eat"],
"symbol": "L", "symbol": "L",
"motifs": ["warmth", "kitchen light", "ring"], "motifs": ["warmth", "kitchen light", "ring"],
"themes": ["love", "home", "grounding"], "themes": ["love", "home", "grounding"],
@@ -188,6 +234,18 @@ CHARACTER_REGISTRY = {
"displayLabel": "sensei chi", "displayLabel": "sensei chi",
"aliases": ["Sensei Chi", "sensei chi", "sensei-chi", "sensei_chi", "sensei"], "aliases": ["Sensei Chi", "sensei chi", "sensei-chi", "sensei_chi", "sensei"],
"territoryColor": "#75a9bd", "territoryColor": "#75a9bd",
"glow": "#9ccddd",
"avatar": "/assets/avatars/sensei-chi.jpeg",
"fallbackAvatar": "/assets/avatars/z.jpeg",
"expressions": {
"default": "/assets/avatars/sensei-chi.jpeg",
"reflective": "/assets/avatars/sensei-chi.jpeg",
"wise": "/assets/avatars/sensei-chi.jpeg",
},
"sizes": {"tiny": 18, "medium": 42, "close": 56, "focus": 82},
"observatory": {"territory": "reflective areas", "anchor": {"x": 0.68, "y": 0.68}, "shimmer": "quiet"},
"presenceTones": ["wise", "melancholy", "soft"],
"presenceKeywords": ["reflection", "patience", "wisdom", "lesson", "tea", "garden", "quiet", "sensei"],
"symbol": "S", "symbol": "S",
"motifs": ["tea", "garden", "quiet lesson"], "motifs": ["tea", "garden", "quiet lesson"],
"themes": ["reflection", "patience", "wisdom"], "themes": ["reflection", "patience", "wisdom"],
@@ -198,6 +256,18 @@ CHARACTER_REGISTRY = {
"displayLabel": "future z", "displayLabel": "future z",
"aliases": ["Future Z", "future z", "future-z", "future_z", "future"], "aliases": ["Future Z", "future z", "future-z", "future_z", "future"],
"territoryColor": "#a58ac9", "territoryColor": "#a58ac9",
"glow": "#c2a4ee",
"avatar": "/assets/avatars/future-z.jpeg",
"fallbackAvatar": "/assets/avatars/z.jpeg",
"expressions": {
"default": "/assets/avatars/future-z.jpeg",
"temporal": "/assets/avatars/future-z.jpeg",
"reassuring": "/assets/avatars/future-z.jpeg",
},
"sizes": {"tiny": 18, "medium": 42, "close": 56, "focus": 82},
"observatory": {"territory": "temporal regions", "anchor": {"x": 0.73, "y": 0.18}, "shimmer": "temporal"},
"presenceTones": ["hopeful", "melancholy", "wise"],
"presenceKeywords": ["future", "time", "clock", "older", "tomorrow", "age", "reassurance", "continuity"],
"symbol": "F", "symbol": "F",
"motifs": ["clock", "age 40", "future log"], "motifs": ["clock", "age 40", "future log"],
"themes": ["time", "reassurance", "continuity"], "themes": ["time", "reassurance", "continuity"],
@@ -2533,6 +2603,42 @@ HIDDEN_APP_HTML = r"""<!doctype html>
background: rgba(251, 241, 220, 0.08); background: rgba(251, 241, 220, 0.08);
font-size: 12px; font-size: 12px;
} }
.avatar-pill, .avatar-row, .character-tag {
display: inline-flex;
align-items: center;
gap: 7px;
}
.avatar-thumb {
width: var(--avatar-size, 28px);
height: var(--avatar-size, 28px);
flex: 0 0 var(--avatar-size, 28px);
border-radius: 50%;
object-fit: cover;
border: 1px solid rgba(255, 246, 223, 0.72);
background: rgba(251, 241, 220, 0.08);
box-shadow: 0 0 18px color-mix(in srgb, var(--avatar-glow, #d3a64d) 42%, transparent);
}
.avatar-row {
width: 100%;
justify-content: space-between;
margin-top: 10px;
padding: 8px;
border: 1px solid rgba(232, 202, 139, 0.15);
border-radius: 8px;
background: rgba(251, 241, 220, 0.055);
}
.avatar-row .subtle { font-size: 12px; }
.character-tag {
min-height: 32px;
border: 1px solid rgba(232, 202, 139, 0.2);
border-radius: 999px;
padding: 3px 9px 3px 4px;
background: rgba(251, 241, 220, 0.075);
color: #f3ddb2;
font-size: 12px;
font-weight: 800;
}
.character-tag .avatar-thumb { --avatar-size: 24px; }
.layer-key { display: grid; gap: 8px; margin-top: 14px; } .layer-key { display: grid; gap: 8px; margin-top: 14px; }
.layer-row { .layer-row {
display: grid; display: grid;
@@ -2576,12 +2682,19 @@ HIDDEN_APP_HTML = r"""<!doctype html>
.memory-node { cursor: grab; transition: opacity 160ms ease, filter 180ms ease; } .memory-node { cursor: grab; transition: opacity 160ms ease, filter 180ms ease; }
.memory-node:active { cursor: grabbing; } .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 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 circle { stroke: rgba(255, 246, 223, 0.82); stroke-width: 1.4; filter: drop-shadow(0 0 7px rgba(255, 232, 177, 0.18)); transition: r 160ms ease, stroke 160ms ease, opacity 160ms ease; } .memory-node .node-shell, .memory-node .node-portrait, .memory-node .node-marker {
.memory-node.selected circle { stroke: #fff3c7; stroke-width: 3; } stroke: rgba(255, 246, 223, 0.82);
.memory-node.dragging circle { stroke: #f3cd7a; stroke-width: 3; opacity: 0.72; } stroke-width: 1.4;
.memory-node.compatible circle { stroke: rgba(255, 243, 199, 0.92); stroke-width: 2.4; filter: drop-shadow(0 0 16px rgba(255, 232, 177, 0.46)); } filter: drop-shadow(0 0 7px var(--node-glow, rgba(255, 232, 177, 0.18)));
.memory-node.drop-target circle { stroke: #fff5c9; stroke-width: 4; filter: drop-shadow(0 0 24px rgba(255, 232, 177, 0.7)); } transition: r 160ms ease, stroke 160ms ease, opacity 160ms ease, filter 180ms ease;
.memory-node.invalid-drop circle { stroke: rgba(208, 107, 120, 0.9); stroke-width: 3; } }
.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; }
.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)); }
.memory-node.invalid-drop .node-shell, .memory-node.invalid-drop .node-portrait { stroke: rgba(208, 107, 120, 0.9); stroke-width: 3; }
.cluster-node { cursor: pointer; transition: transform 160ms ease, filter 160ms ease; } .cluster-node { cursor: pointer; transition: transform 160ms ease, filter 160ms ease; }
.cluster-node circle { .cluster-node circle {
stroke: rgba(255, 243, 199, 0.75); stroke: rgba(255, 243, 199, 0.75);
@@ -2600,6 +2713,10 @@ HIDDEN_APP_HTML = r"""<!doctype html>
} }
.cluster-node .count { font-size: 18px; font-weight: 850; } .cluster-node .count { font-size: 18px; font-weight: 850; }
.cluster-halo { fill: rgba(211, 166, 77, 0.08); stroke: rgba(211, 166, 77, 0.22); stroke-dasharray: 3 8; } .cluster-halo { fill: rgba(211, 166, 77, 0.08); stroke: rgba(211, 166, 77, 0.22); stroke-dasharray: 3 8; }
.territory-anchor { pointer-events: none; opacity: 0.72; }
.territory-anchor circle { fill: rgba(251, 241, 220, 0.035); stroke-width: 1.2; stroke-dasharray: 5 11; }
.territory-anchor image { opacity: 0.88; }
.territory-anchor text { fill: rgba(248, 234, 209, 0.66); font-size: 18px; paint-order: stroke; stroke: rgba(14, 11, 8, 0.78); stroke-width: 3px; }
.drag-preview-line { stroke: rgba(255, 232, 177, 0.78); stroke-width: 3; stroke-dasharray: 8 8; pointer-events: none; } .drag-preview-line { stroke: rgba(255, 232, 177, 0.78); stroke-width: 3; stroke-dasharray: 8 8; pointer-events: none; }
.drag-preview-halo { fill: rgba(211, 166, 77, 0.1); stroke: rgba(255, 232, 177, 0.58); stroke-width: 2; stroke-dasharray: 4 7; pointer-events: none; } .drag-preview-halo { fill: rgba(211, 166, 77, 0.1); stroke: rgba(255, 232, 177, 0.58); stroke-width: 2; stroke-dasharray: 4 7; pointer-events: none; }
.drag-preview-label rect { fill: rgba(18, 16, 13, 0.86); stroke: rgba(255, 232, 177, 0.42); rx: 7; } .drag-preview-label rect { fill: rgba(18, 16, 13, 0.86); stroke: rgba(255, 232, 177, 0.42); rx: 7; }
@@ -2744,6 +2861,15 @@ HIDDEN_APP_HTML = r"""<!doctype html>
0%, 100% { opacity: 0.72; } 0%, 100% { opacity: 0.72; }
50% { opacity: 1; } 50% { opacity: 1; }
} }
@keyframes avatar-breathe {
0%, 100% { transform: scale(1); opacity: 0.92; }
50% { transform: scale(1.035); opacity: 1; }
}
@keyframes constellation-shimmer {
0%, 100% { opacity: 0.48; }
50% { opacity: 0.82; }
}
.constellation-shimmer { animation: constellation-shimmer 9s ease-in-out infinite; }
.core-glow { animation: breathe 7s ease-in-out infinite; } .core-glow { animation: breathe 7s ease-in-out infinite; }
@media (max-width: 1120px) { @media (max-width: 1120px) {
body { overflow: auto; } body { overflow: auto; }
@@ -2807,6 +2933,10 @@ HIDDEN_APP_HTML = r"""<!doctype html>
<button data-tour="connected" type="button">show emotionally connected nodes</button> <button data-tour="connected" type="button">show emotionally connected nodes</button>
</div> </div>
</div> </div>
<div class="guide-card">
<h2>Character Territories</h2>
<div id="characterAtlas" class="stack"></div>
</div>
<div class="guide-card"> <div class="guide-card">
<h2>Integrity</h2> <h2>Integrity</h2>
<div id="integrityStatus" class="subtle">checking character territories.</div> <div id="integrityStatus" class="subtle">checking character territories.</div>
@@ -2845,6 +2975,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
<section class="panel"> <section class="panel">
<h2 id="drawerTitle">Choose a memory</h2> <h2 id="drawerTitle">Choose a memory</h2>
<div id="drawerSub" class="subtle">The side panel opens from the constellation.</div> <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> <div id="preview" class="preview" style="margin-top:10px"></div>
</section> </section>
<section class="panel" style="margin-top:12px"> <section class="panel" style="margin-top:12px">
@@ -2854,6 +2985,8 @@ HIDDEN_APP_HTML = r"""<!doctype html>
<label>Kind<select id="type"></select></label> <label>Kind<select id="type"></select></label>
<label>Depth<select id="familyLayer"></select></label> <label>Depth<select id="familyLayer"></select></label>
<label>Characters<input id="characters" placeholder="lima, aphy" /></label> <label>Characters<input id="characters" placeholder="lima, aphy" /></label>
<div id="characterTags" class="full pills"></div>
<div id="presencePreview" class="full subtle"></div>
<label>Tone<select id="tone"></select></label> <label>Tone<select id="tone"></select></label>
<label>Rarity<select id="rarity"></select></label> <label>Rarity<select id="rarity"></select></label>
<label>Discovery<select id="discoveryDifficulty" placeholder="gentle, patient, hidden" /></label> <label>Discovery<select id="discoveryDifficulty" placeholder="gentle, patient, hidden" /></label>
@@ -2906,6 +3039,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
const fields = ["title","type","familyLayer","characters","tone","rarity","discoveryDifficulty","mysteryLevel","resonanceScore","pageLocation","triggerConditions","content","symbols","narrativeArcs","tags","emotionalRole","continuationLinks","echoes","thematicLinks","symbolicLinks","triggerLinks","parentLinks","childLinks","mirroredEntries","enabled","cssClassHooks","audioSettings","animationTrigger","notes"]; const fields = ["title","type","familyLayer","characters","tone","rarity","discoveryDifficulty","mysteryLevel","resonanceScore","pageLocation","triggerConditions","content","symbols","narrativeArcs","tags","emotionalRole","continuationLinks","echoes","thematicLinks","symbolicLinks","triggerLinks","parentLinks","childLinks","mirroredEntries","enabled","cssClassHooks","audioSettings","animationTrigger","notes"];
const $ = (id) => document.getElementById(id); 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 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";
function html(value) { function html(value) {
return String(value || "").replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char])); return String(value || "").replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char]));
@@ -2913,6 +3047,85 @@ HIDDEN_APP_HTML = r"""<!doctype html>
function splitList(value) { function splitList(value) {
return String(value || "").split(",").map((item) => item.trim()).filter(Boolean); return String(value || "").split(",").map((item) => item.trim()).filter(Boolean);
} }
function avatarRegistry() {
return state.meta.characterRegistry || {};
}
function characterConfig(character) {
const registry = avatarRegistry();
return registry[character] || registry.z || {};
}
function characterLabel(character) {
return characterConfig(character).displayLabel || character || "z";
}
function avatarPath(character, expression = "default") {
const config = characterConfig(character);
const expressions = config.expressions || {};
return expressions[expression] || expressions.default || config.avatar || config.fallbackAvatar || defaultAvatarPath;
}
function avatarGlow(character) {
const config = characterConfig(character);
return config.glow || config.territoryColor || "#d3a64d";
}
function avatarSize(character, size = "medium") {
const sizes = characterConfig(character).sizes || {};
return Number(sizes[size] || sizes.medium || 42);
}
function avatarExpressionForEntry(entry) {
const tone = entry?.emotionalTone || "";
if (tone === "protective") return "protective";
if (tone === "wise") return "wise";
if (tone === "nostalgic") return "nostalgic";
if (tone === "funny") return "amused";
if (String(entry?.type || "").includes("system")) return "system";
if (String(entry?.type || "").includes("future")) return "temporal";
return "default";
}
function avatarImg(character, size = "medium", expression = "default", className = "") {
const px = avatarSize(character, size);
const label = characterLabel(character);
return `<img class="avatar-thumb ${html(className)}" src="${html(avatarPath(character, expression))}" alt="${html(label)} portrait" loading="lazy" style="--avatar-size:${px}px;--avatar-glow:${html(avatarGlow(character))}" onerror="this.onerror=null;this.src='${html(characterConfig(character).fallbackAvatar || defaultAvatarPath)}'">`;
}
function entryTextCorpus(entry) {
return [
entry.title,
entry.content,
entry.type,
entry.triggerConditions,
entry.pageLocation,
entry.emotionalRole,
entry.mysteryLevel,
...(entry.tags || []),
...(entry.symbols || []),
...(entry.narrativeArcs || []),
].join(" ").toLowerCase();
}
function characterPresenceScore(entry, character) {
const config = characterConfig(character);
const explicit = (entry.characters || []).includes(character) ? 80 : 0;
const tone = (config.presenceTones || []).includes(entry.emotionalTone) ? 12 : 0;
const corpus = entryTextCorpus(entry);
const keywords = (config.presenceKeywords || []).reduce((score, word) => score + (corpus.includes(String(word).toLowerCase()) ? 6 : 0), 0);
const motifs = (config.motifs || []).reduce((score, word) => score + (corpus.includes(String(word).toLowerCase()) ? 4 : 0), 0);
return explicit + tone + keywords + motifs;
}
function primaryCharacterForEntry(entry) {
const candidates = Object.keys(avatarRegistry());
const scored = candidates.map((character) => ({ character, score: characterPresenceScore(entry, character) })).sort((a, b) => b.score - a.score);
return scored[0]?.score > 0 ? scored[0].character : (entry.characters || [])[0] || "z";
}
function charactersForEntry(entry) {
const listed = entry.characters?.length ? entry.characters : [primaryCharacterForEntry(entry)];
const primary = primaryCharacterForEntry(entry);
return [primary, ...listed.filter((character) => character !== primary)].filter(Boolean);
}
function svgAvatar(character, radius, clipId, expression = "default", extraClass = "") {
const diameter = radius * 2;
const fallback = characterSymbol(character);
return `<circle class="node-shell ${html(extraClass)}" r="${radius}" fill="${html(characterColor(character) || avatarGlow(character))}" opacity="0.22"></circle>
<image class="node-portrait ${html(extraClass)}" href="${html(avatarPath(character, expression))}" x="${-radius}" y="${-radius}" width="${diameter}" height="${diameter}" preserveAspectRatio="xMidYMid slice" clip-path="url(#${html(clipId)})"></image>
<circle class="node-shell ${html(extraClass)}" r="${radius}" fill="none"></circle>
<text class="node-fallback" x="0" y="4" text-anchor="middle" opacity="0.32">${html(fallback)}</text>`;
}
function normalizeCharacterName(value) { function normalizeCharacterName(value) {
const raw = String(value || "").trim(); const raw = String(value || "").trim();
if (!raw) return ""; if (!raw) return "";
@@ -3173,9 +3386,18 @@ HIDDEN_APP_HTML = r"""<!doctype html>
$("audioSettings").value = entry.audioSettings || ""; $("audioSettings").value = entry.audioSettings || "";
$("animationTrigger").value = entry.animationTrigger || ""; $("animationTrigger").value = entry.animationTrigger || "";
$("notes").value = entry.notes || ""; $("notes").value = entry.notes || "";
renderCharacterPresence(entry);
renderPreview(entry); renderPreview(entry);
renderConnections(entry); renderConnections(entry);
} }
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("");
$("characterTags").innerHTML = characters.map((character) => `<span class="character-tag" style="--avatar-glow:${html(avatarGlow(character))}">${avatarImg(character, "tiny", avatarExpressionForEntry(entry))}${html(characterLabel(character))}</span>`).join("");
const primary = primaryCharacterForEntry(entry);
const territory = characterConfig(primary).observatory?.territory || "this memory territory";
$("presencePreview").innerHTML = `<span class="avatar-row" style="--avatar-glow:${html(avatarGlow(primary))}"><span>${avatarImg(primary, "medium", avatarExpressionForEntry(entry))}</span><span><strong>${html(characterLabel(primary))}</strong><br><span class="subtle">presence preview: ${html(territory)}</span></span></span>`;
}
function layerName(layer) { function layerName(layer) {
return (state.meta.layers || []).find((item) => item.id === String(layer))?.name || `Layer ${layer}`; return (state.meta.layers || []).find((item) => item.id === String(layer))?.name || `Layer ${layer}`;
} }
@@ -3430,33 +3652,46 @@ HIDDEN_APP_HTML = r"""<!doctype html>
return `<line class="edge ${html(pair.kind)}" x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}"></line>`; return `<line class="edge ${html(pair.kind)}" x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}"></line>`;
}).join(""); }).join("");
const occupied = []; const occupied = [];
const defs = [];
const territoryAnchors = renderTerritoryAnchors(defs);
const clusterNodes = clusters.map((cluster) => { const clusterNodes = clusters.map((cluster) => {
const pos = state.clusterPositions.get(cluster.id); const pos = state.clusterPositions.get(cluster.id);
const color = state.mode === "character" ? (characterColor(cluster.label) || toneColors[cluster.tone] || "#d3a64d") : (toneColors[cluster.tone] || "#d3a64d"); const character = state.mode === "character" ? cluster.label : cluster.character || "z";
const color = characterColor(character) || toneColors[cluster.tone] || "#d3a64d";
const size = Math.min(120, 48 + Math.sqrt(cluster.entries.length) * 18 + cluster.rarity * 3); const size = Math.min(120, 48 + Math.sqrt(cluster.entries.length) * 18 + cluster.rarity * 3);
const label = labelForCluster(cluster, pos, size, occupied); const label = labelForCluster(cluster, pos, size, occupied);
const activeDrop = state.drag?.targetKind === "cluster" && state.drag.targetId === cluster.id; const activeDrop = state.drag?.targetKind === "cluster" && state.drag.targetId === cluster.id;
const grow = activeDrop ? 1.08 : 1; const grow = activeDrop ? 1.08 : 1;
return `<g class="cluster-node${activeDrop ? " drop-target" : ""}" data-id="${html(cluster.id)}" tabindex="0" transform="translate(${pos.x} ${pos.y})"> const portraitRadius = Math.max(28, size * 0.46) * grow;
const clipId = `clip-cluster-${slugify(cluster.id)}`;
defs.push(`<clipPath id="${html(clipId)}"><circle r="${portraitRadius}"></circle></clipPath>`);
return `<g class="cluster-node${activeDrop ? " drop-target" : ""}" data-id="${html(cluster.id)}" tabindex="0" transform="translate(${pos.x} ${pos.y})" style="--node-glow:${html(avatarGlow(character))}">
<circle class="cluster-halo" r="${(size + 13) * grow}"></circle> <circle class="cluster-halo" r="${(size + 13) * grow}"></circle>
<circle r="${size * grow}" fill="${color}" opacity="0.58"></circle> <circle r="${size * grow}" fill="${color}" opacity="0.58"></circle>
${state.zoom > 0 || state.mode === "character" ? svgAvatar(character, portraitRadius, clipId, "default", "node-breathe") : ""}
<text class="count" x="0" y="7" text-anchor="middle">${cluster.entries.length}</text> <text class="count" x="0" y="7" text-anchor="middle">${cluster.entries.length}</text>
</g>${label}`; </g>${label}`;
}).join(""); }).join("");
const nodes = entries.map((entry) => { const nodes = entries.map((entry) => {
const pos = state.positions.get(entry.id); const pos = state.positions.get(entry.id);
if (!isWorldVisible(pos.x, pos.y, 220)) return ""; if (!isWorldVisible(pos.x, pos.y, 220)) return "";
const color = toneColors[entry.emotionalTone] || "#d3a64d"; const character = primaryCharacterForEntry(entry);
const size = 18 + Math.min(18, Number(entry.resonanceScore || 3) * 2.2) + (entry.rarity === "rare" || entry.rarity === "very rare" ? 8 : 0); const color = characterColor(character) || toneColors[entry.emotionalTone] || "#d3a64d";
const char = characterSymbol((entry.characters || [])[0]); const baseSize = 18 + Math.min(18, Number(entry.resonanceScore || 3) * 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 label = labelForEntry(entry, pos, size, occupied); const label = labelForEntry(entry, pos, size, occupied);
const dragClass = dragClassForEntry(entry.id); const dragClass = dragClassForEntry(entry.id);
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})"> const clipId = `clip-node-${slugify(entry.id)}`;
<circle r="${size}" fill="${color}" opacity="${entry.enabled === false ? 0.42 : 0.92}"></circle> defs.push(`<clipPath id="${html(clipId)}"><circle r="${size}"></circle></clipPath>`);
<text x="0" y="4" text-anchor="middle">${html(char)}</text> 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
? `<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}`; </g>${label}`;
}).join(""); }).join("");
graph.innerHTML = `<g id="world" transform="${cameraTransform()}">${rings}${clusterEdges}${edges}${clusterNodes}${nodes}<g id="dragLayer"></g></g>`; graph.innerHTML = `<defs>${defs.join("")}</defs><g id="world" transform="${cameraTransform()}">${rings}${territoryAnchors}${clusterEdges}${edges}${clusterNodes}${nodes}<g id="dragLayer"></g></g>`;
graph.querySelectorAll(".cluster-node").forEach((node) => { graph.querySelectorAll(".cluster-node").forEach((node) => {
node.addEventListener("click", () => openCluster(node.dataset.id)); node.addEventListener("click", () => openCluster(node.dataset.id));
node.addEventListener("dblclick", () => openCluster(node.dataset.id)); node.addEventListener("dblclick", () => openCluster(node.dataset.id));
@@ -3475,6 +3710,23 @@ HIDDEN_APP_HTML = r"""<!doctype html>
renderMinimap(scope); renderMinimap(scope);
updateDragPreview(); updateDragPreview();
} }
function renderTerritoryAnchors(defs) {
if (state.camera.k < 0.18 || state.mode === "timeline" || state.mode === "flow") return "";
return Object.entries(avatarRegistry()).map(([character, config]) => {
const anchor = config.observatory?.anchor || { x: 0.5, y: 0.5 };
const x = Math.max(260, Math.min(WORLD.width - 260, Number(anchor.x || 0.5) * WORLD.width));
const y = Math.max(220, Math.min(WORLD.height - 220, Number(anchor.y || 0.5) * WORLD.height));
const radius = state.mode === "character" ? 150 : 105;
const portrait = state.mode === "character" || state.camera.k > 0.42;
const clipId = `clip-territory-${slugify(character)}`;
defs.push(`<clipPath id="${html(clipId)}"><circle r="38"></circle></clipPath>`);
return `<g class="territory-anchor constellation-shimmer" transform="translate(${x} ${y})" style="--node-glow:${html(avatarGlow(character))}">
<circle r="${radius}" stroke="${html(avatarGlow(character))}" opacity="0.36"></circle>
${portrait ? svgAvatar(character, 38, clipId, "default", "node-breathe") : ""}
<text x="0" y="${portrait ? 74 : 6}" text-anchor="middle">${html(characterLabel(character))}</text>
</g>`;
}).join("");
}
function dragClassForEntry(id) { function dragClassForEntry(id) {
if (!state.drag) return ""; if (!state.drag) return "";
if (id === state.drag.sourceId) return " dragging"; if (id === state.drag.sourceId) return " dragging";
@@ -3585,7 +3837,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
function renderBreadcrumb(scope) { function renderBreadcrumb(scope) {
const filters = activeFilters(); const filters = activeFilters();
const parts = [`<button type="button" data-action="home">Observatory</button>`, `<button type="button" data-clear="mode">${html(state.mode)}</button>`]; const parts = [`<button type="button" data-action="home">Observatory</button>`, `<button type="button" data-clear="mode">${html(state.mode)}</button>`];
filters.forEach((filter) => parts.push(`<button type="button" title="${html(filter.help)}" data-clear="${html(filter.id)}">${html(filter.label)}</button>`)); filters.forEach((filter) => parts.push(`<button type="button" title="${html(filter.help)}" data-clear="${html(filter.id)}">${filter.character ? avatarImg(filter.character, "tiny") : ""}${html(filter.label)}</button>`));
if (scope.focusedCluster) parts.push(`<button type="button" data-clear="focus">${html(scope.focusedCluster.label)}</button>`); if (scope.focusedCluster) parts.push(`<button type="button" data-clear="focus">${html(scope.focusedCluster.label)}</button>`);
if (state.focusIds) parts.push(`<button type="button" data-clear="focus">${html(state.focusLabel || "Focused thread")}</button>`); if (state.focusIds) parts.push(`<button type="button" data-clear="focus">${html(state.focusLabel || "Focused thread")}</button>`);
$("breadcrumb").innerHTML = parts.join(""); $("breadcrumb").innerHTML = parts.join("");
@@ -3598,7 +3850,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
const value = (id) => $(id).value; const value = (id) => $(id).value;
if (value("search")) filters.push({ id: "search", label: `Search: ${value("search")}`, help: "Only memories matching this text are visible." }); if (value("search")) filters.push({ id: "search", label: `Search: ${value("search")}`, help: "Only memories matching this text are visible." });
if (value("layerFilter")) filters.push({ id: "layerFilter", label: `Depth: ${layerName(value("layerFilter"))}`, help: "Only this depth layer is visible." }); if (value("layerFilter")) filters.push({ id: "layerFilter", label: `Depth: ${layerName(value("layerFilter"))}`, help: "Only this depth layer is visible." });
if (value("characterFilter")) filters.push({ id: "characterFilter", label: `Character: ${value("characterFilter")}`, help: "Only memories in this character territory are visible." }); if (value("characterFilter")) filters.push({ id: "characterFilter", label: `Character: ${value("characterFilter")}`, character: value("characterFilter"), help: "Only memories in this character territory are visible." });
if (value("toneFilter")) filters.push({ id: "toneFilter", label: `Tone: ${value("toneFilter")}`, help: "Only this emotional tone is visible." }); if (value("toneFilter")) filters.push({ id: "toneFilter", label: `Tone: ${value("toneFilter")}`, help: "Only this emotional tone is visible." });
if (value("rarityFilter")) filters.push({ id: "rarityFilter", label: `Rarity: ${value("rarityFilter")}`, help: "Only this rarity is visible." }); if (value("rarityFilter")) filters.push({ id: "rarityFilter", label: `Rarity: ${value("rarityFilter")}`, help: "Only this rarity is visible." });
return filters; return filters;
@@ -3614,7 +3866,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
$("activeState").innerHTML = `<span class="subtle">Whole sky visible. You can always return here.</span>`; $("activeState").innerHTML = `<span class="subtle">Whole sky visible. You can always return here.</span>`;
return; return;
} }
$("activeState").innerHTML = active.map((item) => `<span class="filter-pill" title="${html(item.help)}">${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)}<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").querySelectorAll("[data-clear]").forEach((button) => button.addEventListener("click", () => clearExplorationPart(button.dataset.clear)));
$("activeState").querySelector("[data-action='soft']")?.addEventListener("click", softReset); $("activeState").querySelector("[data-action='soft']")?.addEventListener("click", softReset);
$("activeState").querySelector("[data-action='full']")?.addEventListener("click", fullReset); $("activeState").querySelector("[data-action='full']")?.addEventListener("click", fullReset);
@@ -4081,14 +4333,18 @@ HIDDEN_APP_HTML = r"""<!doctype html>
} }
function renderPreview(entry) { function renderPreview(entry) {
const body = html(entry.content || "").replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/\*([^*]+)\*/g, "<em>$1</em>"); const body = html(entry.content || "").replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/\*([^*]+)\*/g, "<em>$1</em>");
$("preview").innerHTML = `${body || "<span class='subtle'>This memory is waiting for words.</span>"}<div class="pills" style="margin-top:10px"><span class="pill">${html(layerName(depth(entry)))}</span><span class="pill">${html(entry.emotionalTone)}</span><span class="pill">${html(entry.rarity)}</span>${(entry.symbols || []).map((item) => `<span class="pill">${html(item)}</span>`).join("")}</div>`; const avatarPills = charactersForEntry(entry).map((character) => `<span class="avatar-pill pill" style="--avatar-glow:${html(avatarGlow(character))}">${avatarImg(character, "tiny", avatarExpressionForEntry(entry))}${html(characterLabel(character))}</span>`).join("");
$("preview").innerHTML = `${body || "<span class='subtle'>This memory is waiting for words.</span>"}<div class="pills" style="margin-top:10px">${avatarPills}<span class="pill">${html(layerName(depth(entry)))}</span><span class="pill">${html(entry.emotionalTone)}</span><span class="pill">${html(entry.rarity)}</span>${(entry.symbols || []).map((item) => `<span class="pill">${html(item)}</span>`).join("")}</div>`;
} }
function renderConnections(entry) { function renderConnections(entry) {
const ids = new Set([...(entry.continuationLinks || []), ...(entry.echoes || []), ...(entry.thematicLinks || []), ...(entry.symbolicLinks || []), ...(entry.triggerLinks || []), ...(entry.parentLinks || []), ...(entry.childLinks || []), ...(entry.mirroredEntries || [])]); const ids = new Set([...(entry.continuationLinks || []), ...(entry.echoes || []), ...(entry.thematicLinks || []), ...(entry.symbolicLinks || []), ...(entry.triggerLinks || []), ...(entry.parentLinks || []), ...(entry.childLinks || []), ...(entry.mirroredEntries || [])]);
const sameCharacter = state.entries.filter((other) => other.id !== entry.id && (entry.characters || []).some((name) => (other.characters || []).includes(name))).slice(0, 4); const sameCharacter = state.entries.filter((other) => other.id !== entry.id && (entry.characters || []).some((name) => (other.characters || []).includes(name))).slice(0, 4);
const linked = [...ids].map((id) => state.entries.find((item) => item.id === id)).filter(Boolean); const linked = [...ids].map((id) => state.entries.find((item) => item.id === id)).filter(Boolean);
const items = [...linked, ...sameCharacter].slice(0, 10); const items = [...linked, ...sameCharacter].slice(0, 10);
$("connections").innerHTML = items.length ? items.map((item) => `<button class="connection" type="button" data-id="${html(item.id)}"><strong>${html(item.title)}</strong><br><span class="subtle">${html(layerName(depth(item)))} / ${html(item.type)}</span></button>`).join("") : "<div class='subtle'>No nearby memories yet. Drag from this node to another to begin a thread.</div>"; $("connections").innerHTML = items.length ? items.map((item) => {
const character = primaryCharacterForEntry(item);
return `<button class="connection avatar-row" type="button" data-id="${html(item.id)}" style="--avatar-glow:${html(avatarGlow(character))}"><span>${avatarImg(character, "tiny", avatarExpressionForEntry(item))}</span><span><strong>${html(item.title)}</strong><br><span class="subtle">${html(layerName(depth(item)))} / ${html(item.type)}</span></span></button>`;
}).join("") : "<div class='subtle'>No nearby memories yet. Drag from this node to another to begin a thread.</div>";
$("connections").querySelectorAll("button").forEach((button) => button.addEventListener("click", () => selectNode(button.dataset.id))); $("connections").querySelectorAll("button").forEach((button) => button.addEventListener("click", () => selectNode(button.dataset.id)));
} }
function newEntry(type = "quote") { function newEntry(type = "quote") {
@@ -4163,6 +4419,22 @@ HIDDEN_APP_HTML = r"""<!doctype html>
$("toneFilter").innerHTML = opt("", "All tones") + state.meta.tones.map((value) => opt(value)).join(""); $("toneFilter").innerHTML = opt("", "All tones") + state.meta.tones.map((value) => opt(value)).join("");
$("rarityFilter").innerHTML = opt("", "All rarities") + state.meta.rarities.map((value) => opt(value)).join(""); $("rarityFilter").innerHTML = opt("", "All rarities") + state.meta.rarities.map((value) => opt(value)).join("");
$("layerKey").innerHTML = (state.meta.layers || []).map((layer) => `<div class="layer-row"><span class="depth-mark">${html(layer.id)}</span><span><strong>${html(layer.name)}</strong><span class="subtle">${html(layer.meaning)}</span></span></div>`).join(""); $("layerKey").innerHTML = (state.meta.layers || []).map((layer) => `<div class="layer-row"><span class="depth-mark">${html(layer.id)}</span><span><strong>${html(layer.name)}</strong><span class="subtle">${html(layer.meaning)}</span></span></div>`).join("");
$("characterAtlas").innerHTML = Object.keys(avatarRegistry()).map((character) => {
const config = characterConfig(character);
const territory = config.observatory?.territory || "memory territory";
return `<button class="connection avatar-row" type="button" data-character="${html(character)}" style="--avatar-glow:${html(avatarGlow(character))}"><span>${avatarImg(character, "medium")}</span><span><strong>${html(characterLabel(character))}</strong><br><span class="subtle">${html(territory)}</span></span></button>`;
}).join("");
$("characterAtlas").querySelectorAll("[data-character]").forEach((button) => button.addEventListener("click", () => {
snapshotExploration(`character territory: ${button.dataset.character}`);
$("characterFilter").value = button.dataset.character;
state.mode = "character";
state.focusCluster = "";
state.focusIds = null;
state.focusLabel = "";
$("modebar").querySelectorAll("button").forEach((item) => item.classList.toggle("active", item.dataset.mode === state.mode));
render();
window.setTimeout(() => fitCameraTo(boundsForEntries(visibleScope().entries), true), 0);
}));
} }
async function save() { async function save() {
applyFormToState(); applyFormToState();
@@ -4407,6 +4679,27 @@ class Handler(BaseHTTPRequestHandler):
except Exception as exc: except Exception as exc:
self.send_json({"error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR) self.send_json({"error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
return return
if parsed.path.startswith("/assets/avatars/"):
try:
relative = Path(parsed.path.lstrip("/"))
if relative.is_absolute() or ".." in relative.parts:
self.send_error(HTTPStatus.NOT_FOUND)
return
asset_path = (ROOT / relative).resolve()
avatars_root = (ROOT / "assets" / "avatars").resolve()
if not asset_path.is_file() or avatars_root not in asset_path.parents:
self.send_error(HTTPStatus.NOT_FOUND)
return
body = asset_path.read_bytes()
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", mimetypes.guess_type(asset_path.name)[0] or "application/octet-stream")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
except Exception:
self.send_error(HTTPStatus.NOT_FOUND)
return
self.send_error(HTTPStatus.NOT_FOUND) self.send_error(HTTPStatus.NOT_FOUND)
def do_POST(self) -> None: def do_POST(self) -> None: