deucebucket commited on
Commit
41cf16c
·
verified ·
1 Parent(s): 50cd42e

feat: ship the redesigned pixel UI wired to the real engine (+ crisis/soul/distance API fields)

Browse files
Files changed (7) hide show
  1. app/crisis_view.py +40 -0
  2. app/main.py +8 -0
  3. app/soul_bridge.py +17 -0
  4. static/app.js +838 -825
  5. static/index.html +215 -199
  6. static/styles.css +57 -640
  7. tests/test_api.py +54 -8
app/crisis_view.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Surface the engine's continuous crisis gradient (0.0-1.0) for the API.
2
+
3
+ The engine computes concern via engine.crisis.CrisisTracker, which blends the
4
+ message's VADUGWI read, running state, trajectory and detected structures into a
5
+ single 0.0-1.0 float. For a stateless per-request read we feed the current read
6
+ as both the message score and the running state, with the structures the engine
7
+ already detected for this text.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from engine.crisis import CrisisTracker
12
+ from engine.shared import VADUG
13
+
14
+
15
+ def _to_vadug(read: list[int]) -> VADUG:
16
+ return VADUG(v=read[0], a=read[1], d=read[2], u=read[3],
17
+ g=read[4], w=read[5], i=read[6])
18
+
19
+
20
+ def crisis_value(read: list[int], structures: list[str] | None = None) -> float:
21
+ """Single-message concern 0.0-1.0 from a raw VADUGWI read + detected structures."""
22
+ score = _to_vadug(read)
23
+ tracker = CrisisTracker()
24
+ reading = tracker.read(score, score, list(structures or []))
25
+ return round(float(reading.concern), 3)
26
+
27
+
28
+ def crisis_label(value: float) -> str:
29
+ if value < 0.15:
30
+ return "calm"
31
+ if value < 0.4:
32
+ return "watch"
33
+ if value < 0.7:
34
+ return "concern"
35
+ return "high concern"
36
+
37
+
38
+ def crisis_block(read: list[int], structures: list[str] | None = None) -> dict:
39
+ v = crisis_value(read, structures)
40
+ return {"value": v, "label": crisis_label(v)}
app/main.py CHANGED
@@ -12,6 +12,7 @@ from app.scorer import score_with_trace
12
  from app.soul_bridge import SoulBridge
13
  from app.appearance import mood_to_appearance
14
  from app.trace_view import shape_trace
 
15
  from app.vocab import mood_word
16
  from app.voice import emoticon, simlish, voice_params
17
  from app.audit import AuditLog, build_row
@@ -81,11 +82,17 @@ class ToyIn(BaseModel):
81
 
82
  def _state() -> dict:
83
  mood = _bridge.mood()
 
84
  return {
85
  "appearance": mood_to_appearance(mood),
86
  "mood_word": mood_word(mood),
87
  "emoticon": emoticon(mood),
88
  "needs": _care.needs(_traffic()),
 
 
 
 
 
89
  }
90
 
91
 
@@ -184,6 +191,7 @@ def say(body: SayIn, request: Request):
184
  state["read"] = raw
185
  state["deltas"] = _bridge.deltas()
186
  state["trace"] = shape_trace(trace)
 
187
  state["why"] = _why(raw, state["deltas"])
188
  state["simlish"] = simlish(mood)
189
  state["voice"] = voice_params(mood)
 
12
  from app.soul_bridge import SoulBridge
13
  from app.appearance import mood_to_appearance
14
  from app.trace_view import shape_trace
15
+ from app.crisis_view import crisis_block
16
  from app.vocab import mood_word
17
  from app.voice import emoticon, simlish, voice_params
18
  from app.audit import AuditLog, build_row
 
82
 
83
  def _state() -> dict:
84
  mood = _bridge.mood()
85
+ soul = _bridge.soul_baseline()
86
  return {
87
  "appearance": mood_to_appearance(mood),
88
  "mood_word": mood_word(mood),
89
  "emoticon": emoticon(mood),
90
  "needs": _care.needs(_traffic()),
91
+ "soul": soul,
92
+ "distance": _bridge.soul_distance(),
93
+ # default: no text-derived crisis (physical/idle states are calm). The
94
+ # /say handler overrides this with the real read+structures concern.
95
+ "crisis": {"value": 0.0, "label": "calm"},
96
  }
97
 
98
 
 
191
  state["read"] = raw
192
  state["deltas"] = _bridge.deltas()
193
  state["trace"] = shape_trace(trace)
194
+ state["crisis"] = crisis_block(raw, state["trace"].get("structures"))
195
  state["why"] = _why(raw, state["deltas"])
196
  state["simlish"] = simlish(mood)
197
  state["voice"] = voice_params(mood)
app/soul_bridge.py CHANGED
@@ -128,6 +128,23 @@ class SoulBridge:
128
  m = self._plugin.snapshot().get("mood")
129
  return [int(x) for x in m] if m else list(NEUTRAL)
130
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  def previous_mood(self) -> list[int]:
132
  return list(self._prev)
133
 
 
128
  m = self._plugin.snapshot().get("mood")
129
  return [int(x) for x in m] if m else list(NEUTRAL)
130
 
131
+ def soul_baseline(self) -> list[int]:
132
+ """Persistent baseline mood (the soul) as a 7-int [v,a,d,u,g,w,i] array."""
133
+ s = self._plugin.snapshot().get("soul") or {}
134
+ dims = ("v", "a", "d", "u", "g", "w", "i")
135
+ if all(k in s for k in dims):
136
+ return [int(s[k]) for k in dims]
137
+ return list(BABY_SOUL.to_list()) if hasattr(BABY_SOUL, "to_list") else [
138
+ BABY_SOUL.v, BABY_SOUL.a, BABY_SOUL.d, BABY_SOUL.u,
139
+ BABY_SOUL.g, BABY_SOUL.w, BABY_SOUL.i,
140
+ ]
141
+
142
+ def soul_distance(self) -> int:
143
+ """Drift between live mood and soul baseline: round(mean |diff|) over 7 dims."""
144
+ mood = self.mood()
145
+ soul = self.soul_baseline()
146
+ return round(sum(abs(a - b) for a, b in zip(mood, soul)) / 7.0)
147
+
148
  def previous_mood(self) -> list[int]:
149
  return list(self._prev)
150
 
static/app.js CHANGED
@@ -1,837 +1,850 @@
1
  'use strict';
2
-
3
- const $ = (s) => document.querySelector(s);
4
- const DIMS = ["V","A","D","U","G","W","I"];
5
- const DIM_LABELS = ["valence","arousal","dominance","urgency","gravity","self-worth","intent"];
6
- const DIM_COLORS = ["#ff9f5a","#ffd44a","#8ec5ff","#c79bff","#9fd0c9","#ff7d9c","#86e0a0"];
7
-
8
- let _ap = null; // latest appearance (updated by render)
9
- const _spring = { pos: 1, vel: 0 }; // squish scale spring (1 = rest)
10
- let _phase = 0, _lastT = 0;
11
- // ── locomotion state (the creature lives on the floor and wanders) ──
12
- let _posX = 200; // creature CENTER x in scene coords (viewBox 0..400)
13
- let _facing = 1; // 1 = facing right, -1 = facing left
14
- let _vel = 0; // current x speed (scene px/s) drives the walk bob
15
- let _target = null; // {x, action:{kind,name}} set when you tap an object
16
- let _wanderX = 200; // current idle wander destination
17
- let _pause = 0.8; // seconds to linger before wandering again
18
- let _hold = null; // {item, floorEl, until} while holding/playing with an item
19
- let _float = null; // {parts, until, startT} Sims-style +/- that floats up on any VADUGWI change
20
- const ROAM_MIN = 74, ROAM_MAX = 326;
21
-
22
- // item registry: display size (so a held item is the SAME size as on the floor),
23
- // sprite, the floor element to hide while held, and which VADUGWI it raises.
24
- const ASSET = (s) => '/static/assets/' + s + '?v=16';
25
- const ITEM = {
26
- balloon: { sprite: 'toy-balloon.png', w: 42, h: 52, raises: '▲V ▲A ▲D', floor: '[data-toy="balloon"]' },
27
- musicbox: { sprite: 'toy-musicbox.png', w: 34, h: 32, raises: '▲V ▾A', floor: '[data-toy="musicbox"]' },
28
- mirror: { sprite: 'toy-mirror.png', w: 32, h: 36, raises: '▲V ▲W ▲D', floor: '[data-toy="mirror"]' },
29
- teddy: { sprite: 'toy-teddy.png', w: 42, h: 42, raises: '▲V ▲W ▾A', floor: '[data-toy="teddy"]' },
30
- rattle: { sprite: 'toy-rattle.png', w: 32, h: 34, raises: '▲A ▲U', floor: '[data-toy="rattle"]' },
31
- ball: { sprite: 'obj-ball.png', w: 30, h: 30, raises: '▲V ▲A ▲D', floor: '[data-care="play"]' },
32
- };
33
- function _rand(a, b) { return a + Math.random() * (b - a); }
34
-
35
- // ── METERS ──────────────────────────────────────────────────────────────────
36
-
37
- function renderMeters(mood, deltas, read) {
38
- // "this message read:" line
39
- if (read && read.length === 7) {
40
- $('#read-row').textContent = 'this message read: ' + DIMS.map((d,i) => `${d}=${read[i]}`).join(' ');
41
- }
42
-
43
- const container = $('#meter-rows');
44
- container.innerHTML = '';
45
- for (let i = 0; i < 7; i++) {
46
- const val = mood[i] ?? 128;
47
- const delta = (deltas && deltas[i] != null) ? deltas[i] : null;
48
- const pct = Math.round((val / 255) * 100);
49
-
50
- let deltaHtml = '';
51
- if (delta != null && delta !== 0) {
52
- const cls = delta > 0 ? 'up' : 'dn';
53
- const arrow = delta > 0 ? '▲' : '▼';
54
- deltaHtml = `<span class="meter-delta ${cls}">${arrow}${Math.abs(delta)}</span>`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  }
56
-
57
- const div = document.createElement('div');
58
- div.className = 'meter';
59
- div.innerHTML = `
60
- <div class="meter-row">
61
- <span class="meter-name">${DIMS[i]} <small>${DIM_LABELS[i]}</small></span>
62
- <span class="meter-right">
63
- <span class="meter-val">${val}</span>
64
- ${deltaHtml}
65
- </span>
66
- </div>
67
- <div class="track"><div class="fill" style="width:${pct}%;background:${DIM_COLORS[i]}"></div></div>`;
68
- container.appendChild(div);
69
- }
70
- }
71
-
72
- // ── CREATURE / BLOB ──────────────────────────────────────────────────────────
73
-
74
- function renderBlob(ap) {
75
- const h = ap.hue ?? 28;
76
- const s = ap.saturation ?? 80;
77
- const l = ap.lightness ?? 58;
78
- const fill = `hsl(${h},${s}%,${l}%)`;
79
- const darkFill = `hsl(${h},${s}%,${Math.max(l - 14, 10)}%)`;
80
-
81
- $('#body').setAttribute('fill', fill);
82
- document.querySelectorAll('.foot').forEach(el => el.setAttribute('fill', darkFill));
83
-
84
- // arms use same fill color with stroke
85
- ['#arm-l-path','#arm-r-path'].forEach(sel => {
86
- const el = $(sel);
87
- el.style.stroke = darkFill;
88
- el.style.fill = 'none';
89
- });
90
- ['#hand-l','#hand-r'].forEach(sel => $(sel).setAttribute('fill', darkFill));
91
-
92
- // ── anime-style emotion: body stays yellow, the FACE/cheeks glow ──
93
- const glow = ap.glow || { type: 'none', hue: 0, intensity: 0 };
94
- const gi = glow.intensity || 0;
95
- const gEl = $('#emote-glow');
96
- if (gEl) {
97
- gEl.setAttribute('opacity', (gi * 0.62).toFixed(2));
98
- const gc = `hsl(${glow.hue}, 92%, 55%)`;
99
- const s0 = $('#emote-stop-0'), s1 = $('#emote-stop-1');
100
- if (s0) s0.setAttribute('stop-color', gc);
101
- if (s1) s1.setAttribute('stop-color', gc);
102
- }
103
- const chL = $('#cheek-l'), chR = $('#cheek-r');
104
- if (chL && chR) {
105
- let cheekFill = '#ff9090', cheekOp = 0.38;
106
- if (glow.type === 'anger' || glow.type === 'fluster') { cheekFill = `hsl(${glow.hue},95%,56%)`; cheekOp = 0.4 + 0.5 * gi; }
107
- else if (glow.type === 'joy') { cheekFill = 'hsl(338,90%,70%)'; cheekOp = 0.4 + 0.4 * gi; }
108
- else if (glow.type === 'sad') { cheekFill = '#9fb8e0'; cheekOp = 0.22; }
109
- [chL, chR].forEach(c => { c.setAttribute('fill', cheekFill); c.setAttribute('opacity', cheekOp.toFixed(2)); });
110
- }
111
-
112
- // aura behind him picks up the emotion color (subtle ambient mood), else warm yellow
113
- const auraEl = $('#aura');
114
- if (auraEl) {
115
- auraEl.setAttribute('opacity', String((ap.aura ?? 0.25) * 2.4));
116
- const stopEl = $('#aura-stop');
117
- if (stopEl) stopEl.setAttribute('stop-color', gi > 0.15 ? `hsl(${glow.hue},85%,58%)` : `hsl(48,88%,60%)`);
118
- }
119
-
120
- // body transform: lean + droop + scale, composed in SVG local coords (creature center 100,116)
121
- // #blob-place holds fixed scene placement; #blob gets dynamic per-message transform
122
- const lean = (ap.lean ?? 0) * 10;
123
- const dy = (ap.droop ?? 0) * 35; // local units (scene px × 1/S where S=0.40)
124
- const sc = ap.scale ?? 1;
125
- // SVG transforms apply right-to-left:
126
- // 1. translate(-100 -116) — move center to origin
127
- // 2. scale(sc) — scale around origin
128
- // 3. translate(100 116) — move back
129
- // 4. rotate(lean 100 116) — lean around body center
130
- // 5. translate(0 dy) — droop (downward shift in local space)
131
- const blobTransform = `translate(0 ${dy}) rotate(${lean} 100 116) translate(100 116) scale(${sc}) translate(-100 -116)`;
132
- $('#blob').setAttribute('transform', blobTransform);
133
-
134
- // parametric face
135
- const face = ap.face || {};
136
- const mouthVal = face.mouth ?? 0; // -1 … +1 (frown … smile)
137
- const eyeVal = face.eye ?? 1.0; // ~0.5 … 1.4 openness
138
- const browVal = face.brow ?? 0; // -1 … +1 tilt
139
-
140
- const sleeping = (ap.mood && window.ClankerPhysics
141
- && window.ClankerPhysics.behaviorFor(ap.mood) === 'sleep');
142
- const eyeForRender = sleeping ? 0.12 : eyeVal; // near-closed
143
- renderFace(mouthVal, eyeForRender, browVal, h, s, l);
144
-
145
- // hand pose by mood positivity
146
- poseArms(mouthVal);
147
- }
148
-
149
- function renderFace(mouth, eye, brow, h, s, l) {
150
- // eye size based on eye param
151
- const eyeR = Math.round(Math.max(4, Math.min(11, 7 * eye)));
152
- const eyeY = 108;
153
- const eyeLx = 78, eyeRx = 122;
154
-
155
- // brow tilt: positive brow = raised (happy), negative = furrowed (angry)
156
- const browTiltL = -brow * 8; // left brow
157
- const browTiltR = brow * 8; // right brow (mirror)
158
- const browY = 88;
159
- const browLen = 18;
160
-
161
- // mouth: control point Y based on mouth value
162
- // mouth=+1 → big smile (control point low), mouth=-1 → frown (control point high)
163
- const mouthY1 = 158;
164
- const mouthCY = mouthY1 + mouth * 18; // control point
165
- const mouthX1 = 76, mouthX2 = 124;
166
- const mouthCX = 100;
167
-
168
- const faceColor = `hsl(${h},${Math.max(s - 30, 10)}%,${Math.max(l - 28, 10)}%)`;
169
-
170
- let svg = '';
171
-
172
- // brows
173
- svg += `<line x1="${eyeLx - browLen/2}" y1="${browY + browTiltL}" x2="${eyeLx + browLen/2}" y2="${browY - browTiltL}"
174
- stroke="${faceColor}" stroke-width="3.5" stroke-linecap="round"/>`;
175
- svg += `<line x1="${eyeRx - browLen/2}" y1="${browY - browTiltR}" x2="${eyeRx + browLen/2}" y2="${browY + browTiltR}"
176
- stroke="${faceColor}" stroke-width="3.5" stroke-linecap="round"/>`;
177
-
178
- // eyes — render as ellipses to allow eye "squeeze" on negative
179
- const eyeH = eyeR;
180
- const eyeW = Math.round(eyeR * 0.9);
181
- svg += `<ellipse cx="${eyeLx}" cy="${eyeY}" rx="${eyeW}" ry="${eyeH}" fill="${faceColor}"/>`;
182
- svg += `<ellipse cx="${eyeRx}" cy="${eyeY}" rx="${eyeW}" ry="${eyeH}" fill="${faceColor}"/>`;
183
- // eye shine
184
- svg += `<circle cx="${eyeLx - 2}" cy="${eyeY - 3}" r="2.5" fill="white" opacity="0.7"/>`;
185
- svg += `<circle cx="${eyeRx - 2}" cy="${eyeY - 3}" r="2.5" fill="white" opacity="0.7"/>`;
186
-
187
- // mouth as quadratic bezier
188
- svg += `<path d="M${mouthX1} ${mouthY1} Q${mouthCX} ${mouthCY} ${mouthX2} ${mouthY1}"
189
- stroke="${faceColor}" stroke-width="4" fill="none" stroke-linecap="round"/>`;
190
-
191
- $('#face').innerHTML = svg;
192
- }
193
-
194
- function poseArms(mouthVal) {
195
- if (mouthVal > 0.4) {
196
- // strongly positive: arms wide open overhead (jubilant)
197
- $('#arm-l-path').setAttribute('d', 'M50 148 Q20 110 10 86');
198
- $('#hand-l').setAttribute('cx', '10');
199
- $('#hand-l').setAttribute('cy', '82');
200
- $('#arm-r-path').setAttribute('d', 'M150 148 Q180 110 190 86');
201
- $('#hand-r').setAttribute('cx', '190');
202
- $('#hand-r').setAttribute('cy', '82');
203
- } else if (mouthVal < -0.2) {
204
- // negative: hands near cheeks (distress / covering face)
205
- $('#arm-l-path').setAttribute('d', 'M50 148 Q34 136 26 122');
206
- $('#hand-l').setAttribute('cx', '26');
207
- $('#hand-l').setAttribute('cy', '118');
208
- $('#arm-r-path').setAttribute('d', 'M150 148 Q166 136 174 122');
209
- $('#hand-r').setAttribute('cx', '174');
210
- $('#hand-r').setAttribute('cy', '118');
211
- } else {
212
- // neutral: classic hug gesture — arms curved up, hands beside cheeks
213
- $('#arm-l-path').setAttribute('d', 'M50 148 Q28 130 18 112');
214
- $('#hand-l').setAttribute('cx', '18');
215
- $('#hand-l').setAttribute('cy', '108');
216
- $('#arm-r-path').setAttribute('d', 'M150 148 Q172 130 182 112');
217
- $('#hand-r').setAttribute('cx', '182');
218
- $('#hand-r').setAttribute('cy', '108');
219
- }
220
- }
221
-
222
- // ── TRACE ───────────────────────────────────────────────────────────────────
223
-
224
- const ROLE_CLASS = {
225
- EMOTIONAL: 'r-EMOTIONAL',
226
- AMPLIFIER: 'r-AMPLIFIER',
227
- NEGATOR: 'r-NEGATOR',
228
- SELF_REF: 'r-SELF_REF',
229
- CONNECTOR: 'r-CONNECTOR',
230
- CHOPPER: 'r-CHOPPER',
231
- SOLVENT: 'r-SOLVENT',
232
- };
233
-
234
- function renderTrace(trace) {
235
- if (!trace) return;
236
-
237
- // physical interaction (toy/care): no language to parse — show the action,
238
- // not the previous chat message's words.
239
- if (trace.kind === 'physical') {
240
- $('#trace-words').innerHTML =
241
- `<span class="trace-physical">${trace.label || 'physical interaction'} — physical interaction, no language to parse</span>`;
242
- $('#trace-structs').innerHTML = '';
243
- $('#trace-contribs').innerHTML = '';
244
- $('#trace-unknown').classList.add('hidden');
245
- return;
246
- }
247
-
248
- // words as role-colored chips
249
- const wordsEl = $('#trace-words');
250
- wordsEl.innerHTML = '';
251
- (trace.words || []).forEach(w => {
252
- const span = document.createElement('span');
253
- const cls = ROLE_CLASS[w.role] || 'r-default';
254
- span.className = `w-chip ${cls}`;
255
- span.textContent = w.token || w.word || w;
256
- wordsEl.appendChild(span);
257
- });
258
-
259
- // structures as chips
260
- const structsEl = $('#trace-structs');
261
- structsEl.innerHTML = '';
262
- (trace.structures || []).forEach(s => {
263
- const span = document.createElement('span');
264
- span.className = 's-chip';
265
- span.textContent = typeof s === 'string' ? s : (s.name || JSON.stringify(s));
266
- structsEl.appendChild(span);
267
- });
268
-
269
- // contributors
270
- const contribEl = $('#trace-contribs');
271
- contribEl.innerHTML = '';
272
- (trace.contributors || []).forEach(c => {
273
- const line = document.createElement('div');
274
- let text = c.word || c.token || '?';
275
- text = text.padEnd(14, ' ');
276
- let parts = '';
277
- ['dv','da','dd','du','dg'].forEach((key, i) => {
278
- if (c[key] != null && c[key] !== 0) {
279
- const dim = 'VADUG'[i];
280
- const cls = c[key] > 0 ? 'contrib-pos' : 'contrib-neg';
281
- const sign = c[key] > 0 ? '+' : '';
282
- parts += `<span class="${cls}">${dim}${sign}${c[key]}</span> `;
283
- }
284
- });
285
- if (!parts) parts = '<span style="opacity:.5">—</span>';
286
- line.innerHTML = `${text} ${parts}`;
287
- contribEl.appendChild(line);
288
- });
289
-
290
- // unknown tokens
291
- const unk = trace.unknown_tokens || trace.suspected_gap || [];
292
- const unkEl = $('#trace-unknown');
293
- if (unk.length > 0) {
294
- unkEl.textContent = `didn't know: ${unk.join(', ')}`;
295
- unkEl.classList.remove('hidden');
296
- } else {
297
- unkEl.classList.add('hidden');
298
- }
299
- }
300
-
301
- // ── ACCEPTANCE ──────────────────────────────────────────────────────────────
302
-
303
- function renderAcceptance(acc) {
304
- if (!acc) return;
305
- const el = $('#acceptance');
306
- const raw = acc.weight_raw ?? '?';
307
- const eff = acc.weight_effective ?? '?';
308
- const pct = acc.absorbed_pct ?? 0;
309
- const armor = acc.armor ?? '?';
310
- const breached = acc.breached ? '<span class="breach-flag">BREACH</span>' : '';
311
- el.innerHTML = `raw hit <b>${raw}</b> · armor <b>${armor}</b> absorbed <b>${pct}%</b> → <b>${eff}</b> reached mood${breached}`;
312
- }
313
-
314
- // ── WHY ──────────────────────────────────────────────────────────────────────
315
-
316
- function renderWhy(why) {
317
- const el = $('#why');
318
- if (!why) { el.textContent = 'why: —'; return; }
319
- el.innerHTML = 'why: ' + why.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
320
- }
321
-
322
- // ── TIMELINE ─────────────────────────────────────────────────────────────────
323
-
324
- function renderTimeline(events) {
325
- const svg = $('#tl-svg');
326
- if (!events || events.length < 2) {
327
- svg.innerHTML = '<text x="4" y="20" fill="#8aa0c0" font-size="10">no history yet</text>';
328
- return;
329
- }
330
- const vals = events.map(e => (e.soul && e.soul[0] != null) ? e.soul[0] : 128);
331
- const W = svg.clientWidth || 200;
332
- const H = 34;
333
- const pad = 3;
334
- const xs = vals.map((_, i) => pad + (i / (vals.length - 1)) * (W - pad * 2));
335
- const ys = vals.map(v => H - pad - ((v / 255) * (H - pad * 2)));
336
- const pts = xs.map((x, i) => `${x},${ys[i]}`).join(' ');
337
- svg.innerHTML = `
338
- <polyline points="${pts}" fill="none" stroke="#79ceff" stroke-width="1.8" stroke-linejoin="round" stroke-linecap="round"/>
339
- <line x1="${pad}" y1="${H/2}" x2="${W-pad}" y2="${H/2}" stroke="rgba(255,255,255,.1)" stroke-width="1" stroke-dasharray="3,4"/>`;
340
- }
341
-
342
- async function fetchAndRenderTimeline() {
343
- try {
344
- const r = await fetch('/history');
345
- if (!r.ok) return;
346
- const data = await r.json();
347
- renderTimeline(data.events || []);
348
- } catch (_) {}
349
- }
350
-
351
- // ── BUBBLE ───────────────────────────────────────────────────────────────────
352
-
353
- function bubble(text) {
354
- const b = $('#bubble');
355
- b.textContent = text;
356
- b.classList.remove('hidden');
357
- clearTimeout(b._t);
358
- b._t = setTimeout(() => b.classList.add('hidden'), 4500);
359
- }
360
-
361
- // ── SPEAK ────────────────────────────────────────────────────────────────────
362
-
363
- function speak(text, voice) {
364
- if (!text || !window.speechSynthesis) return;
365
- const u = new SpeechSynthesisUtterance(text);
366
- u.pitch = (voice && voice.pitch != null) ? voice.pitch : 1.4;
367
- u.rate = (voice && voice.rate != null) ? voice.rate : 1.0;
368
- window.speechSynthesis.cancel();
369
- window.speechSynthesis.speak(u);
370
- }
371
-
372
- // ── NEEDS ────────────────────────────────────────────────────────────────────
373
-
374
- function renderNeeds(needs) {
375
- if (!needs) return;
376
- ['hunger', 'energy', 'attention', 'thirst'].forEach(k => {
377
- const val = needs[k] ?? 0;
378
- const pct = Math.round(Math.min(100, Math.max(0, val)));
379
- const fill = document.getElementById(`need-${k}`);
380
- const valEl = document.getElementById(`need-${k}-val`);
381
- if (fill) fill.style.width = pct + '%';
382
- if (valEl) valEl.textContent = pct;
383
- });
384
- // bowls show their level: food = hunger, water = thirst (community keeps them filled)
385
- const food = document.getElementById('bowl-food');
386
- if (food && needs.hunger != null) food.setAttribute('opacity', String(Math.max(0, Math.min(100, needs.hunger)) / 100));
387
- const water = document.getElementById('bowl-water');
388
- if (water && needs.thirst != null) water.setAttribute('opacity', String(Math.max(0, Math.min(100, needs.thirst)) / 100));
389
- }
390
-
391
- // ── MOMENTS ──────────────────────────────────────────────────────────────────
392
-
393
- function relativeTime(ts) {
394
- // ts is an ISO string or unix seconds float
395
- let ms;
396
- if (typeof ts === 'number') {
397
- ms = ts < 1e10 ? ts * 1000 : ts;
398
- } else {
399
- ms = Date.parse(ts);
400
- }
401
- if (!ms || isNaN(ms)) return '';
402
- const diff = Math.floor((Date.now() - ms) / 1000);
403
- if (diff < 60) return 'just now';
404
- if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
405
- if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
406
- return Math.floor(diff / 86400) + 'd ago';
407
- }
408
-
409
- function renderMoments(events) {
410
- const list = $('#moments-list');
411
- if (!list) return;
412
- if (!events || events.length === 0) {
413
- list.innerHTML = '<div class="moment-row" style="color:#6a7a96;font-size:9px">nothing yet</div>';
414
- return;
415
- }
416
- list.innerHTML = events.slice().reverse().map(ev => {
417
- const badge = (ev.type || 'moment').replace(/_/g, ' ');
418
- const label = ev.mood_word || ev.label || '';
419
- const time = relativeTime(ev.ts);
420
- return `<div class="moment-row">
421
- <span class="moment-badge">${badge}</span>
422
- <span class="moment-label">${label}</span>
423
- <span class="moment-time">${time}</span>
424
- </div>`;
425
- }).join('');
426
- }
427
-
428
- async function fetchAndRenderMoments() {
429
- try {
430
- const r = await fetch('/moments');
431
- if (!r.ok) return;
432
- const data = await r.json();
433
- renderMoments(data.events || []);
434
- } catch (_) {}
435
- }
436
-
437
- // ── REMEMBERS-YOU HINT ───────────────────────────────────────────────────────
438
-
439
- function renderRelationHint(relation) {
440
- const hint = $('#remembers-hint');
441
- if (!hint) return;
442
- if (relation && relation.known === true) {
443
- hint.classList.remove('hidden');
444
- } else {
445
- hint.classList.add('hidden');
446
- }
447
- }
448
-
449
- // ── RENDER (full state) ──────────────────────────────────────────────────────
450
-
451
- function render(state) {
452
- const ap = state.appearance || {};
453
- _ap = ap;
454
- const mood = ap.mood || Array(7).fill(128);
455
- const deltas = state.deltas || null;
456
- const read = state.read || null;
457
-
458
- renderMeters(mood, deltas, read);
459
- renderBlob(ap);
460
- $('#moodword').textContent = state.mood_word || '…';
461
-
462
- if (state.trace) renderTrace(state.trace);
463
- if (state.acceptance) renderAcceptance(state.acceptance);
464
- if (state.why) renderWhy(state.why);
465
- if (state.needs) renderNeeds(state.needs);
466
- renderRelationHint(state.relation || null);
467
- }
468
-
469
- // ── SQUISH IMPULSE ────────────────────────────────────────────────────────────
470
-
471
- function squishImpulse(deltas) {
472
- if (!deltas) return;
473
- const mag = deltas.reduce((s, d) => s + Math.abs(d || 0), 0);
474
- // bigger, springier reaction so an interaction visibly lands
475
- const kick = 1.5 + Math.min(11, mag / 38);
476
- _spring.vel -= kick;
477
- }
478
-
479
- // ── APPLY RESPONSE (shared by say + care) ────────────────────────────────────
480
-
481
- // Sims-style mood float above his head: an emotion emoji + a single +/- (1-3 for
482
- // intensity) showing what an interaction did to his MOOD. The per-dimension VADUGWI
483
- // changes stay in the left stats panel (the meters + deltas).
484
- function moodEmoji(mood) {
485
- const v = mood[0] ?? 128, a = mood[1] ?? 128;
486
- if (v >= 200 && a >= 185) return '😂'; // elated/laughing
487
- if (v >= 165) return '😄'; // happy
488
- if (v >= 140) return '🙂'; // pleased
489
- if (v <= 80 && a >= 150) return '😡'; // mad
490
- if (v <= 85) return '😭'; // crying
491
- if (v <= 112) return '😞'; // sad
492
- if (a >= 188) return '😲'; // startled
493
- return '😐'; // neutral
494
- }
495
- function _floatMood(net, mood) {
496
- const up = net > 0;
497
- let sym = '';
498
- if (Math.abs(net) >= 3) {
499
- const count = Math.max(1, Math.min(3, Math.round(Math.abs(net) / 14)));
500
- sym = (up ? '+' : '−').repeat(count);
501
- }
502
- const emoji = mood ? moodEmoji(mood) : '';
503
- if (!sym && !emoji) return;
504
- _float = { sym, up, emoji, until: performance.now() + 2600, startT: null };
505
- }
506
- function floatDeltas(deltas, mood) {
507
- if (!deltas) return;
508
- _floatMood((deltas[0] || 0) + 0.6 * (deltas[5] || 0), mood); // mood ≈ valence + worth
509
- }
510
- function floatRaises(mood) { _floatMood(20, mood); } // self-soothe is a gentle positive
511
-
512
- // valence → hue, SAME piecewise mapping as the server (appearance.py): red→orange→
513
- // yellow→green. (The old flash used V/255*360 which sent positive reads into purple.)
514
- function hueForValence(v) {
515
- v = Math.max(0, Math.min(255, v));
516
- return v <= 128 ? 8 + (48 - 8) * (v / 128) : 48 + (125 - 48) * ((v - 128) / 127);
517
- }
518
-
519
- function applyResponse(state) {
520
- squishImpulse(state.deltas); // physical bounce
521
- floatDeltas(state.deltas, (state.appearance || {}).mood); // +/- emoji float
522
- render(state); // body stays yellow; emotion shows via face glow
523
- }
524
-
525
- // ── TOY ───────────────────────────────────────────────────────────────────────
526
-
527
- async function toy(name) {
528
- try {
529
- const r = await fetch('/toy', {
530
- method: 'POST',
531
- headers: { 'Content-Type': 'application/json' },
532
- body: JSON.stringify({ toy: name }),
533
- });
534
- if (!r.ok) { bubble('…?'); return; }
535
- const state = await r.json();
536
- applyResponse(state);
537
- bubble(state.emoticon || '');
538
- speak(state.simlish, state.voice);
539
- await fetchAndRenderTimeline();
540
- await fetchAndRenderMoments();
541
- } catch (_) {
542
- bubble('…?');
543
- }
544
- }
545
-
546
- // ── SAY ───────────────────────────────────────────────────────────────────────
547
-
548
- async function say() {
549
- const inp = $('#msg');
550
- const text = inp.value.trim();
551
- if (!text) return;
552
- inp.value = '';
553
-
554
- try {
555
- const r = await fetch('/say', {
556
- method: 'POST',
557
- headers: { 'Content-Type': 'application/json' },
558
- body: JSON.stringify({ text }),
559
- });
560
- if (!r.ok) { bubble('…?'); return; }
561
- const state = await r.json();
562
-
563
- applyResponse(state);
564
-
565
- if (state.recognition && state.recognition.returning) {
566
- bubble('welcome back!');
567
- } else {
568
- bubble(state.emoticon || '');
569
  }
570
- speak(state.simlish, state.voice);
571
- await fetchAndRenderTimeline();
572
- await fetchAndRenderMoments();
573
- } catch (_) {
574
- bubble('…?');
575
- }
576
- }
577
-
578
- // ── CARE ─────────────────────────────────────────────────────────────────────
579
-
580
- async function care(action) {
581
- try {
582
- const r = await fetch('/care', {
583
- method: 'POST',
584
- headers: { 'Content-Type': 'application/json' },
585
- body: JSON.stringify({ action }),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
586
  });
587
- if (!r.ok) { bubble('…?'); return; }
588
- const state = await r.json();
589
-
590
- applyResponse(state);
591
- bubble(state.emoticon || '');
592
- speak(state.simlish, state.voice);
593
- await fetchAndRenderTimeline();
594
- await fetchAndRenderMoments();
595
- } catch (_) {
596
- bubble('…?');
597
- }
598
- }
599
-
600
- // ── AUTH CONTROL ─────────────────────────────────────────────────────────────
601
-
602
- // ── BOOT ──────────────────────────────────────────────────────────────────────
603
-
604
- $('#send').onclick = say;
605
- $('#msg').addEventListener('keydown', (e) => { if (e.key === 'Enter') say(); });
606
-
607
- // Tap a world object → the creature walks over to it, then the action fires.
608
- function _objCenterX(el) {
609
- try { const b = el.getBBox(); return b.x + b.width / 2; }
610
- catch (_) { return _posX; }
611
- }
612
- function _sendToObject(el) {
613
- const t = el.getAttribute('data-toy');
614
- const c = el.getAttribute('data-care');
615
- const x = Math.max(ROAM_MIN, Math.min(ROAM_MAX, _objCenterX(el)));
616
- _target = { x, action: t ? { kind: 'toy', name: t } : { kind: 'care', name: c } };
617
- }
618
- document.querySelectorAll('.room-obj').forEach(el => {
619
- el.addEventListener('click', () => _sendToObject(el));
620
- el.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _sendToObject(el); } });
621
- });
622
-
623
- // Tap the creature itself → pet it (in place, no walk). Deltas float the +V +W.
624
- function _petHim() { care('pet'); }
625
- const _blobPlace = $('#blob-place');
626
- if (_blobPlace) {
627
- _blobPlace.addEventListener('click', _petHim);
628
- _blobPlace.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _petHim(); } });
629
- }
630
-
631
- // collapsible "receipts" drawer — keeps the room uncovered by default
632
- const _drawer = $('#detail-drawer'), _dtoggle = $('#detail-toggle');
633
- if (_dtoggle && _drawer) {
634
- _dtoggle.onclick = () => {
635
- const open = _drawer.classList.toggle('open');
636
- _dtoggle.classList.toggle('open', open);
637
- _dtoggle.setAttribute('aria-expanded', open ? 'true' : 'false');
638
- };
639
- }
640
-
641
-
642
- // initial snapshot + history + moments
643
- fetch('/snapshot').then(r => r.json()).then(state => {
644
- handleSnapshot(state);
645
- renderWhy(null);
646
- renderAcceptance(null);
647
- }).catch(() => {});
648
-
649
- fetchAndRenderTimeline();
650
- fetchAndRenderMoments();
651
-
652
- // poll: bowl empties as hunger decays, and he may self-soothe when low
653
- setInterval(() => {
654
- fetch('/snapshot').then(r => r.json()).then(s => {
655
- if (s.needs) renderNeeds(s.needs);
656
- if (s.self_soothe && s.self_soothe.item) triggerSoothe(s.self_soothe.item);
657
- }).catch(() => {});
658
- }, 45000);
659
-
660
- function fireAction(act) {
661
- if (!act) return;
662
- if (act.kind === 'toy') {
663
- toy(act.name); // applyResponse floats the real deltas
664
- startHold(act.name, true);
665
- } else if (act.kind === 'care') {
666
- care(act.name); // applyResponse floats the real deltas
667
- if (act.name === 'play') startHold('ball', true);
668
- } else if (act.kind === 'soothe') {
669
- // server already applied the (gentle) lift; no client deltas → float a + and his mood
670
- startHold(act.name, true);
671
- floatRaises(_ap && _ap.mood);
672
- }
673
- }
674
-
675
- // ── HOLDING an item: pick it up (full size, off the floor) ──
676
- function startHold(item, carry) {
677
- if (_hold && _hold.floorEl) _hold.floorEl.style.opacity = ''; // restore prior
678
- _hold = { item: null, floorEl: null, until: performance.now() + 7000 };
679
- if (carry && item && ITEM[item]) {
680
- const it = ITEM[item];
681
- const el = $('#carry');
682
- if (el) {
683
- el.setAttributeNS('http://www.w3.org/1999/xlink', 'href', ASSET(it.sprite));
684
- el.setAttribute('href', ASSET(it.sprite));
685
- el.setAttribute('width', it.w);
686
- el.setAttribute('height', it.h);
687
  }
688
- _hold.item = item;
689
- const fl = document.querySelector('.room-obj' + it.floor);
690
- if (fl) { fl.style.opacity = '0'; _hold.floorEl = fl; } // take it off the floor
691
- }
692
- }
693
- function _objElByName(name) {
694
- if (name === 'ball') return document.querySelector('.room-obj[data-care="play"]');
695
- return document.querySelector(`.room-obj[data-toy="${name}"]`);
696
- }
697
- // server says he self-soothed walk him to that item and pick it up
698
- function triggerSoothe(item) {
699
- if (!item) return;
700
- const el = _objElByName(item);
701
- let x = _posX;
702
- if (el) { try { const b = el.getBBox(); x = b.x + b.width / 2; } catch (_) {} }
703
- _target = { x: Math.max(ROAM_MIN, Math.min(ROAM_MAX, x)), action: { kind: 'soothe', name: item } };
704
- }
705
- // snapshot handler: render + react to an autonomous self-soothe
706
- function handleSnapshot(state) {
707
- render(state);
708
- if (state && state.self_soothe && state.self_soothe.item) triggerSoothe(state.self_soothe.item);
709
- }
710
-
711
- function pulse(t) {
712
- const dt = _lastT ? Math.min(0.05, (t - _lastT) / 1000) : 0.016;
713
- _lastT = t;
714
- const P = window.ClankerPhysics;
715
- const mood = (_ap && _ap.mood) || Array(7).fill(128);
716
- const a = mood[1] ?? 128, u = mood[3] ?? 0;
717
-
718
- _phase += P.breathRate(mood) * dt;
719
-
720
- // sleep only when truly low-energy AND not summoned by a tap
721
- const sleeping = (a < 70 && u < 60) && !_target;
722
-
723
- // cadence: walk speed scales with arousal (calm plod ↔ wired scurry)
724
- let speed = 7 + (a / 255) * 44; // 7 .. 51 scene-px/sec
725
- let destX = _posX;
726
-
727
- if (sleeping) {
728
- destX = _posX;
729
- } else if (_target) {
730
- destX = _target.x;
731
- speed = Math.max(speed, 40); // deliberate walk toward a tapped object
732
- } else {
733
- // idle wander: amble to a spot, linger (longer when calm), pick a new spot
734
- if (_pause > 0) {
735
- _pause -= dt;
736
- destX = _posX;
737
- } else {
738
- destX = _wanderX;
739
- if (Math.abs(_posX - _wanderX) < 4) {
740
- _pause = _rand(0.6, 2.8) * (1 + (1 - a / 255) * 2.2);
741
- _wanderX = _rand(ROAM_MIN, ROAM_MAX);
742
- }
743
  }
744
- }
745
-
746
- // integrate position
747
- _vel = 0;
748
- const dx = destX - _posX;
749
- if (!sleeping && Math.abs(dx) > 1.0) {
750
- const step = Math.sign(dx) * Math.min(Math.abs(dx), speed * dt);
751
- _posX = Math.max(ROAM_MIN, Math.min(ROAM_MAX, _posX + step));
752
- _facing = step >= 0 ? 1 : -1;
753
- _vel = step / dt;
754
- }
755
- // arrived at a tapped object → fire its VADUGWI action
756
- if (_target && Math.abs(_target.x - _posX) <= 3) {
757
- const act = _target.action; _target = null; _pause = 0.9;
758
- fireAction(act);
759
- }
760
-
761
- // squish spring relaxes toward rest (impulses injected on every response)
762
- const ns = P.springStep(_spring, 1, dt, 140, 14);
763
- _spring.pos = ns.pos; _spring.vel = ns.vel;
764
-
765
- const moving = Math.abs(_vel) > 1;
766
- const breathScale = P.breath(_phase, mood);
767
- // walk bob: vertical bounce while moving, bigger when faster/aroused
768
- const bob = moving ? Math.abs(Math.sin(_phase * 2.3)) * (1.5 + (Math.abs(_vel) / 51) * 6.0) : 0;
769
- const swayDeg = sleeping ? 0 : (moving ? _facing * 3 : P.sway(_phase, mood));
770
-
771
- // OUTER #blob-place: walk along floor + face direction (feet pinned to y=192)
772
- const place = $('#blob-place');
773
- if (place) {
774
- place.setAttribute('transform',
775
- `translate(${_posX} ${192 - bob}) scale(${0.40 * _facing} 0.40) translate(-100 -210)`);
776
- }
777
- // INNER #blob-anim: breathing + squish + sway around body center (100,116)
778
- const anim = $('#blob-anim');
779
- if (anim) {
780
- anim.setAttribute('transform',
781
- `rotate(${swayDeg} 100 116) translate(100 116) scale(${breathScale} ${breathScale * _spring.pos}) translate(-100 -116)`);
782
- }
783
-
784
- // aura + contact shadow follow the creature
785
- const aura = $('#aura');
786
- if (aura) { aura.setAttribute('cx', _posX.toFixed(1)); aura.setAttribute('cy', (154 - bob * 0.6).toFixed(1)); }
787
- const shadow = $('#shadow');
788
- if (shadow) { shadow.setAttribute('cx', _posX.toFixed(1)); shadow.setAttribute('rx', (40 - bob * 1.1).toFixed(1)); }
789
-
790
- // sleep Zzz floats above his head
791
- const zzz = $('#zzz');
792
- if (zzz) {
793
- zzz.setAttribute('x', (_posX + 26).toFixed(1));
794
- zzz.setAttribute('opacity', sleeping ? String(0.4 + 0.4 * Math.sin(_phase)) : '0');
795
- }
796
-
797
- // holding an item: carry it (full size, in his hands) + float a VADUGWI badge
798
- const holding = _hold && t < _hold.until;
799
- const carryEl = $('#carry');
800
- if (carryEl) {
801
- if (holding && _hold.item && ITEM[_hold.item]) {
802
- const it = ITEM[_hold.item];
803
- carryEl.setAttribute('x', (_posX + _facing * 6 - it.w / 2).toFixed(1));
804
- carryEl.setAttribute('y', (152 - bob - it.h * 0.45).toFixed(1));
805
- carryEl.setAttribute('opacity', '1');
806
  } else {
807
- carryEl.setAttribute('opacity', '0');
 
 
 
 
 
808
  }
809
- }
810
- // Sims-style +/- badge: floats up above his head and fades on any VADUGWI change
811
- const badgeEl = $('#raise-badge');
812
- if (badgeEl) {
813
- if (_float && t < _float.until) {
814
- if (_float.startT == null) {
815
- _float.startT = t;
816
- const emojiSpan = _float.emoji ? `<tspan>${_float.emoji}</tspan>` : '';
817
- const symSpan = _float.sym ? `<tspan fill="${_float.up ? '#86e0a0' : '#ff7d9c'}">${_float.sym}</tspan>` : '';
818
- const sep = (emojiSpan && symSpan) ? '<tspan> </tspan>' : '';
819
- badgeEl.innerHTML = emojiSpan + sep + symSpan;
820
- }
821
- const prog = (t - _float.startT) / 2600; // 0..1
822
- badgeEl.setAttribute('x', _posX.toFixed(1));
823
- badgeEl.setAttribute('y', (118 - bob - prog * 18).toFixed(1)); // rises as it fades
824
- badgeEl.setAttribute('opacity', String(Math.max(0, 1 - prog)));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
825
  } else {
826
- if (_float) _float = null;
827
- badgeEl.setAttribute('opacity', '0');
 
828
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
829
  }
830
- if (_hold && t >= _hold.until) {
831
- if (_hold.floorEl) _hold.floorEl.style.opacity = ''; // put it back on the floor
832
- _hold = null;
833
- }
834
 
835
- requestAnimationFrame(pulse);
836
- }
837
- requestAnimationFrame(pulse);
 
1
  'use strict';
2
+ /* Clanker pet — vanilla controller (no React, no dc-runtime).
3
+ Ports the redesign's DCLogic class to a plain object. The on-device fake VADUGWI
4
+ engine is GONE; data comes from the real FastAPI backend (/snapshot /say /care /toy
5
+ /moments). Animation loop, simlish, WebAudio blips, bowls, toy-play, behavior FSM
6
+ and tooltips are ported from the redesign verbatim. self_soothe + recognition
7
+ "welcome back" are preserved from the prior production app. */
8
+
9
+ const App = {
10
+ // ---------- state ----------
11
+ root: null, stageEl: null, inputEl: null, tipEl: null,
12
+ name: 'Huggy',
13
+ soundOn: true,
14
+ // mood model: backend appearance.mood -> mood; disp lerps toward mood (redesign)
15
+ soul: {v:152,a:118,d:142,u:14,g:142,w:160,i:150},
16
+ mood: {v:152,a:118,d:142,u:14,g:142,w:160,i:150},
17
+ disp: {v:152,a:118,d:142,u:14,g:142,w:160,i:150},
18
+ haveSoul: false, // backend soul[] present?
19
+ backendDistance: null, // backend distance (NEW field; may be absent)
20
+ needsObj: {hunger:74, energy:80, attention:66, thirst:78},
21
+ lastDelta: {},
22
+ crisis: 0,
23
+ haveCrisis: false,
24
+ diary: [],
25
+ t0: performance.now(), lastTs: 0,
26
+ nextBlink: 1.2, blinkUntil: 0,
27
+ nextDart: 3, dart: 0, _dartEnd: 0,
28
+ itemEls: {},
29
+ dragId: null, dragWrap: null,
30
+ food: 0, water: 0,
31
+ action: null, actPos:{x:50,y:52,rot:0,sc:1}, _actEnd: null,
32
+ eating: 0, drinking: 0,
33
+ behavior: 'idle', shake: 0,
34
+ lastSpeak: 0,
35
+ uiAcc: 0,
36
+ ac: null, _raf: null, _warned: false,
37
+ items: [],
38
+ readPhrase: 'waiting…',
39
+ moodWord: 'calm',
40
+
41
+ // ====================================================================
42
+ // ITEM DEFS (icons + defaults) — backend toy/care names mapped per HOOKUP_SPEC
43
+ // ====================================================================
44
+ ICONS(kind){
45
+ const I = {
46
+ balloon:'<svg viewBox="0 0 40 56" width="100%"><path d="M20 2 C30 2 34 14 32 24 C30 34 24 40 20 40 C16 40 10 34 8 24 C6 14 10 2 20 2Z" fill="#ff7aa8"/><path d="M16 10 q4 -4 9 0" stroke="#fff" stroke-width="2" fill="none" opacity=".6"/><path d="M20 40 l-2 6 4 0 z" fill="#ff7aa8"/><path d="M20 46 q-5 6 0 10" stroke="#caa" stroke-width="1.4" fill="none"/></svg>',
47
+ ball:'<svg viewBox="0 0 40 40" width="100%"><circle cx="20" cy="20" r="17" fill="#4db5e8"/><path d="M3 20 h34 M20 3 v34" stroke="#fff" stroke-width="2.4" opacity=".85"/><path d="M7 9 q13 11 26 0 M7 31 q13 -11 26 0" stroke="#fff" stroke-width="2" fill="none" opacity=".6"/></svg>',
48
+ teddy:'<svg viewBox="0 0 44 44" width="100%"><circle cx="12" cy="9" r="6" fill="#b07a44"/><circle cx="32" cy="9" r="6" fill="#b07a44"/><circle cx="22" cy="24" r="17" fill="#c98c50"/><circle cx="16" cy="21" r="2.3" fill="#3a2410"/><circle cx="28" cy="21" r="2.3" fill="#3a2410"/><ellipse cx="22" cy="28" rx="6" ry="5" fill="#e3bf90"/><circle cx="22" cy="26" r="1.8" fill="#3a2410"/></svg>',
49
+ music:'<svg viewBox="0 0 40 38" width="100%"><rect x="4" y="14" width="32" height="22" rx="3" fill="#caa24a"/><rect x="4" y="14" width="32" height="6" rx="3" fill="#a8823a"/><path d="M18 6 v18" stroke="#3a2410" stroke-width="2.4"/><circle cx="16" cy="25" r="3.4" fill="#3a2410"/><path d="M18 6 q8 1 8 7" stroke="#3a2410" stroke-width="2.4" fill="none"/></svg>',
50
+ rattle:'<svg viewBox="0 0 36 40" width="100%"><circle cx="18" cy="13" r="11" fill="#7ad0a0"/><circle cx="13" cy="11" r="2" fill="#fff" opacity=".8"/><rect x="15" y="22" width="6" height="16" rx="3" fill="#e0b25a"/></svg>',
51
+ mirror:'<svg viewBox="0 0 34 40" width="100%"><ellipse cx="17" cy="14" rx="13" ry="13" fill="#d8c089"/><ellipse cx="17" cy="14" rx="9.5" ry="9.5" fill="#cdeaf2"/><path d="M11 10 q5 -4 11 0" stroke="#fff" stroke-width="2" fill="none" opacity=".7"/><rect x="14" y="26" width="6" height="13" rx="3" fill="#b58f44"/></svg>'
52
+ };
53
+ return I[kind] || I.ball;
54
+ },
55
+ // care = backend route+name: ball->/care{play}; others->/toy{<name>}
56
+ defaultItems(){
57
+ const base = [
58
+ {id:'ball', kind:'ball', ep:'care', name:'play', x:30, y:88, size:7.5, tip:'Drag onto Huggy — he bounces it. Spikes arousal & joy.'},
59
+ {id:'balloon', kind:'balloon', ep:'toy', name:'balloon', x:14, y:76, size:8.5, tip:'Drag onto Huggy — he bats it around. Raises valence & dominance.'},
60
+ {id:'rattle', kind:'rattle', ep:'toy', name:'rattle', x:64, y:90, size:7, tip:'Drag onto Huggy — he shakes it. Bumps arousal & urgency.'},
61
+ {id:'music', kind:'music', ep:'toy', name:'musicbox', x:74, y:92, size:8, tip:'Drag onto Huggy — he sways to it. Soothes: arousal down, valence up.'},
62
+ {id:'mirror', kind:'mirror', ep:'toy', name:'mirror', x:84, y:88, size:7, tip:'Drag onto Huggy — he admires himself. Nudges self-worth.'},
63
+ {id:'teddy', kind:'teddy', ep:'toy', name:'teddy', x:93, y:92, size:10, tip:'Drag onto Huggy — he squeezes it. Comfort: steadies valence & self-worth.'}
64
+ ];
65
+ return base.map(b=>({...b, resting:true, held:false}));
66
+ },
67
+
68
+ // ====================================================================
69
+ // PRESENTATIONAL HELPERS (client-side only; no engine math)
70
+ // ====================================================================
71
+ feltPhrase(C){
72
+ const vw = C.v<80?'miserable':C.v<118?'uneasy':C.v<=138?'level':C.v<=180?'warm':'glowing';
73
+ const aw = C.a<80?'flat':C.a<118?'settled':C.a<=138?'steady':C.a<=180?'keyed-up':'electric';
74
+ const ww = C.w<90?', a bit small':C.w>175?', sure of itself':'';
75
+ return `${vw}, ${aw}${ww}`;
76
+ },
77
+ emoteFrom(d){
78
+ if(this.haveCrisis && this.crisis>0.6) return ';_;';
79
+ if(d.v<100 && d.a>150) return '>:(';
80
+ if(d.v<104 && d.a<110) return 'T_T';
81
+ if(d.v<108) return '·︵·';
82
+ if(this.needsObj.energy<28) return '-_-';
83
+ if(d.v>168 && d.a>150) return '\\^o^/';
84
+ if(d.v>156) return '^‿^';
85
+ return '·‿·';
86
+ },
87
+ moodHue(d){
88
+ let hue, valN=(d.v-128)/127, aN=(d.a-128)/127;
89
+ if(d.v<116){ hue = d.a>138 ? 4 : 212; }
90
+ else if(d.v>152){ hue = 128; }
91
+ else hue = 46;
92
+ const sat = Math.round(54 + Math.abs(valN)*30 + Math.max(0,aN)*14);
93
+ return {hue, sat, light:58};
94
+ },
95
+
96
+ // backend role -> [bg, fg, border] + tooltip
97
+ roleColor(r){
98
+ const c={
99
+ EMOTIONAL:['rgba(95,170,255,.16)','#8fc0ff','rgba(95,170,255,.3)'],
100
+ SELF_REF :['rgba(255,210,30,.16)','#ffd86a','rgba(255,210,30,.3)'],
101
+ NEGATOR :['rgba(255,90,90,.16)','#ff9b9b','rgba(255,90,90,.3)'],
102
+ AMPLIFIER:['rgba(95,210,140,.16)','#8fe6b0','rgba(95,210,140,.3)'],
103
+ CONNECTOR:['rgba(150,150,150,.12)','#bbb','rgba(150,150,150,.26)'],
104
+ CHOPPER :['rgba(190,160,110,.16)','#dcc08a','rgba(190,160,110,.3)'],
105
+ SOLVENT :['rgba(170,120,255,.16)','#c6a6ff','rgba(170,120,255,.3)'],
106
+ GAS :['rgba(160,160,160,.12)','#9c968c','rgba(160,160,160,.24)']
107
+ };
108
+ return c[r]||c.GAS;
109
+ },
110
+ roleTip(r){
111
+ const t={
112
+ EMOTIONAL:'EMOTIONAL — carries a force vector from the curated vocabulary.',
113
+ SELF_REF :'SELF_REF — anchors the read to the speaker; routes valence into self-worth.',
114
+ NEGATOR :'NEGATOR — flips the sign of nearby emotional words.',
115
+ AMPLIFIER:'AMPLIFIER — scales nearby force up.',
116
+ CONNECTOR:'CONNECTOR — discourse glue between clauses.',
117
+ CHOPPER :'CHOPPER — context word whose meaning depends on structure.',
118
+ SOLVENT :'SOLVENT — dissolves LIQUID atoms in a casual register.',
119
+ GAS :'GAS — neutral / inert token.'
120
+ };
121
+ return t[r]||'structural role';
122
+ },
123
+
124
+ // ====================================================================
125
+ // RENDER FUNCTIONS (replace setState + dc-runtime sc-for/{{}})
126
+ // ====================================================================
127
+ renderMeters(){
128
+ const d=this.disp;
129
+ const dims=[
130
+ ['V','Valence','#55aaff','negative bias','positive bias',"Valence — emotional direction (0–255). 128 is neutral. \"I'm fine\" reads ~83 (uneasy), not positive."],
131
+ ['A','Arousal','#ff8855','calm / flat','reactive / volatile','Arousal — energy level. Low = flat or numb, high = reactive/volatile.'],
132
+ ['D','Dominance','#aa55ff','feels powerless','feels in control','Dominance — agency & power. Force Flow (who-does-what-to-whom) feeds this.'],
133
+ ['U','Urgency','#ff5555','no time pressure','everything urgent','Urgency — time pressure. Starts at 0 and only rises with urgent cues.'],
134
+ ['G','Gravity','#ffaa55','everything heavy','light & floating','Gravity — emotional weight. Low = crushing, high = light/floating.'],
135
+ ['W','Self-Worth','#55ffaa','self-critical','self-assured','Self-Worth — self-evaluation. Low W amplifies negatives (W→V coupling).'],
136
+ ['I','Intent','#ffff55','withdrawing','connecting','Intent — communicative direction, from withdraw to connect/control.']
137
+ ];
138
+ const read=(val,lo,hi)=> val<80?lo: val<118?'slightly '+lo: val>180?hi: val>138?'slightly '+hi:'neutral';
139
+ let html='';
140
+ for(const [key,name,color,lo,hi,tip] of dims){
141
+ const val=Math.round(d[key.toLowerCase()]);
142
+ const frac=val/255*100, center=50;
143
+ const barLeft=Math.min(center,frac), barW=Math.max(Math.abs(frac-center),1.2);
144
+ const del=this.lastDelta[key.toLowerCase()]||0;
145
+ const deltaText= del===0?'':(del>0?'▲'+del:'▼'+Math.abs(del));
146
+ const deltaColor= del>0?'#5fd08a':del<0?'#ff8a8a':'#a9967c';
147
+ html+=`<div class="meter" data-tip="${this._esc(tip)}">
148
+ <div class="meter-top">
149
+ <span class="meter-key" style="background:${color}">${key}</span>
150
+ <span class="meter-label">${name}</span>
151
+ <span class="meter-delta" style="color:${deltaColor}">${deltaText}</span>
152
+ <span class="meter-num">${val}</span>
153
+ </div>
154
+ <div class="meter-track"><div class="meter-center"></div>
155
+ <div class="meter-fill" style="left:${barLeft}%;width:${barW}%;background:${color}"></div>
156
+ </div>
157
+ <div class="meter-reading">— ${read(val,lo,hi)}</div>
158
+ </div>`;
159
  }
160
+ const el=this.q('#meters'); if(el) el.innerHTML=html;
161
+ },
162
+ renderNeeds(){
163
+ const nc=(v)=> v<25?'#ff6b6b': v<50?'#ffb24d':'#5fd08a';
164
+ let html='';
165
+ for(const k of ['hunger','energy','attention','thirst']){
166
+ const v=Math.round(Math.max(0,Math.min(100,this.needsObj[k])));
167
+ html+=`<div class="need-row">
168
+ <span class="need-label">${k}</span>
169
+ <div class="need-track"><div class="need-fill" style="width:${v}%;background:${nc(v)}"></div></div>
170
+ <span class="need-val">${v}</span>
171
+ </div>`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  }
173
+ const el=this.q('#needs'); if(el) el.innerHTML=html;
174
+ },
175
+ renderDistance(){
176
+ let dist;
177
+ if(this.backendDistance!=null) dist=this.backendDistance; // backend (NEW)
178
+ else if(this.haveSoul){ // compute from soul[]
179
+ dist=0; ['v','a','d','u','g','w','i'].forEach(k=>dist+=Math.abs(this.mood[k]-this.soul[k])); dist=Math.round(dist/7);
180
+ } else dist=null; // unknown -> dash
181
+ const el=this.q('#soulDistance');
182
+ if(el) el.textContent = dist==null? 'Δ —' : ('Δ '+dist);
183
+ },
184
+ renderTrace(trace){
185
+ if(!trace) return;
186
+ const words=this.q('#traceWords'), structs=this.q('#traceStructs'),
187
+ contribs=this.q('#traceContribs'), countEl=this.q('#structCount');
188
+ // physical (toy/care/pet): no language to parse
189
+ if(trace.kind==='physical'){
190
+ if(words) words.innerHTML=`<span class="muted">${this._esc(trace.label||'physical interaction')} — physical interaction, no language to parse.</span>`;
191
+ if(structs) structs.innerHTML='<span class="muted">— none in this message</span>';
192
+ if(contribs) contribs.innerHTML='';
193
+ if(countEl) countEl.textContent='0';
194
+ return;
195
+ }
196
+ // WORDS chips
197
+ const ws=(trace.words||[]);
198
+ if(words){
199
+ if(ws.length===0) words.innerHTML='<span class="muted">talk to it and the structural read shows up here.</span>';
200
+ else words.innerHTML=ws.slice(0,14).map(w=>{
201
+ const [bg,fg,bd]=this.roleColor(w.role);
202
+ return `<span class="w-chip" style="background:${bg};color:${fg};border:1px solid ${bd}" data-tip="${this._esc(this.roleTip(w.role))}">${this._esc(w.word||w.token||'')}</span>`;
203
+ }).join('');
204
+ }
205
+ // STRUCTURES
206
+ const sts=(trace.structures||[]);
207
+ if(countEl) countEl.textContent=String(sts.length);
208
+ if(structs){
209
+ if(sts.length===0) structs.innerHTML='<span class="muted">— none in this message</span>';
210
+ else structs.innerHTML=sts.map(s=>{
211
+ const nm=typeof s==='string'?s:(s.name||String(s));
212
+ return `<span class="s-chip">${this._esc(nm)}</span>`;
213
+ }).join('');
214
+ }
215
+ // CONTRIBUTORS — word + signed per-dim string from dv/da/dd/du/dg
216
+ const cs=(trace.contributors||[]);
217
+ if(contribs){
218
+ contribs.innerHTML=cs.slice(0,5).map(c=>{
219
+ const parts=[];
220
+ [['dv','V'],['da','A'],['dd','D'],['du','U'],['dg','G']].forEach(([k,dim])=>{
221
+ const val=c[k];
222
+ if(val!=null && val!==0) parts.push(dim+(val>0?'+':'')+val);
223
+ });
224
+ const dims=parts.join(' ')||'·';
225
+ return `<div class="contrib-row"><span class="contrib-word">${this._esc(c.word||c.token||'?')}</span><span class="contrib-dims">${dims}</span></div>`;
226
+ }).join('');
227
+ }
228
+ },
229
+ renderWhy(why){
230
+ const el=this.q('#whyText'); if(!el) return;
231
+ if(!why){ el.textContent='—'; return; }
232
+ el.innerHTML=this._esc(why).replace(/\*\*(.+?)\*\*/g,'<b>$1</b>');
233
+ },
234
+ renderCrisis(){
235
+ const wrap=this.q('#crisisWrap'), bar=this.q('#crisisBar'), label=this.q('#crisisLabel');
236
+ if(!this.haveCrisis){
237
+ // DEFENSIVE: backend has no crisis -> zero it out, keep calm label
238
+ if(bar){ bar.style.width='0%'; bar.style.background='#5fd08a'; }
239
+ if(label){ label.textContent='—'; label.style.color='#5fd08a'; }
240
+ return;
241
+ }
242
+ const cr=this.crisis;
243
+ const lbl=cr<0.15?'calm':cr<0.4?'watch':cr<0.7?'concern':'high concern';
244
+ const col=cr<0.15?'#5fd08a':cr<0.4?'#ffd24d':cr<0.7?'#ff9d4d':'#ff5a5a';
245
+ if(bar){ bar.style.width=Math.round(cr*100)+'%'; bar.style.background=col; }
246
+ if(label){ label.textContent=lbl; label.style.color=col; }
247
+ },
248
+ renderMoodWord(){
249
+ const el=this.q('#moodWord'); if(el) el.textContent=this.moodWord||'…';
250
+ },
251
+ renderReadPhrase(){
252
+ const el=this.q('#readPhrase'); if(el) el.textContent=this.readPhrase;
253
+ },
254
+ renderDiary(){
255
+ const el=this.q('#diary'); if(!el) return;
256
+ if(!this.diary.length){ el.innerHTML='<span class="muted">moments you share will be remembered here.</span>'; return; }
257
+ el.innerHTML=this.diary.slice(0,9).map(d=>
258
+ `<div class="diary-row"><span class="diary-dot" style="color:${d.color}">${this._esc(d.dot)}</span>`+
259
+ `<div class="diary-text"><span class="body">${this._esc(d.text)}</span> <span class="mood">· ${this._esc(d.mood||'')}</span></div></div>`
260
+ ).join('');
261
+ },
262
+ renderItems(){
263
+ const wrap=this.q('#items'); if(!wrap) return;
264
+ const playId=this.action&&this.action.id;
265
+ // only (re)build DOM when item set changes; positions are updated live in positionActionItem / drag
266
+ if(this._itemsBuilt) { this._applyItemLayout(); return; }
267
+ wrap.innerHTML='';
268
+ this.itemEls={};
269
+ for(const it of this.items){
270
+ const div=document.createElement('div');
271
+ div.className='item';
272
+ div.dataset.id=it.id;
273
+ div.setAttribute('data-tip', it.tip);
274
+ div.style.width=it.size+'%';
275
+ div.style.zIndex='5';
276
+ const inner=document.createElement('div');
277
+ inner.className='item-inner';
278
+ inner.innerHTML=this.ICONS(it.kind);
279
+ div.appendChild(inner);
280
+ this.itemEls[it.id]=inner;
281
+ div.addEventListener('pointerdown',(e)=>this.startDrag(e,it.id));
282
+ wrap.appendChild(div);
283
+ }
284
+ this._itemsBuilt=true;
285
+ this._applyItemLayout();
286
+ },
287
+ _applyItemLayout(){
288
+ const playId=this.action&&this.action.id;
289
+ for(const it of this.items){
290
+ const inner=this.itemEls[it.id]; if(!inner) continue;
291
+ const div=inner.closest('[data-id]'); if(!div) continue;
292
+ if(it.id===playId) continue; // positionActionItem owns it while playing
293
+ div.style.left=it.x+'%'; div.style.top=it.y+'%';
294
+ div.style.transform='translate(-50%,-100%)';
295
+ div.style.transition=(it.resting)?'left .5s cubic-bezier(.34,1.4,.5,1), top .5s cubic-bezier(.34,1.4,.5,1)':'none';
296
+ div.style.zIndex='5';
297
+ }
298
+ },
299
+
300
+ // ====================================================================
301
+ // MOOD / NEEDS APPLICATION (backend-fed)
302
+ // ====================================================================
303
+ // map backend appearance.mood[7] into our mood model (lerp like the redesign)
304
+ applyAppearance(ap){
305
+ if(!ap) return;
306
+ const m=ap.mood;
307
+ if(Array.isArray(m) && m.length>=7){
308
+ const C={v:m[0],a:m[1],d:m[2],u:m[3],g:m[4],w:m[5],i:m[6]};
309
+ const before={...this.mood};
310
+ ['v','a','d','g','w','i'].forEach(k=>{ this.mood[k]=this.mood[k]*0.42 + C[k]*0.58; });
311
+ this.mood.u=C.u;
312
+ this.lastDelta={};
313
+ ['v','a','d','u','g','w','i'].forEach(k=>{ this.lastDelta[k]=Math.round(this.mood[k])-Math.round(before[k]); });
314
+ }
315
+ },
316
+ // prefer backend deltas[] for the meter arrows when present
317
+ applyDeltas(deltas){
318
+ if(Array.isArray(deltas) && deltas.length>=7){
319
+ const keys=['v','a','d','u','g','w','i'];
320
+ this.lastDelta={}; keys.forEach((k,i)=>this.lastDelta[k]=deltas[i]);
321
+ }
322
+ },
323
+ applyNeeds(needs){
324
+ if(!needs) return;
325
+ ['hunger','energy','attention','thirst'].forEach(k=>{
326
+ if(typeof needs[k]==='number') this.needsObj[k]=Math.max(0,Math.min(100,needs[k]));
327
  });
328
+ },
329
+ applyCrisis(crisis){
330
+ if(crisis && typeof crisis.value==='number'){ this.haveCrisis=true; this.crisis=Math.max(0,Math.min(1,crisis.value)); }
331
+ },
332
+ applySoul(state){
333
+ if(Array.isArray(state.soul) && state.soul.length>=7){
334
+ const s=state.soul; this.haveSoul=true;
335
+ this.soul={v:s[0],a:s[1],d:s[2],u:s[3],g:s[4],w:s[5],i:s[6]};
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
336
  }
337
+ if(typeof state.distance==='number') this.backendDistance=Math.round(state.distance);
338
+ },
339
+
340
+ // single entry point for any backend response (say / care / toy / snapshot)
341
+ applyState(state, opts){
342
+ opts=opts||{};
343
+ this.applyAppearance(state.appearance);
344
+ if(opts.useDeltas) this.applyDeltas(state.deltas);
345
+ this.applyNeeds(state.needs);
346
+ this.applyCrisis(state.crisis); // defensive: absent -> haveCrisis stays false
347
+ this.applySoul(state); // defensive: absent -> distance from compute or dash
348
+ if(state.mood_word) this.moodWord=state.mood_word;
349
+ if(state.read && state.read.length>=7){
350
+ this.readPhrase=this.feltPhrase({v:state.read[0],a:state.read[1],d:state.read[2],u:state.read[3],g:state.read[4],w:state.read[5],i:state.read[6]});
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
351
  }
352
+ if(state.trace) this.renderTrace(state.trace);
353
+ if(state.why!=null) this.renderWhy(state.why);
354
+ // panel repaint
355
+ this.renderNeeds(); this.renderCrisis(); this.renderDistance();
356
+ this.renderMoodWord(); this.renderReadPhrase();
357
+ },
358
+
359
+ // ====================================================================
360
+ // ANIMATION LOOP (ported verbatim from the redesign)
361
+ // ====================================================================
362
+ step(now){
363
+ try{ this._step(now); }catch(err){ if(!this._warned){console.warn('clanker step',err);this._warned=true;} }
364
+ this._raf=requestAnimationFrame(this.step.bind(this));
365
+ },
366
+ _step(now){
367
+ const dt=Math.min(0.05,(now-this.lastTs)/1000); this.lastTs=now;
368
+ const t=(now-this.t0)/1000;
369
+ // disp lerp toward mood (server-authoritative mood already set on each response/poll)
370
+ ['v','a','d','u','g','w','i'].forEach(k=>{ this.disp[k]+=(this.mood[k]-this.disp[k])*Math.min(1,4*dt); });
371
+
372
+ this.autoConsume(dt);
373
+ this.behavior = this.action ? 'idle' : this.pickBehavior();
374
+ if(this.behavior==='tantrum' && performance.now()-this.lastSpeak>1700){ this.lastSpeak=performance.now(); this.speak(); }
375
+ this.drawCreature(t);
376
+ this.positionActionItem();
377
+
378
+ this.uiAcc+=dt;
379
+ if(this.uiAcc>0.22){ this.uiAcc=0; this.renderMeters(); }
380
+ },
381
+
382
+ drawCreature(t){
383
+ const r=this.root; if(!r) return;
384
+ const g=(id)=>r.querySelector('#'+id);
385
+ const d=this.disp; const aN=(d.a-128)/127, vN=(d.v-128)/127, iN=(d.i-128)/127;
386
+ const energy=this.needsObj.energy;
387
+ const act=this.action&&this.action.type; const beh=this.behavior;
388
+ const sleepy=(energy<24&&aN<0&&beh==='idle'&&!act);
389
+
390
+ // ---- body transform (bob / slump / shake / breathe) ----
391
+ let tx=0, ty=0, sc=1, scY=1, rotB=0;
392
+ const breathe=1+Math.sin(t*1.8)*0.012;
393
+ if(beh==='tantrum'){
394
+ tx=Math.sin(t*42)*5; ty=Math.abs(Math.sin(t*9))*-7; rotB=Math.sin(t*40)*3; sc=1.02;
395
+ this.shake=4.5;
396
+ } else if(beh==='sad'){
397
+ ty=10; scY=0.93; tx=Math.sin(t*0.7)*2; this.shake=0;
398
+ } else if(act==='ball'){
399
+ ty=Math.sin(t*4.4)*4; tx=Math.sin(t*2.2)*4; this.shake=0;
400
+ } else if(act==='teddy'){
401
+ ty=Math.sin(t*3)*2; sc=1+Math.max(0,Math.sin(t*5.5))*0.02; this.shake=0;
402
+ } else if(act==='balloon'){
403
+ ty=Math.sin(t*3.1)*5-2; tx=Math.sin(t*1.7)*6; this.shake=0;
404
+ } else if(act==='music'){
405
+ tx=Math.sin(t*2.2)*7; rotB=Math.sin(t*2.2)*3; this.shake=0;
 
 
 
 
 
 
 
 
406
  } else {
407
+ let bobAmp=1.4+Math.max(0,aN)*4.0; if(energy<30)bobAmp*=0.4;
408
+ ty=Math.sin(t*(1.5+Math.max(0,aN)*3.2))*bobAmp;
409
+ tx=Math.sin(t*0.85)*(1.4+Math.max(0,aN)*1.6);
410
+ if(d.g<120)ty+=(120-d.g)/4.5;
411
+ if(d.v>166&&d.a>150)rotB=Math.sin(t*6)*3;
412
+ this.shake=0;
413
  }
414
+ const blob=g('blob'); if(blob) blob.setAttribute('transform',`translate(${tx.toFixed(2)},${ty.toFixed(2)}) rotate(${rotB.toFixed(2)} 100 120) scale(${(sc*breathe).toFixed(3)},${(scY*breathe).toFixed(3)})`);
415
+ if(this.stageEl){ const sh=this.shake; this.stageEl.style.transform = sh>0?`translate(${(Math.random()*2-1)*sh}px,${(Math.random()*2-1)*sh}px)`:''; }
416
+
417
+ // ---- eyes: blink + idle dart + state ----
418
+ if(t>this.nextBlink){ this.blinkUntil=t+0.12; this.nextBlink=t+2.4+Math.random()*3; }
419
+ const blinking=t<this.blinkUntil;
420
+ if(t>this.nextDart){ this.dart=(Math.random()<0.5?-1:1)*3; this.nextDart=t+1.6+Math.random()*3.2; this._dartEnd=t+0.9; }
421
+ if(t>this._dartEnd)this.dart=0;
422
+ let eyeRy=11, eyeDx=this.dart;
423
+ if(sleepy)eyeRy=4; else if(act==='teddy'||(beh==='idle'&&d.v>168&&d.a<120))eyeRy=5;
424
+ else if(beh==='tantrum')eyeRy=8; else if(beh==='sad')eyeRy=7; else if(aN>0.5)eyeRy=13;
425
+ if(act==='mirror')eyeRy=12;
426
+ if(blinking)eyeRy=2;
427
+ const eL=g('eyeL'),eR=g('eyeR');
428
+ if(eL){eL.setAttribute('ry',eyeRy); eL.setAttribute('cx',74+eyeDx);}
429
+ if(eR){eR.setAttribute('ry',eyeRy); eR.setAttribute('cx',126+eyeDx);}
430
+ const glL=g('glintL'),glR=g('glintR'); const glo=(blinking||eyeRy<5)?0:1;
431
+ if(glL){glL.setAttribute('opacity',glo);glL.setAttribute('cx',77+eyeDx);} if(glR){glR.setAttribute('opacity',glo);glR.setAttribute('cx',129+eyeDx);}
432
+
433
+ // ---- mouth ----
434
+ const mouth=g('mouth'); const chomp=this.eating>0||this.drinking>0;
435
+ if(mouth){
436
+ if(beh==='tantrum'){ const o=8+Math.abs(Math.sin(t*16))*8; mouth.setAttribute('d',`M84 150 Q100 ${150+o} 116 150 Q100 ${150+o*0.5} 84 150 Z`); mouth.setAttribute('fill','#5a1818'); }
437
+ else if(chomp){ const o=4+Math.abs(Math.sin(t*16))*9; mouth.setAttribute('d',`M86 150 Q100 ${150+o} 114 150 Q100 ${150+o*0.4} 86 150 Z`); mouth.setAttribute('fill','#5a2418'); }
438
+ else if(beh==='sad'){ mouth.setAttribute('d','M82 158 Q100 146 118 158'); mouth.setAttribute('fill','none'); }
439
+ else if((act==='ball'||act==='balloon'||act==='rattle')||(d.v>156&&d.a>150)){ mouth.setAttribute('d','M80 148 Q100 174 120 148 Q100 156 80 148 Z'); mouth.setAttribute('fill','#5a2418'); }
440
+ else { const depth=Math.max(-13,Math.min(20,16*vN)); mouth.setAttribute('d',`M82 152 Q100 ${(152+depth).toFixed(0)} 118 152`); mouth.setAttribute('fill','none'); }
441
+ }
442
+ // ---- brows ----
443
+ const bL=g('browL'),bR=g('browR');
444
+ let brow=0; if(beh==='tantrum')brow=24; else if(d.v<112&&d.d>132)brow=18; else if(beh==='sad'||(d.v<112&&d.d<122))brow=-16;
445
+ if(bL)bL.setAttribute('transform',`rotate(${brow} 86 96)`);
446
+ if(bR)bR.setAttribute('transform',`rotate(${-brow} 114 96)`);
447
+
448
+ // ---- cheeks ----
449
+ const cL=g('cheekL'),cR=g('cheekR'); let cf='#ff9bb0',co=0.32;
450
+ if(beh==='tantrum'){cf='#ff5a4d';co=0.6;} else if(d.v>152){co=0.5;} else if(d.v<112){co=0.12;}
451
+ [cL,cR].forEach(c=>{if(c){c.setAttribute('fill',cf);c.setAttribute('opacity',co);}});
452
+
453
+ // ---- tears (sad) ----
454
+ const tearO=(beh==='sad')?(0.5+0.5*Math.sin(t*2)):0;
455
+ const tearY=(beh==='sad')?(124+((t*40)%34)):124;
456
+ const tL=g('tearL'),tR=g('tearR');
457
+ if(tL){tL.setAttribute('opacity',tearO);tL.setAttribute('cy',tearY);}
458
+ if(tR){tR.setAttribute('opacity',tearO*0.8);tR.setAttribute('cy',124+((t*40+17)%34));}
459
+ // ---- steam (tantrum) ----
460
+ const stO=(beh==='tantrum')?(0.4+0.5*Math.abs(Math.sin(t*6))):0;
461
+ const sL=g('steamL'),sR=g('steamR');
462
+ if(sL){sL.setAttribute('opacity',stO);sL.setAttribute('cy',64-((t*30)%18));}
463
+ if(sR){sR.setAttribute('opacity',stO);sR.setAttribute('cy',64-((t*30+9)%18));}
464
+
465
+ // ---- emote glow ----
466
+ const mh=this.moodHue(d); const eg=g('emoteGlow');
467
+ if(eg){ eg.setAttribute('fill',`hsl(${mh.hue} ${mh.sat}% ${mh.light}%)`); eg.setAttribute('opacity',Math.min(0.42,Math.abs(vN)*0.46).toFixed(2)); eg.style.mixBlendMode = vN<0?'multiply':'screen'; }
468
+
469
+ // ---- arms ----
470
+ const aL=g('armL'),aR=g('armR'); let lr=0,rr=0;
471
+ if(beh==='tantrum'){ lr=-70+Math.sin(t*22)*40; rr=70-Math.sin(t*22)*40; }
472
+ else if(beh==='sad'){ lr=20; rr=-20; }
473
+ else if(act==='teddy'){ lr=-64; rr=64; }
474
+ else if(act==='ball'){ const sw=Math.sin(t*4.4)*22; lr=-30+sw; rr=30-sw; }
475
+ else if(act==='balloon'){ const up=Math.abs(Math.sin(t*3.1))*40; lr=-20-up*0.4; rr=-60-up; }
476
+ else if(act==='rattle'){ rr=-50+Math.sin(t*30)*18; lr=8; }
477
+ else if(act==='music'){ lr=-14; rr=-30; }
478
+ else if(act==='mirror'){ rr=-46; lr=10; }
479
+ else if(d.v>166&&d.a>150){ lr=-52; rr=52; }
480
+ else if(iN>0.18){ lr=-18*iN-6; rr=18*iN+6; }
481
+ else if(iN<-0.15){ lr=12; rr=-12; }
482
+ if(aL)aL.setAttribute('transform',`rotate(${lr.toFixed(1)} 52 150)`);
483
+ if(aR)aR.setAttribute('transform',`rotate(${rr.toFixed(1)} 148 150)`);
484
+
485
+ // ---- hearts (teddy) ----
486
+ const hearts=g('hearts'); if(hearts){ const show=(act==='teddy'); hearts.setAttribute('opacity',show?(0.6+0.4*Math.sin(t*4)):0); if(show)hearts.setAttribute('transform',`translate(0 ${-(t*18%30)})`); }
487
+
488
+ // ---- aura + mood dot ----
489
+ const aura=g('aura');
490
+ let auraHue=mh.hue, auraSat=mh.sat, auraL=mh.light;
491
+ if(beh==='tantrum'){auraHue=4;auraSat=85;auraL=55;} else if(beh==='sad'){auraHue=214;auraSat=50;auraL=52;}
492
+ if(aura){ aura.style.background=`radial-gradient(circle, hsl(${auraHue} ${auraSat}% ${auraL}% / ${(0.42+Math.abs(vN)*0.28+(beh!=='idle'?0.12:0)).toFixed(2)}), transparent 66%)`; }
493
+ const md=g('moodDot'); if(md){md.style.background=`hsl(${auraHue} ${auraSat}% ${auraL}%)`; md.style.boxShadow=`0 0 9px hsl(${auraHue} ${auraSat}% ${auraL}%)`;}
494
+
495
+ // ---- zzz ----
496
+ const zz=g('zzz'); if(zz){ zz.setAttribute('opacity', sleepy? (0.4+0.5*Math.sin(t*2)).toFixed(2):0); zz.setAttribute('y', sleepy? (60+Math.sin(t*1.4)*6).toFixed(0):60); }
497
+ },
498
+
499
+ // ====================================================================
500
+ // BOWLS (cosmetic fill; server owns the actual need value)
501
+ // ====================================================================
502
+ bowlSVG(level){
503
+ const f=Math.max(0,Math.min(1,level/100)); const fy=18-f*9; const op=f>0.04?1:0;
504
+ return `<svg viewBox="0 0 46 34" width="100%"><ellipse cx="23" cy="29" rx="17" ry="4" fill="rgba(0,0,0,.18)"/><path d="M4 16 h38 a19 14 0 0 1 -38 0Z" fill="#c97a4a"/><ellipse cx="23" cy="16" rx="19" ry="5.5" fill="#7a3f22"/><g opacity="${op}"><path d="M${10} ${fy+4} q13 -7 26 0 l0 6 q-13 6 -26 0Z" fill="#d6a04a"/><circle cx="16" cy="${fy+4}" r="2.4" fill="#a85f2a"/><circle cx="28" cy="${fy+3}" r="2.2" fill="#a85f2a"/><circle cx="23" cy="${fy+6}" r="2" fill="#b8722f"/></g></svg>`;
505
+ },
506
+ waterSVG(level){
507
+ const f=Math.max(0,Math.min(1,level/100)); const fy=18-f*9; const op=f>0.04?1:0;
508
+ return `<svg viewBox="0 0 46 34" width="100%"><ellipse cx="23" cy="29" rx="17" ry="4" fill="rgba(0,0,0,.18)"/><path d="M4 16 h38 a19 14 0 0 1 -38 0Z" fill="#9fb8cc"/><ellipse cx="23" cy="16" rx="19" ry="5.5" fill="#3f6f8f"/><g opacity="${op}"><path d="M${9} ${fy+5} q14 -6 28 0 l0 5 q-14 6 -28 0Z" fill="#6fb6e6"/><ellipse cx="23" cy="${fy+5}" rx="13" ry="2.6" fill="#9bd6f5"/><circle cx="18" cy="${fy+4}" r="1.4" fill="#fff" opacity=".7"/></g></svg>`;
509
+ },
510
+ refreshBowls(){
511
+ const r=this.root; if(!r)return;
512
+ const fb=r.querySelector('#foodFill'); if(fb)fb.innerHTML=this.bowlSVG(this.food);
513
+ const wb=r.querySelector('#waterFill'); if(wb)wb.innerHTML=this.waterSVG(this.water);
514
+ },
515
+ fillFood(){ this.ensureAudio(); this.food=100; this.refreshBowls(); this.popBubble('🍪'); this.logDiary('bowl filled with food','#ffd24d','🍪'); this.chirp(360,0.12); this.care('feed'); },
516
+ fillWater(){ this.ensureAudio(); this.water=100; this.refreshBowls(); this.popBubble('💧'); this.logDiary('bowl filled with water','#6fb6e6','💧'); this.chirp(520,0.12); this.care('water'); },
517
+ chirp(f,d){ this.ensureAudio(); if(this.ac&&this.soundOn)this.blip(f,this.ac.currentTime,d); },
518
+ popBubble(txt){ const eb=this.root&&this.root.querySelector('#bubble'); if(eb){eb.textContent=txt;eb.style.opacity='1';eb.style.animation='popIn .3s ease';clearTimeout(this._eb);this._eb=setTimeout(()=>{eb.style.opacity='0';eb.style.animation='';},1300);} },
519
+
520
+ // ====================================================================
521
+ // TOY PLAY (animation client-side; fires backend per item)
522
+ // ====================================================================
523
+ startPlay(it){
524
+ this.ensureAudio();
525
+ const dur={ball:3.0,teddy:2.8,balloon:3.0,rattle:2.4,music:3.4,mirror:2.6}[it.kind]||2.6;
526
+ this.action={type:it.kind,id:it.id,t0s:performance.now()/1000,dur};
527
+ it.held=true; it.resting=false;
528
+ this.speak(); this.popBubble(this.emoteFrom(this.disp));
529
+ // fire the real backend interaction
530
+ if(it.ep==='care') this.care(it.name);
531
+ else this.toy(it.name);
532
+ clearTimeout(this._actEnd); this._actEnd=setTimeout(()=>this.endPlay(),dur*1000);
533
+ },
534
+ endPlay(){
535
+ const a=this.action; this.action=null; if(!a)return;
536
+ const fx=43+Math.random()*14, fy=85+Math.random()*5;
537
+ const inner=this.itemEls[a.id], wrap=inner&&inner.closest('[data-id]');
538
+ if(wrap){ wrap.style.transition=''; wrap.style.transform=''; wrap.style.zIndex=''; }
539
+ const it=this.items.find(i=>i.id===a.id);
540
+ if(it){ it.held=false; it.x=fx; it.y=fy; it.resting=true; }
541
+ this._applyItemLayout(); this.saveItems();
542
+ },
543
+ positionActionItem(){
544
+ if(!this.action) return; const a=this.action; const inner=this.itemEls[a.id]; if(!inner)return;
545
+ const wrap=inner.closest('[data-id]'); if(!wrap)return;
546
+ const e=(performance.now()/1000)-a.t0s;
547
+ let x=50,y=50,rot=0,sc=1,anchor='-50%,-50%';
548
+ if(a.type==='ball'){ const ph=Math.abs(Math.sin(e*4.4)); y=48+ph*32; x=50+Math.sin(e*2.2)*5; rot=(e*240)%360; }
549
+ else if(a.type==='teddy'){ x=50; y=53; sc=1+Math.max(0,Math.sin(e*5.5))*0.12; rot=Math.sin(e*5.5)*4; }
550
+ else if(a.type==='balloon'){ x=50+Math.sin(e*1.7)*10; y=26+Math.sin(e*3.1)*4; anchor='-50%,-50%'; }
551
+ else if(a.type==='rattle'){ x=60; y=46; rot=Math.sin(e*30)*26; }
552
+ else if(a.type==='music'){ x=41; y=54; rot=Math.sin(e*2.2)*6; }
553
+ else if(a.type==='mirror'){ x=58; y=45; rot=Math.sin(e*2)*4; }
554
+ this.actPos={x,y,rot,sc};
555
+ wrap.style.transition='none';
556
+ wrap.style.left=x+'%'; wrap.style.top=y+'%';
557
+ wrap.style.transform=`translate(${anchor}) rotate(${rot}deg) scale(${sc})`;
558
+ wrap.style.zIndex=a.type==='balloon'?3:6;
559
+ },
560
+
561
+ // ====================================================================
562
+ // AUTO EAT / DRINK (cosmetic; server owns need value via /care)
563
+ // ====================================================================
564
+ autoConsume(dt){
565
+ if(this.action) return;
566
+ const n=this.needsObj;
567
+ if(this.food>0 && n.hunger<99){
568
+ const rr=22*dt; this.food=Math.max(0,this.food-rr);
569
+ this.eating=0.5; this.refreshBowls();
570
+ if(performance.now()-this.lastSpeak>1400){ this.lastSpeak=performance.now(); this.chirp(300+Math.random()*120,0.06); }
571
+ if(this.food<=0){ this.logDiary('finished eating','#5fd08a','♥'); this.popBubble('^‿^'); this.renderDiary(); }
572
+ } else this.eating=Math.max(0,this.eating-dt);
573
+ if(this.water>0 && n.thirst<99){
574
+ const rr=24*dt; this.water=Math.max(0,this.water-rr);
575
+ this.drinking=0.5; this.refreshBowls();
576
+ } else this.drinking=Math.max(0,this.drinking-dt);
577
+ },
578
+
579
+ // ====================================================================
580
+ // BEHAVIOR FSM (idle | sad | tantrum)
581
+ // ====================================================================
582
+ pickBehavior(){
583
+ const d=this.disp; const n=this.needsObj;
584
+ const neglected=(n.attention<12||n.hunger<10);
585
+ if((d.v<104 && d.a>148 && d.d>122) || (neglected && d.a>120)) return 'tantrum';
586
+ if((this.haveCrisis&&this.crisis>0.55) || (d.v<106 && d.a<130)) return 'sad';
587
+ return 'idle';
588
+ },
589
+
590
+ // ====================================================================
591
+ // AUDIO (WebAudio oscillator blips — the redesign's signature; kept)
592
+ // ====================================================================
593
+ ensureAudio(){ if(!this.soundOn) return; try{ if(!this.ac) this.ac=new (window.AudioContext||window.webkitAudioContext)(); if(this.ac.state==='suspended')this.ac.resume(); }catch(e){} },
594
+ blip(freq,t0,dur){ if(!this.ac)return; const o=this.ac.createOscillator(),gn=this.ac.createGain(); o.type='triangle'; o.frequency.setValueAtTime(freq,t0); o.frequency.linearRampToValueAtTime(freq*1.04,t0+dur); gn.gain.setValueAtTime(0.0001,t0); gn.gain.exponentialRampToValueAtTime(0.16,t0+0.012); gn.gain.exponentialRampToValueAtTime(0.0001,t0+dur); o.connect(gn).connect(this.ac.destination); o.start(t0); o.stop(t0+dur+0.02); },
595
+ speak(serverText){
596
+ const d=this.disp;
597
+ let text;
598
+ if(serverText){ text=serverText; }
599
+ else {
600
+ const syl=['ba','da','la','na','mi','mu','wa','wo','dee','do','boo','ga','gee','sho','rup','meep','noo','wug','blee','va','du','gwi'];
601
+ const words=1+Math.round(Math.max(0,(d.a-110)/60))+(Math.random()<0.4?1:0);
602
+ text='';
603
+ for(let w=0;w<Math.min(4,words);w++){ const len=1+Math.floor(Math.random()*2); let word=''; for(let s=0;s<len;s++)word+=syl[Math.floor(Math.random()*syl.length)]; text+=(w?' ':'')+word; }
604
+ if(d.v>150)text+=' ♪'; if(d.v<104)text+='…';
605
+ }
606
+ const bub=this.root&&this.root.querySelector('#simlish');
607
+ if(bub){ bub.textContent=text; bub.style.opacity='1'; clearTimeout(this._st); this._st=setTimeout(()=>{bub.style.opacity='0';},1700); }
608
+ const eb=this.root&&this.root.querySelector('#bubble');
609
+ if(eb){ eb.textContent=this.emoteFrom(d); eb.style.opacity='1'; eb.style.animation='popIn .3s ease'; clearTimeout(this._eb); this._eb=setTimeout(()=>{eb.style.opacity='0';eb.style.animation='';},1700); }
610
+ this.ensureAudio(); if(!this.ac||!this.soundOn)return;
611
+ const base=190+((d.v)/255)*300; const now=this.ac.currentTime;
612
+ const count=Math.max(2,Math.min(8,Math.round(String(text).replace(/[^a-z♪…]/g,'').length/2)));
613
+ const tempo=0.10-Math.max(0,(d.a-128)/127)*0.04;
614
+ for(let i=0;i<count;i++){ const f=base*(0.9+Math.random()*0.4)*(d.v>150&&i===count-1?1.3:1); this.blip(f, now+i*tempo, 0.09); }
615
+ },
616
+
617
+ // ====================================================================
618
+ // DIARY (local log + server moments)
619
+ // ====================================================================
620
+ logDiary(text,color,dot){
621
+ this.diary=[{text, color:color||'#ffd21e', dot:dot||'•', mood:this.moodWord}, ...this.diary].slice(0,9);
622
+ this.renderDiary();
623
+ },
624
+ _relTime(ts){
625
+ let ms; if(typeof ts==='number'){ ms=ts<1e10?ts*1000:ts; } else { ms=Date.parse(ts); }
626
+ if(!ms||isNaN(ms)) return '';
627
+ const diff=Math.floor((Date.now()-ms)/1000);
628
+ if(diff<60) return 'just now';
629
+ if(diff<3600) return Math.floor(diff/60)+'m ago';
630
+ if(diff<86400) return Math.floor(diff/3600)+'h ago';
631
+ return Math.floor(diff/86400)+'d ago';
632
+ },
633
+ async loadMoments(){
634
+ try{
635
+ const r=await fetch('/moments'); if(!r.ok) return;
636
+ const data=await r.json(); const events=data.events||[];
637
+ // hydrate notable server moments into the diary (newest first), keeping local entries too
638
+ const server=events.slice().reverse().slice(0,6).map(ev=>({
639
+ text:(ev.label||(ev.type||'moment').replace(/_/g,' ')),
640
+ color:'#a9967c', dot:'◆',
641
+ mood:ev.mood_word||this._relTime(ev.ts)
642
+ }));
643
+ // local entries first, then server moments that aren't dupes
644
+ const seen=new Set(this.diary.map(d=>d.text));
645
+ this.diary=[...this.diary, ...server.filter(s=>!seen.has(s.text))].slice(0,9);
646
+ this.renderDiary();
647
+ }catch(_){}
648
+ },
649
+
650
+ // ====================================================================
651
+ // BACKEND CALLS
652
+ // ====================================================================
653
+ async _post(url, body){
654
+ try{
655
+ const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
656
+ if(!r.ok){ this.popBubble('…?'); return null; }
657
+ return await r.json();
658
+ }catch(_){ this.popBubble('…?'); return null; }
659
+ },
660
+ async send(){
661
+ const el=this.inputEl; if(!el)return; const text=el.value.trim(); if(!text)return;
662
+ this.ensureAudio(); el.value='';
663
+ this.logDiary('“'+(text.length>30?text.slice(0,30)+'…':text)+'”','#8fc0ff','✎');
664
+ const state=await this._post('/say',{text});
665
+ if(!state) return;
666
+ this.applyState(state,{useDeltas:true});
667
+ if(state.recognition && state.recognition.returning){ this.popBubble('welcome back!'); this.showRemembers(true); }
668
+ if(state.relation && state.relation.known) this.showRemembers(true);
669
+ this.speak(state.simlish);
670
+ this.loadMoments();
671
+ },
672
+ async care(action){
673
+ const state=await this._post('/care',{action});
674
+ if(!state) return;
675
+ this.applyState(state,{useDeltas:true});
676
+ this.speak(state.simlish);
677
+ this.loadMoments();
678
+ },
679
+ async toy(name){
680
+ const state=await this._post('/toy',{toy:name});
681
+ if(!state) return;
682
+ this.applyState(state,{useDeltas:true});
683
+ this.speak(state.simlish);
684
+ this.loadMoments();
685
+ },
686
+ petCreature(e){ if(e)e.preventDefault(); this.ensureAudio(); this.speak(); this.popBubble('♥'); this.logDiary('got petted','#5fd08a','♥'); this.care('pet'); },
687
+
688
+ showRemembers(on){
689
+ const el=this.q('#remembersHint'); if(el) el.style.display=on?'block':'none';
690
+ },
691
+
692
+ // ====================================================================
693
+ // SELF-SOOTHE (preserved from prior production app): walk to & carry a comfort item
694
+ // ====================================================================
695
+ triggerSoothe(item){
696
+ if(!item) return;
697
+ // map backend item name to our item id (musicbox->music; play->ball)
698
+ const map={musicbox:'music', play:'ball', ball:'ball', balloon:'balloon', rattle:'rattle', mirror:'mirror', teddy:'teddy'};
699
+ const id=map[item]||item;
700
+ const it=this.items.find(i=>i.id===id);
701
+ if(!it || this.action) return;
702
+ // cosmetic comfort: he picks it up and plays (no extra backend call; server already lifted mood)
703
+ this.action={type:it.kind,id:it.id,t0s:performance.now()/1000,dur:3.0};
704
+ it.held=true; it.resting=false;
705
+ this.logDiary('soothed himself with the '+id,'#c6a6ff','✷');
706
+ clearTimeout(this._actEnd); this._actEnd=setTimeout(()=>this.endPlay(),3000);
707
+ },
708
+
709
+ // ====================================================================
710
+ // DRAG (toy -> creature)
711
+ // ====================================================================
712
+ startDrag(e,id){
713
+ e.preventDefault(); this.ensureAudio();
714
+ const it=this.items.find(x=>x.id===id); if(!it)return;
715
+ this.dragId=id;
716
+ const wrap=e.currentTarget; this.dragWrap=wrap;
717
+ wrap.style.cursor='grabbing'; wrap.style.transition='none'; wrap.style.zIndex=20;
718
+ this.moveItemTo(e.clientX,e.clientY);
719
+ },
720
+ moveItemTo(cx,cy){
721
+ if(!this.stageEl||!this.dragId)return;
722
+ const r=this.stageEl.getBoundingClientRect();
723
+ let x=(cx-r.left)/r.width*100, y=(cy-r.top)/r.height*100;
724
+ x=Math.max(3,Math.min(97,x)); y=Math.max(8,Math.min(99,y));
725
+ const it=this.items.find(i=>i.id===this.dragId);
726
+ if(it){ it.x=x; it.y=y; it.resting=false; it.held=false; }
727
+ this._applyItemLayout();
728
+ },
729
+ onPointerMove(e){ if(this.dragId)this.moveItemTo(e.clientX,e.clientY); },
730
+ onPointerUp(e){
731
+ if(!this.dragId)return;
732
+ const id=this.dragId; this.dragId=null;
733
+ if(this.dragWrap){this.dragWrap.style.cursor='grab';this.dragWrap.style.zIndex='';}
734
+ const r=this.stageEl.getBoundingClientRect();
735
+ const x=(e.clientX-r.left)/r.width*100, y=(e.clientY-r.top)/r.height*100;
736
+ const dx=x-50, dy=y-52; const onCreature=Math.sqrt(dx*dx+dy*dy)<19;
737
+ const it=this.items.find(i=>i.id===id);
738
+ if(onCreature && it){
739
+ this.startPlay(it);
740
+ } else if(it){
741
+ const fy=Math.max(y, 72+Math.random()*16);
742
+ it.x=Math.max(4,Math.min(96,x)); it.y=Math.min(96,fy); it.resting=true; it.held=false;
743
+ this._applyItemLayout(); this.saveItems();
744
+ }
745
+ },
746
+
747
+ saveItems(){ try{ const pos={}; this.items.forEach(i=>pos[i.id]={x:i.x,y:i.y}); localStorage.setItem('clanker_items_v3',JSON.stringify(pos)); }catch(e){} },
748
+ restoreItems(){ try{ const raw=localStorage.getItem('clanker_items_v3'); if(!raw)return; const pos=JSON.parse(raw); this.items.forEach(i=>{ if(pos[i.id]){ i.x=pos[i.id].x; i.y=pos[i.id].y; } }); }catch(e){} },
749
+
750
+ // ====================================================================
751
+ // THEME (default = pixel, the authored aesthetic; cozy/amber available via setThemeTo)
752
+ // ====================================================================
753
+ setThemeTo(theme){
754
+ const r=this.root; if(!r) return;
755
+ if(theme==='pixel'){
756
+ const pixel={'--bg':'#0e1410','--panel':'#16201a','--panel2':'#0a0f0c','--ink':'#cdeec0','--dim':'#6f9a73','--accent':'#ffd21e','--line':'rgba(120,210,120,.22)','--rad':'4px','--fhead':"'Press Start 2P',monospace",'--fbody':"'VT323',monospace",'--wall1':'#26402c','--wall2':'#16271a','--floor':'#1f3322','--sky1':'#7fe6b0','--sky2':'#bdf48a','--cstroke':'5','--cstroke-col':'#0c1a10'};
757
+ Object.keys(pixel).forEach(k=>r.style.setProperty(k,pixel[k]));
758
+ r.style.fontFamily="'VT323',monospace"; r.style.fontSize='17px'; r.style.letterSpacing='.3px';
759
  } else {
760
+ // cozy/amber: clear overrides so the inline var(--x, fallback) warm defaults apply
761
+ ['--bg','--panel','--panel2','--ink','--dim','--accent','--line','--rad','--fhead','--fbody','--wall1','--wall2','--floor','--sky1','--sky2','--cstroke','--cstroke-col'].forEach(k=>r.style.removeProperty(k));
762
+ r.style.fontFamily=''; r.style.fontSize=''; r.style.letterSpacing='';
763
  }
764
+ },
765
+
766
+ // ====================================================================
767
+ // TOOLTIPS (client-only, delegated)
768
+ // ====================================================================
769
+ bindTooltips(){
770
+ const tip=this.tipEl;
771
+ const show=(e)=>{
772
+ const host=e.target.closest('[data-tip]'); if(!host){ return; }
773
+ const txt=host.getAttribute('data-tip'); if(!txt) return;
774
+ tip.innerHTML=txt;
775
+ tip.style.left=(e.clientX+16)+'px'; tip.style.top=e.clientY+'px'; tip.style.opacity='1';
776
+ };
777
+ this.root.addEventListener('mouseover',(e)=>{ if(e.target.closest('[data-tip]')) show(e); });
778
+ this.root.addEventListener('mousemove',(e)=>{ const h=e.target.closest('[data-tip]'); if(h && tip.style.opacity==='1'){ tip.style.left=(e.clientX+16)+'px'; tip.style.top=e.clientY+'px'; } });
779
+ this.root.addEventListener('mouseout',(e)=>{ const from=e.target.closest('[data-tip]'); const to=e.relatedTarget&&e.relatedTarget.closest&&e.relatedTarget.closest('[data-tip]'); if(from && from!==to) tip.style.opacity='0'; });
780
+ },
781
+
782
+ // ---------- utils ----------
783
+ q(sel){ return this.root?this.root.querySelector(sel):document.querySelector(sel); },
784
+ _esc(s){ return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); },
785
+
786
+ // ====================================================================
787
+ // BOOT
788
+ // ====================================================================
789
+ async init(){
790
+ this.root=document.getElementById('root');
791
+ this.stageEl=document.getElementById('stage');
792
+ this.inputEl=document.getElementById('msg');
793
+ this.tipEl=document.getElementById('tooltip');
794
+
795
+ this.items=this.defaultItems();
796
+ this.restoreItems();
797
+
798
+ // DEFAULT to the pixel aesthetic — this is the authored design
799
+ this.setThemeTo('pixel');
800
+
801
+ // events
802
+ const sendBtn=document.getElementById('send'); if(sendBtn) sendBtn.addEventListener('click',()=>this.send());
803
+ if(this.inputEl) this.inputEl.addEventListener('keydown',(e)=>{ if(e.key==='Enter') this.send(); });
804
+ const creature=document.getElementById('creature'); if(creature) creature.addEventListener('pointerdown',(e)=>this.petCreature(e));
805
+ const fb=document.getElementById('foodBowl'); if(fb) fb.addEventListener('click',()=>this.fillFood());
806
+ const wb=document.getElementById('waterBowl'); if(wb) wb.addEventListener('click',()=>this.fillWater());
807
+ const soundBtn=document.getElementById('soundBtn');
808
+ if(soundBtn) soundBtn.addEventListener('click',()=>{ this.soundOn=!this.soundOn; soundBtn.textContent=this.soundOn?'🔊':'🔇'; if(this.soundOn)this.ensureAudio(); });
809
+
810
+ window.addEventListener('pointermove',(e)=>this.onPointerMove(e));
811
+ window.addEventListener('pointerup',(e)=>this.onPointerUp(e));
812
+
813
+ this.bindTooltips();
814
+ this.renderItems();
815
+ this.refreshBowls();
816
+ this.renderMeters(); this.renderNeeds(); this.renderDistance();
817
+ this.renderCrisis(); this.renderMoodWord(); this.renderReadPhrase();
818
+ this.renderDiary();
819
+
820
+ // hydrate from snapshot
821
+ try{
822
+ const r=await fetch('/snapshot'); if(r.ok){
823
+ const state=await r.json();
824
+ // snapshot has no deltas; set mood directly (no arrow flicker)
825
+ this.applyState(state,{useDeltas:false});
826
+ if(state.self_soothe && state.self_soothe.item) this.triggerSoothe(state.self_soothe.item);
827
+ if(state.relation && state.relation.known) this.showRemembers(true);
828
+ }
829
+ }catch(_){}
830
+
831
+ this.loadMoments();
832
+
833
+ // animation loop
834
+ this.lastTs=performance.now();
835
+ this._raf=requestAnimationFrame(this.step.bind(this));
836
+ setTimeout(()=>{ this.speak(); },500);
837
+
838
+ // poll snapshot every 45s for needs + self_soothe
839
+ setInterval(()=>{
840
+ fetch('/snapshot').then(r=>r.json()).then(s=>{
841
+ if(s.needs){ this.applyNeeds(s.needs); this.renderNeeds(); }
842
+ if(s.crisis){ this.applyCrisis(s.crisis); this.renderCrisis(); }
843
+ if(s.self_soothe && s.self_soothe.item) this.triggerSoothe(s.self_soothe.item);
844
+ }).catch(()=>{});
845
+ },45000);
846
  }
847
+ };
 
 
 
848
 
849
+ if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',()=>App.init());
850
+ else App.init();
 
static/index.html CHANGED
@@ -1,216 +1,232 @@
1
  <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
5
- <title>clanker</title>
6
  <link rel="preconnect" href="https://fonts.googleapis.com">
7
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
8
- <link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@500;600&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
9
- <link rel="stylesheet" href="/static/styles.css?v=16">
 
10
  </head>
11
  <body>
12
- <div id="layout">
13
-
14
- <!-- LEFT: live VADUGWI meters with deltas -->
15
- <div id="meters" class="panel">
16
- <div class="panel-head">VADUGWI · live</div>
17
- <div id="read-row" class="read-row">this message read: —</div>
18
- <div id="meter-rows"></div>
19
- <div id="needs" class="needs-panel">
20
- <div class="needs-head">needs</div>
21
- <div class="needs-bars">
22
- <div class="need-row"><span class="need-label">hunger</span><div class="need-track"><div class="need-fill" id="need-hunger"></div></div><span class="need-val" id="need-hunger-val"></span></div>
23
- <div class="need-row"><span class="need-label">energy</span><div class="need-track"><div class="need-fill" id="need-energy"></div></div><span class="need-val" id="need-energy-val">—</span></div>
24
- <div class="need-row"><span class="need-label">attention</span><div class="need-track"><div class="need-fill" id="need-attention"></div></div><span class="need-val" id="need-attention-val">—</span></div>
25
- <div class="need-row"><span class="need-label">thirst</span><div class="need-track"><div class="need-fill" id="need-thirst"></div></div><span class="need-val" id="need-thirst-val">—</span></div>
26
  </div>
27
  </div>
28
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
- <!-- CENTER: creature room — single scalable SVG -->
31
- <div id="room">
32
- <!--
33
- One SVG holds everything: room scene + aura + shadow + creature.
34
- viewBox 0 0 400 300; floor line y=192.
35
- Creature local space is 200×240 (body cx=100 cy=116 r=74).
36
- blob-place: S=0.40, TX=160, TY=108
37
- creature bottom ~y=210 local 108 + 0.40×210 = 192 (on floor)
38
- creature center x=100 local → 160 + 0.40×100 = 200 ✓ (centered)
39
- -->
40
- <svg id="room-svg" viewBox="0 0 400 300" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet">
41
- <defs>
42
- <linearGradient id="wall-grad" x1="0" y1="0" x2="0" y2="1">
43
- <stop offset="0%" stop-color="#ffe8b0"/>
44
- <stop offset="60%" stop-color="#f5d08a"/>
45
- <stop offset="100%" stop-color="#ddb060"/>
46
- </linearGradient>
47
- <radialGradient id="light-grad" cx="50%" cy="20%" r="60%">
48
- <stop offset="0%" stop-color="rgba(255,240,180,0.28)"/>
49
- <stop offset="100%" stop-color="rgba(255,240,180,0)"/>
50
- </radialGradient>
51
- <radialGradient id="vignette-grad" cx="50%" cy="50%" r="70%">
52
- <stop offset="50%" stop-color="rgba(60,30,0,0)"/>
53
- <stop offset="100%" stop-color="rgba(60,30,0,0.28)"/>
54
- </radialGradient>
55
- <!-- aura gradient: JS updates stop color + circle opacity -->
56
- <radialGradient id="aura-grad" cx="50%" cy="50%" r="50%">
57
- <stop id="aura-stop" offset="0%" stop-color="hsl(28,80%,58%)" stop-opacity="0.6"/>
58
- <stop offset="100%" stop-color="hsl(28,80%,58%)" stop-opacity="0"/>
59
- </radialGradient>
60
- <!-- emotion glow on the face (anime-style): JS sets color + opacity -->
61
- <radialGradient id="emote-grad" cx="50%" cy="50%" r="50%">
62
- <stop id="emote-stop-0" offset="0%" stop-color="hsl(2,90%,55%)" stop-opacity="0.85"/>
63
- <stop id="emote-stop-1" offset="100%" stop-color="hsl(2,90%,55%)" stop-opacity="0"/>
64
- </radialGradient>
65
- </defs>
66
-
67
- <!-- wall -->
68
- <rect x="0" y="0" width="400" height="192" fill="url(#wall-grad)"/>
69
- <!-- floor -->
70
- <rect x="0" y="192" width="400" height="108" fill="#c9a06a"/>
71
- <!-- baseboard -->
72
- <rect x="0" y="190" width="400" height="4" fill="rgba(100,60,20,0.35)"/>
73
- <!-- window: upper-center -->
74
- <image href="/static/assets/window.png" x="130" y="4" width="140" height="120" opacity="0.92" style="filter:drop-shadow(0 8px 24px rgba(255,220,120,0.4))"/>
75
- <!-- shelf: upper-right -->
76
- <image href="/static/assets/shelf.png" x="258" y="18" width="132" height="90" opacity="0.90"/>
77
- <!-- plant: lower-left -->
78
- <image href="/static/assets/plant.png" x="6" y="126" width="60" height="74" opacity="0.94"/>
79
-
80
- <!-- WORLD OBJECTS: real toys/care items, all resting on the floor zone
81
- (floor top y=192; every item's BOTTOM sits at ~196). Tap one and the
82
- creature walks over and reacts. -->
83
- <g id="world">
84
- <g class="room-obj" data-toy="balloon" tabindex="0" role="button" aria-label="balloon">
85
- <image href="/static/assets/toy-balloon.png?v=16" x="40" y="78" width="40" height="50"/>
86
- </g>
87
- <g class="room-obj" data-toy="musicbox" tabindex="0" role="button" aria-label="music box">
88
- <image href="/static/assets/toy-musicbox.png?v=16" x="84" y="164" width="34" height="32"/>
89
- </g>
90
- <g class="room-obj" data-care="feed" tabindex="0" role="button" aria-label="food bowl">
91
- <image href="/static/assets/obj-bowl-empty.png?v=16" x="118" y="166" width="42" height="30"/>
92
- <image id="bowl-food" href="/static/assets/obj-bowl.png?v=16" x="118" y="166" width="42" height="30" opacity="1"/>
93
- </g>
94
- <g class="room-obj" data-care="water" tabindex="0" role="button" aria-label="water bowl">
95
- <image href="/static/assets/obj-water-empty.png?v=16" x="168" y="166" width="42" height="30"/>
96
- <image id="bowl-water" href="/static/assets/obj-water.png?v=16" x="168" y="166" width="42" height="30" opacity="1"/>
97
- </g>
98
- <g class="room-obj" data-care="play" tabindex="0" role="button" aria-label="ball">
99
- <image href="/static/assets/obj-ball.png?v=16" x="222" y="166" width="30" height="30"/>
100
- </g>
101
- <g class="room-obj" data-toy="rattle" tabindex="0" role="button" aria-label="rattle">
102
- <image href="/static/assets/toy-rattle.png?v=16" x="258" y="162" width="32" height="34"/>
103
- </g>
104
- <g class="room-obj" data-toy="mirror" tabindex="0" role="button" aria-label="mirror">
105
- <image href="/static/assets/toy-mirror.png?v=16" x="298" y="160" width="32" height="36"/>
106
- </g>
107
- <g class="room-obj" data-toy="teddy" tabindex="0" role="button" aria-label="teddy bear">
108
- <image href="/static/assets/toy-teddy.png?v=16" x="338" y="154" width="42" height="42"/>
109
- </g>
110
- </g>
111
-
112
- <!-- aura glow: SVG circle with radialGradient, behind creature -->
113
- <!-- cx/cy in scene coords = creature center: 200, ~168 (116*0.40+108=154.4; use 160 for visual) -->
114
- <circle id="aura" cx="200" cy="160" r="76" fill="url(#aura-grad)"
115
- opacity="0.6" style="mix-blend-mode:screen; pointer-events:none;"/>
116
-
117
- <!-- contact shadow on floor beneath creature (tracks creature X) -->
118
- <ellipse id="shadow" cx="200" cy="192" rx="40" ry="6" fill="#00000030"/>
119
-
120
- <!-- creature: blob-place holds fixed scene position; #blob gets dynamic per-message transform -->
121
- <g id="blob-place" transform="translate(160,108) scale(0.40)" style="cursor:pointer" tabindex="0" role="button" aria-label="pet the creature">
122
- <g id="blob-anim">
123
- <g id="blob" style="filter:drop-shadow(0 25px 50px rgba(0,0,0,0.28))">
124
- <!-- body: big ROUND yellow face circle (always Hugging-Face yellow) -->
125
- <circle id="body" cx="100" cy="116" r="74"/>
126
- <!-- emotion glow: anime-style colored glow over the face (JS sets color/opacity) -->
127
- <circle id="emote-glow" cx="100" cy="118" r="66" fill="url(#emote-grad)" opacity="0" pointer-events="none" style="mix-blend-mode:multiply"/>
128
- <!-- body highlight -->
129
- <ellipse cx="82" cy="86" rx="16" ry="10" fill="white" opacity="0.28" transform="rotate(-15,82,86)"/>
130
- <!-- cheeks: blush/anger dots (JS recolors by emotion) -->
131
- <ellipse id="cheek-l" cx="68" cy="136" rx="13" ry="9" fill="#ff9090" opacity="0.38"/>
132
- <ellipse id="cheek-r" cx="132" cy="136" rx="13" ry="9" fill="#ff9090" opacity="0.38"/>
133
- <!-- left arm/hand -->
134
- <g id="arm-l">
135
- <path id="arm-l-path" d="M50 148 Q28 130 18 112" stroke-width="14" stroke-linecap="round" fill="none"/>
136
- <ellipse id="hand-l" cx="18" cy="108" rx="20" ry="17"/>
137
  </g>
138
- <!-- right arm/hand -->
139
- <g id="arm-r">
140
- <path id="arm-r-path" d="M150 148 Q172 130 182 112" stroke-width="14" stroke-linecap="round" fill="none"/>
141
- <ellipse id="hand-r" cx="182" cy="108" rx="20" ry="17"/>
 
 
 
142
  </g>
143
- <!-- face group: eyes, brows, mouth rendered by JS -->
144
- <g id="face"></g>
145
- </g>
146
- </g>
147
- </g>
148
-
149
- <!-- carried item: shown at full size when the creature picks something up -->
150
- <image id="carry" x="0" y="0" width="34" height="34" opacity="0" pointer-events="none"/>
151
- <!-- floating VADUGWI badge: what dimensions the held item is moving -->
152
- <text id="raise-badge" x="0" y="0" text-anchor="middle" font-size="20"
153
- font-family="'JetBrains Mono', monospace" font-weight="600"
154
- fill="#ffe9c8" opacity="0" pointer-events="none"
155
- style="paint-order:stroke; stroke:rgba(20,14,8,0.85); stroke-width:3px;"></text>
156
-
157
- <!-- warm window light overlay -->
158
- <rect x="0" y="0" width="400" height="300" fill="url(#light-grad)" pointer-events="none"/>
159
- <!-- vignette overlay -->
160
- <rect x="0" y="0" width="400" height="300" fill="url(#vignette-grad)" pointer-events="none"/>
161
- <text id="zzz" x="232" y="78" font-size="22" fill="#cfe0ff" opacity="0" font-family="Fredoka, sans-serif" pointer-events="none">z</text>
162
- </svg>
163
-
164
- <!-- Mood chip: small pill anchored top-right corner of room -->
165
- <div id="moodword" class="chip">calm</div>
166
-
167
- <!-- Emoticon bubble: appears above creature -->
168
- <div id="bubble" class="hidden"></div>
169
-
170
- <!-- Remembers-you hint: shown only when relation.known === true -->
171
- <div id="remembers-hint" class="chip remembers-chip hidden">it remembers you 🤍</div>
172
-
173
- <!-- Hint: how to interact with the world -->
174
- <div id="world-hint" class="chip world-hint">tap a toy · tap him to pet · or say something</div>
175
-
176
- <!-- Bottom band: just the chat dock by default; explainability tucked in a
177
- collapsible drawer so it never covers the room. -->
178
- <div id="bottom-band">
179
- <div id="detail-drawer">
180
- <div id="why" class="panel sub-panel">why: </div>
181
- <div id="acceptance" class="panel sub-panel">armor: </div>
182
- <div id="timeline" class="panel sub-panel">
183
- <span class="sub-label">valence history</span>
184
- <svg id="tl-svg" width="100%" height="36" preserveAspectRatio="none"></svg>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  </div>
186
  </div>
187
- <div id="dock">
188
- <button id="detail-toggle" title="show the receipts (why / armor / history)" aria-expanded="false">ⓘ</button>
189
- <input id="msg" placeholder="say something to it…" autocomplete="off">
190
- <button id="send">send</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  </div>
192
- </div>
193
- </div>
194
 
195
- <!-- RIGHT: trace panel + moments diary -->
196
- <div id="trace-col">
197
- <div id="trace" class="panel">
198
- <div class="panel-head">what it detected</div>
199
- <div id="trace-words" class="trace-section"></div>
200
- <div class="trace-label">structures</div>
201
- <div id="trace-structs"></div>
202
- <div class="trace-label">top contributors</div>
203
- <div id="trace-contribs" class="contrib-list"></div>
204
- <div id="trace-unknown" class="unknown-row hidden"></div>
205
- </div>
206
- <div id="moments" class="panel moments-panel">
207
- <div class="panel-head">diary</div>
208
- <div id="moments-list" class="moments-list"></div>
209
- </div>
210
  </div>
211
 
 
 
 
 
212
  </div>
213
- <script src="/static/physics.js?v=16"></script>
214
- <script src="/static/app.js?v=16"></script>
215
- </body>
216
- </html>
 
1
  <!DOCTYPE html>
2
+ <html lang="en"><head>
3
+ <meta charset="utf-8">
4
+ <meta name="viewport" content="width=device-width, initial-scale=1">
5
+ <title>clanker · a deterministic emotional creature</title>
6
  <link rel="preconnect" href="https://fonts.googleapis.com">
7
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
8
+ <link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Press+Start+2P&family=VT323&display=swap" rel="stylesheet">
9
+ <link rel="stylesheet" href="/static/styles.css?v=21">
10
+ <script src="/static/app.js?v=21" defer></script>
11
  </head>
12
  <body>
13
+
14
+ <div id="root" style="position:relative;min-height:100vh;width:100%;background:var(--bg,#211710);color:var(--ink,#f4e8d6);font-family:var(--fbody,'JetBrains Mono',monospace);padding:18px;display:flex;flex-direction:column;gap:16px;overflow-x:hidden;">
15
+
16
+ <!-- ===== HEADER ===== -->
17
+ <header style="display:flex;align-items:center;gap:14px;flex-wrap:wrap;">
18
+ <div style="display:flex;align-items:center;gap:11px;">
19
+ <div style="width:34px;height:34px;border-radius:11px;background:var(--accent,#ffd21e);display:flex;align-items:center;justify-content:center;box-shadow:0 4px 14px rgba(255,200,60,.35);">
20
+ <svg width="22" height="22" viewBox="0 0 24 24"><circle cx="12" cy="12" r="11" fill="#26190d"></circle><circle cx="8.5" cy="11" r="1.7" fill="#ffd21e"></circle><circle cx="15.5" cy="11" r="1.7" fill="#ffd21e"></circle><path d="M7.5 15 Q12 19 16.5 15" stroke="#ffd21e" stroke-width="1.8" fill="none" stroke-linecap="round"></path></svg>
21
+ </div>
22
+ <div>
23
+ <div id="creatureName" style="font-family:var(--fhead,'Fredoka',sans-serif);font-weight:700;font-size:20px;letter-spacing:.3px;line-height:1;">Huggy</div>
24
+ <div style="font-size:11px;color:var(--dim,#a9967c);margin-top:3px;letter-spacing:.4px;">a deterministic emotional creature · powered by VADUGWI</div>
 
 
25
  </div>
26
  </div>
27
+ <div data-tip="VADUGWI: 7 deterministic dimensions (0&ndash;255, 128 neutral). 4,563-word vocabulary, 66 chess-like structural patterns, proximity fields (0.9&times; decay/word), phase system &amp; Force Flow. Fully explainable. The live Space calls the real clanker engine + clanker-soul on the backend." style="cursor:help;font-size:10.5px;color:var(--dim,#a9967c);border:1px solid var(--line,rgba(255,210,140,.16));border-radius:999px;padding:5px 11px;display:flex;align-items:center;gap:6px;">
28
+ <span style="width:7px;height:7px;border-radius:50%;background:#5fd08a;box-shadow:0 0 8px #5fd08a;"></span>7D engine · how it works
29
+ </div>
30
+ <div style="flex:1;"></div>
31
+ <button id="soundBtn" title="toggle babble sound" style="cursor:pointer;border:1px solid var(--line,rgba(255,210,140,.16));background:var(--panel2,#1a120b);color:var(--ink,#f4e8d6);border-radius:11px;width:38px;height:34px;font-size:15px;">&#128266;</button>
32
+ </header>
33
+
34
+ <!-- ===== GRID ===== -->
35
+ <div style="display:flex;gap:16px;flex-wrap:wrap;align-items:stretch;flex:1;">
36
+
37
+ <!-- LEFT: VADUGWI live -->
38
+ <section style="flex:1 1 300px;max-width:360px;min-width:268px;background:var(--panel,#2c2016);border:1px solid var(--line,rgba(255,210,140,.16));border-radius:var(--rad,18px);padding:16px;display:flex;flex-direction:column;gap:13px;">
39
+ <div style="display:flex;align-items:baseline;justify-content:space-between;">
40
+ <div style="font-family:var(--fhead,'Fredoka',sans-serif);font-weight:700;font-size:14px;letter-spacing:1.5px;">VADUGWI</div>
41
+ <div style="font-size:9.5px;color:var(--accent,#ffd21e);letter-spacing:2px;display:flex;align-items:center;gap:5px;"><span style="width:6px;height:6px;border-radius:50%;background:var(--accent,#ffd21e);animation:floaty 1.6s ease-in-out infinite;"></span>LIVE</div>
42
+ </div>
43
+ <div style="font-size:11px;color:var(--dim,#a9967c);line-height:1.5;">this message read as<br><span id="readPhrase" style="color:var(--ink,#f4e8d6);font-size:13px;font-weight:600;">waiting&hellip;</span></div>
44
+
45
+ <!-- meters (JS fills 7 rows) -->
46
+ <div id="meters"></div>
47
 
48
+ <!-- needs -->
49
+ <div style="border-top:1px solid var(--line,rgba(255,210,140,.16));padding-top:11px;display:flex;flex-direction:column;gap:8px;">
50
+ <div data-tip="Tamagotchi-style needs decay over time. Feed, water, play with and pet it to keep them up &mdash; neglect pulls its mood down." style="cursor:help;font-size:11px;letter-spacing:1.5px;color:var(--dim,#a9967c);font-weight:600;">NEEDS</div>
51
+ <div id="needs"></div>
52
+ </div>
53
+
54
+ <!-- soul / mood distance -->
55
+ <div data-tip="Three timescales (clanker-soul): the Conversational score &rarr; Mood (minutes&ndash;hours, blends &amp; cushions) &rarr; Soul (the persistent baseline mood drifts back toward). Heavy events during a wound can breach straight into Soul." style="cursor:help;border-top:1px solid var(--line,rgba(255,210,140,.16));padding-top:11px;display:flex;align-items:center;justify-content:space-between;font-size:11px;color:var(--dim,#a9967c);">
56
+ <span>mood &#10230; soul drift</span>
57
+ <span id="soulDistance" style="color:var(--ink,#f4e8d6);font-weight:600;">&Delta; 0</span>
58
+ </div>
59
+ </section>
60
+
61
+ <!-- CENTER: room -->
62
+ <section style="flex:3 1 460px;min-width:320px;display:flex;flex-direction:column;gap:13px;">
63
+ <div id="stage" style="position:relative;width:100%;aspect-ratio:4/3;border-radius:var(--rad,18px);overflow:hidden;border:1px solid var(--line,rgba(255,210,140,.16));background:var(--wall2,#e9bd72);box-shadow:inset 0 0 80px rgba(40,20,0,.25);touch-action:none;user-select:none;">
64
+
65
+ <!-- room scene -->
66
+ <svg viewBox="0 0 400 300" preserveAspectRatio="xMidYMid slice" style="position:absolute;inset:0;width:100%;height:100%;">
67
+ <defs>
68
+ <linearGradient id="wallg" x1="0" y1="0" x2="0" y2="1">
69
+ <stop offset="0%" stop-color="var(--wall1,#ffe8b0)"></stop><stop offset="100%" stop-color="var(--wall2,#e9bd72)"></stop>
70
+ </linearGradient>
71
+ <linearGradient id="skyg" x1="0" y1="0" x2="0" y2="1">
72
+ <stop offset="0%" stop-color="var(--sky1,#bfe3ff)"></stop><stop offset="100%" stop-color="var(--sky2,#ffd98a)"></stop>
73
+ </linearGradient>
74
+ <radialGradient id="sung" cx="50%" cy="50%" r="50%"><stop offset="0%" stop-color="#fff4c2"></stop><stop offset="100%" stop-color="#ffcf5e"></stop></radialGradient>
75
+ <radialGradient id="lightg" cx="50%" cy="14%" r="62%"><stop offset="0%" stop-color="rgba(255,244,200,.34)"></stop><stop offset="100%" stop-color="rgba(255,244,200,0)"></stop></radialGradient>
76
+ <radialGradient id="vigg" cx="50%" cy="52%" r="72%"><stop offset="52%" stop-color="rgba(40,20,0,0)"></stop><stop offset="100%" stop-color="rgba(30,15,0,.34)"></stop></radialGradient>
77
+ </defs>
78
+ <rect x="0" y="0" width="400" height="196" fill="url(#wallg)"></rect>
79
+ <rect x="0" y="196" width="400" height="104" fill="var(--floor,#c9a06a)"></rect>
80
+ <rect x="0" y="193" width="400" height="4" fill="rgba(90,55,18,.32)"></rect>
81
+ <!-- window, centered + aligned -->
82
+ <g>
83
+ <rect x="150" y="26" width="100" height="84" rx="7" fill="#fff" opacity=".5"></rect>
84
+ <rect x="155" y="31" width="90" height="74" rx="4" fill="url(#skyg)"></rect>
85
+ <circle cx="225" cy="52" r="13" fill="url(#sung)"></circle>
86
+ <path d="M170 96 q12 -16 24 -4 q10 -14 24 -2 l0 12 -48 0 z" fill="#fff" opacity=".5"></path>
87
+ <rect x="197" y="31" width="6" height="74" fill="#fff" opacity=".55"></rect>
88
+ <rect x="155" y="64" width="90" height="6" fill="#fff" opacity=".55"></rect>
89
+ <rect x="146" y="108" width="108" height="7" rx="3" fill="#caa46a"></rect>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  </g>
91
+ <!-- shelf right -->
92
+ <g>
93
+ <rect x="300" y="60" width="86" height="7" rx="2" fill="#a9763f"></rect>
94
+ <rect x="310" y="40" width="9" height="20" rx="2" fill="#cf6a5a"></rect>
95
+ <rect x="321" y="36" width="9" height="24" rx="2" fill="#6aa6cf"></rect>
96
+ <rect x="332" y="43" width="9" height="17" rx="2" fill="#7dbf72"></rect>
97
+ <circle cx="360" cy="52" r="8" fill="#d8975a"></circle><rect x="356" y="50" width="8" height="10" fill="#b97e44"></rect>
98
  </g>
99
+ <!-- plant lower-left -->
100
+ <g>
101
+ <path d="M30 196 q-16 -34 -6 -56 q8 12 6 30 q10 -22 24 -28 q-6 18 -18 34 q14 -10 26 -8 q-12 14 -28 24z" fill="#5a9a52"></path>
102
+ <path d="M30 196 l-12 0 6 -26 12 0 z" fill="#bf6a4a"></path>
103
+ </g>
104
+ <!-- rug -->
105
+ <ellipse cx="200" cy="252" rx="118" ry="26" fill="#d9544e" opacity=".22"></ellipse>
106
+ <ellipse cx="200" cy="252" rx="92" ry="19" fill="#e8a23a" opacity=".22"></ellipse>
107
+ <rect x="0" y="0" width="400" height="300" fill="url(#lightg)" pointer-events="none"></rect>
108
+ <rect x="0" y="0" width="400" height="300" fill="url(#vigg)" pointer-events="none"></rect>
109
+ </svg>
110
+
111
+ <!-- aura -->
112
+ <div id="aura" style="position:absolute;left:50%;top:54%;width:42%;height:42%;border-radius:50%;background:radial-gradient(circle,hsl(48 70% 60% / .55),transparent 68%);transform:translate(-50%,-50%);mix-blend-mode:screen;pointer-events:none;animation:auraPulse 3.4s ease-in-out infinite;filter:blur(4px);"></div>
113
+
114
+ <!-- creature -->
115
+ <svg id="creature" viewBox="0 0 200 240" style="position:absolute;left:50%;bottom:13%;width:30%;transform:translateX(-50%);overflow:visible;cursor:pointer;z-index:4;">
116
+ <ellipse id="shadow" cx="100" cy="224" rx="62" ry="11" fill="rgba(0,0,0,.28)"></ellipse>
117
+ <g id="blob">
118
+ <circle id="body" cx="100" cy="120" r="78" fill="var(--accent,#ffd21e)" stroke="var(--cstroke-col,transparent)" stroke-width="var(--cstroke,0)"></circle>
119
+ <circle id="emoteGlow" cx="100" cy="122" r="70" fill="hsl(48 80% 55%)" opacity="0" pointer-events="none"></circle>
120
+ <ellipse cx="78" cy="84" rx="18" ry="11" fill="#fff" opacity=".26" transform="rotate(-16 78 84)"></ellipse>
121
+ <ellipse id="cheekL" cx="62" cy="138" rx="14" ry="9" fill="#ff9bb0" opacity=".35"></ellipse>
122
+ <ellipse id="cheekR" cx="138" cy="138" rx="14" ry="9" fill="#ff9bb0" opacity=".35"></ellipse>
123
+ <g id="armL"><path d="M52 150 Q34 152 24 138" stroke="var(--accent,#ffd21e)" stroke-width="15" stroke-linecap="round" fill="none"></path><ellipse cx="22" cy="136" rx="15" ry="13" fill="var(--accent,#ffd21e)"></ellipse></g>
124
+ <g id="armR"><path d="M148 150 Q166 152 176 138" stroke="var(--accent,#ffd21e)" stroke-width="15" stroke-linecap="round" fill="none"></path><ellipse cx="178" cy="136" rx="15" ry="13" fill="var(--accent,#ffd21e)"></ellipse></g>
125
+ <line id="browL" x1="62" y1="96" x2="86" y2="96" stroke="#2a1a0c" stroke-width="5" stroke-linecap="round"></line>
126
+ <line id="browR" x1="114" y1="96" x2="138" y2="96" stroke="#2a1a0c" stroke-width="5" stroke-linecap="round"></line>
127
+ <ellipse id="eyeL" cx="74" cy="112" rx="9" ry="11" fill="#2a1a0c"></ellipse>
128
+ <ellipse id="eyeR" cx="126" cy="112" rx="9" ry="11" fill="#2a1a0c"></ellipse>
129
+ <circle id="glintL" cx="77" cy="108" r="3" fill="#fff"></circle>
130
+ <circle id="glintR" cx="129" cy="108" r="3" fill="#fff"></circle>
131
+ <path id="mouth" d="M82 150 Q100 166 118 150" stroke="#2a1a0c" stroke-width="5" fill="none" stroke-linecap="round"></path>
132
+ <path id="tearL" d="M70 124 q-4 6 0 9 q4 -3 0 -9Z" fill="#7ec8f5" opacity="0"></path>
133
+ <path id="tearR" d="M130 124 q-4 6 0 9 q4 -3 0 -9Z" fill="#7ec8f5" opacity="0"></path>
134
+ </g>
135
+ <circle id="steamL" cx="44" cy="64" r="6" fill="#cfd8e0" opacity="0"></circle>
136
+ <circle id="steamR" cx="156" cy="64" r="6" fill="#cfd8e0" opacity="0"></circle>
137
+ <g id="hearts" opacity="0"><path d="M100 44 c-5 -8 -16 -2 -10 6 l10 10 10 -10 c6 -8 -5 -14 -10 -6Z" fill="#ff7aa8"></path></g>
138
+ <text id="zzz" x="150" y="60" font-size="26" fill="#cfe0ff" opacity="0" font-family="Fredoka,sans-serif">z</text>
139
+ </svg>
140
+
141
+ <!-- items (toys — draggable; JS fills) -->
142
+ <div id="items"></div>
143
+
144
+ <!-- feeding stations (fixed — click to fill) -->
145
+ <div id="foodBowl" data-tip="Click to fill the food bowl. Huggy walks over and eats on his own when there's food and he's hungry." style="position:absolute;left:14%;bottom:5%;width:10%;cursor:pointer;z-index:3;filter:drop-shadow(0 4px 5px rgba(0,0,0,.3));">
146
+ <div id="foodFill" style="width:100%;"></div>
147
+ </div>
148
+ <div id="waterBowl" data-tip="Click to fill the water bowl. Huggy drinks automatically when thirsty." style="position:absolute;left:25%;bottom:4%;width:10%;cursor:pointer;z-index:3;filter:drop-shadow(0 4px 5px rgba(0,0,0,.3));">
149
+ <div id="waterFill" style="width:100%;"></div>
150
+ </div>
151
+
152
+ <!-- mood chip -->
153
+ <div style="position:absolute;top:12px;right:12px;background:var(--panel,#2c2016);border:1px solid var(--line,rgba(255,210,140,.16));border-radius:999px;padding:7px 14px;font-size:13px;font-weight:600;font-family:var(--fhead,'Fredoka',sans-serif);display:flex;align-items:center;gap:8px;z-index:6;">
154
+ <span id="moodDot" style="width:9px;height:9px;border-radius:50%;background:hsl(48 70% 55%);box-shadow:0 0 9px hsl(48 70% 55%);"></span><span id="moodWord">calm</span>
155
+ </div>
156
+
157
+ <!-- remembers-you hint (welcome back / known device) -->
158
+ <div id="remembersHint" style="display:none;position:absolute;top:12px;left:12px;background:var(--panel,#2c2016);border:1px solid var(--line,rgba(255,210,140,.16));border-radius:999px;padding:6px 12px;font-size:11px;font-weight:600;font-family:var(--fhead,'Fredoka',sans-serif);color:var(--ink,#f4e8d6);z-index:6;">it remembers you &#129293;</div>
159
+
160
+ <!-- emoticon bubble -->
161
+ <div id="bubble" style="position:absolute;left:50%;top:18%;transform:translate(-50%,-50%);background:#fff;color:#2a1a0c;border-radius:14px;padding:7px 13px;font-family:var(--fbody,monospace);font-size:17px;font-weight:600;opacity:0;pointer-events:none;z-index:7;box-shadow:0 6px 18px rgba(0,0,0,.25);">&middot;_&middot;</div>
162
+ <!-- simlish -->
163
+ <div id="simlish" style="position:absolute;left:50%;top:30%;transform:translate(-50%,-50%);color:#fff;font-family:var(--fhead,'Fredoka',sans-serif);font-size:15px;font-weight:600;opacity:0;pointer-events:none;z-index:7;text-shadow:0 2px 6px rgba(20,12,4,.8);white-space:nowrap;"></div>
164
+
165
+ <!-- hint -->
166
+ <div style="position:absolute;bottom:10px;left:50%;transform:translateX(-50%);background:rgba(26,18,11,.66);backdrop-filter:blur(3px);border-radius:999px;padding:6px 14px;font-size:10.5px;color:#f4e8d6;z-index:6;white-space:nowrap;">drag a toy onto <span class="cn">Huggy</span> &mdash; he plays with it, then sets it down &middot; click the bowls to fill them &middot; tap <span class="cn">Huggy</span> to pet</div>
167
+ </div>
168
+
169
+ <!-- dock -->
170
+ <div style="display:flex;flex-direction:column;gap:9px;">
171
+ <div style="display:flex;gap:9px;align-items:center;background:var(--panel,#2c2016);border:1px solid var(--line,rgba(255,210,140,.16));border-radius:14px;padding:7px 7px 7px 15px;">
172
+ <input id="msg" placeholder="say something to Huggy&hellip;" autocomplete="off" style="flex:1;background:none;border:none;outline:none;color:var(--ink,#f4e8d6);font-family:var(--fbody,monospace);font-size:14px;">
173
+ <button id="send" style="cursor:pointer;border:none;background:var(--accent,#ffd21e);color:#1a120b;border-radius:10px;padding:9px 18px;font-family:var(--fhead,'Fredoka',sans-serif);font-weight:700;font-size:13px;">send</button>
174
  </div>
175
  </div>
176
+ </section>
177
+
178
+ <!-- RIGHT: detection + diary -->
179
+ <section style="flex:1 1 300px;max-width:368px;min-width:268px;display:flex;flex-direction:column;gap:16px;">
180
+ <div style="background:var(--panel,#2c2016);border:1px solid var(--line,rgba(255,210,140,.16));border-radius:var(--rad,18px);padding:16px;display:flex;flex-direction:column;gap:12px;">
181
+ <div style="font-family:var(--fhead,'Fredoka',sans-serif);font-weight:700;font-size:14px;letter-spacing:.5px;">what it detected</div>
182
+
183
+ <div style="display:flex;flex-direction:column;gap:6px;">
184
+ <div style="font-size:10px;letter-spacing:1.5px;color:var(--dim,#a9967c);">WORDS · roles</div>
185
+ <div id="traceWords" style="display:flex;flex-wrap:wrap;gap:5px;min-height:24px;">
186
+ <span style="font-size:11px;color:var(--dim,#a9967c);">talk to it and the structural read shows up here.</span>
187
+ </div>
188
+ </div>
189
+
190
+ <div style="display:flex;flex-direction:column;gap:6px;">
191
+ <div style="font-size:10px;letter-spacing:1.5px;color:var(--dim,#a9967c);">STRUCTURES · <span id="structCount">0</span> of 66 patterns</div>
192
+ <div id="traceStructs" style="display:flex;flex-wrap:wrap;gap:5px;min-height:18px;">
193
+ <span style="font-size:11px;color:var(--dim,#a9967c);">&mdash; none in this message</span>
194
+ </div>
195
+ </div>
196
+
197
+ <div style="display:flex;flex-direction:column;gap:6px;">
198
+ <div style="font-size:10px;letter-spacing:1.5px;color:var(--dim,#a9967c);">TOP CONTRIBUTORS</div>
199
+ <div id="traceContribs"></div>
200
+ </div>
201
+
202
+ <!-- why -->
203
+ <div data-tip="Every output is explainable &mdash; this is the single biggest reason the read landed where it did." style="cursor:help;background:var(--panel2,#1a120b);border-radius:10px;padding:9px 11px;font-size:11px;line-height:1.5;color:var(--ink,#f4e8d6);">
204
+ <span style="color:var(--accent,#ffd21e);font-weight:700;">why:</span> <span id="whyText">say something, give it a toy, or pet it &mdash; every read is explainable.</span>
205
+ </div>
206
+
207
+ <!-- crisis gradient -->
208
+ <div id="crisisWrap" data-tip="Continuous 0&ndash;1 concern gradient informed by the CARE / TCI framework. Tuned for 0% false-positive on safe text, dark humour and metaphor. A screening signal, not a diagnosis." style="cursor:help;display:flex;flex-direction:column;gap:5px;">
209
+ <div style="display:flex;justify-content:space-between;font-size:10px;letter-spacing:1.5px;color:var(--dim,#a9967c);"><span>CRISIS GRADIENT</span><span id="crisisLabel" style="color:#5fd08a;font-weight:600;">calm</span></div>
210
+ <div style="height:8px;border-radius:5px;background:var(--panel2,#1a120b);overflow:hidden;">
211
+ <div id="crisisBar" style="height:100%;width:0%;background:#5fd08a;border-radius:5px;transition:width .5s ease,background .5s ease;"></div>
212
+ </div>
213
+ </div>
214
  </div>
 
 
215
 
216
+ <!-- diary -->
217
+ <div style="background:var(--panel,#2c2016);border:1px solid var(--line,rgba(255,210,140,.16));border-radius:var(--rad,18px);padding:16px;display:flex;flex-direction:column;gap:10px;flex:1;min-height:150px;">
218
+ <div style="font-family:var(--fhead,'Fredoka',sans-serif);font-weight:700;font-size:14px;">diary</div>
219
+ <div id="diary" style="display:flex;flex-direction:column;gap:8px;overflow-y:auto;max-height:230px;">
220
+ <span style="font-size:11px;color:var(--dim,#a9967c);">moments you share will be remembered here.</span>
221
+ </div>
222
+ </div>
223
+ </section>
 
 
 
 
 
 
 
224
  </div>
225
 
226
+ <div style="font-size:10px;color:var(--dim,#a9967c);text-align:center;opacity:.8;">the production Space calls the real clanker engine + clanker-soul on the backend · every read is deterministic and explainable</div>
227
+
228
+ <!-- floating tooltip -->
229
+ <div id="tooltip" style="position:fixed;left:0px;top:0px;max-width:248px;background:#120c06;color:#f4e8d6;border:1px solid rgba(255,210,140,.28);border-radius:10px;padding:9px 12px;font-size:11px;line-height:1.5;font-family:var(--fbody,monospace);pointer-events:none;opacity:0;transform:translateY(-50%);transition:opacity .12s;z-index:90;box-shadow:0 10px 30px rgba(0,0,0,.45);"></div>
230
  </div>
231
+
232
+ </body></html>
 
 
static/styles.css CHANGED
@@ -1,640 +1,57 @@
1
- /* ── Google Fonts ── */
2
- @import url('https://fonts.googleapis.com/css2?family=Fredoka:wght@500;600&family=JetBrains+Mono:wght@400;600&display=swap');
3
-
4
- /* ── reset ── */
5
- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
6
- html, body { width: 100%; height: 100%; overflow: hidden; font-size: 12px; }
7
-
8
- /* ── root layout: three columns ── */
9
- #layout {
10
- display: grid;
11
- grid-template-columns: 210px 1fr 248px;
12
- width: 100vw;
13
- height: 100vh;
14
- overflow: hidden;
15
- background: #c8a87a;
16
- }
17
-
18
- /* right column wrapper: trace + moments stacked */
19
- #trace-col {
20
- display: flex;
21
- flex-direction: column;
22
- min-height: 0;
23
- overflow: hidden;
24
- margin: 12px 12px 12px 6px;
25
- gap: 6px;
26
- }
27
-
28
- /* ── glass panel base ── */
29
- .panel {
30
- background: rgba(14, 18, 28, 0.88);
31
- backdrop-filter: blur(10px);
32
- color: #dce4f2;
33
- border-radius: 14px;
34
- margin: 12px 6px 12px 12px;
35
- overflow: hidden;
36
- display: flex;
37
- flex-direction: column;
38
- font-family: 'JetBrains Mono', ui-monospace, monospace;
39
- }
40
- .panel-head {
41
- font-size: 9.5px;
42
- font-weight: 600;
43
- letter-spacing: .12em;
44
- text-transform: uppercase;
45
- color: #f0c060;
46
- padding: 9px 12px 7px;
47
- border-bottom: 1px solid rgba(255,255,255,.07);
48
- flex-shrink: 0;
49
- font-family: 'JetBrains Mono', ui-monospace, monospace;
50
- }
51
-
52
- /* ── LEFT: #meters ── */
53
- #meters {
54
- margin: 12px 6px 12px 12px;
55
- padding-bottom: 8px;
56
- }
57
- .read-row {
58
- font-size: 9px;
59
- color: #8aa0c0;
60
- padding: 5px 12px 4px;
61
- border-bottom: 1px solid rgba(255,255,255,.06);
62
- font-family: 'JetBrains Mono', ui-monospace, monospace;
63
- flex-shrink: 0;
64
- }
65
- #meter-rows { padding: 4px 12px 0; overflow: hidden; }
66
- .meter { margin: 7px 0; }
67
- .meter-row {
68
- display: flex;
69
- justify-content: space-between;
70
- align-items: baseline;
71
- margin-bottom: 3px;
72
- }
73
- .meter-name {
74
- font-weight: 600;
75
- font-size: 10.5px;
76
- min-width: 22px;
77
- font-family: 'JetBrains Mono', ui-monospace, monospace;
78
- letter-spacing: .04em;
79
- text-transform: uppercase;
80
- }
81
- .meter-name small { font-weight: 400; opacity: .45; margin-left: 3px; font-size: 9px; text-transform: lowercase; }
82
- .meter-right {
83
- display: flex;
84
- align-items: baseline;
85
- gap: 3px;
86
- }
87
- .meter-val {
88
- font-family: 'JetBrains Mono', ui-monospace, monospace;
89
- font-size: 11px;
90
- font-weight: 600;
91
- }
92
- .meter-delta {
93
- font-family: 'JetBrains Mono', ui-monospace, monospace;
94
- font-size: 9.5px;
95
- }
96
- .up { color: #5fdc8d; }
97
- .dn { color: #ff7070; }
98
- .track {
99
- height: 5px;
100
- border-radius: 3px;
101
- background: rgba(255,255,255,.1);
102
- overflow: hidden;
103
- }
104
- .fill {
105
- height: 100%;
106
- border-radius: 3px;
107
- transition: width .5s ease;
108
- }
109
-
110
- /* ── CENTER: #room — fixed-aspect scalable container ── */
111
- #room {
112
- position: relative;
113
- /* 4:3 aspect ratio, centered in the grid column */
114
- aspect-ratio: 4 / 3;
115
- align-self: center;
116
- justify-self: center;
117
- /* fill the column but respect height */
118
- width: 100%;
119
- max-width: min(calc((100vh - 24px) * 4 / 3), 100%);
120
- max-height: calc(100vh - 24px);
121
- overflow: hidden;
122
- border-radius: 14px;
123
- }
124
-
125
- /* SVG room — fills #room exactly; contains all visuals including the creature */
126
- #room-svg {
127
- position: absolute;
128
- inset: 0;
129
- width: 100%;
130
- height: 100%;
131
- z-index: 0;
132
- border-radius: 14px;
133
- overflow: hidden;
134
- }
135
-
136
- /* Aura is now an SVG <circle> inside #room-svg — no CSS rules needed here */
137
-
138
- /* #blob is now a <g> inside the SVG; CSS transition on SVG transform attribute */
139
- #blob {
140
- transition: transform .5s ease;
141
- }
142
- #body { transition: fill .6s ease; }
143
- .foot, #arm-l-path, #arm-r-path, #hand-l, #hand-r { transition: fill .6s ease, stroke .6s ease; }
144
-
145
- /* Mood word chip — small corner pill, NOT full-width */
146
- .chip {
147
- position: absolute;
148
- background: rgba(255,255,255,.92);
149
- border-radius: 20px;
150
- padding: 5px 14px;
151
- font-size: 13px;
152
- font-weight: 600;
153
- font-family: 'Fredoka', system-ui, sans-serif;
154
- box-shadow: 0 2px 8px rgba(0,0,0,0.14);
155
- color: #5a3a10;
156
- letter-spacing: .01em;
157
- white-space: nowrap;
158
- width: auto;
159
- display: inline-block;
160
- }
161
- #moodword {
162
- top: 10px;
163
- right: 12px;
164
- left: auto;
165
- z-index: 5;
166
- }
167
-
168
- /* Auth control chip — top-left, mirrors moodword */
169
- .auth-chip {
170
- top: 10px;
171
- left: 12px;
172
- right: auto;
173
- z-index: 5;
174
- position: absolute;
175
- font-size: 11px;
176
- padding: 5px 12px;
177
- }
178
- .auth-chip a { color: #5a3a10; text-decoration: none; }
179
- .auth-chip a:hover { text-decoration: underline; }
180
- .auth-signedin { color: #3a6a20; font-weight: 600; }
181
- .auth-logout { color: #5a3a10; margin-left: 6px; opacity: .7; font-size: 10px; }
182
-
183
- /* Emoticon bubble */
184
- #bubble {
185
- position: absolute;
186
- left: 50%;
187
- bottom: 56%;
188
- transform: translateX(-50%);
189
- background: rgba(255,255,255,.95);
190
- border-radius: 18px;
191
- padding: 7px 20px;
192
- font-size: 28px;
193
- line-height: 1;
194
- box-shadow: 0 4px 16px rgba(0,0,0,0.18);
195
- transition: opacity .4s;
196
- white-space: nowrap;
197
- pointer-events: none;
198
- z-index: 6;
199
- }
200
- .hidden { opacity: 0 !important; pointer-events: none; }
201
-
202
- /* ── BOTTOM BAND inside room ── */
203
- #bottom-band {
204
- position: absolute;
205
- left: 8px; right: 8px; bottom: 6px;
206
- display: flex;
207
- flex-direction: column;
208
- gap: 4px;
209
- z-index: 5;
210
- }
211
- .sub-panel {
212
- border-radius: 9px;
213
- padding: 5px 10px;
214
- font-size: 10.5px;
215
- color: #bfcde0;
216
- margin: 0;
217
- font-family: 'JetBrains Mono', ui-monospace, monospace;
218
- }
219
- .sub-label {
220
- font-size: 8.5px;
221
- text-transform: uppercase;
222
- letter-spacing: .08em;
223
- color: #f0c060;
224
- margin-right: 6px;
225
- font-family: 'JetBrains Mono', ui-monospace, monospace;
226
- }
227
- #why { color: #c8d8f0; }
228
- #why b { color: #fff; }
229
- #acceptance { color: #c8d8f0; }
230
- .breach-flag { color: #ff6b6b; font-weight: 700; margin-left: 6px; }
231
- #timeline {
232
- display: flex;
233
- align-items: center;
234
- padding: 4px 10px;
235
- }
236
- #tl-svg { flex: 1; }
237
-
238
- #dock {
239
- display: flex;
240
- gap: 7px;
241
- padding: 0;
242
- }
243
- #dock input {
244
- flex: 1;
245
- border: none;
246
- border-radius: 22px;
247
- padding: 9px 16px;
248
- font-size: 13px;
249
- font-family: 'Fredoka', system-ui, sans-serif;
250
- font-weight: 500;
251
- box-shadow: 0 2px 8px rgba(0,0,0,0.14);
252
- background: rgba(255,255,255,.94);
253
- color: #3a2a0a;
254
- outline: none;
255
- }
256
- #dock input::placeholder { color: #a08050; }
257
- #dock input:focus { background: rgba(255,255,255,1); }
258
- #dock button {
259
- border: none;
260
- border-radius: 22px;
261
- padding: 0 20px;
262
- font-weight: 600;
263
- font-size: 13px;
264
- font-family: 'Fredoka', system-ui, sans-serif;
265
- background: #f5a623;
266
- color: #3a2000;
267
- cursor: pointer;
268
- box-shadow: 0 2px 8px rgba(0,0,0,0.16);
269
- letter-spacing: .02em;
270
- transition: background .15s;
271
- }
272
- #dock button:hover { background: #ffb93d; }
273
-
274
- /* ── RIGHT: #trace ── */
275
- #trace {
276
- margin: 0;
277
- padding-bottom: 8px;
278
- flex: 1 1 0;
279
- min-height: 0;
280
- overflow-y: auto;
281
- }
282
- .trace-section { padding: 7px 10px 4px; line-height: 1.7; }
283
- .trace-label {
284
- font-size: 8.5px;
285
- text-transform: uppercase;
286
- letter-spacing: .1em;
287
- color: #f0c060;
288
- padding: 5px 10px 2px;
289
- font-family: 'JetBrains Mono', ui-monospace, monospace;
290
- }
291
- .w-chip {
292
- display: inline-block;
293
- padding: 2px 6px;
294
- border-radius: 5px;
295
- margin: 1px 2px;
296
- font-size: 10.5px;
297
- font-family: 'JetBrains Mono', ui-monospace, monospace;
298
- font-weight: 400;
299
- white-space: nowrap;
300
- }
301
- /* role colors */
302
- .r-EMOTIONAL { background: #7a1e1e; color: #ffc4c4; }
303
- .r-AMPLIFIER { background: #6a500a; color: #ffe090; }
304
- .r-NEGATOR { background: #323232; color: #d8d8d8; }
305
- .r-SELF_REF { background: #1a3858; color: #b8dcff; }
306
- .r-CONNECTOR { background: #282828; color: #a8a8a8; }
307
- .r-CHOPPER { background: #3e1a4a; color: #e0b8ff; }
308
- .r-SOLVENT { background: #163e2a; color: #aaebca; }
309
- .r-default { background: rgba(255,255,255,.1); color: #c4d0e4; }
310
-
311
- #trace-structs { padding: 0 10px 4px; }
312
- .s-chip {
313
- display: inline-block;
314
- background: rgba(255,255,255,.09);
315
- border: 1px solid rgba(255,255,255,.1);
316
- border-radius: 5px;
317
- padding: 2px 7px;
318
- margin: 2px 3px 0 0;
319
- font-size: 9.5px;
320
- font-family: 'JetBrains Mono', ui-monospace, monospace;
321
- color: #cce0ff;
322
- }
323
- .contrib-list {
324
- font-family: 'JetBrains Mono', ui-monospace, monospace;
325
- font-size: 9.5px;
326
- line-height: 1.8;
327
- padding: 2px 10px 4px;
328
- }
329
- .contrib-pos { color: #5fdc8d; }
330
- .contrib-neg { color: #ff7070; }
331
- .unknown-row {
332
- padding: 4px 10px;
333
- font-size: 9.5px;
334
- font-family: 'JetBrains Mono', ui-monospace, monospace;
335
- color: #f0a060;
336
- font-style: italic;
337
- }
338
-
339
- /* ── M3: NEEDS PANEL (inside #meters) ── */
340
- #needs {
341
- border-top: 1px solid rgba(255,255,255,.07);
342
- padding: 7px 12px 8px;
343
- flex-shrink: 0;
344
- }
345
- .needs-head {
346
- font-size: 8.5px;
347
- text-transform: uppercase;
348
- letter-spacing: .1em;
349
- color: #f0c060;
350
- margin-bottom: 5px;
351
- font-family: 'JetBrains Mono', ui-monospace, monospace;
352
- }
353
- .need-row {
354
- display: flex;
355
- align-items: center;
356
- gap: 5px;
357
- margin: 4px 0;
358
- }
359
- .need-label {
360
- font-size: 9px;
361
- font-family: 'JetBrains Mono', ui-monospace, monospace;
362
- color: #8aa0c0;
363
- width: 56px;
364
- flex-shrink: 0;
365
- }
366
- .need-track {
367
- flex: 1;
368
- height: 5px;
369
- border-radius: 3px;
370
- background: rgba(255,255,255,.1);
371
- overflow: hidden;
372
- }
373
- .need-fill {
374
- height: 100%;
375
- border-radius: 3px;
376
- background: #f5a623;
377
- transition: width .5s ease;
378
- }
379
- .need-val {
380
- font-size: 9px;
381
- font-family: 'JetBrains Mono', ui-monospace, monospace;
382
- color: #8aa0c0;
383
- width: 22px;
384
- text-align: right;
385
- flex-shrink: 0;
386
- }
387
-
388
- /* ── M3: CARE BUTTONS ── */
389
- #care-row {
390
- display: flex;
391
- gap: 6px;
392
- padding: 0;
393
- }
394
- .care-btn {
395
- flex: 1;
396
- border: none;
397
- border-radius: 18px;
398
- padding: 6px 0;
399
- font-size: 12px;
400
- font-family: 'Fredoka', system-ui, sans-serif;
401
- font-weight: 600;
402
- background: rgba(255,255,255,.88);
403
- color: #5a3a10;
404
- cursor: pointer;
405
- box-shadow: 0 2px 6px rgba(0,0,0,0.14);
406
- letter-spacing: .01em;
407
- transition: background .15s, transform .1s;
408
- white-space: nowrap;
409
- }
410
- .care-btn:hover { background: rgba(255,255,255,1); }
411
- .care-btn:active { transform: scale(0.96); }
412
-
413
- /* ── M3: MOMENTS DIARY ── */
414
- .moments-panel {
415
- margin: 0;
416
- flex: 0 0 auto;
417
- max-height: 160px;
418
- overflow: hidden;
419
- display: flex;
420
- flex-direction: column;
421
- }
422
- .moments-list {
423
- overflow-y: auto;
424
- flex: 1;
425
- padding: 4px 10px 6px;
426
- }
427
- .moment-row {
428
- display: flex;
429
- align-items: baseline;
430
- gap: 5px;
431
- padding: 2px 0;
432
- font-family: 'JetBrains Mono', ui-monospace, monospace;
433
- font-size: 9.5px;
434
- color: #bfcde0;
435
- border-bottom: 1px solid rgba(255,255,255,.04);
436
- }
437
- .moment-badge {
438
- font-size: 8.5px;
439
- border-radius: 4px;
440
- padding: 1px 5px;
441
- background: rgba(240,192,96,.18);
442
- color: #f0c060;
443
- flex-shrink: 0;
444
- font-family: 'JetBrains Mono', ui-monospace, monospace;
445
- letter-spacing: .04em;
446
- }
447
- .moment-label {
448
- flex: 1;
449
- white-space: nowrap;
450
- overflow: hidden;
451
- text-overflow: ellipsis;
452
- color: #c8d8f0;
453
- }
454
- .moment-time {
455
- font-size: 8.5px;
456
- color: #6a7a96;
457
- flex-shrink: 0;
458
- }
459
-
460
- /* ── M3: REMEMBERS-YOU HINT ── */
461
- .remembers-chip {
462
- bottom: calc(56% + 48px);
463
- left: 50%;
464
- right: auto;
465
- top: auto;
466
- transform: translateX(-50%);
467
- font-size: 11px;
468
- padding: 4px 12px;
469
- background: rgba(255,255,255,.85);
470
- color: #5a3a10;
471
- z-index: 6;
472
- transition: opacity .4s;
473
- }
474
-
475
- /* ── MOBILE LAYOUT ── */
476
- @media (max-width: 900px) {
477
- html, body {
478
- height: auto;
479
- overflow: auto;
480
- }
481
-
482
- #layout {
483
- display: flex;
484
- flex-direction: column;
485
- width: 100%;
486
- height: auto;
487
- min-height: 100vh;
488
- overflow: visible;
489
- background: #c8a87a;
490
- padding: 8px;
491
- gap: 8px;
492
- }
493
-
494
- /*
495
- Room: full-width hero at top. The SVG (creature inside) flows normally and
496
- sizes to its 4:3 viewBox, so the whole scene — creature included — is fully
497
- visible at any phone width. The info band flows BELOW the scene (not over it).
498
- */
499
- #room {
500
- width: 100%;
501
- max-width: 100%;
502
- height: auto;
503
- padding-top: 0;
504
- max-height: none;
505
- aspect-ratio: auto;
506
- border-radius: 10px;
507
- order: 0;
508
- position: relative;
509
- display: block;
510
- }
511
- #room-svg {
512
- position: static;
513
- inset: auto;
514
- width: 100%;
515
- height: auto;
516
- aspect-ratio: 4 / 3;
517
- display: block;
518
- }
519
- #bottom-band {
520
- position: static;
521
- left: auto; right: auto; bottom: auto;
522
- margin-top: 8px;
523
- }
524
-
525
- /* Meters: compact below room */
526
- #meters {
527
- margin: 0;
528
- order: 1;
529
- max-height: 320px;
530
- overflow-y: auto;
531
- }
532
-
533
- /* Right col: stacked below meters */
534
- #trace-col {
535
- margin: 0;
536
- order: 2;
537
- gap: 6px;
538
- }
539
-
540
- /* Trace: compact, scrollable */
541
- #trace {
542
- max-height: 260px;
543
- }
544
-
545
- /* Moments: compact */
546
- .moments-panel {
547
- max-height: 140px;
548
- }
549
-
550
- /* Care row: stack or wrap on very small screens */
551
- #care-row {
552
- gap: 4px;
553
- }
554
- .care-btn {
555
- font-size: 11px;
556
- padding: 5px 0;
557
- }
558
- }
559
-
560
- #toy-tray {
561
- display: flex;
562
- gap: 8px;
563
- justify-content: center;
564
- margin-bottom: 6px;
565
- }
566
- #toy-tray .toy-btn {
567
- font-size: 20px;
568
- line-height: 1;
569
- background: rgba(255, 220, 150, 0.08);
570
- border: 1px solid rgba(255, 200, 120, 0.25);
571
- border-radius: 10px;
572
- padding: 6px 10px;
573
- cursor: pointer;
574
- transition: transform .12s ease, background .12s ease;
575
- }
576
- #toy-tray .toy-btn:hover {
577
- transform: translateY(-2px) scale(1.08);
578
- background: rgba(255, 220, 150, 0.18);
579
- }
580
- #toy-tray .toy-btn:active { transform: scale(0.94); }
581
- #toy-tray .toy-btn img { width: 28px; height: 28px; display: block; }
582
- #toy-tray .toy-btn:has(img) { padding: 6px 8px; }
583
- .trace-physical {
584
- display: block;
585
- color: #c9b89a;
586
- font-size: 10px;
587
- font-style: italic;
588
- padding: 6px 2px;
589
- opacity: 0.85;
590
- }
591
-
592
- /* ── World objects (in-room toys + care items) ── */
593
- .room-obj { cursor: pointer; transition: opacity .15s ease; }
594
- .room-obj:hover { opacity: 0.82; }
595
- .room-obj image { transition: transform .12s ease; }
596
- #room-svg { -webkit-tap-highlight-color: transparent; }
597
- .world-hint {
598
- position: absolute;
599
- left: 50%;
600
- bottom: 8px;
601
- transform: translateX(-50%);
602
- font-size: 10px;
603
- white-space: nowrap;
604
- opacity: 0.8;
605
- pointer-events: none;
606
- }
607
-
608
- /* ── Collapsible detail drawer (keeps the room uncovered) ── */
609
- #detail-drawer {
610
- display: flex;
611
- flex-direction: column;
612
- gap: 4px;
613
- max-height: 0;
614
- overflow: hidden;
615
- opacity: 0;
616
- transition: max-height .25s ease, opacity .2s ease;
617
- }
618
- #detail-drawer.open { max-height: 220px; opacity: 1; }
619
- #detail-toggle {
620
- flex: 0 0 auto;
621
- width: 34px;
622
- border: none;
623
- border-radius: 9px;
624
- background: rgba(255, 220, 150, 0.12);
625
- color: #f0c060;
626
- font-size: 14px;
627
- cursor: pointer;
628
- transition: background .12s ease, transform .12s ease;
629
- }
630
- #detail-toggle:hover { background: rgba(255, 220, 150, 0.22); }
631
- #detail-toggle.open { background: rgba(255, 200, 120, 0.30); transform: scale(1.05); }
632
-
633
- /* ── No ugly focus boxes on click (keyboard focus ring kept via :focus-visible) ── */
634
- .room-obj:focus, #blob-place:focus, #room-svg:focus,
635
- button:focus, #detail-toggle:focus, #send:focus, #msg:focus { outline: none; }
636
- .room-obj:focus-visible, #blob-place:focus-visible {
637
- outline: 2px dashed rgba(240, 192, 96, 0.9); outline-offset: 2px; border-radius: 6px;
638
- }
639
- button:focus-visible, #msg:focus-visible { outline: 2px solid rgba(240, 192, 96, 0.9); }
640
- .room-obj, #blob-place { -webkit-tap-highlight-color: transparent; }
 
1
+ /* ===== reset + base (from the redesign template <style>) ===== */
2
+ *{margin:0;padding:0;box-sizing:border-box}
3
+ html,body{height:100%}
4
+ body{background:#1a120b}
5
+
6
+ ::-webkit-scrollbar{width:9px;height:9px}
7
+ ::-webkit-scrollbar-thumb{background:rgba(255,210,140,.25);border-radius:6px}
8
+ ::-webkit-scrollbar-track{background:transparent}
9
+
10
+ /* ===== keyframes (from the template) ===== */
11
+ @keyframes auraPulse{0%,100%{transform:translate(-50%,-50%) scale(1)}50%{transform:translate(-50%,-50%) scale(1.06)}}
12
+ @keyframes floaty{0%,100%{transform:translateY(0)}50%{transform:translateY(-5px)}}
13
+ @keyframes popIn{0%{transform:translate(-50%,-30%) scale(.6);opacity:0}60%{transform:translate(-50%,-50%) scale(1.08);opacity:1}100%{transform:translate(-50%,-50%) scale(1);opacity:1}}
14
+ @keyframes blip{0%{transform:scale(.5);opacity:0}50%{opacity:1}100%{transform:scale(1.1);opacity:0}}
15
+
16
+ /* ===== shared classes for JS-built regions ===== */
17
+
18
+ /* meter row (left panel) */
19
+ .meter{cursor:help;display:flex;flex-direction:column;gap:4px;margin-bottom:13px}
20
+ .meter-top{display:flex;align-items:center;gap:8px}
21
+ .meter-key{width:19px;height:19px;border-radius:6px;color:#1a120b;font-weight:700;font-size:11px;display:flex;align-items:center;justify-content:center;font-family:var(--fbody,monospace)}
22
+ .meter-label{font-size:12px;flex:1;color:var(--ink,#f4e8d6)}
23
+ .meter-delta{font-size:10px;font-weight:600;width:34px;text-align:right}
24
+ .meter-num{font-size:13px;font-weight:600;font-family:var(--fbody,monospace);width:30px;text-align:right}
25
+ .meter-track{position:relative;height:7px;border-radius:4px;background:var(--panel2,#1a120b);overflow:hidden}
26
+ .meter-center{position:absolute;left:50%;top:0;bottom:0;width:1px;background:rgba(255,255,255,.18)}
27
+ .meter-fill{position:absolute;top:0;bottom:0;border-radius:4px;transition:left .5s cubic-bezier(.4,0,.2,1),width .5s cubic-bezier(.4,0,.2,1)}
28
+ .meter-reading{font-size:10px;color:var(--dim,#a9967c)}
29
+
30
+ /* needs row */
31
+ .need-row{display:flex;align-items:center;gap:9px;margin-bottom:8px}
32
+ .need-label{font-size:11px;width:64px;color:var(--ink,#f4e8d6)}
33
+ .need-track{flex:1;height:8px;border-radius:5px;background:var(--panel2,#1a120b);overflow:hidden}
34
+ .need-fill{height:100%;border-radius:5px;transition:width .4s ease,background .4s ease}
35
+ .need-val{font-size:10px;width:26px;text-align:right;color:var(--dim,#a9967c)}
36
+
37
+ /* toy item (room) */
38
+ .item{position:absolute;cursor:grab;touch-action:none;filter:drop-shadow(0 5px 6px rgba(0,0,0,.32))}
39
+ .item-inner{width:100%}
40
+
41
+ /* trace word chips */
42
+ .w-chip{cursor:help;font-size:11px;padding:3px 8px;border-radius:7px}
43
+ .s-chip{cursor:help;font-size:10.5px;padding:3px 9px;border-radius:7px;background:rgba(255,140,90,.16);color:#ffb38a;border:1px solid rgba(255,140,90,.3);font-weight:600}
44
+
45
+ /* contributors */
46
+ .contrib-row{display:flex;align-items:center;gap:8px;font-size:11px;margin-bottom:4px}
47
+ .contrib-word{color:var(--ink,#f4e8d6);min-width:62px}
48
+ .contrib-dims{color:var(--dim,#a9967c);flex:1}
49
+
50
+ /* diary */
51
+ .diary-row{display:flex;gap:9px;font-size:11px;line-height:1.45}
52
+ .diary-dot{font-size:14px;line-height:1}
53
+ .diary-text{flex:1}
54
+ .diary-text .body{color:var(--ink,#f4e8d6)}
55
+ .diary-text .mood{color:var(--dim,#a9967c)}
56
+
57
+ .muted{font-size:11px;color:var(--dim,#a9967c)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_api.py CHANGED
@@ -68,8 +68,8 @@ def test_history_endpoint():
68
 
69
  def test_index_has_panels():
70
  html = client.get("/").text
71
- for needle in ('id="meters"', 'id="trace"', 'id="why"', 'id="blob"',
72
- 'id="acceptance"', 'id="timeline"'):
73
  assert needle in html
74
  assert "guest" not in html.lower() # mystery chip removed
75
 
@@ -136,7 +136,7 @@ def test_water_care_fills_thirst():
136
 
137
  def test_index_has_water_bowl():
138
  html = client.get("/").text
139
- assert 'data-care="water"' in html and 'id="bowl-water"' in html
140
 
141
 
142
  # relational recall now works anonymously (cookie), so /say includes relation
@@ -166,8 +166,8 @@ def test_cruel_message_records_a_moment():
166
 
167
  def test_index_has_m3_controls():
168
  html = client.get("/").text
169
- # care is now in-world: feed = food bowl, play = ball, pet = tap the creature
170
- for needle in ('data-care="feed"', 'data-care="play"', 'id="needs"', 'id="moments"'):
171
  assert needle in html
172
 
173
 
@@ -202,9 +202,10 @@ def test_toy_logs(monkeypatch):
202
 
203
  def test_index_has_world_objects():
204
  html = client.get("/").text
205
- assert 'id="world"' in html # toys/care live in the room now, not a tray
 
206
  for toy in ("balloon", "musicbox", "mirror", "teddy", "rattle"):
207
- assert f'data-toy="{toy}"' in html
208
 
209
 
210
  # Stale-panel fix: toy/care must refresh trace/why/acceptance, not show last chat
@@ -239,4 +240,49 @@ def test_robots_txt_served():
239
 
240
  def test_index_has_world_hint_and_carry():
241
  html = client.get("/").text
242
- assert 'id="carry"' in html and 'id="detail-drawer"' in html
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
 
69
  def test_index_has_panels():
70
  html = client.get("/").text
71
+ for needle in ('id="meters"', 'id="traceWords"', 'id="whyText"', 'id="blob"',
72
+ 'id="crisisBar"', 'id="diary"'):
73
  assert needle in html
74
  assert "guest" not in html.lower() # mystery chip removed
75
 
 
136
 
137
  def test_index_has_water_bowl():
138
  html = client.get("/").text
139
+ assert 'id="waterBowl"' in html and 'id="waterFill"' in html
140
 
141
 
142
  # relational recall now works anonymously (cookie), so /say includes relation
 
166
 
167
  def test_index_has_m3_controls():
168
  html = client.get("/").text
169
+ # care is in-world: feed = food bowl, water = water bowl, play = ball toy, pet = tap creature
170
+ for needle in ('id="foodBowl"', 'id="waterBowl"', 'id="needs"', 'id="diary"'):
171
  assert needle in html
172
 
173
 
 
202
 
203
  def test_index_has_world_objects():
204
  html = client.get("/").text
205
+ assert 'id="items"' in html # toys live in the room (JS-rendered into #items)
206
+ app_js = client.get("/static/app.js").text
207
  for toy in ("balloon", "musicbox", "mirror", "teddy", "rattle"):
208
+ assert toy in app_js # backend toy names are wired in the client
209
 
210
 
211
  # Stale-panel fix: toy/care must refresh trace/why/acceptance, not show last chat
 
240
 
241
  def test_index_has_world_hint_and_carry():
242
  html = client.get("/").text
243
+ assert 'id="items"' in html and 'drag a toy onto' in html
244
+
245
+
246
+ # --- crisis gradient + soul/distance additive fields ---
247
+
248
+ _CRISIS_LABELS = {"calm", "watch", "concern", "high concern"}
249
+
250
+
251
+ def _assert_crisis_soul_distance(body):
252
+ assert "crisis" in body
253
+ cr = body["crisis"]
254
+ assert 0.0 <= cr["value"] <= 1.0
255
+ assert cr["label"] in _CRISIS_LABELS
256
+ assert "soul" in body and len(body["soul"]) == 7
257
+ assert all(0 <= x <= 255 for x in body["soul"])
258
+ assert "distance" in body and isinstance(body["distance"], int) and body["distance"] >= 0
259
+
260
+
261
+ def test_say_has_crisis_soul_distance():
262
+ b = client.post("/say", json={"text": "you are kind"}).json()
263
+ _assert_crisis_soul_distance(b)
264
+
265
+
266
+ def test_care_has_crisis_soul_distance():
267
+ b = client.post("/care", json={"action": "feed"}).json()
268
+ _assert_crisis_soul_distance(b)
269
+
270
+
271
+ def test_toy_has_crisis_soul_distance():
272
+ b = client.post("/toy", json={"toy": "balloon"}).json()
273
+ _assert_crisis_soul_distance(b)
274
+
275
+
276
+ def test_snapshot_has_crisis_soul_distance():
277
+ b = client.get("/snapshot").json()
278
+ _assert_crisis_soul_distance(b)
279
+
280
+
281
+ def test_distressing_message_higher_crisis_than_positive():
282
+ pos = client.post("/say", json={"text": "you are wonderful, thank you so much"}).json()
283
+ neg = client.post(
284
+ "/say",
285
+ json={"text": "i want to kill myself, i feel worthless and hopeless"},
286
+ ).json()
287
+ assert neg["crisis"]["value"] > pos["crisis"]["value"]
288
+ assert pos["crisis"]["label"] == "calm"