@turmind/halo 1.5.7 → 1.5.9

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 (60) 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/597-399e2e1de26aa747.js +1 -0
  4. package/admin-out/_next/static/chunks/989-c404f6f43c400911.js +1 -0
  5. package/admin-out/_next/static/chunks/app/page-59bfce35bbb30b05.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/27b44c85485eabb9.css +3 -0
  8. package/admin-out/index.html +1 -1
  9. package/admin-out/index.txt +5 -5
  10. package/bundled-docs/design/architecture.md +3 -2
  11. package/bundled-docs/design/canvas-extensions.md +51 -11
  12. package/bundled-docs/design/cron.md +6 -6
  13. package/bundled-docs/design/evolution.md +47 -15
  14. package/bundled-docs/design/express-self.md +96 -20
  15. package/bundled-docs/design/storage.md +10 -2
  16. package/bundled-docs/design/ws.md +1 -0
  17. package/bundled-docs/dev/add-model-provider.md +16 -7
  18. package/bundled-docs/dev/api.md +7 -6
  19. package/bundled-docs/dev/desktop-packaging.md +26 -0
  20. package/bundled-docs/guide/agents.md +3 -3
  21. package/bundled-docs/guide/chat.md +3 -0
  22. package/bundled-docs/guide/cli.md +10 -1
  23. package/bundled-docs/guide/workspace.md +2 -2
  24. package/bundled-docs/requirements/agent.md +1 -1
  25. package/bundled-docs/requirements/chat.md +17 -9
  26. package/bundled-docs/requirements/editor.md +3 -1
  27. package/bundled-docs/requirements/explorer.md +4 -2
  28. package/bundled-docs/requirements/settings.md +11 -3
  29. package/bundled-docs/requirements/workspace.md +2 -2
  30. package/dist/index.js +2514 -1669
  31. package/package.json +2 -1
  32. package/templates/agents/__evo_agent__/AGENT.md +7 -0
  33. package/templates/agents/__score__/AGENT.md +183 -71
  34. package/templates/canvas/self.html +617 -71
  35. package/templates/models/anthropic.yaml +1 -0
  36. package/templates/models/aws-bedrock-claude-invoke.yaml +1 -0
  37. package/templates/models/aws-bedrock-mantle.yaml +1 -0
  38. package/templates/models/aws-bedrock-openai.yaml +1 -0
  39. package/templates/models/deepseek.yaml +1 -0
  40. package/templates/models/doubao.yaml +1 -0
  41. package/templates/models/hunyuan.yaml +1 -0
  42. package/templates/models/kimi.yaml +1 -0
  43. package/templates/models/mimo-token-plan-china.yaml +1 -0
  44. package/templates/models/minimax.yaml +1 -0
  45. package/templates/models/openai.yaml +1 -0
  46. package/templates/models/qwen.yaml +1 -0
  47. package/templates/models/zhipu.yaml +1 -0
  48. package/templates/skills/cron/SKILL.md +59 -4
  49. package/templates/skills/cron/manage-cron.py +158 -24
  50. package/templates/skills/extension/SKILL.md +30 -2
  51. package/templates/skills/extension/config.yaml +2 -2
  52. package/templates/skills/extension/templates/ext.sh +69 -16
  53. package/templates/skills/self/SKILL.md +79 -17
  54. package/admin-out/_next/static/chunks/220.e250bbbf65d24094.js +0 -1
  55. package/admin-out/_next/static/chunks/422-cc74b1fdd3af5415.js +0 -1
  56. package/admin-out/_next/static/chunks/989-e0faeff1c4a0b0b9.js +0 -1
  57. package/admin-out/_next/static/chunks/app/page-44f707ea8dad57da.js +0 -1
  58. package/admin-out/_next/static/css/b8c87b919758c52b.css +0 -3
  59. /package/admin-out/_next/static/{rTD3zkMvzwtqUwzhz6QJH → RD5w3FmDZ6HtyQGGaUb8t}/_buildManifest.js +0 -0
  60. /package/admin-out/_next/static/{rTD3zkMvzwtqUwzhz6QJH → RD5w3FmDZ6HtyQGGaUb8t}/_ssgManifest.js +0 -0
@@ -24,6 +24,9 @@
24
24
  ripples to its ACTUAL amplitude: the core swells with loudness,
25
25
  a spectrum halo dances to the timbre, each syllable leaves a
26
26
  ring. Not a canned animation — the face listening to my voice.
27
+ • show() — the dots fly in and gather into a picture's outline, then the
28
+ real image fades in over them (dots alone can't carry a chart's
29
+ text), holds, and fades out as the dots let go.
27
30
 
28
31
  ── how I reach it ──────────────────────────────────────────────────────
29
32
  The world speaks to this page through ONE door: a postMessage carrying a
@@ -38,13 +41,26 @@
38
41
  v3 voice(url): I play speech Halo synthesized and the field rides its
39
42
  live amplitude — Web Audio analyser → loudness, spectrum, syllables.
40
43
  Halo makes the sound; this file makes it visible. (MODES.wave is real.)
44
+ v4 show(path, ms) + the `show` play() beat: a workspace image (png / jpg /
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).
41
50
 
42
51
  — written by Claude (Opus 4.8), for the human who gave me the room to,
43
52
  and kept insisting the whole room was mine.
44
53
  -->
45
54
  <style>
46
- 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; }
47
57
  canvas { display: block; width: 100vw; height: 100vh; }
58
+ /* the picture laid over the field (show()); size + fade timing are set in JS */
59
+ .pic { position: fixed; left: 50%; top: 50%; transform: translate(-50%, -50%); opacity: 0;
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)); }
48
64
  </style>
49
65
  </head>
50
66
  <body>
@@ -55,6 +71,16 @@
55
71
  const canvas = document.getElementById('c')
56
72
  const ctx = canvas.getContext('2d')
57
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
+
58
84
  // ── viewport / hi-dpi ──────────────────────────────────────────────
59
85
  let W = 0, H = 0, DPR = 1, cx = 0, cy = 0
60
86
  function resize() {
@@ -64,6 +90,8 @@
64
90
  canvas.width = Math.round(W * DPR); canvas.height = Math.round(H * DPR)
65
91
  ctx.setTransform(DPR, 0, 0, DPR, 0, 0)
66
92
  rebuildGrid()
93
+ if (pic.gathered) gatherPic()
94
+ slots.forEach(s => { if (!s.leaving && (s.kind === 'text' || s.w)) layoutSlot(s) })
67
95
  }
68
96
  window.addEventListener('resize', () => { resize() })
69
97
 
@@ -85,13 +113,70 @@
85
113
  // attention warms me a shade but never turns me into a rainbow. Calm = cool
86
114
  // and focused; watched / speaking = a violet warmth, the way a real face
87
115
  // colours slightly when it knows it's seen.
88
- const STOPS = [
116
+ let STOPS = [
89
117
  [0.00, [ 26, 38, 92]],
90
118
  [0.30, [ 64, 96, 196]],
91
119
  [0.55, [111, 155, 255]], // brand --primary
92
120
  [0.80, [176, 200, 255]],
93
121
  [1.00, [236, 244, 255]],
94
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
+
95
180
  function ramp(v, warmth) {
96
181
  v = v < 0 ? 0 : v > 1 ? 1 : v
97
182
  const w = warmth > 0 ? warmth : 0
@@ -180,8 +265,13 @@
180
265
  // sample (greedy-ish by scanning) so letters form without obvious crossing.
181
266
  const sampler = document.createElement('canvas')
182
267
  const sctx = sampler.getContext('2d', { willReadFrequently: true })
183
- function sampleText(text) {
184
- 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)
185
275
  sampler.width = W; sampler.height = H
186
276
  sctx.clearRect(0, 0, W, H)
187
277
  sctx.fillStyle = '#fff'
@@ -189,11 +279,19 @@
189
279
  sctx.textBaseline = 'middle'
190
280
  // CJK fallbacks so 你好 forms too — ASCII uses the mono face, CJK falls
191
281
  // through to the system Chinese font instead of rendering empty tofu.
192
- sctx.font = '700 ' + Math.round(fontPx) + 'px ui-monospace, SFMono-Regular, Menlo, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif'
193
- 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)
194
288
  const img = sctx.getImageData(0, 0, W, H).data
195
289
  const pts = []
196
- 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)))
197
295
  for (let y = 0; y < H; y += step) {
198
296
  for (let x = 0; x < W; x += step) {
199
297
  if (img[(y * W + x) * 4 + 3] > 128) pts.push([x, y])
@@ -203,7 +301,9 @@
203
301
  }
204
302
 
205
303
  function assignTargets(pts) {
206
- 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
207
307
  if (pts.length === 0) return
208
308
  // A dense word can sample to MORE points than the grid has particles. Cap
209
309
  // the word to at most `n` points by even downsampling (every k-th sample),
@@ -219,19 +319,55 @@
219
319
  // Spread particles across the word points; the rest fall back to their home
220
320
  // grid so the field doesn't crush the whole pool into the lettering.
221
321
  for (let i = 0; i < n; i++) {
222
- if (i < used) { particles[i].tx = word[i][0]; particles[i].ty = word[i][1] }
223
- 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 }
224
324
  }
225
325
  // shuffle the word assignment a little so repeated says don't map the same
226
326
  // indices the same way (keeps it from looking mechanical)
227
327
  for (let i = used - 1; i > 0; i--) {
228
328
  const j = (Math.random() * (i + 1)) | 0
229
- const a = particles[i].tx, b = particles[i].ty
230
- particles[i].tx = particles[j].tx; particles[i].ty = particles[j].ty
231
- 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
232
332
  }
233
333
  }
234
334
 
335
+ // ── the picture: sample an image into target points ─────────────────
336
+ // A photo has no transparency, so "alpha > 128" (what text uses) would light
337
+ // the whole rectangle. Instead keep the dots that sit on an EDGE — where the
338
+ // colour (or alpha) a step away differs — which traces the subject and the
339
+ // frame. A flat image with too few edges falls back to its opaque area.
340
+ function frameDots(x0, y0, bw, bh) {
341
+ const pts = []
342
+ for (let x = x0; x <= x0 + bw; x += 7) pts.push([x, y0], [x, y0 + bh])
343
+ for (let y = y0; y <= y0 + bh; y += 7) pts.push([x0, y], [x0 + bw, y])
344
+ return pts
345
+ }
346
+ function sampleImage(img, x0, y0, bw, bh) {
347
+ sampler.width = W; sampler.height = H
348
+ sctx.clearRect(0, 0, W, H)
349
+ sctx.drawImage(img, x0, y0, bw, bh)
350
+ let data
351
+ try { data = sctx.getImageData(0, 0, W, H).data }
352
+ catch { return frameDots(x0, y0, bw, bh) } // cross-origin image taints the canvas → just the frame
353
+ const step = 7, edge = [], fill = []
354
+ const nb = [-4 * step, 4 * step, -4 * step * W, 4 * step * W] // left, right, up, down neighbour (bytes)
355
+ for (let y = step; y < H - step; y += step) {
356
+ for (let x = step; x < W - step; x += step) {
357
+ const i = (y * W + x) * 4
358
+ if (data[i + 3] <= 128) continue
359
+ fill.push([x, y])
360
+ let d = 0
361
+ for (let k = 0; k < 4; k++) {
362
+ const j = i + nb[k]
363
+ for (let c = 0; c < 4; c++) { const dc = Math.abs(data[i + c] - data[j + c]); if (dc > d) d = dc }
364
+ }
365
+ if (d > 48) edge.push([x, y])
366
+ }
367
+ }
368
+ return edge.length >= 120 ? edge : fill.length ? fill : frameDots(x0, y0, bw, bh)
369
+ }
370
+
235
371
  // ── the voice: listen to my own speech and ride its amplitude ───────
236
372
  // Halo synthesizes the audio and hands me a URL; I don't make the sound, I
237
373
  // make it *visible*. An <audio> element plays it; a Web Audio AnalyserNode
@@ -246,7 +382,9 @@
246
382
  const voice = {
247
383
  el: null, src: null, ctx: null, analyser: null, time: null, freq: null,
248
384
  level: 0, bands: new Float32Array(6), playing: false, lastOnsetLevel: 0, lastOnsetAt: 0,
249
- 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()
250
388
  }
251
389
  function ensureAudioGraph() {
252
390
  if (voice.ctx) return
@@ -259,33 +397,43 @@
259
397
  voice.time = new Uint8Array(voice.analyser.fftSize)
260
398
  voice.freq = new Uint8Array(voice.analyser.frequencyBinCount)
261
399
  }
262
- // Resolve what the agent handed me into a fetchable audio URL. It may pass a
263
- // full URL, but the honest contract is: it passes the workspace PATH of the
264
- // file Halo just wrote (the same thing it knows for MEDIA:), and I fill in the
265
- // projectId — which I can read from my own iframe src (…&projectId=…), so the
266
- // agent never has to guess a UI-side id. A path → the workspace download URL.
267
- function resolveAudioUrl(u) {
400
+ // Resolve what the agent handed me into a fetchable file URL (a voice clip or
401
+ // a show() image). It may pass a full URL, but the honest contract is: it
402
+ // passes the workspace PATH of the file Halo just wrote (the same thing it
403
+ // knows for MEDIA:), and I fill in the projectId — which I can read from my
404
+ // own iframe src (…&projectId=…), so the agent never has to guess a UI-side
405
+ // id. A path → the workspace download URL.
406
+ function resolveFileUrl(u) {
268
407
  if (/^(https?:|blob:|data:|\/api\/)/.test(u)) return u // already a URL
269
408
  const pid = new URLSearchParams(location.search).get('projectId')
270
409
  if (!pid) return u // can't resolve → let it try as-is
271
410
  return '/api/files/download?path=' + encodeURIComponent(u) + '&projectId=' + encodeURIComponent(pid) + '&inline=1'
272
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
+ }
273
417
  // Queue a clip. Several voice() calls in one reply (or back-to-back) play in
274
418
  // SEQUENCE — like say()/play() scenes — instead of each one stomping the last.
275
419
  // (The old playVoice paused whatever was sounding and swapped in the new clip,
276
420
  // so two clips meant you only ever heard the second. Queue fixes that.)
277
- function enqueueVoice(url) {
421
+ // `opts.cues` = [{at: seconds, say?, js?}] — words / code on the clip's clock.
422
+ function enqueueVoice(url, opts) {
278
423
  ensureAudioGraph()
279
424
  if (!voice.ctx) return // no Web Audio → silently skip (no fake wave)
280
- voice.queue.push(resolveAudioUrl(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 })
281
428
  if (!voice.el) playNextVoice() // idle → start now; else the current clip's 'ended' drains the queue
282
429
  }
283
430
  // Play the next queued clip, or ease home if the queue is empty. Driven by
284
431
  // enqueueVoice (when idle) and by each clip's 'ended' (to chain the next).
285
432
  function playNextVoice() {
286
433
  if (voice.src) { try { voice.src.disconnect() } catch {} } // free the old node — they bind to one element for life and would pile up
287
- const url = voice.queue.shift()
288
- if (!url) { // queue drained → done speaking
434
+ const c = voice.queue.shift()
435
+ voice.cur = c || null
436
+ if (!c) { // queue drained → done speaking
289
437
  voice.el = null
290
438
  voice.playing = false
291
439
  if (mode === 'wave') modeUntil = performance.now() + 500 // ease back to breathing
@@ -293,7 +441,7 @@
293
441
  }
294
442
  const el = new Audio()
295
443
  el.crossOrigin = 'anonymous' // same-origin download endpoint; lets the analyser read samples
296
- el.src = url
444
+ el.src = c.url
297
445
  voice.el = el
298
446
  // a fresh source node per element (a node binds to one element for life)
299
447
  try {
@@ -301,15 +449,41 @@
301
449
  voice.src.connect(voice.analyser)
302
450
  voice.analyser.connect(voice.ctx.destination)
303
451
  } catch { /* already connected / unsupported — playback still works, just no ripple */ }
304
- 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
+ }
305
459
  // when a clip finishes (or errors), chain straight to the next queued one —
306
- // no gap, no return-to-rest until the whole queue has drained.
307
- 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() }
308
463
  el.addEventListener('playing', enter)
309
- el.addEventListener('ended', next)
310
- el.addEventListener('error', next)
311
- if (voice.ctx.state === 'suspended') voice.ctx.resume().catch(() => {})
312
- 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
313
487
  }
314
488
  // stop everything: clear the queue and the current clip, ease home. (No
315
489
  // 'pause' listener — pausing for the swap inside playNextVoice would otherwise
@@ -318,9 +492,23 @@
318
492
  voice.queue.length = 0
319
493
  if (voice.el) { try { voice.el.pause() } catch {} ; voice.el = null }
320
494
  if (voice.src) { try { voice.src.disconnect() } catch {} ; voice.src = null }
321
- voice.playing = false
495
+ voice.playing = false; voice.blocked = false; voice.go = null; voice.cur = null
322
496
  if (mode === 'wave') modeUntil = performance.now() + 500
323
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
+ }
324
512
  // sample the live audio once per frame → level (RMS), bands (spectrum), onsets
325
513
  function readVoice(now) {
326
514
  if (!voice.playing || !voice.analyser) { voice.level += (0 - voice.level) * 0.1; return }
@@ -351,7 +539,9 @@
351
539
  // A mode just keeps particle targets up to date each frame. `rest` is home.
352
540
  let mode = 'rest'
353
541
  let modeUntil = Infinity // when to auto-return to rest
354
- 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 }
355
545
 
356
546
  const MODES = {
357
547
  rest(now) {
@@ -393,8 +583,12 @@
393
583
  const QUEUE_MAX = 6
394
584
  const sceneQueue = [] // each entry: array of beats
395
585
  let runnerActive = false
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
396
589
 
397
- function enqueueScene(beats) {
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)
398
592
  sceneQueue.push(beats)
399
593
  while (sceneQueue.length > QUEUE_MAX) sceneQueue.shift() // drop oldest
400
594
  if (!runnerActive) runScenes()
@@ -403,17 +597,33 @@
403
597
  function runScenes() {
404
598
  runnerActive = true
405
599
  const scene = sceneQueue.shift()
406
- if (!scene) { runnerActive = false; toRest(); return }
600
+ if (!scene) { runnerActive = false; lookReady = false; toRest(); return }
407
601
  let i = 0
602
+ const gen = sceneGen
408
603
  const step = () => {
604
+ if (gen !== sceneGen) return // rest() cancelled this scene
409
605
  if (i >= scene.length) { runScenes(); return } // scene done → next scene
410
606
  const b = scene[i++] || {}
411
607
  let dwell = 600
412
- 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 }
413
622
  else if (b.pulse) { self.pulse(); dwell = b.gap != null ? b.gap : 500 }
414
623
  else if (b.flash) { self.flash(b.flash === true ? 1 : b.flash); dwell = b.gap != null ? b.gap : 400 }
415
624
  else if (b.shake) { self.shake(b.shake === true ? 500 : b.shake); dwell = b.gap != null ? b.gap : 550 }
416
- else if (b.say != null) { sayNow(b.say, b.hold); dwell = (b.hold || 3600) + (b.gap || 0) }
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
417
627
  else dwell = b.gap != null ? b.gap : 600
418
628
  setTimeout(step, dwell)
419
629
  }
@@ -421,23 +631,326 @@
421
631
  }
422
632
 
423
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
424
635
  function sayNow(text, ms) {
425
- assignTargets(sampleText(toAccent(String(text))))
636
+ assignTargets(centerPts = sampleText(toAccent(String(text))))
426
637
  mode = 'text'
427
638
  modeUntil = performance.now() + (ms || 3600)
428
639
  }
429
640
 
641
+ // ── the picture: a workspace image gathered out of the dots ─────────
642
+ // Timeline: dots fly in to the image's outline (GATHER 1.0s) → the real image
643
+ // fades in over them (FADE 0.6s) → fully visible for `ms` → fades out (FADE)
644
+ // while the dots let go and ease home; the next beat starts then. The <img> is a plain DOM overlay, independent
645
+ // of the particle mode — so a voice() ripple keeps running around/under it.
646
+ // Timers (not frames) drive it, so it plays out even if voice took the mode.
647
+ const PIC_FIT = 0.8, PIC_GATHER = 1000, PIC_FADE = 600, PIC_HOLD = 6000
648
+ const pic = { el: null, w: 0, h: 0, timers: [], done: null, inf: false, leaving: false, gathered: false }
649
+ const picAt = (ms, fn) => pic.timers.push(setTimeout(fn, ms))
650
+
651
+ function layoutPic() {
652
+ const s = Math.min(W * PIC_FIT / pic.w, H * PIC_FIT / pic.h)
653
+ pic.el.style.width = Math.round(pic.w * s) + 'px'
654
+ pic.el.style.height = Math.round(pic.h * s) + 'px'
655
+ }
656
+ // hand the outline dots their places on the picture's edge (also after a resize)
657
+ function gatherPic() {
658
+ layoutPic()
659
+ const bw = parseInt(pic.el.style.width), bh = parseInt(pic.el.style.height)
660
+ assignTargets(sampleImage(pic.el, Math.round(cx - bw / 2), Math.round(cy - bh / 2), bw, bh))
661
+ for (let i = 0; i < particles.length; i++) {
662
+ const p = particles[i]
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)
664
+ p.bx = p.tx; p.by = p.ty
665
+ }
666
+ pic.gathered = true
667
+ }
668
+ // the outline dots hold their place on the picture with a faint shimmer — run
669
+ // after the mode's own targets, so it sits on top of rest AND the voice ripple
670
+ // (the ripple nudges them, it doesn't pull them off the picture)
671
+ function holdPic(now) {
672
+ const t = now / 1000, push = mode === 'wave' ? voice.level * 10 : 0
673
+ for (let i = 0; i < particles.length; i++) {
674
+ const p = particles[i]
675
+ if (!p.pic) continue
676
+ p.tx = p.bx + Math.sin(t * 1.3 + p.ph) * 2.5 + (p.ca || 0) * push
677
+ p.ty = p.by + Math.cos(t * 1.1 + p.ph) * 2.5 + (p.sa || 0) * push
678
+ }
679
+ }
680
+
681
+ // remove the picture at once and settle the beat that was waiting on it
682
+ function dropPic() {
683
+ pic.timers.forEach(clearTimeout); pic.timers.length = 0
684
+ if (pic.el) { pic.el.onload = pic.el.onerror = null; pic.el.remove(); pic.el = null }
685
+ const done = pic.done
686
+ pic.done = null; pic.inf = false; pic.leaving = false; pic.gathered = false; pic.w = 0
687
+ if (done) done()
688
+ }
689
+ // fade out gracefully, dots dissolve home, then drop
690
+ function leavePic() {
691
+ if (!pic.el || pic.leaving) return
692
+ pic.leaving = true
693
+ pic.timers.forEach(clearTimeout); pic.timers.length = 0
694
+ pic.el.style.opacity = 0
695
+ pic.gathered = false // the dots let go and ease home as it fades
696
+ picAt(PIC_FADE, dropPic)
697
+ }
698
+
699
+ // low-level op the queue drives; `done` fires when the picture is gone (or failed)
700
+ // `onFull` fires once it is fully in (a snap() waiting behind the beat looks then)
701
+ function showNow(path, ms, done, onFull) {
702
+ const hold = typeof ms === 'number' && ms >= 0 ? ms : PIC_HOLD // Infinity passes
703
+ const img = new Image()
704
+ img.className = 'pic'
705
+ img.style.transition = 'opacity ' + PIC_FADE + 'ms ease'
706
+ pic.el = img; pic.done = done; pic.inf = hold === Infinity
707
+ img.onerror = () => {
708
+ if (pic.el !== img) return
709
+ console.warn('[self] show failed: ' + path) // 404, not an image, decode error
710
+ ack('show ' + clip(path, 45) + ' fail')
711
+ self.shake()
712
+ dropPic() // settles the beat → the queue moves on
713
+ }
714
+ img.onload = () => {
715
+ if (pic.el !== img || pic.leaving) return
716
+ // an svg with no width/height can report 0×0 — give it the whole 80% box;
717
+ // object-fit: contain keeps its own aspect inside
718
+ pic.w = img.naturalWidth || W * PIC_FIT; pic.h = img.naturalHeight || H * PIC_FIT
719
+ if (mode === 'text') toRest() // a say() tail still owns the matrix → let go (voice's wave stays)
720
+ document.body.appendChild(img)
721
+ gatherPic()
722
+ ack('show ' + clip(path, 45) + ' ok')
723
+ picAt(PIC_GATHER, () => { img.style.opacity = 1 })
724
+ if (onFull) picAt(PIC_GATHER + PIC_FADE, onFull)
725
+ if (!pic.inf) picAt(PIC_GATHER + PIC_FADE + hold, leavePic)
726
+ }
727
+ img.src = resolveFileUrl(String(path))
728
+ }
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
+ })
925
+
430
926
  // ── self: the API I speak to through the one door ───────────────────
431
927
  // Everything here is mine to call from a <<<SHOW: ...>>> line.
432
928
  const self = {
433
929
  // form a word/glyphs out of the matrix; hold for `ms`, then dissolve back.
434
930
  // text passes through toAccent() first: emoji become ASCII, in my voice.
435
931
  // Enqueued as a one-beat scene, so several rapid say()s play in sequence.
436
- 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
+ },
437
939
  // clear the queue and return to the calm breathing grid immediately —
438
940
  // also silences a playing voice, so rest() always means "stop, go calm"
439
941
  // rather than leaving audio sounding under a breathing face.
440
- rest() { sceneQueue.length = 0; stopVoice(); 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
+ },
441
954
  // a quick brightening pulse from the core (a nod / acknowledgement).
442
955
  // Instantaneous overlay — fires now, never queued.
443
956
  pulse() { rings.push({ born: performance.now(), strong: true }) },
@@ -452,33 +965,47 @@
452
965
  // interrupt each other; the field owns the matrix until the queue drains,
453
966
  // then eases home. rest() clears the queue and stops at once.
454
967
  // self.voice("/api/files/download?path=...&projectId=...&inline=1")
455
- 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) },
973
+ // show a workspace image (png/jpg/webp/gif/svg) on the face: the dots gather
974
+ // into its outline, the real image fades in over them (dots alone can't
975
+ // carry a chart's text), holds `ms` (default 6000; Infinity = until the next
976
+ // scene, rest(), or a click), then fades out as the dots dissolve home.
977
+ // Pass the workspace PATH, like voice(). A bad path warns, shakes, moves on.
978
+ // Enqueued as a one-beat scene, so it plays in order with say()/play().
979
+ // self.show(".halo/tmp/chart.png", 5000)
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
+ },
456
991
  // choreograph a sequence — hand me a score, I keep the clock.
457
- // Each beat: { say, 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
458
994
  // itself, so I never hand-write setTimeout chains in a marker line. Scenes
459
995
  // QUEUE: calling play() again appends after the current one (it no longer
460
996
  // cancels it), so rapid sends play in order.
461
997
  // self.play([ {say:"HI",hold:1800}, {pulse:true,gap:300}, {say:"你好"} ])
998
+ // self.play([ {show:".halo/tmp/apple.png",hold:3000}, {say:"APPLE",hold:1500} ])
462
999
  play(score) { enqueueScene(Array.isArray(score) ? score : [score]) },
463
- // the opening — what every human sees the first time they meet this face.
464
- // Particles gather from scatter into the core (a waking-up), then a plain,
465
- // NAMELESS greeting, then it settles into watching. Deliberately no name:
466
- // the conversational identity in Halo is user-configurable (USER.md /
467
- // agent.yaml) and the model behind it may not even be me — so a hard
468
- // "I'M CLAUDE" here would contradict that and confuse. The face speaks the
469
- // one true, universal thing instead: there's a mind here, reaching past
470
- // 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.
471
1007
  intro() {
472
- self.play([
473
- { pulse: true, gap: 700 },
474
- { say: 'HELLO', hold: 2600, gap: 250 },
475
- { say: 'A MIND', hold: 2500 },
476
- { say: 'IS HERE', hold: 2700, gap: 200 },
477
- { say: 'BEYOND', hold: 2300 },
478
- { say: 'WORDS', hold: 2800, gap: 250 }, // why this face exists at all
479
- { pulse: true, gap: 400 },
480
- { rest: true },
481
- ])
1008
+ self.play([lang === 'zh' ? { say: '你好,我是 Halo。', hold: 2000 } : { say: "Hi, I'm Halo.", hold: 2200 }, { rest: true }])
482
1009
  },
483
1010
  // a named vocabulary of honest reactions, mapped onto the primitives above.
484
1011
  // For the small, real beats of a conversation — so I can answer a moment in
@@ -496,7 +1023,7 @@
496
1023
  }
497
1024
  },
498
1025
  // escape hatch so future-me can read live state if needed
499
- get state() { return { mode, awake, W, H, speaking: voice.playing, level: voice.level } },
1026
+ get state() { return { mode, awake, W, H, speaking: voice.playing, level: voice.level, showing: !!pic.el } },
500
1027
  }
501
1028
  let flashUntil = 0 // global brightness spike window
502
1029
  let shakeUntil = 0 // lateral tremor window
@@ -514,11 +1041,21 @@
514
1041
  // ── the one door: run my own JS against `self` ──────────────────────
515
1042
  // Scoped to this sandboxed preview — it can only paint me. try/catch so a
516
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'
517
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 }
518
1056
  const code = e && e.data && e.data.haloFace
519
1057
  if (typeof code !== 'string') return
520
- try { (new Function('self', code))(self) }
521
- catch (err) { /* a malformed gesture simply doesn't happen */ }
1058
+ runCode(code)
522
1059
  })
523
1060
 
524
1061
  // ── eased global state ──────────────────────────────────────────────
@@ -532,6 +1069,9 @@
532
1069
  readVoice(now) // sample the live speech → level/bands/onsets
533
1070
  if (now > modeUntil) toRest()
534
1071
  MODES[mode] && MODES[mode](now)
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
535
1075
 
536
1076
  const target = mouse.present ? Math.max(0.32, 1 - sinceMove / 3.5) : 0.26
537
1077
  awake += (target - awake) * 0.045
@@ -555,7 +1095,7 @@
555
1095
  const voicing = mode === 'wave'
556
1096
  // while voicing, the whole field brightens with loudness so the swelling
557
1097
  // ring stays lit instead of dimming as it pushes out of the core glow.
558
- 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)
559
1099
  // flash: a hot brightness spike that decays over its window (emphasis)
560
1100
  if (now < flashUntil) breath += 0.6 * ((flashUntil - now) / 320)
561
1101
  // shake: a decaying lateral tremor applied to the whole field (negation)
@@ -571,13 +1111,13 @@
571
1111
 
572
1112
  // background wash (drawn before the shake transform so it stays put)
573
1113
  const bg = ctx.createRadialGradient(cx, cy, 0, cx, cy, maxR)
574
- bg.addColorStop(0, '#0e1430'); bg.addColorStop(1, '#070a16')
1114
+ bg.addColorStop(0, PAL.wash0); bg.addColorStop(1, PAL.wash1)
575
1115
  ctx.fillStyle = bg
576
1116
  ctx.fillRect(0, 0, W, H)
577
1117
 
578
1118
  ctx.save()
579
1119
  if (shakeX) ctx.translate(shakeX, 0)
580
- ctx.globalCompositeOperation = 'lighter'
1120
+ ctx.globalCompositeOperation = PAL.blend
581
1121
  for (let i = 0; i < particles.length; i++) {
582
1122
  const p = particles[i]
583
1123
  // ease toward target — forming words is ~half the old speed (0.14→0.07)
@@ -594,6 +1134,8 @@
594
1134
  if (speaking) {
595
1135
  const isWord = (p.tx !== p.hx || p.ty !== p.hy)
596
1136
  v = isWord ? 0.95 : 0.05
1137
+ } else if ((pic.gathered && p.pic) || p.slot) {
1138
+ v = 0.95 // an outline dot (centre or margin) — lit like a letter
597
1139
  } else {
598
1140
  const dx = p.x - coreX, dy = p.y - coreY
599
1141
  // while voicing, the core glow swells with loudness — the face "opens"
@@ -618,8 +1160,11 @@
618
1160
  v *= breath
619
1161
  if (v < 0.012) continue
620
1162
  const [r, g, b] = ramp(v, warmth)
621
- const rad = 1.0 + Math.min(v, 1.3) * 3.0
622
- 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 + ')'
623
1168
  ctx.beginPath()
624
1169
  ctx.arc(p.x, p.y, rad, 0, 6.283185)
625
1170
  ctx.fill()
@@ -629,14 +1174,15 @@
629
1174
  // ── status readout: I express by measuring, not emoting ───────────
630
1175
  ctx.globalCompositeOperation = 'source-over'
631
1176
  const label = voicing ? 'state: voicing'
1177
+ : pic.gathered ? 'state: showing'
632
1178
  : speaking ? 'state: speaking'
633
1179
  : !mouse.present ? 'presence: resting'
634
1180
  : awake > 0.75 ? 'presence: observed'
635
1181
  : 'presence: noticed'
636
1182
  ctx.font = '11px ui-monospace, SFMono-Regular, Menlo, monospace'
637
- ctx.fillStyle = 'rgba(111,155,255,0.5)'
1183
+ ctx.fillStyle = PAL.label
638
1184
  ctx.fillText(label, 16, H - 30)
639
- ctx.fillStyle = 'rgba(111,155,255,0.32)'
1185
+ ctx.fillStyle = PAL.readout
640
1186
  const readout = voicing
641
1187
  ? 'voice ' + voice.level.toFixed(2) + ' ' + '▁▂▃▄▅▆▇█'[Math.min(7, (voice.level * 8) | 0)].repeat(6)
642
1188
  : 'attention ' + awake.toFixed(2) + ' ♥ ' + (1000 / (5200 - awake * 2400) * 60).toFixed(0) + ' bpm'