avatar support next to messages
All checks were successful
Build Org Website / build (push) Successful in 50s
All checks were successful
Build Org Website / build (push) Successful in 50s
This commit is contained in:
@@ -12,6 +12,7 @@ import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import mimetypes
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from email.parser import BytesParser
|
||||
@@ -148,6 +149,18 @@ CHARACTER_REGISTRY = {
|
||||
"displayLabel": "young z",
|
||||
"aliases": ["Young Z", "young z", "young-z", "young_z", "young"],
|
||||
"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",
|
||||
"motifs": ["crayon sun", "blanket cape", "childhood desk"],
|
||||
"themes": ["childhood", "play", "memory", "safety"],
|
||||
@@ -158,6 +171,14 @@ CHARACTER_REGISTRY = {
|
||||
"displayLabel": "z",
|
||||
"aliases": ["Z", "z", "zaine"],
|
||||
"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",
|
||||
"motifs": ["archive", "website", "home"],
|
||||
"themes": ["selfhood", "return", "making"],
|
||||
@@ -168,6 +189,18 @@ CHARACTER_REGISTRY = {
|
||||
"displayLabel": "aphy",
|
||||
"aliases": ["Aphy", "aphy", "aphy_bot", "aphy bot"],
|
||||
"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",
|
||||
"motifs": ["console", "diagnostic", "backup"],
|
||||
"themes": ["humor", "systems", "care through tools"],
|
||||
@@ -178,6 +211,19 @@ CHARACTER_REGISTRY = {
|
||||
"displayLabel": "lima",
|
||||
"aliases": ["Lima", "lima"],
|
||||
"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",
|
||||
"motifs": ["warmth", "kitchen light", "ring"],
|
||||
"themes": ["love", "home", "grounding"],
|
||||
@@ -188,6 +234,18 @@ CHARACTER_REGISTRY = {
|
||||
"displayLabel": "sensei chi",
|
||||
"aliases": ["Sensei Chi", "sensei chi", "sensei-chi", "sensei_chi", "sensei"],
|
||||
"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",
|
||||
"motifs": ["tea", "garden", "quiet lesson"],
|
||||
"themes": ["reflection", "patience", "wisdom"],
|
||||
@@ -198,6 +256,18 @@ CHARACTER_REGISTRY = {
|
||||
"displayLabel": "future z",
|
||||
"aliases": ["Future Z", "future z", "future-z", "future_z", "future"],
|
||||
"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",
|
||||
"motifs": ["clock", "age 40", "future log"],
|
||||
"themes": ["time", "reassurance", "continuity"],
|
||||
@@ -2533,6 +2603,42 @@ HIDDEN_APP_HTML = r"""<!doctype html>
|
||||
background: rgba(251, 241, 220, 0.08);
|
||||
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-row {
|
||||
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: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 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.selected circle { stroke: #fff3c7; stroke-width: 3; }
|
||||
.memory-node.dragging circle { stroke: #f3cd7a; stroke-width: 3; opacity: 0.72; }
|
||||
.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)); }
|
||||
.memory-node.drop-target circle { stroke: #fff5c9; stroke-width: 4; filter: drop-shadow(0 0 24px rgba(255, 232, 177, 0.7)); }
|
||||
.memory-node.invalid-drop circle { stroke: rgba(208, 107, 120, 0.9); stroke-width: 3; }
|
||||
.memory-node .node-shell, .memory-node .node-portrait, .memory-node .node-marker {
|
||||
stroke: rgba(255, 246, 223, 0.82);
|
||||
stroke-width: 1.4;
|
||||
filter: drop-shadow(0 0 7px var(--node-glow, rgba(255, 232, 177, 0.18)));
|
||||
transition: r 160ms ease, stroke 160ms ease, opacity 160ms ease, filter 180ms ease;
|
||||
}
|
||||
.memory-node .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 circle {
|
||||
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-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-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; }
|
||||
@@ -2744,6 +2861,15 @@ HIDDEN_APP_HTML = r"""<!doctype html>
|
||||
0%, 100% { opacity: 0.72; }
|
||||
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; }
|
||||
@media (max-width: 1120px) {
|
||||
body { overflow: auto; }
|
||||
@@ -2807,6 +2933,10 @@ HIDDEN_APP_HTML = r"""<!doctype html>
|
||||
<button data-tour="connected" type="button">show emotionally connected nodes</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="guide-card">
|
||||
<h2>Character Territories</h2>
|
||||
<div id="characterAtlas" class="stack"></div>
|
||||
</div>
|
||||
<div class="guide-card">
|
||||
<h2>Integrity</h2>
|
||||
<div id="integrityStatus" class="subtle">checking character territories.</div>
|
||||
@@ -2845,6 +2975,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
|
||||
<section class="panel">
|
||||
<h2 id="drawerTitle">Choose a memory</h2>
|
||||
<div id="drawerSub" class="subtle">The side panel opens from the constellation.</div>
|
||||
<div id="drawerAvatars" class="pills" style="margin-top:10px"></div>
|
||||
<div id="preview" class="preview" style="margin-top:10px"></div>
|
||||
</section>
|
||||
<section class="panel" style="margin-top:12px">
|
||||
@@ -2854,6 +2985,8 @@ HIDDEN_APP_HTML = r"""<!doctype html>
|
||||
<label>Kind<select id="type"></select></label>
|
||||
<label>Depth<select id="familyLayer"></select></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>Rarity<select id="rarity"></select></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 $ = (id) => document.getElementById(id);
|
||||
const toneColors = { warm: "#d3a64d", funny: "#7fb089", nostalgic: "#d06b78", wise: "#75a9bd", strange: "#a58ac9", soft: "#e6bd8c", hopeful: "#9fcf9f", protective: "#d98f71", melancholy: "#8fa7c6" };
|
||||
const defaultAvatarPath = "/assets/avatars/z.jpeg";
|
||||
|
||||
function html(value) {
|
||||
return String(value || "").replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[char]));
|
||||
@@ -2913,6 +3047,85 @@ HIDDEN_APP_HTML = r"""<!doctype html>
|
||||
function splitList(value) {
|
||||
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) {
|
||||
const raw = String(value || "").trim();
|
||||
if (!raw) return "";
|
||||
@@ -3173,9 +3386,18 @@ HIDDEN_APP_HTML = r"""<!doctype html>
|
||||
$("audioSettings").value = entry.audioSettings || "";
|
||||
$("animationTrigger").value = entry.animationTrigger || "";
|
||||
$("notes").value = entry.notes || "";
|
||||
renderCharacterPresence(entry);
|
||||
renderPreview(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) {
|
||||
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>`;
|
||||
}).join("");
|
||||
const occupied = [];
|
||||
const defs = [];
|
||||
const territoryAnchors = renderTerritoryAnchors(defs);
|
||||
const clusterNodes = clusters.map((cluster) => {
|
||||
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 label = labelForCluster(cluster, pos, size, occupied);
|
||||
const activeDrop = state.drag?.targetKind === "cluster" && state.drag.targetId === cluster.id;
|
||||
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 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>
|
||||
</g>${label}`;
|
||||
}).join("");
|
||||
const nodes = entries.map((entry) => {
|
||||
const pos = state.positions.get(entry.id);
|
||||
if (!isWorldVisible(pos.x, pos.y, 220)) return "";
|
||||
const color = toneColors[entry.emotionalTone] || "#d3a64d";
|
||||
const size = 18 + Math.min(18, Number(entry.resonanceScore || 3) * 2.2) + (entry.rarity === "rare" || entry.rarity === "very rare" ? 8 : 0);
|
||||
const char = characterSymbol((entry.characters || [])[0]);
|
||||
const character = primaryCharacterForEntry(entry);
|
||||
const color = characterColor(character) || toneColors[entry.emotionalTone] || "#d3a64d";
|
||||
const baseSize = 18 + Math.min(18, Number(entry.resonanceScore || 3) * 2.2) + (entry.rarity === "rare" || entry.rarity === "very rare" ? 8 : 0);
|
||||
const 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 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})">
|
||||
<circle r="${size}" fill="${color}" opacity="${entry.enabled === false ? 0.42 : 0.92}"></circle>
|
||||
<text x="0" y="4" text-anchor="middle">${html(char)}</text>
|
||||
const clipId = `clip-node-${slugify(entry.id)}`;
|
||||
defs.push(`<clipPath id="${html(clipId)}"><circle r="${size}"></circle></clipPath>`);
|
||||
const distant = state.zoom === 0;
|
||||
return `<g class="memory-node${entry.id === state.selectedId ? " selected" : ""}${entry.enabled === false ? " resting" : ""}${dragClass}" data-id="${html(entry.id)}" tabindex="0" transform="translate(${pos.x} ${pos.y})" style="--node-glow:${html(avatarGlow(character))}">
|
||||
${distant
|
||||
? `<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}`;
|
||||
}).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) => {
|
||||
node.addEventListener("click", () => openCluster(node.dataset.id));
|
||||
node.addEventListener("dblclick", () => openCluster(node.dataset.id));
|
||||
@@ -3475,6 +3710,23 @@ HIDDEN_APP_HTML = r"""<!doctype html>
|
||||
renderMinimap(scope);
|
||||
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) {
|
||||
if (!state.drag) return "";
|
||||
if (id === state.drag.sourceId) return " dragging";
|
||||
@@ -3585,7 +3837,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
|
||||
function renderBreadcrumb(scope) {
|
||||
const filters = activeFilters();
|
||||
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 (state.focusIds) parts.push(`<button type="button" data-clear="focus">${html(state.focusLabel || "Focused thread")}</button>`);
|
||||
$("breadcrumb").innerHTML = parts.join("");
|
||||
@@ -3598,7 +3850,7 @@ HIDDEN_APP_HTML = r"""<!doctype html>
|
||||
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("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("rarityFilter")) filters.push({ id: "rarityFilter", label: `Rarity: ${value("rarityFilter")}`, help: "Only this rarity is visible." });
|
||||
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>`;
|
||||
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").querySelector("[data-action='soft']")?.addEventListener("click", softReset);
|
||||
$("activeState").querySelector("[data-action='full']")?.addEventListener("click", fullReset);
|
||||
@@ -4081,14 +4333,18 @@ HIDDEN_APP_HTML = r"""<!doctype html>
|
||||
}
|
||||
function renderPreview(entry) {
|
||||
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) {
|
||||
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 linked = [...ids].map((id) => state.entries.find((item) => item.id === id)).filter(Boolean);
|
||||
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)));
|
||||
}
|
||||
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("");
|
||||
$("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("");
|
||||
$("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() {
|
||||
applyFormToState();
|
||||
@@ -4407,6 +4679,27 @@ class Handler(BaseHTTPRequestHandler):
|
||||
except Exception as exc:
|
||||
self.send_json({"error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||
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)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
|
||||
Reference in New Issue
Block a user