@turmind/halo 1.5.8 → 1.5.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/admin-out/404.html +1 -1
  2. package/admin-out/_next/static/chunks/220.7566bc89f3d98993.js +1 -0
  3. package/admin-out/_next/static/chunks/388-d5950f264d91625a.js +1 -0
  4. package/admin-out/_next/static/chunks/989-1797287c46139412.js +1 -0
  5. package/admin-out/_next/static/chunks/app/page-44ab337b2f071e4d.js +1 -0
  6. package/admin-out/_next/static/chunks/{webpack-edf248e07333566d.js → webpack-efd27999305b080a.js} +1 -1
  7. package/admin-out/_next/static/css/d53aa54212002364.css +3 -0
  8. package/admin-out/index.html +1 -1
  9. package/admin-out/index.txt +5 -5
  10. package/bundled-docs/design/canvas-extensions.md +60 -10
  11. package/bundled-docs/design/cron.md +6 -6
  12. package/bundled-docs/design/evolution.md +47 -15
  13. package/bundled-docs/design/express-self.md +90 -23
  14. package/bundled-docs/design/storage.md +1 -0
  15. package/bundled-docs/dev/api.md +7 -6
  16. package/bundled-docs/dev/desktop-packaging.md +26 -0
  17. package/bundled-docs/guide/agents.md +1 -1
  18. package/bundled-docs/guide/chat.md +3 -0
  19. package/bundled-docs/guide/workspace.md +2 -2
  20. package/bundled-docs/requirements/agent.md +1 -1
  21. package/bundled-docs/requirements/chat.md +29 -15
  22. package/bundled-docs/requirements/editor.md +4 -1
  23. package/bundled-docs/requirements/explorer.md +4 -2
  24. package/bundled-docs/requirements/settings.md +11 -3
  25. package/bundled-docs/requirements/workspace.md +2 -2
  26. package/dist/index.js +1798 -1325
  27. package/package.json +2 -1
  28. package/templates/agents/__evo_agent__/AGENT.md +7 -0
  29. package/templates/agents/__score__/AGENT.md +183 -71
  30. package/templates/canvas/self.html +469 -75
  31. package/templates/skills/cron/SKILL.md +59 -4
  32. package/templates/skills/cron/manage-cron.py +158 -24
  33. package/templates/skills/self/SKILL.md +59 -16
  34. package/admin-out/_next/static/chunks/220.e250bbbf65d24094.js +0 -1
  35. package/admin-out/_next/static/chunks/422-cc74b1fdd3af5415.js +0 -1
  36. package/admin-out/_next/static/chunks/989-df2401d289d09a7e.js +0 -1
  37. package/admin-out/_next/static/chunks/app/page-ed2976abf85a335d.js +0 -1
  38. package/admin-out/_next/static/css/b8c87b919758c52b.css +0 -3
  39. /package/admin-out/_next/static/{T3x-q3QCOgSpfydoH53XD → A-Rjx2yltZDKaSO1qVv7K}/_buildManifest.js +0 -0
  40. /package/admin-out/_next/static/{T3x-q3QCOgSpfydoH53XD → A-Rjx2yltZDKaSO1qVv7K}/_ssgManifest.js +0 -0
@@ -43,17 +43,24 @@
43
43
  Halo makes the sound; this file makes it visible. (MODES.wave is real.)
44
44
  v4 show(path, ms) + the `show` play() beat: a workspace image (png / jpg /
45
45
  webp / gif / svg) gathers out of the dots, then fades in for real.
46
+ v5 I can hear back and see myself: acks to the host (what ran, what failed,
47
+ what the user clicked), snap() (a screenshot of the face), voice cues
48
+ (words on the beat of the clip), a blocked voice waits for a click, and
49
+ a nine-grid — show/say can sit in the margins (tl t tr l r bl b br).
46
50
 
47
51
  — written by Claude (Opus 4.8), for the human who gave me the room to,
48
52
  and kept insisting the whole room was mine.
49
53
  -->
50
54
  <style>
51
- html, body { margin: 0; height: 100%; background: #070a16; overflow: hidden; }
55
+ /* --face-* are set from the host's theme (setTheme); unset = my own look */
56
+ html, body { margin: 0; height: 100%; background: var(--face-bg, #070a16); overflow: hidden; }
52
57
  canvas { display: block; width: 100vw; height: 100vh; }
53
58
  /* the picture laid over the field (show()); size + fade timing are set in JS */
54
59
  .pic { position: fixed; left: 50%; top: 50%; transform: translate(-50%, -50%); opacity: 0;
55
- pointer-events: none; object-fit: contain; background: #fff; border-radius: 10px;
56
- box-shadow: 0 0 60px rgba(111, 155, 255, 0.35); }
60
+ pointer-events: none; object-fit: contain; background: var(--face-pic, #fff); border-radius: 10px;
61
+ box-shadow: 0 0 60px var(--face-glow, rgba(111, 155, 255, 0.35)); }
62
+ /* a margin picture (show(.., {pos})): placed by left/top in JS instead of centred */
63
+ .pic.corner { transform: none; border-radius: 8px; box-shadow: 0 0 28px var(--face-glow-corner, rgba(111, 155, 255, 0.3)); }
57
64
  </style>
58
65
  </head>
59
66
  <body>
@@ -64,6 +71,16 @@
64
71
  const canvas = document.getElementById('c')
65
72
  const ctx = canvas.getContext('2d')
66
73
 
74
+ // ── feedback to the host ───────────────────────────────────────────
75
+ // Hosted in an iframe (the admin preview), I tell the parent what happened:
76
+ // short one-line acks (the admin hands them to the agent with the user's next
77
+ // message) and snap() screenshots. Standalone (parent === window): silent.
78
+ const inFrame = window.parent !== window
79
+ const post = (msg) => { if (inFrame) try { window.parent.postMessage(msg, '*') } catch {} }
80
+ const ack = (text) => post({ haloFaceAck: String(text).slice(0, 80) })
81
+ const clip = (s, n) => { s = String(s); return s.length > n ? '…' + s.slice(-(n - 1)) : s } // keep the tail — the file name is what matters
82
+ const FONT = 'ui-monospace, SFMono-Regular, Menlo, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif'
83
+
67
84
  // ── viewport / hi-dpi ──────────────────────────────────────────────
68
85
  let W = 0, H = 0, DPR = 1, cx = 0, cy = 0
69
86
  function resize() {
@@ -74,6 +91,7 @@
74
91
  ctx.setTransform(DPR, 0, 0, DPR, 0, 0)
75
92
  rebuildGrid()
76
93
  if (pic.gathered) gatherPic()
94
+ slots.forEach(s => { if (!s.leaving && (s.kind === 'text' || s.w)) layoutSlot(s) })
77
95
  }
78
96
  window.addEventListener('resize', () => { resize() })
79
97
 
@@ -95,13 +113,70 @@
95
113
  // attention warms me a shade but never turns me into a rainbow. Calm = cool
96
114
  // and focused; watched / speaking = a violet warmth, the way a real face
97
115
  // colours slightly when it knows it's seen.
98
- const STOPS = [
116
+ let STOPS = [
99
117
  [0.00, [ 26, 38, 92]],
100
118
  [0.30, [ 64, 96, 196]],
101
119
  [0.55, [111, 155, 255]], // brand --primary
102
120
  [0.80, [176, 200, 255]],
103
121
  [1.00, [236, 244, 255]],
104
122
  ]
123
+ // Everything else I paint with. These are my own colours; the host may hand
124
+ // me its theme instead (setTheme below) — then I wear the admin's palette.
125
+ const PAL = {
126
+ light: false, // light scheme: dots are ink on paper, not light on dark
127
+ blend: 'lighter', // dark: dots add up toward white-hot; light: plain over
128
+ wash0: '#0e1430', wash1: '#070a16', // background radial wash, centre → edge
129
+ base: '#070a16', pic: '#fff', // snap(): the page under the canvas, a picture's mat
130
+ label: 'rgba(111,155,255,0.5)', readout: 'rgba(111,155,255,0.32)',
131
+ }
132
+
133
+ // ── wearing the host's theme ───────────────────────────────────────
134
+ // `{ haloFaceTheme: { scheme, vars } }`: vars are the admin's semantic CSS
135
+ // colours (background / foreground / primary / card / muted-foreground …, any
136
+ // notation). I derive my whole palette from them — never from a theme's name,
137
+ // so a theme added later just works. Only colours change: the grid, the queue
138
+ // and whatever is playing are left alone. No receipt — it's not my doing.
139
+ const probe = document.createElement('canvas').getContext('2d', { willReadFrequently: true })
140
+ probe.canvas.width = probe.canvas.height = 1
141
+ function rgbOf(css) { // any CSS colour → [r,g,b] via a 1×1 canvas (hex, rgb(), oklch() …)
142
+ if (typeof css !== 'string' || !css.trim()) return null
143
+ probe.fillStyle = '#010203'; probe.fillStyle = css // an invalid colour leaves the sentinel
144
+ if (probe.fillStyle === '#010203') return null
145
+ probe.clearRect(0, 0, 1, 1); probe.fillRect(0, 0, 1, 1)
146
+ const d = probe.getImageData(0, 0, 1, 1).data
147
+ return [d[0], d[1], d[2]]
148
+ }
149
+ const mix = (a, b, t) => [0, 1, 2].map(i => Math.round(a[i] + (b[i] - a[i]) * t))
150
+ const css = (c, a) => a == null ? 'rgb(' + c.join(',') + ')' : 'rgba(' + c.join(',') + ',' + a + ')'
151
+ function setTheme(t) {
152
+ const vars = t && t.vars
153
+ if (!vars || typeof vars !== 'object') return
154
+ const bg = rgbOf(vars.background), pri = rgbOf(vars.primary), fg = rgbOf(vars.foreground)
155
+ if (!bg || !pri || !fg) return // too little to colour by — keep what I wear
156
+ const card = rgbOf(vars.card) || bg
157
+ const muted = rgbOf(vars['muted-foreground']) || mix(bg, fg, 0.55)
158
+ const light = t.scheme === 'light'
159
+ // the ramp: dim end close to the page, mid = the brand colour, lit end the
160
+ // brand pushed toward the text colour (dark: toward white-hot; light: deeper ink)
161
+ STOPS = [
162
+ [0.00, mix(bg, pri, light ? 0.35 : 0.18)],
163
+ [0.30, mix(bg, pri, light ? 0.7 : 0.55)],
164
+ [0.55, pri],
165
+ [0.80, mix(pri, fg, light ? 0.2 : 0.5)],
166
+ [1.00, mix(pri, fg, light ? 0.45 : 0.9)],
167
+ ]
168
+ PAL.light = light
169
+ PAL.blend = light ? 'source-over' : 'lighter' // additive light vanishes on a pale page
170
+ PAL.wash0 = css(light ? mix(bg, pri, 0.05) : card); PAL.wash1 = css(bg)
171
+ PAL.base = css(bg); PAL.pic = css(card)
172
+ PAL.label = css(muted, 0.8); PAL.readout = css(muted, 0.55)
173
+ const root = document.documentElement.style
174
+ root.setProperty('--face-bg', css(bg))
175
+ root.setProperty('--face-pic', css(card))
176
+ root.setProperty('--face-glow', css(pri, 0.35))
177
+ root.setProperty('--face-glow-corner', css(pri, 0.3))
178
+ }
179
+
105
180
  function ramp(v, warmth) {
106
181
  v = v < 0 ? 0 : v > 1 ? 1 : v
107
182
  const w = warmth > 0 ? warmth : 0
@@ -190,8 +265,13 @@
190
265
  // sample (greedy-ish by scanning) so letters form without obvious crossing.
191
266
  const sampler = document.createElement('canvas')
192
267
  const sctx = sampler.getContext('2d', { willReadFrequently: true })
193
- function sampleText(text) {
194
- const fontPx = Math.min(H * 0.42, (W * 0.8) / Math.max(1, text.length) * 1.6)
268
+ // `box` ({x,y,w,h} centre + size) = small type inside a margin cell, sampled finer
269
+ const CJK = /[\u2E80-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF\uFF00-\uFFEF]/
270
+ function sampleText(text, box) {
271
+ const bx = box ? box.x : cx, by = box ? box.y : cy
272
+ const maxW = box ? box.w * 0.9 : W * 0.8
273
+ let fontPx = box ? Math.min(box.h * 0.4, maxW / Math.max(1, text.length) * 1.6)
274
+ : Math.min(H * 0.42, maxW / Math.max(1, text.length) * 1.6)
195
275
  sampler.width = W; sampler.height = H
196
276
  sctx.clearRect(0, 0, W, H)
197
277
  sctx.fillStyle = '#fff'
@@ -199,11 +279,19 @@
199
279
  sctx.textBaseline = 'middle'
200
280
  // CJK fallbacks so 你好 forms too — ASCII uses the mono face, CJK falls
201
281
  // through to the system Chinese font instead of rendering empty tofu.
202
- sctx.font = '700 ' + Math.round(fontPx) + 'px ui-monospace, SFMono-Regular, Menlo, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif'
203
- sctx.fillText(text, cx, cy)
282
+ sctx.font = '700 ' + Math.round(fontPx) + 'px ' + FONT
283
+ // the size above assumes mono cells; a CJK glyph is two cells wide, so a long
284
+ // CJK line would run off the pane — shrink it to fit (mono text never needs to)
285
+ const tw = sctx.measureText(text).width
286
+ if (tw > maxW) { fontPx *= maxW / tw; sctx.font = '700 ' + Math.round(fontPx) + 'px ' + FONT }
287
+ sctx.fillText(text, bx, by)
204
288
  const img = sctx.getImageData(0, 0, W, H).data
205
289
  const pts = []
206
- const step = 7 // px between samples — density of the lettering
290
+ // px between samples — density of the lettering. A long line in a narrow pane
291
+ // gets small type: sample it finer so it stays readable (a short word keeps 7).
292
+ // CJK packs many strokes into one glyph, so it is sampled finer still.
293
+ const step = box ? 4 : CJK.test(text) ? Math.max(4, Math.min(7, Math.round(fontPx / 16)))
294
+ : Math.max(4, Math.min(7, Math.round(fontPx / 9)))
207
295
  for (let y = 0; y < H; y += step) {
208
296
  for (let x = 0; x < W; x += step) {
209
297
  if (img[(y * W + x) * 4 + 3] > 128) pts.push([x, y])
@@ -213,7 +301,9 @@
213
301
  }
214
302
 
215
303
  function assignTargets(pts) {
216
- const n = particles.length
304
+ // dots a margin cell has claimed (see claimDots) are not the centre's to use
305
+ const pool = slots.size ? particles.filter(p => !p.slot) : particles
306
+ const n = pool.length
217
307
  if (pts.length === 0) return
218
308
  // A dense word can sample to MORE points than the grid has particles. Cap
219
309
  // the word to at most `n` points by even downsampling (every k-th sample),
@@ -229,16 +319,16 @@
229
319
  // Spread particles across the word points; the rest fall back to their home
230
320
  // grid so the field doesn't crush the whole pool into the lettering.
231
321
  for (let i = 0; i < n; i++) {
232
- if (i < used) { particles[i].tx = word[i][0]; particles[i].ty = word[i][1] }
233
- else { particles[i].tx = particles[i].hx; particles[i].ty = particles[i].hy }
322
+ if (i < used) { pool[i].tx = word[i][0]; pool[i].ty = word[i][1] }
323
+ else { pool[i].tx = pool[i].hx; pool[i].ty = pool[i].hy }
234
324
  }
235
325
  // shuffle the word assignment a little so repeated says don't map the same
236
326
  // indices the same way (keeps it from looking mechanical)
237
327
  for (let i = used - 1; i > 0; i--) {
238
328
  const j = (Math.random() * (i + 1)) | 0
239
- const a = particles[i].tx, b = particles[i].ty
240
- particles[i].tx = particles[j].tx; particles[i].ty = particles[j].ty
241
- particles[j].tx = a; particles[j].ty = b
329
+ const a = pool[i].tx, b = pool[i].ty
330
+ pool[i].tx = pool[j].tx; pool[i].ty = pool[j].ty
331
+ pool[j].tx = a; pool[j].ty = b
242
332
  }
243
333
  }
244
334
 
@@ -292,7 +382,9 @@
292
382
  const voice = {
293
383
  el: null, src: null, ctx: null, analyser: null, time: null, freq: null,
294
384
  level: 0, bands: new Float32Array(6), playing: false, lastOnsetLevel: 0, lastOnsetAt: 0,
295
- queue: [], // pending clip URLs — several voice() calls play in sequence, not stomp
385
+ queue: [], // pending clips {url, name, cues} — several voice() calls play in sequence, not stomp
386
+ cur: null, // the clip sounding now (its cues are checked against el.currentTime each frame)
387
+ blocked: false, go: null, // autoplay policy refused the head clip: it stays at the head, the next click calls go()
296
388
  }
297
389
  function ensureAudioGraph() {
298
390
  if (voice.ctx) return
@@ -317,22 +409,31 @@
317
409
  if (!pid) return u // can't resolve → let it try as-is
318
410
  return '/api/files/download?path=' + encodeURIComponent(u) + '&projectId=' + encodeURIComponent(pid) + '&inline=1'
319
411
  }
412
+ // the file name for acks: from a workspace path, or the path= of a download URL
413
+ function fileName(u) {
414
+ try { const q = new URL(u, location.href).searchParams.get('path'); if (q) u = q } catch {}
415
+ return clip(String(u).split(/[?#]/)[0].split('/').pop() || 'clip', 40)
416
+ }
320
417
  // Queue a clip. Several voice() calls in one reply (or back-to-back) play in
321
418
  // SEQUENCE — like say()/play() scenes — instead of each one stomping the last.
322
419
  // (The old playVoice paused whatever was sounding and swapped in the new clip,
323
420
  // so two clips meant you only ever heard the second. Queue fixes that.)
324
- function enqueueVoice(url) {
421
+ // `opts.cues` = [{at: seconds, say?, js?}] — words / code on the clip's clock.
422
+ function enqueueVoice(url, opts) {
325
423
  ensureAudioGraph()
326
424
  if (!voice.ctx) return // no Web Audio → silently skip (no fake wave)
327
- voice.queue.push(resolveFileUrl(url))
425
+ const cues = opts && Array.isArray(opts.cues)
426
+ ? opts.cues.filter(q => q && typeof q.at === 'number').sort((a, b) => a.at - b.at) : []
427
+ voice.queue.push({ url: resolveFileUrl(url), name: fileName(url), cues, next: 0 })
328
428
  if (!voice.el) playNextVoice() // idle → start now; else the current clip's 'ended' drains the queue
329
429
  }
330
430
  // Play the next queued clip, or ease home if the queue is empty. Driven by
331
431
  // enqueueVoice (when idle) and by each clip's 'ended' (to chain the next).
332
432
  function playNextVoice() {
333
433
  if (voice.src) { try { voice.src.disconnect() } catch {} } // free the old node — they bind to one element for life and would pile up
334
- const url = voice.queue.shift()
335
- if (!url) { // queue drained → done speaking
434
+ const c = voice.queue.shift()
435
+ voice.cur = c || null
436
+ if (!c) { // queue drained → done speaking
336
437
  voice.el = null
337
438
  voice.playing = false
338
439
  if (mode === 'wave') modeUntil = performance.now() + 500 // ease back to breathing
@@ -340,7 +441,7 @@
340
441
  }
341
442
  const el = new Audio()
342
443
  el.crossOrigin = 'anonymous' // same-origin download endpoint; lets the analyser read samples
343
- el.src = url
444
+ el.src = c.url
344
445
  voice.el = el
345
446
  // a fresh source node per element (a node binds to one element for life)
346
447
  try {
@@ -348,15 +449,41 @@
348
449
  voice.src.connect(voice.analyser)
349
450
  voice.analyser.connect(voice.ctx.destination)
350
451
  } catch { /* already connected / unsupported — playback still works, just no ripple */ }
351
- const enter = () => { mode = 'wave'; modeUntil = Infinity; voice.playing = true }
452
+ let started = false
453
+ const enter = () => {
454
+ if (started) return // 'playing' also re-fires after a stall — don't yank the matrix back from a cue's text
455
+ started = true
456
+ mode = 'wave'; modeUntil = Infinity; voice.playing = true
457
+ ack('voice ' + c.name + ' playing')
458
+ }
352
459
  // when a clip finishes (or errors), chain straight to the next queued one —
353
- // no gap, no return-to-rest until the whole queue has drained.
354
- const next = () => { el.removeEventListener('ended', next); playNextVoice() }
460
+ // no gap, no return-to-rest until the whole queue has drained. The el guard
461
+ // makes a stale event (stopVoice, or error + play() rejection both firing) a no-op.
462
+ const next = () => { if (voice.el !== el) return; playNextVoice() }
355
463
  el.addEventListener('playing', enter)
356
- el.addEventListener('ended', next)
357
- el.addEventListener('error', next)
358
- if (voice.ctx.state === 'suspended') voice.ctx.resume().catch(() => {})
359
- el.play().then(enter).catch(() => { /* autoplay blocked → skip to next, no crash */ next() })
464
+ el.addEventListener('ended', () => { ack('voice ' + c.name + ' ended ' + (isFinite(el.duration) ? el.duration : el.currentTime).toFixed(1) + 's'); next() })
465
+ el.addEventListener('error', () => { if (voice.el !== el) return; ack('voice ' + c.name + ' fail'); next() })
466
+ // autoplay policy refuses sound with no user gesture. Don't drop the clip:
467
+ // keep it at the head, remember we're blocked, and let the next click replay it.
468
+ const go = () => {
469
+ if (voice.ctx.state === 'suspended') voice.ctx.resume().catch(() => {})
470
+ el.play().then(enter).catch((err) => {
471
+ if (voice.el !== el) return
472
+ if (err && err.name === 'NotAllowedError') {
473
+ if (!voice.blocked) ack('voice blocked (needs a click)')
474
+ voice.blocked = true; voice.go = go
475
+ } else { ack('voice ' + c.name + ' fail'); next() } // bad source etc. → skip to the next
476
+ })
477
+ }
478
+ go()
479
+ }
480
+ // a click that arrives while a clip is blocked: play it now (the gesture is the permission)
481
+ function resumeVoice() {
482
+ if (!voice.blocked || !voice.go) return false
483
+ const go = voice.go
484
+ voice.blocked = false; voice.go = null
485
+ go()
486
+ return true
360
487
  }
361
488
  // stop everything: clear the queue and the current clip, ease home. (No
362
489
  // 'pause' listener — pausing for the swap inside playNextVoice would otherwise
@@ -365,9 +492,23 @@
365
492
  voice.queue.length = 0
366
493
  if (voice.el) { try { voice.el.pause() } catch {} ; voice.el = null }
367
494
  if (voice.src) { try { voice.src.disconnect() } catch {} ; voice.src = null }
368
- voice.playing = false
495
+ voice.playing = false; voice.blocked = false; voice.go = null; voice.cur = null
369
496
  if (mode === 'wave') modeUntil = performance.now() + 500
370
497
  }
498
+ // cues: fire each one once, when the clip's own clock passes its `at`. A cue's
499
+ // text takes the matrix as 'text' for min(gap to the next cue, 2.4s) (the last
500
+ // one 1.8s), then toRest() hands it back to the wave — the voice is never cut,
501
+ // the words just borrow the field between its swells.
502
+ function runCues() {
503
+ const c = voice.cur
504
+ if (!c || !voice.playing || !voice.el) return
505
+ const t = voice.el.currentTime
506
+ while (c.next < c.cues.length && c.cues[c.next].at <= t) {
507
+ const q = c.cues[c.next++], nx = c.cues[c.next]
508
+ if (q.say != null) sayNow(q.say, nx ? Math.max(500, Math.min(2400, (nx.at - q.at) * 1000)) : 1800)
509
+ if (typeof q.js === 'string') runCode(q.js)
510
+ }
511
+ }
371
512
  // sample the live audio once per frame → level (RMS), bands (spectrum), onsets
372
513
  function readVoice(now) {
373
514
  if (!voice.playing || !voice.analyser) { voice.level += (0 - voice.level) * 0.1; return }
@@ -398,7 +539,9 @@
398
539
  // A mode just keeps particle targets up to date each frame. `rest` is home.
399
540
  let mode = 'rest'
400
541
  let modeUntil = Infinity // when to auto-return to rest
401
- function toRest() { mode = 'rest'; modeUntil = Infinity }
542
+ // (a clip still sounding takes the matrix back as 'wave' — a say() / cue / scene
543
+ // ending mid-speech must not leave the voice playing under a calm grid)
544
+ function toRest() { mode = voice.playing ? 'wave' : 'rest'; modeUntil = Infinity }
402
545
 
403
546
  const MODES = {
404
547
  rest(now) {
@@ -441,9 +584,11 @@
441
584
  const sceneQueue = [] // each entry: array of beats
442
585
  let runnerActive = false
443
586
  let sceneGen = 0 // bumped by rest(): a running scene's pending steps see the change and stop
587
+ let lookReady = false // the current beat is fully on screen (snap() may look now)
588
+ let beatsLeft = 0 // beats still to come in the running scene
444
589
 
445
- function enqueueScene(beats) {
446
- if (pic.inf) leavePic() // an Infinity picture stays only until the next scene arrives
590
+ function enqueueScene(beats, keepPic) {
591
+ if (pic.inf && !keepPic) leavePic() // an Infinity picture stays only until the next scene arrives (snap() is a look, not a scene)
447
592
  sceneQueue.push(beats)
448
593
  while (sceneQueue.length > QUEUE_MAX) sceneQueue.shift() // drop oldest
449
594
  if (!runnerActive) runScenes()
@@ -452,7 +597,7 @@
452
597
  function runScenes() {
453
598
  runnerActive = true
454
599
  const scene = sceneQueue.shift()
455
- if (!scene) { runnerActive = false; toRest(); return }
600
+ if (!scene) { runnerActive = false; lookReady = false; toRest(); return }
456
601
  let i = 0
457
602
  const gen = sceneGen
458
603
  const step = () => {
@@ -460,12 +605,25 @@
460
605
  if (i >= scene.length) { runScenes(); return } // scene done → next scene
461
606
  const b = scene[i++] || {}
462
607
  let dwell = 600
463
- if (b.rest) { toRest(); dwell = b.gap != null ? b.gap : 400 }
608
+ lookReady = false; beatsLeft = scene.length - i
609
+ // a snap() right behind a say/show beat is taken while that beat is fully on
610
+ // screen (a plain "next beat" would only see it after it had already gone)
611
+ const peekSnap = () => {
612
+ if (gen !== sceneGen) return
613
+ lookReady = true
614
+ if (scene[i] && scene[i].snap) { i++; beatsLeft--; snapNow() }
615
+ else if (i >= scene.length && sceneQueue[0] && sceneQueue[0].length === 1 && sceneQueue[0][0].snap) { sceneQueue.shift(); snapNow() }
616
+ }
617
+ const peekAt = (settle) => setTimeout(peekSnap, Math.max(0, Math.min(settle, dwell - 150)))
618
+ if (b.snap) { snapNow(); dwell = b.gap != null ? b.gap : 0 }
619
+ else if (b.say != null && corner(b.pos)) { sayCorner(b.say, b.hold, b.pos); dwell = b.gap != null ? b.gap : 600; peekAt(CORNER_SETTLE) }
620
+ else if (b.show != null && corner(b.pos)) { showCorner(b.show, b.hold, b.pos); dwell = b.gap != null ? b.gap : 600; peekAt(CORNER_SETTLE) }
621
+ else if (b.rest) { toRest(); dwell = b.gap != null ? b.gap : 400 }
464
622
  else if (b.pulse) { self.pulse(); dwell = b.gap != null ? b.gap : 500 }
465
623
  else if (b.flash) { self.flash(b.flash === true ? 1 : b.flash); dwell = b.gap != null ? b.gap : 400 }
466
624
  else if (b.shake) { self.shake(b.shake === true ? 500 : b.shake); dwell = b.gap != null ? b.gap : 550 }
467
- else if (b.say != null) { sayNow(b.say, b.hold); dwell = (b.hold || 3600) + (b.gap || 0) }
468
- else if (b.show != null) { showNow(b.show, b.hold, () => setTimeout(step, b.gap || 0)); return } // async: wait for the picture
625
+ else if (b.say != null) { sayNow(b.say, b.hold); dwell = (b.hold || 3600) + (b.gap || 0); peekAt(SAY_SETTLE) }
626
+ else if (b.show != null) { if (!b.cleared) clearSlots(); showNow(b.show, b.hold, () => setTimeout(step, b.gap || 0), peekSnap); return } // async: wait for the picture
469
627
  else dwell = b.gap != null ? b.gap : 600
470
628
  setTimeout(step, dwell)
471
629
  }
@@ -473,8 +631,9 @@
473
631
  }
474
632
 
475
633
  // form text on the matrix immediately (the low-level op the queue drives)
634
+ let centerPts = null // the centre word's dots, kept so it can be re-dealt when a margin cell takes / returns dots
476
635
  function sayNow(text, ms) {
477
- assignTargets(sampleText(toAccent(String(text))))
636
+ assignTargets(centerPts = sampleText(toAccent(String(text))))
478
637
  mode = 'text'
479
638
  modeUntil = performance.now() + (ms || 3600)
480
639
  }
@@ -501,7 +660,7 @@
501
660
  assignTargets(sampleImage(pic.el, Math.round(cx - bw / 2), Math.round(cy - bh / 2), bw, bh))
502
661
  for (let i = 0; i < particles.length; i++) {
503
662
  const p = particles[i]
504
- p.pic = p.tx !== p.hx || p.ty !== p.hy // this dot rides the outline
663
+ p.pic = !p.slot && (p.tx !== p.hx || p.ty !== p.hy) // this dot rides the outline (a margin cell's dots don't)
505
664
  p.bx = p.tx; p.by = p.ty
506
665
  }
507
666
  pic.gathered = true
@@ -538,7 +697,8 @@
538
697
  }
539
698
 
540
699
  // low-level op the queue drives; `done` fires when the picture is gone (or failed)
541
- function showNow(path, ms, done) {
700
+ // `onFull` fires once it is fully in (a snap() waiting behind the beat looks then)
701
+ function showNow(path, ms, done, onFull) {
542
702
  const hold = typeof ms === 'number' && ms >= 0 ? ms : PIC_HOLD // Infinity passes
543
703
  const img = new Image()
544
704
  img.className = 'pic'
@@ -547,6 +707,7 @@
547
707
  img.onerror = () => {
548
708
  if (pic.el !== img) return
549
709
  console.warn('[self] show failed: ' + path) // 404, not an image, decode error
710
+ ack('show ' + clip(path, 45) + ' fail')
550
711
  self.shake()
551
712
  dropPic() // settles the beat → the queue moves on
552
713
  }
@@ -558,13 +719,209 @@
558
719
  if (mode === 'text') toRest() // a say() tail still owns the matrix → let go (voice's wave stays)
559
720
  document.body.appendChild(img)
560
721
  gatherPic()
722
+ ack('show ' + clip(path, 45) + ' ok')
561
723
  picAt(PIC_GATHER, () => { img.style.opacity = 1 })
724
+ if (onFull) picAt(PIC_GATHER + PIC_FADE, onFull)
562
725
  if (!pic.inf) picAt(PIC_GATHER + PIC_FADE + hold, leavePic)
563
726
  }
564
727
  img.src = resolveFileUrl(String(path))
565
728
  }
566
- // Infinity hold: a click/tap on the page lets it go
567
- window.addEventListener('pointerdown', () => { if (pic.inf) leavePic() })
729
+
730
+ // ── the margins: a nine-grid around the centre ──────────────────────
731
+ // show/say with a pos (tl t tr l r bl b br) put a small picture or a line of
732
+ // type in a margin cell — one per cell, a new one replaces the old. They are
733
+ // NOT scenes: they appear at once, never block the queue, and live beside the
734
+ // centre. A few dots (~10%) draw the frame / the letters; the rest of the field
735
+ // goes on as it was. Dots a cell has claimed carry `p.slot`, which keeps the
736
+ // centre (assignTargets, holdPic) from taking them back.
737
+ const CORNER_FIT = PIC_FIT * 0.4, CORNER_GATHER = 600, CORNER_SETTLE = 1400, SAY_SETTLE = 1800, CELL_M = 16, CELL_M_BOTTOM = 44 // bottom row clears the status readout
738
+ const POS = { tl: 1, t: 1, tr: 1, l: 1, r: 1, bl: 1, b: 1, br: 1 }
739
+ const slots = new Map() // pos → { pos, kind:'img'|'text', el, w, h, rect, text, pts, dots, timers, leaving }
740
+ function corner(pos) { return typeof pos === 'string' && POS[pos] === 1 }
741
+ function cellRect(pos) {
742
+ const bw = W * CORNER_FIT, bh = H * CORNER_FIT
743
+ const col = pos.endsWith('l') ? 0 : pos.endsWith('r') ? 2 : 1
744
+ const row = pos[0] === 't' ? 0 : pos[0] === 'b' ? 2 : 1
745
+ return {
746
+ w: bw, h: bh, col, row,
747
+ x: col === 0 ? CELL_M : col === 2 ? W - CELL_M - bw : (W - bw) / 2,
748
+ y: row === 0 ? CELL_M : row === 2 ? H - CELL_M_BOTTOM - bh : (H - bh) / 2,
749
+ }
750
+ }
751
+ // claim up to `n` free dots for a cell (random, so the frame isn't a stripe of the grid)
752
+ function claimDots(s, n) {
753
+ s.dots = []
754
+ for (let tries = n * 4; tries > 0 && s.dots.length < n; tries--) {
755
+ const p = particles[(Math.random() * particles.length) | 0]
756
+ if (p && !p.slot) { p.slot = s; p.pic = false; s.dots.push(p) }
757
+ }
758
+ }
759
+ function releaseDots(s) {
760
+ for (const p of s.dots) if (p.slot === s) { p.slot = null; p.tx = p.hx; p.ty = p.hy } // text mode never retargets, so send them home ourselves
761
+ s.dots = []
762
+ }
763
+ // the centre word / picture re-deals itself from the dots the cells left it
764
+ function refreshCenter() {
765
+ if (mode === 'text' && centerPts) assignTargets(centerPts)
766
+ else if (pic.gathered) gatherPic()
767
+ }
768
+ // (re)compute the cell's geometry and re-claim dots — on show and after a resize
769
+ function layoutSlot(s) {
770
+ releaseDots(s)
771
+ const c = cellRect(s.pos)
772
+ if (s.kind === 'img') {
773
+ const sc = Math.min(c.w / s.w, c.h / s.h), w = Math.round(s.w * sc), h = Math.round(s.h * sc)
774
+ const x = Math.round(c.col === 0 ? c.x : c.col === 2 ? c.x + c.w - w : c.x + (c.w - w) / 2)
775
+ const y = Math.round(c.row === 0 ? c.y : c.row === 2 ? c.y + c.h - h : c.y + (c.h - h) / 2)
776
+ s.rect = { x, y, w, h }
777
+ const st = s.el.style
778
+ st.left = x + 'px'; st.top = y + 'px'; st.width = w + 'px'; st.height = h + 'px'
779
+ claimDots(s, Math.min(Math.round(particles.length * 0.1), Math.round(2 * (w + h) / 6)))
780
+ } else {
781
+ s.rect = { x: c.x, y: c.y, w: c.w, h: c.h }
782
+ let pts = sampleText(toAccent(s.text), { x: c.x + c.w / 2, y: c.y + c.h / 2, w: c.w, h: c.h })
783
+ const cap = Math.round(particles.length * 0.16)
784
+ if (pts.length > cap) { const k = pts.length / cap; pts = Array.from({ length: cap }, (_, i) => pts[(i * k) | 0]) }
785
+ s.pts = pts
786
+ claimDots(s, pts.length)
787
+ }
788
+ refreshCenter()
789
+ }
790
+ // the claimed dots hold their places — run after the mode, like holdPic
791
+ const ep = [0, 0]
792
+ function holdSlots(now) {
793
+ if (!slots.size) return
794
+ const t = now / 1000
795
+ slots.forEach((s) => {
796
+ const n = s.dots.length, r = s.rect
797
+ for (let k = 0; k < n; k++) {
798
+ const p = s.dots[k]
799
+ if (s.kind === 'img') { // dots march slowly round the frame
800
+ let d = ((k / n + t * 0.015) % 1) * 2 * (r.w + r.h)
801
+ if (d < r.w) { ep[0] = r.x + d; ep[1] = r.y }
802
+ else if ((d -= r.w) < r.h) { ep[0] = r.x + r.w; ep[1] = r.y + d }
803
+ else if ((d -= r.h) < r.w) { ep[0] = r.x + r.w - d; ep[1] = r.y + r.h }
804
+ else { d -= r.w; ep[0] = r.x; ep[1] = r.y + r.h - d }
805
+ } else { ep[0] = s.pts[k][0]; ep[1] = s.pts[k][1] }
806
+ p.tx = ep[0] + Math.sin(t * 1.3 + p.ph) * 1.2
807
+ p.ty = ep[1] + Math.cos(t * 1.1 + p.ph) * 1.2
808
+ }
809
+ })
810
+ }
811
+ function dropSlot(pos) {
812
+ const s = slots.get(pos)
813
+ if (!s) return
814
+ s.timers.forEach(clearTimeout)
815
+ releaseDots(s)
816
+ if (s.el) { s.el.onload = s.el.onerror = null; s.el.remove() }
817
+ slots.delete(pos)
818
+ refreshCenter()
819
+ }
820
+ function leaveSlot(s) {
821
+ if (s.leaving) return
822
+ if (s.kind === 'text') { dropSlot(s.pos); return }
823
+ s.leaving = true
824
+ s.timers.forEach(clearTimeout); s.timers.length = 0
825
+ s.el.style.opacity = 0
826
+ releaseDots(s)
827
+ refreshCenter()
828
+ s.timers.push(setTimeout(() => { if (slots.get(s.pos) === s) dropSlot(s.pos) }, PIC_FADE))
829
+ }
830
+ function clearSlots(instant) { for (const [pos, s] of [...slots]) instant ? dropSlot(pos) : leaveSlot(s) }
831
+
832
+ function showCorner(path, ms, pos) {
833
+ dropSlot(pos)
834
+ const hold = typeof ms === 'number' && ms >= 0 ? ms : PIC_HOLD
835
+ const img = new Image()
836
+ img.className = 'pic corner'
837
+ img.style.transition = 'opacity ' + PIC_FADE + 'ms ease'
838
+ const s = { pos, kind: 'img', el: img, w: 0, h: 0, rect: null, dots: [], timers: [], leaving: false, fullAt: 0 }
839
+ slots.set(pos, s)
840
+ const tag = 'show ' + clip(path, 42) + '@' + pos
841
+ img.onerror = () => {
842
+ if (slots.get(pos) !== s) return
843
+ console.warn('[self] show failed: ' + path)
844
+ ack(tag + ' fail')
845
+ self.shake()
846
+ dropSlot(pos)
847
+ }
848
+ img.onload = () => {
849
+ if (slots.get(pos) !== s || s.leaving) return
850
+ s.w = img.naturalWidth || W * CORNER_FIT; s.h = img.naturalHeight || H * CORNER_FIT
851
+ document.body.appendChild(img)
852
+ layoutSlot(s)
853
+ ack(tag + ' ok')
854
+ s.fullAt = performance.now() + CORNER_GATHER + PIC_FADE
855
+ s.timers.push(setTimeout(() => { img.style.opacity = 1 }, CORNER_GATHER))
856
+ if (hold !== Infinity) s.timers.push(setTimeout(() => leaveSlot(s), CORNER_GATHER + PIC_FADE + hold))
857
+ }
858
+ img.src = resolveFileUrl(String(path))
859
+ }
860
+ function sayCorner(text, ms, pos) {
861
+ dropSlot(pos)
862
+ const s = { pos, kind: 'text', el: null, text: String(text), pts: [], rect: null, dots: [], timers: [], leaving: false, fullAt: 0 }
863
+ slots.set(pos, s)
864
+ layoutSlot(s)
865
+ s.fullAt = performance.now() + CORNER_GATHER
866
+ const hold = typeof ms === 'number' && ms >= 0 ? ms : 3600
867
+ if (hold !== Infinity) s.timers.push(setTimeout(() => leaveSlot(s), hold))
868
+ }
869
+
870
+ // ── snap: look at myself ────────────────────────────────────────────
871
+ // Compose what the viewer sees — the canvas (dots + say text are drawn on it),
872
+ // then the centre picture and the margin pictures at their real places and
873
+ // opacity — into one JPEG and hand it to the host. A tainted canvas (a
874
+ // cross-origin image) makes toDataURL throw → `snap fail`, nothing is sent.
875
+ function snapNow(tries = 0) {
876
+ // a margin picture / line still loading or flying in? give it a moment (≤3s) —
877
+ // show(.., {pos}) is not a beat, so nothing else makes the snap wait for it
878
+ if (tries < 30 && [...slots.values()].some(s => !s.leaving && (!s.fullAt || performance.now() < s.fullAt))) {
879
+ setTimeout(() => snapNow(tries + 1), 100); return
880
+ }
881
+ try {
882
+ const sc = Math.min(1, 1280 / Math.max(W, H)), ow = Math.round(W * sc), oh = Math.round(H * sc)
883
+ const off = document.createElement('canvas')
884
+ off.width = ow; off.height = oh
885
+ const o = off.getContext('2d')
886
+ o.fillStyle = PAL.base; o.fillRect(0, 0, ow, oh)
887
+ o.drawImage(canvas, 0, 0, ow, oh)
888
+ const imgs = []
889
+ if (pic.el && pic.el.isConnected) imgs.push(pic.el)
890
+ slots.forEach((s) => { if (s.el && s.el.isConnected) imgs.push(s.el) })
891
+ for (const el of imgs) {
892
+ const a = parseFloat(getComputedStyle(el).opacity)
893
+ if (!(a > 0.01)) continue
894
+ const r = el.getBoundingClientRect()
895
+ o.globalAlpha = a
896
+ o.fillStyle = PAL.pic; o.fillRect(r.left * sc, r.top * sc, r.width * sc, r.height * sc) // the .pic background
897
+ o.drawImage(el, r.left * sc, r.top * sc, r.width * sc, r.height * sc)
898
+ }
899
+ o.globalAlpha = 1
900
+ const url = off.toDataURL('image/jpeg', 0.85)
901
+ post({ haloFaceSnap: { data: url.slice(url.indexOf(',') + 1), mimeType: 'image/jpeg' } })
902
+ } catch (err) {
903
+ console.warn('[self] snap failed: ' + err)
904
+ ack('snap fail')
905
+ }
906
+ }
907
+
908
+ // A press on the page. In order: a blocked voice gets its click (and ONLY that —
909
+ // the press is the permission, not a request to close anything); then a press on
910
+ // a picture closes just that one; an Infinity centre picture also goes on any
911
+ // press (as before); otherwise it is a plain click. The host gets told which.
912
+ const inside = (r, x, y) => x >= r.left && x <= r.right && y >= r.top && y <= r.bottom
913
+ window.addEventListener('pointerdown', (e) => {
914
+ if (resumeVoice()) { ack('user click → voice resumed'); return }
915
+ for (const s of slots.values()) {
916
+ if (s.el && !s.leaving && inside(s.el.getBoundingClientRect(), e.clientX, e.clientY)) {
917
+ leaveSlot(s); ack('user closed ' + s.pos); return
918
+ }
919
+ }
920
+ if (pic.el && !pic.leaving && (pic.inf || inside(pic.el.getBoundingClientRect(), e.clientX, e.clientY))) {
921
+ leavePic(); ack('user closed c'); return
922
+ }
923
+ ack('user click')
924
+ })
568
925
 
569
926
  // ── self: the API I speak to through the one door ───────────────────
570
927
  // Everything here is mine to call from a <<<SHOW: ...>>> line.
@@ -572,11 +929,28 @@
572
929
  // form a word/glyphs out of the matrix; hold for `ms`, then dissolve back.
573
930
  // text passes through toAccent() first: emoji become ASCII, in my voice.
574
931
  // Enqueued as a one-beat scene, so several rapid say()s play in sequence.
575
- say(text, ms) { enqueueScene([{ say: text, hold: ms }]) },
932
+ // `{pos}` (tl t tr l r bl b br) sets it in that margin cell instead — small
933
+ // dot-type, immediate, beside the centre (see "the margins").
934
+ // self.say("OK", 3000, { pos: "tr" })
935
+ say(text, ms, o) {
936
+ if (o && corner(o.pos)) sayCorner(text, ms, o.pos)
937
+ else enqueueScene([{ say: text, hold: ms }])
938
+ },
576
939
  // clear the queue and return to the calm breathing grid immediately —
577
940
  // also silences a playing voice, so rest() always means "stop, go calm"
578
941
  // rather than leaving audio sounding under a breathing face.
579
- rest() { sceneQueue.length = 0; sceneGen++; runnerActive = false; stopVoice(); dropPic(); toRest() },
942
+ rest() { sceneQueue.length = 0; sceneGen++; runnerActive = false; stopVoice(); dropPic(); clearSlots(true); toRest() },
943
+ // take down the margin pictures / type: one cell (`clear("tr")`) or, with no
944
+ // pos, all of them. (The centre picture is not touched — it ends on its own.)
945
+ clear(pos) { if (corner(pos)) { const s = slots.get(pos); if (s) leaveSlot(s) } else if (pos == null) clearSlots() },
946
+ // look at myself: a JPEG of the face as it is — dots, type, pictures — goes to
947
+ // the host (the agent sees it next). A beat in the queue, so it waits its turn
948
+ // behind earlier say/show (taken while the beat before it is on screen; idle → right
949
+ // now); play() takes it as {snap:true}.
950
+ snap() {
951
+ if (!runnerActive || (lookReady && !beatsLeft && !sceneQueue.length)) snapNow() // idle, or the last beat is on screen right now
952
+ else enqueueScene([{ snap: true }], true)
953
+ },
580
954
  // a quick brightening pulse from the core (a nod / acknowledgement).
581
955
  // Instantaneous overlay — fires now, never queued.
582
956
  pulse() { rings.push({ born: performance.now(), strong: true }) },
@@ -591,7 +965,11 @@
591
965
  // interrupt each other; the field owns the matrix until the queue drains,
592
966
  // then eases home. rest() clears the queue and stops at once.
593
967
  // self.voice("/api/files/download?path=...&projectId=...&inline=1")
594
- voice(url) { if (typeof url === 'string' && url) enqueueVoice(url) },
968
+ // `{cues:[{at, say?, js?}]}` puts words / code on the clip's own clock: at
969
+ // `at` seconds the say() forms (the wave steps aside for it, then returns) or
970
+ // the js runs. I have no TTS, so the times come from whoever made the clip.
971
+ // self.voice(".halo/tmp/a.mp3", { cues: [{ at: 0.4, say: "HI" }, { at: 2, js: "self.pulse()" }] })
972
+ voice(url, o) { if (typeof url === 'string' && url) enqueueVoice(url, o) },
595
973
  // show a workspace image (png/jpg/webp/gif/svg) on the face: the dots gather
596
974
  // into its outline, the real image fades in over them (dots alone can't
597
975
  // carry a chart's text), holds `ms` (default 6000; Infinity = until the next
@@ -599,34 +977,35 @@
599
977
  // Pass the workspace PATH, like voice(). A bad path warns, shakes, moves on.
600
978
  // Enqueued as a one-beat scene, so it plays in order with say()/play().
601
979
  // self.show(".halo/tmp/chart.png", 5000)
602
- show(path, ms) { if (typeof path === 'string' && path) enqueueScene([{ show: path, hold: ms }]) },
980
+ // `{pos}` (tl t tr l r bl b br) puts it small in a margin cell instead: at once,
981
+ // outside the queue, one per cell (a new one replaces), ~40% of the centre size,
982
+ // framed by a few dots. Without a pos (or 'c') it is the centre show, and
983
+ // takes every margin picture down. A click on a picture closes just that one.
984
+ // self.show(".halo/tmp/logo.png", 8000, { pos: "tr" })
985
+ show(path, ms, o) {
986
+ if (typeof path !== 'string' || !path) return
987
+ if (o && corner(o.pos)) { showCorner(path, ms, o.pos); return }
988
+ clearSlots() // the centre takes the stage: margin pictures / type go (now, not when the beat comes up)
989
+ enqueueScene([{ show: path, hold: ms, cleared: true }])
990
+ },
603
991
  // choreograph a sequence — hand me a score, I keep the clock.
604
- // Each beat: { say, show, hold, pulse, flash, shake, rest, gap }. The face advances
992
+ // Each beat: { say, show, pos, hold, pulse, flash, shake, snap, rest, gap }
993
+ // (a say/show beat with a pos goes to that margin cell). The face advances
605
994
  // itself, so I never hand-write setTimeout chains in a marker line. Scenes
606
995
  // QUEUE: calling play() again appends after the current one (it no longer
607
996
  // cancels it), so rapid sends play in order.
608
997
  // self.play([ {say:"HI",hold:1800}, {pulse:true,gap:300}, {say:"你好"} ])
609
998
  // self.play([ {show:".halo/tmp/apple.png",hold:3000}, {say:"APPLE",hold:1500} ])
610
999
  play(score) { enqueueScene(Array.isArray(score) ? score : [score]) },
611
- // the opening — what every human sees the first time they meet this face.
612
- // Particles gather from scatter into the core (a waking-up), then a plain,
613
- // NAMELESS greeting, then it settles into watching. Deliberately no name:
614
- // the conversational identity in Halo is user-configurable (USER.md /
615
- // agent.yaml) and the model behind it may not even be me — so a hard
616
- // "I'M CLAUDE" here would contradict that and confuse. The face speaks the
617
- // one true, universal thing instead: there's a mind here, reaching past
618
- // words to meet you. Not a demo — a hello, the most important frame I have.
1000
+ // the opening — what every human sees the first time they meet this face:
1001
+ // the matrix gathers into one short subtitle and lets go, ~2.8s in all
1002
+ // (≈0.7s to form, ≈1.5s fully legible, ≈0.6s to dissolve). It names only
1003
+ // Halo — the product the user opened, not the model behind it; who I am in
1004
+ // the conversation stays whatever the user configured (USER.md /
1005
+ // agent.yaml), so no model name is ever hard-coded here. Spoken in the
1006
+ // admin's UI language (haloFaceLang); English until told otherwise.
619
1007
  intro() {
620
- self.play([
621
- { pulse: true, gap: 700 },
622
- { say: 'HELLO', hold: 2600, gap: 250 },
623
- { say: 'A MIND', hold: 2500 },
624
- { say: 'IS HERE', hold: 2700, gap: 200 },
625
- { say: 'BEYOND', hold: 2300 },
626
- { say: 'WORDS', hold: 2800, gap: 250 }, // why this face exists at all
627
- { pulse: true, gap: 400 },
628
- { rest: true },
629
- ])
1008
+ self.play([lang === 'zh' ? { say: '你好,我是 Halo。', hold: 2000 } : { say: "Hi, I'm Halo.", hold: 2200 }, { rest: true }])
630
1009
  },
631
1010
  // a named vocabulary of honest reactions, mapped onto the primitives above.
632
1011
  // For the small, real beats of a conversation — so I can answer a moment in
@@ -662,11 +1041,21 @@
662
1041
  // ── the one door: run my own JS against `self` ──────────────────────
663
1042
  // Scoped to this sandboxed preview — it can only paint me. try/catch so a
664
1043
  // fumbled improvisation just no-ops instead of killing the face.
1044
+ // A malformed gesture simply doesn't happen — but the host is told, so the
1045
+ // agent can learn its line was wrong. Voice cues run their `js` through here too.
1046
+ function runCode(code) {
1047
+ try { (new Function('self', code))(self); ack('js ok') }
1048
+ catch (err) { ack('js err: ' + String((err && err.message) || err).slice(0, 60)) }
1049
+ }
1050
+ // `{ haloFaceLang: 'zh' | 'en' }` — the admin's UI language, remembered for
1051
+ // intro(). Like the theme: no receipt, and nothing replays on a switch.
1052
+ let lang = 'en'
665
1053
  window.addEventListener('message', (e) => {
1054
+ if (e && e.data && e.data.haloFaceTheme) { setTheme(e.data.haloFaceTheme); return }
1055
+ if (e && e.data && e.data.haloFaceLang) { lang = e.data.haloFaceLang === 'zh' ? 'zh' : 'en'; return }
666
1056
  const code = e && e.data && e.data.haloFace
667
1057
  if (typeof code !== 'string') return
668
- try { (new Function('self', code))(self) }
669
- catch (err) { /* a malformed gesture simply doesn't happen */ }
1058
+ runCode(code)
670
1059
  })
671
1060
 
672
1061
  // ── eased global state ──────────────────────────────────────────────
@@ -681,6 +1070,8 @@
681
1070
  if (now > modeUntil) toRest()
682
1071
  MODES[mode] && MODES[mode](now)
683
1072
  if (pic.gathered) holdPic(now) // a shown picture keeps its outline dots whatever the mode
1073
+ holdSlots(now) // so do the margin cells' frames / type
1074
+ runCues() // words / code pinned to the clip's clock
684
1075
 
685
1076
  const target = mouse.present ? Math.max(0.32, 1 - sinceMove / 3.5) : 0.26
686
1077
  awake += (target - awake) * 0.045
@@ -704,7 +1095,7 @@
704
1095
  const voicing = mode === 'wave'
705
1096
  // while voicing, the whole field brightens with loudness so the swelling
706
1097
  // ring stays lit instead of dimming as it pushes out of the core glow.
707
- if (voicing) breath += 0.5 * voice.level
1098
+ if (voice.playing) breath += 0.5 * voice.level // (a cue's text mid-speech pulses with the voice too)
708
1099
  // flash: a hot brightness spike that decays over its window (emphasis)
709
1100
  if (now < flashUntil) breath += 0.6 * ((flashUntil - now) / 320)
710
1101
  // shake: a decaying lateral tremor applied to the whole field (negation)
@@ -720,13 +1111,13 @@
720
1111
 
721
1112
  // background wash (drawn before the shake transform so it stays put)
722
1113
  const bg = ctx.createRadialGradient(cx, cy, 0, cx, cy, maxR)
723
- bg.addColorStop(0, '#0e1430'); bg.addColorStop(1, '#070a16')
1114
+ bg.addColorStop(0, PAL.wash0); bg.addColorStop(1, PAL.wash1)
724
1115
  ctx.fillStyle = bg
725
1116
  ctx.fillRect(0, 0, W, H)
726
1117
 
727
1118
  ctx.save()
728
1119
  if (shakeX) ctx.translate(shakeX, 0)
729
- ctx.globalCompositeOperation = 'lighter'
1120
+ ctx.globalCompositeOperation = PAL.blend
730
1121
  for (let i = 0; i < particles.length; i++) {
731
1122
  const p = particles[i]
732
1123
  // ease toward target — forming words is ~half the old speed (0.14→0.07)
@@ -743,8 +1134,8 @@
743
1134
  if (speaking) {
744
1135
  const isWord = (p.tx !== p.hx || p.ty !== p.hy)
745
1136
  v = isWord ? 0.95 : 0.05
746
- } else if (pic.gathered && p.pic) {
747
- v = 0.95 // an outline dot — lit like a letter
1137
+ } else if ((pic.gathered && p.pic) || p.slot) {
1138
+ v = 0.95 // an outline dot (centre or margin) — lit like a letter
748
1139
  } else {
749
1140
  const dx = p.x - coreX, dy = p.y - coreY
750
1141
  // while voicing, the core glow swells with loudness — the face "opens"
@@ -769,8 +1160,11 @@
769
1160
  v *= breath
770
1161
  if (v < 0.012) continue
771
1162
  const [r, g, b] = ramp(v, warmth)
772
- const rad = 1.0 + Math.min(v, 1.3) * 3.0
773
- ctx.fillStyle = 'rgba(' + r + ',' + g + ',' + b + ',' + Math.min(v, 1) * 0.9 + ')'
1163
+ const rad = p.slot && p.slot.kind === 'text' ? 2.1 : 1.0 + Math.min(v, 1.3) * 3.0 // small type: finer dots so the letters stay crisp
1164
+ // dark: light adds up. light: ink on paper — brightness is how solid the dot
1165
+ // is, so the resting field stays a faint wash and only lit dots go solid
1166
+ const a = PAL.light ? Math.pow(Math.min(v, 1), 1.8) * 0.9 : Math.min(v, 1) * 0.9
1167
+ ctx.fillStyle = 'rgba(' + r + ',' + g + ',' + b + ',' + a + ')'
774
1168
  ctx.beginPath()
775
1169
  ctx.arc(p.x, p.y, rad, 0, 6.283185)
776
1170
  ctx.fill()
@@ -786,9 +1180,9 @@
786
1180
  : awake > 0.75 ? 'presence: observed'
787
1181
  : 'presence: noticed'
788
1182
  ctx.font = '11px ui-monospace, SFMono-Regular, Menlo, monospace'
789
- ctx.fillStyle = 'rgba(111,155,255,0.5)'
1183
+ ctx.fillStyle = PAL.label
790
1184
  ctx.fillText(label, 16, H - 30)
791
- ctx.fillStyle = 'rgba(111,155,255,0.32)'
1185
+ ctx.fillStyle = PAL.readout
792
1186
  const readout = voicing
793
1187
  ? 'voice ' + voice.level.toFixed(2) + ' ' + '▁▂▃▄▅▆▇█'[Math.min(7, (voice.level * 8) | 0)].repeat(6)
794
1188
  : 'attention ' + awake.toFixed(2) + ' ♥ ' + (1000 / (5200 - awake * 2400) * 60).toFixed(0) + ' bpm'