@double_geste/agent-standup 0.0.0-stage → 1.22.1

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.
@@ -0,0 +1,1688 @@
1
+ <!doctype html>
2
+ <html lang="fr">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>Agent Standup</title>
7
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E🎥%3C/text%3E%3C/svg%3E">
8
+ <style>
9
+ :root { --bg:#12131a; --panel:#1b1d27; --panel2:#232634; --line:#2a2d3b; --text:#e9eaf0; --dim:#9aa0b6; --ease:cubic-bezier(.2,.8,.2,1); --accent:#7c9cff; --gold:#f5c04a; --ok:#58d68d; --warn:#f0a020; --red:#ff5a5a; --r:10px }
10
+ * { box-sizing:border-box }
11
+ html, body { height:100%; margin:0 }
12
+ body { background:var(--bg); color:var(--text); font:13.5px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,"PingFang TC","PingFang SC","Hiragino Sans","Microsoft JhengHei","Microsoft YaHei","Noto Sans CJK TC","Noto Sans CJK SC","Noto Sans CJK JP",sans-serif; display:flex; flex-direction:column; overflow:hidden }
13
+ button, select { font:inherit; color:inherit }
14
+ button { background:none; border:0; cursor:pointer; padding:0 }
15
+ button:focus-visible, select:focus-visible, input:focus-visible { outline:2px solid var(--accent); outline-offset:2px; border-radius:6px }
16
+ .dim { color:var(--dim) }
17
+
18
+ /* ---------- header ---------- */
19
+ header { height:48px; flex:none; display:flex; align-items:center; gap:10px; padding:0 12px; border-bottom:1px solid var(--line); background:var(--bg) }
20
+ .brand { display:flex; align-items:center; gap:9px; min-width:0 }
21
+ .logo { width:26px; height:26px; border-radius:8px; background:#d97757; display:grid; place-items:center; font-size:15px; flex:none }
22
+ .brand b { font:700 14px "Comic Sans MS","Chalkboard SE",system-ui,sans-serif; white-space:nowrap }
23
+ .brand small { display:block; color:var(--dim); font-size:11px; max-width:260px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
24
+ .sp { flex:1 }
25
+ .tok { font-size:11px; font-weight:600; padding:3px 9px; border-radius:99px; background:var(--panel2); color:#c3c6d4; white-space:nowrap } .tok:hover { color:#fff }
26
+ .tbadge { position:absolute; top:30px; right:6px; display:flex; gap:4px; z-index:2; pointer-events:none } .tbadge span { font-size:10.5px; padding:1px 7px; border-radius:99px; background:rgba(14,15,24,.78); backdrop-filter:blur(6px) }
27
+ .tbadge .e { color:#ff7b72 } .tbadge .s { color:#ffb347 } .tbadge .q { color:#9aa3bd }
28
+ .tile.slow { border-color:#ff9f1c !important; animation:slowp 1.6s ease-in-out infinite } @keyframes slowp { 50% { box-shadow:0 0 0 4px rgba(255,159,28,.35) } }
29
+ .tile.quiet { opacity:.72 }
30
+ .tag .tk { color:#aab2c5; font-size:10px } .tag .tk:empty { display:none }
31
+ .keys { padding:6px 10px 4px; color:var(--dim); font-size:10.5px; line-height:1.5 }
32
+ #pill { font-size:11px; font-weight:600; letter-spacing:.04em; padding:3px 9px; border-radius:99px; background:var(--panel2); color:var(--dim); white-space:nowrap }
33
+ #pill.live { color:#fff; background:#c0392b } #pill.live::before { content:'● '; animation:pulse 1.4s infinite }
34
+ #pill.wait { color:var(--ok) } #pill.wait::before { content:'● '; opacity:.7 }
35
+ @keyframes pulse { 50% { opacity:.3 } }
36
+ .seg { display:flex; background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:2px }
37
+ .seg button { padding:4px 11px; border-radius:6px; color:var(--dim); font-size:12.5px }
38
+ .seg button.on { background:var(--panel2); color:var(--text) }
39
+ .ic { width:32px; height:32px; border-radius:8px; display:grid; place-items:center; font-size:15px; color:var(--dim) }
40
+ .ic:hover { background:var(--panel2); color:var(--text) } .ic.on { color:var(--text); background:var(--panel2) }
41
+
42
+ /* ---------- layout ---------- */
43
+ main { flex:1; display:flex; min-height:0; position:relative }
44
+ #center { flex:1; display:flex; flex-direction:column; min-width:0; min-height:0; position:relative }
45
+ #stage { flex:1; overflow:auto; padding:14px; display:grid; gap:12px; align-content:start; grid-auto-flow:dense;
46
+ grid-template-columns:repeat(var(--cols,4),1fr); grid-auto-rows:var(--rh,160px) }
47
+ #tree { flex:1; overflow:auto; padding:16px 18px; display:none }
48
+ body.tree #stage, body.board #stage, body.board #tree { display:none } body.tree #tree { display:block } body.board #boardv { display:block } body.board #present, body.board #gantt { display:none !important }
49
+ #roster { display:none; width:232px; flex:none; flex-direction:column; gap:5px; padding:10px 8px; overflow:auto; border-right:1px solid var(--line); background:var(--panel) }
50
+ @media (min-width:900px) { body.rost:not(.tree):not(.board) #roster { display:flex } }
51
+ #roster h3 { margin:2px 6px 4px; font-size:11px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--dim) }
52
+ .rr { display:flex; flex-direction:column; gap:2px; padding:6px 9px; border-radius:9px; border-left:3px solid transparent; background:var(--panel2); cursor:pointer; min-width:0 }
53
+ .rr:hover { background:#2a2f45 } .rr.sel { outline:1px solid var(--accent) }
54
+ .rr .n { display:flex; align-items:center; gap:7px; min-width:0 } .rr .dot { width:9px; height:9px; border-radius:50%; flex:none } .rr b { font-size:12.5px } .rr .ro { color:var(--dim); font-size:11px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; min-width:0 }
55
+ .rr .st { font-size:11.5px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
56
+ .rr.k-working { border-left-color:var(--gold) } .rr.k-working .st { color:var(--gold) } .rr.k-waiting { border-left-color:#8fd3ff } .rr.k-waiting .st { color:#8fd3ff }
57
+ .rr.k-blocked { border-left-color:#ff5252; background:rgba(192,57,43,.28) } .rr.k-blocked .st { color:#fff; font-weight:700 } .rr.k-stalled { border-left-color:#ffb347 } .rr.k-stalled .st { color:#ffb347; font-weight:700 }
58
+ .rr.k-done { opacity:.82 } .rr.k-done .st { color:var(--ok) } .rr.k-idle .st { color:var(--dim) } .rr.speaking { box-shadow:0 0 0 2px rgba(124,156,255,.5) }
59
+ #boardv { flex:1; overflow:auto; padding:16px 18px; display:none }
60
+ .bsum { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px; font-size:12.5px } .bsum span { padding:3px 10px; border-radius:99px; background:var(--panel2); border:1px solid var(--line) }
61
+ .bgrid { display:grid; grid-template-columns:repeat(auto-fill,minmax(270px,1fr)); gap:10px }
62
+ .bcard { text-align:left; background:var(--panel2); border:1px solid var(--line); border-radius:12px; padding:11px 13px; display:flex; flex-direction:column; gap:5px; cursor:pointer }
63
+ .bcard:hover { border-color:var(--accent) } .bcard.blocked { border-color:#ff5252; box-shadow:0 0 0 2px rgba(255,82,82,.25) } .bcard.working { border-color:rgba(240,160,32,.7) } .bcard.waiting { border-color:rgba(110,190,255,.8) } .bcard.idle { opacity:.8 }
64
+ .bcard .bt { font-weight:600; font-size:13.5px; overflow:hidden; text-overflow:ellipsis; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical } .bcard .bp, .bcard .bm { color:var(--dim); font-size:11.5px }
65
+ .bcard .stc { align-self:flex-start }
66
+ .stc { font-size:11.5px; padding:2px 9px; border-radius:99px; background:rgba(14,15,24,.6); white-space:nowrap } .stc.working { color:var(--gold) } .stc.waiting { color:#8fd3ff } .stc.blocked { color:#fff; background:#c0392b; font-weight:700 } .stc.stalled { color:#1b1d27; background:#ffb347; font-weight:700 } .stc.done, .stc.turn { color:var(--ok) } .stc.idle { color:var(--dim) }
67
+ .ctxp { font-size:11px; font-weight:600; padding:3px 9px; border-radius:99px; background:var(--panel2); white-space:nowrap; cursor:default } .ctxp.lo, .cx.lo { color:#7ee2a8 } .ctxp.mid, .cx.mid { color:#ffc857 } .ctxp.hi, .cx.hi { color:#fff; background:#c0392b }
68
+ .cx:empty { display:none } .tag .cx { font-size:10px }
69
+ #hl { display:none; flex-wrap:wrap; gap:6px; padding:5px 12px; border-top:1px solid var(--line); background:var(--panel) } body.replay #hl.has { display:flex } body.board #hl, body.tree #hl { display:none !important }
70
+ .hlc { font-size:11.5px; padding:3px 10px; border-radius:99px; background:var(--panel2); border:1px solid var(--line); color:var(--text) } .hlc:hover { border-color:var(--accent) }
71
+ .tbadge .l { color:#ff9f1c } .tbadge span { font-size:11px } .tbadge .q { color:#b4bcd4 }
72
+ .ic:active, .btn:active { transform:scale(.96) } .rr, .ic, .bcard { transition:background .15s var(--ease), border-color .15s var(--ease), transform .15s var(--ease) }
73
+ .meet { font-size:11.5px; color:var(--dim); white-space:nowrap; display:flex; gap:8px; align-items:center } .meet i { width:8px; height:8px; border-radius:50%; background:#ff4d4d; animation:pulse 1.6s infinite }
74
+ @media (max-width:760px) { .meet { display:none } }
75
+ #reactBar { display:none; position:absolute; right:12px; bottom:98px; z-index:5; gap:4px; padding:4px 6px; border-radius:99px; background:rgba(20,22,32,.82); border:1px solid var(--line); backdrop-filter:blur(8px); opacity:.55; transition:opacity .2s }
76
+ body.react:not(.tree):not(.board) #reactBar { display:flex } #reactBar:hover { opacity:1 } #reactBar button { font-size:17px; padding:2px 6px; border-radius:99px; background:none; transition:transform .12s } #reactBar button:hover { transform:scale(1.25) } #reactBar button:active { transform:scale(.9) }
77
+ .thought { position:absolute; top:34%; right:10%; font-size:22px; z-index:3; pointer-events:none; opacity:0 } .thought.go { animation:thought 2.6s ease-out both }
78
+ @keyframes thought { 0% { opacity:0; transform:translateY(8px) scale(.6) } 15% { opacity:1; transform:scale(1) } 70% { opacity:1 } 100% { opacity:0; transform:translateY(-26px) } }
79
+ .tile.oops { animation:oops .5s } @keyframes oops { 20% { transform:translateX(-5px) rotate(-1.2deg); box-shadow:0 0 0 3px rgba(255,82,82,.6) } 50% { transform:translateX(5px) rotate(1.2deg) } 80% { transform:translateX(-2px) } }
80
+ .confetti { position:absolute; top:-12px; width:8px; height:12px; z-index:7; pointer-events:none; animation:fall 2.4s ease-in forwards } @keyframes fall { to { transform:translateY(105vh) rotate(720deg); opacity:.9 } }
81
+ .prop { position:absolute; top:26px; left:8px; font-size:26px; z-index:3; pointer-events:none; filter:drop-shadow(0 2px 4px rgba(0,0,0,.5)) } .prop:empty { display:none }
82
+ .prop.blocked { animation:handup .7s ease-in-out infinite; transform-origin:70% 90% } @keyframes handup { 50% { transform:rotate(-24deg) translateY(-3px) } }
83
+ .prop.stalled { animation:shake .5s ease-in-out infinite } @keyframes shake { 25% { transform:translateX(-3px) } 75% { transform:translateX(3px) } }
84
+ .prop.waiting { animation:tick 2.4s ease-in-out infinite } @keyframes tick { 50% { transform:rotate(180deg) } }
85
+ .prop.cheer { animation:cheer 1.1s ease-out both } @keyframes cheer { 0% { transform:scale(.3) translateY(10px); opacity:0 } 40% { transform:scale(1.25); opacity:1 } 100% { transform:translateY(-14px); opacity:0 } }
86
+ /* ---------- screen sharing (presentation mode) ---------- */
87
+ #present { display:none; flex:none; position:relative; height:clamp(190px,46vh,430px); background:radial-gradient(ellipse at 50% 0,#1b2033,#0b0d14); border-bottom:1px solid var(--line); padding:12px 16px; justify-content:center }
88
+ #present.on { display:flex; animation:fadein .3s }
89
+ .pwin { flex:0 1 900px; display:flex; flex-direction:column; border-radius:10px; overflow:hidden; background:#0d1117; box-shadow:0 18px 50px rgba(0,0,0,.6), 0 0 0 2px rgba(240,160,32,.55) }
90
+ .pbar { display:flex; align-items:center; gap:6px; padding:7px 10px; background:#1c1f2b; font-size:11.5px; color:#9aa0b4 } .pbar i { width:10px; height:10px; border-radius:50%; display:inline-block }
91
+ .pbar .r { background:#ff5f57 } .pbar .y { background:#febc2e } .pbar .g { background:#28c840 } #ptitle { margin:0 0 0 14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; min-width:0 }
92
+ #pbody { flex:1; overflow:hidden; padding:10px 14px; font:12.5px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace; color:#d6dae6 } .pwin.editor #pbody { background:#1a1b26; padding:0 }
93
+ .ptab { display:flex; align-items:center; gap:10px; background:#16161e; padding:5px 12px; border-bottom:1px solid #2a2b3d; font:11.5px -apple-system,system-ui,sans-serif } .ptab span { background:#1a1b26; padding:3px 10px; border-radius:6px 6px 0 0; color:#e9eaf0 } .ptab em { color:#6b7089; font-style:normal }
94
+ .ln { display:flex; gap:10px; padding:0 12px; white-space:pre-wrap; word-break:break-all } .ln b { width:12px; flex:none; color:#7a7f98; font-weight:400 } .ln.add { background:rgba(63,185,80,.16); color:#9be9a8 } .ln.del { background:rgba(248,81,73,.16); color:#ffa198 }
95
+ .tl { white-space:pre-wrap; word-break:break-word } .tl .ps { color:#7ee787 } .tl.cmd { color:#e6edf3 } .tl.tool { color:#8b95b0 } .dotg { color:#58a6ff }
96
+ .tl.new { animation:lineIn .28s ease-out } @keyframes lineIn { from { opacity:0; transform:translateY(5px) } }
97
+ .cursor { display:inline-block; width:7px; height:14px; background:#c9d1d9; vertical-align:-2px; animation:blinkc 1s steps(1) infinite } @keyframes blinkc { 50% { opacity:0 } }
98
+ #pstat { flex:none; padding:4px 12px; background:#161925; color:#8b95b0; font:11.5px ui-monospace,Menlo,monospace; border-top:1px solid #232637; white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
99
+ #ptodo { flex:0 0 230px; margin-left:12px; align-self:flex-start; max-height:calc(100% - 140px); overflow:hidden; background:rgba(20,22,32,.92); border:1px solid #2a2d3d; border-radius:10px; padding:8px 10px; font-size:12px }
100
+ #ptodo h5 { margin:0 0 6px; font-size:10.5px; letter-spacing:.08em; text-transform:uppercase; color:var(--dim) } .td { display:flex; gap:7px; padding:2px 0; color:#c3c6d4 } .td i { font-style:normal; flex:none } .td.completed { color:#6b7089; text-decoration:line-through } .td.completed i { color:var(--ok) } .td.in_progress { color:#fff } .td.in_progress i { color:var(--gold) }
101
+ .tl.out { color:#aab2c5 } .tl.err { color:#ff7b72 } .tl.dim { color:#6b7089 } .tl.run { color:#ffd24a } .tl.cmd { margin-top:5px }
102
+ .ln.ctx b { width:34px; text-align:right; color:#4b5069 } .ln.ctx { color:#d3d7e6 } .ln.err { color:#ff7b72 } .ln.dim { color:#6b7089 } .ln.run { color:#ffd24a }
103
+ .spin { display:inline-block; width:9px; height:9px; margin-right:6px; border:2px solid #ffd24a; border-top-color:transparent; border-radius:50%; animation:spin .7s linear infinite; vertical-align:-1px } @keyframes spin { to { transform:rotate(360deg) } }
104
+ .ticker em { font-style:normal; color:#9aa3bd; font-size:10px } .ticker em.ok { color:#58d68d } .ticker em.bad { color:#ff7b72 }
105
+ .status { max-width:84%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
106
+ #pbadge { margin-left:auto; flex:none; background:rgba(240,160,32,.94); color:#201400; font-weight:600; font-size:11px; padding:1px 10px; border-radius:99px; white-space:nowrap }
107
+ #present.has-todo #pcam { right:270px } #pcam { position:absolute; right:28px; bottom:22px; width:158px; height:118px; z-index:2; cursor:default; box-shadow:0 8px 24px rgba(0,0,0,.6) }
108
+ #pcap { display:none; position:absolute; left:24px; right:200px; bottom:20px; z-index:2; background:rgba(10,11,16,.84); backdrop-filter:blur(6px); border-radius:10px; padding:6px 12px; font-size:13px } #pcap:not(:empty) { display:block }
109
+ body.narrow #present { height:clamp(150px,36vh,260px) } body.narrow #pcam { width:110px; height:82px } body.narrow #pcap { right:150px }
110
+ #gantt { display:none; flex:none; width:100%; background:#0f1118; border-top:1px solid var(--line); cursor:pointer }
111
+ body.replay #gantt { display:block }
112
+ aside { display:none; width:320px; flex:none; border-left:1px solid var(--line); background:var(--panel); flex-direction:column; min-height:0 }
113
+ body.log aside { display:flex }
114
+ aside h2 { margin:0; padding:11px 14px; font-size:11px; letter-spacing:.08em; text-transform:uppercase; color:var(--dim); border-bottom:1px solid var(--line) }
115
+ #log { flex:1; overflow:auto }
116
+ .row { padding:7px 14px; border-bottom:1px solid var(--line); font-size:12.5px }
117
+ body.replay .row { cursor:pointer } body.replay .row:hover { background:var(--panel2) }
118
+ .row.now { background:#283052 } .row .who { font-weight:600 } .row .arrow, .row .k { color:var(--dim); font-size:11px }
119
+ .row .txt { color:#c3c6d4; word-break:break-word } .row.order .who { color:var(--gold) } .row.report .who { color:var(--ok) }
120
+ footer { display:none; height:44px; flex:none; align-items:center; gap:12px; padding:0 14px; border-top:1px solid var(--line) }
121
+ body.replay footer { display:flex }
122
+ input[type=range] { accent-color:var(--accent) } #seek { flex:1 }
123
+ @media (max-width:760px) { aside { position:absolute; right:0; top:0; bottom:0; z-index:6; box-shadow:-10px 0 30px rgba(0,0,0,.5) } .brand small, #viewSeg { display:none } }
124
+ @media (max-width:420px) { .brand b { display:none } }
125
+ @media (max-width:360px) { .logo { display:none } }
126
+ @media (max-height:640px) { #gantt { display:none !important } }
127
+
128
+ /* ---------- tiles (paper cut-out) ---------- */
129
+ .tile { position:relative; height:100%; border-radius:14px; overflow:hidden; border:1px solid rgba(255,255,255,.1); background:#222; box-shadow:0 2px 12px rgba(0,0,0,.35); cursor:pointer; transition:opacity .3s, border-color .2s, box-shadow .2s; animation:join .35s ease }
130
+ @keyframes join { from { opacity:0; transform:scale(.88) } }
131
+ .tile svg { width:100%; height:100%; display:block }
132
+ .tile.lead { order:-2 } .tile.focus { order:-3; grid-column:span var(--fcols,2); grid-row:span var(--fr,2) } .tile.speaking { border-color:var(--accent); box-shadow:0 0 0 3px rgba(124,156,255,.4), 0 10px 30px rgba(0,0,0,.55) }
133
+ .tile.lead.speaking { border-color:var(--gold); box-shadow:0 0 0 3px rgba(245,192,74,.4), 0 10px 30px rgba(0,0,0,.55) }
134
+ body.presenting .tile.focus { order:0; grid-column:auto; grid-row:auto } .tile.focus .bubble { font-size:16px; max-width:72% }
135
+ .tile.working { border-color:rgba(240,160,32,.8) } .tile.done svg { filter:saturate(.75) brightness(.93) }
136
+ .tile.pop { animation:pop .6s } @keyframes pop { 35% { transform:scale(1.07); box-shadow:0 0 0 6px rgba(88,214,141,.5) } }
137
+ .tag { position:absolute; left:6px; bottom:6px; right:6px; display:flex; gap:5px; pointer-events:none }
138
+ .tag b, .tag span { background:rgba(14,15,24,.78); backdrop-filter:blur(6px); padding:2px 9px; border-radius:99px; font-size:11.5px; white-space:nowrap }
139
+ .tag span { color:#e3e6f0; font-size:10.5px; overflow:hidden; text-overflow:ellipsis; min-width:0 }
140
+ .status { position:absolute; top:6px; right:6px; font-size:11.5px; font-weight:600; padding:2px 9px; border-radius:99px; background:rgba(14,15,24,.8); backdrop-filter:blur(6px) }
141
+ .status.working { color:var(--gold) } .status.done { color:var(--ok) }
142
+ .status.waiting { color:#8fd3ff } .status.blocked { color:#fff; background:#c0392b; font-weight:700 } .status.stalled { color:#1b1d27; background:#ffb347; font-weight:700 }
143
+ .tile.waiting { border-color:rgba(110,190,255,.85) } .tile.blocked { border-color:#ff5252 !important; animation:slowp 1.2s ease-in-out infinite } .tile.stalled { border-color:#ffb347 !important }
144
+ .board { display:flex; gap:6px; font-size:11.5px; flex-wrap:nowrap; min-width:0; overflow:hidden } .board em { font-style:normal } .board span { flex:none; padding:2px 8px; border-radius:99px; background:var(--panel2); border:1px solid var(--line); white-space:nowrap }
145
+ .board .working { color:var(--gold) } .board .waiting { color:#8fd3ff } .board .blocked { color:#fff; background:#c0392b; border-color:#c0392b } .board .stalled { color:#1b1d27; background:#ffb347; border-color:#ffb347 } .board .done { color:var(--ok) }
146
+ @media (max-width:1180px) { .board em { display:none } .meet { display:none } }
147
+ @media (max-width:640px) { .board { display:none } }
148
+ .bubble { display:none; position:absolute; top:6px; left:6px; right:62px; background:#fff; color:#1b1d27; border-radius:11px; padding:6px 10px; font-size:12.5px; line-height:1.35; box-shadow:0 4px 14px rgba(0,0,0,.35); max-height:58%; overflow:hidden }
149
+ .tile.bye .bubble { display:block; font-size:13.5px; animation:fadein .25s } .tile.speaking .bubble { display:block; font-size:14.5px; max-height:50% } .bubble small { display:block; color:#6a6f85; font-size:10.5px }
150
+ .ticker { display:none; position:absolute; left:6px; right:6px; bottom:30px; font:10.5px/1.3 ui-monospace,Menlo,monospace; color:#fff; background:rgba(15,20,35,.8); border-radius:5px; padding:2px 7px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
151
+ .ticker.on { display:block } .ticker i { font-style:normal; color:#ffd24a } .ticker.flash { animation:flash .25s } @keyframes flash { from { background:#3b5bdb } }
152
+ /* character animation: all state-driven from the tile's classes */
153
+ .fig { transform-origin:50px 76px; animation:bob 4.4s ease-in-out infinite; animation-delay:var(--d,0s) } @keyframes bob { 50% { transform:translateY(-.9px) } }
154
+ .tile.speaking .fig { animation:talk .46s ease-in-out infinite } @keyframes talk { 50% { transform:scale(1.025,.985) translateY(-.5px) } }
155
+ .tile.vanishing { pointer-events:none; z-index:3; animation:vanish 1.2s cubic-bezier(.45,0,.75,.35) forwards }
156
+ @keyframes vanish { 0% { filter:none; transform:none; opacity:1 } 16% { filter:brightness(1.7) saturate(1.5); transform:scale(1.05) } 55% { filter:brightness(2.3) blur(1.5px); transform:scale(.88) rotate(-3deg); opacity:.85 } 100% { filter:brightness(3.4) blur(11px); transform:scale(.12) rotate(11deg); opacity:0 } }
157
+ .tile.vanishing::after { content:''; position:absolute; inset:0; background:radial-gradient(circle,#fff 0,rgba(255,255,255,0) 68%); opacity:0; animation:flash2 .7s ease-out .15s }
158
+ @keyframes flash2 { 40% { opacity:.9 } }
159
+ .puff { position:fixed; left:0; top:0; z-index:9; pointer-events:none; border-radius:50% }
160
+ #toasts { position:absolute; left:12px; bottom:12px; z-index:6; display:flex; flex-direction:column; gap:6px; pointer-events:none }
161
+ .toast { background:rgba(27,29,39,.92); border:1px solid var(--line); border-radius:99px; padding:5px 12px; font-size:12px; animation:toast 3s ease forwards } @keyframes toast { 0% { opacity:0; transform:translateY(6px) } 10%, 80% { opacity:1; transform:none } 100% { opacity:0 } }
162
+ .eye { transform-box:fill-box; transform-origin:center; animation:blink 4.6s infinite; animation-delay:var(--d,0s) } @keyframes blink { 0%,94%,100% { transform:scaleY(1) } 97% { transform:scaleY(.08) } }
163
+ .brow { transform-box:fill-box; transform-origin:center; transition:transform .25s } .tile.speaking .brow { animation:brow .7s ease-in-out infinite } @keyframes brow { 50% { transform:translateY(-1.1px) } }
164
+ .tile.working .brow.l { transform:rotate(13deg) translateY(.5px) } .tile.working .brow.r { transform:rotate(-13deg) translateY(.5px) } .tile.working .pupil { transform:translateY(1.2px) }
165
+ .arm.l { transform-origin:27px 48px } .arm.r { transform-origin:73px 48px } .tile.speaking .arm.r { animation:wave .7s ease-in-out infinite } @keyframes wave { 50% { transform:rotate(-34deg) } }
166
+ .tile.working .arm { display:none } .tile .laptop, .tile .hands, .tile .sparks, .tile .grin, .tile .eyes-happy, .tile .mouth { display:none }
167
+ .tile.working .laptop, .tile.working .hands, .tile.done .sparks, .tile.done .eyes-happy { display:inline } .tile.done .eyes-open { display:none }
168
+ .tile.speaking .mouth { display:inline } .tile.speaking .smile, .tile.speaking .grin { display:none } .tile.done:not(.speaking) .smile { display:none } .tile.done:not(.speaking) .grin { display:inline }
169
+ .hand { animation:type .34s ease-in-out infinite } .hand.r { animation-delay:-.17s } @keyframes type { 50% { transform:translateY(-1.3px) } }
170
+ .tw { animation:tw 3.2s ease-in-out infinite } .pulse { animation:tw 1.7s ease-in-out infinite } @keyframes tw { 50% { opacity:.25 } }
171
+ .drift { animation:drift 24s ease-in-out infinite alternate } @keyframes drift { to { transform:translateX(9px) } }
172
+ .steam { animation:steam 2.6s ease-in-out infinite } @keyframes steam { 50% { transform:translateY(-1.4px); opacity:.25 } }
173
+ .tile.done .sparks path { animation:tw 1.6s ease-in-out infinite }
174
+ @media (prefers-reduced-motion:reduce) { *, *::before, *::after { animation:none !important; transition:none !important } .confetti, .fly { display:none !important } }
175
+ .tile:focus-visible, .rr:focus-visible, .bcard:focus-visible { outline:2px solid var(--accent); outline-offset:2px }
176
+ .fly { position:fixed; left:0; top:0; font-size:28px; z-index:8; pointer-events:none; filter:drop-shadow(0 3px 4px rgba(0,0,0,.5)) }
177
+
178
+ /* ---------- overlays ---------- */
179
+ #gate, #empty { position:absolute; inset:0; z-index:4; display:none; flex-direction:column; align-items:center; justify-content:center; gap:14px; text-align:center; padding:24px; background:rgba(18,19,26,.82); backdrop-filter:blur(3px) }
180
+ #gate.on, #empty.on { display:flex } #empty { background:var(--bg) }
181
+ #gate h2, #empty h2 { margin:0; font:800 22px "Comic Sans MS","Chalkboard SE",system-ui,sans-serif } #gate p, #empty p { margin:0; color:var(--dim); max-width:380px }
182
+ .cta { background:var(--accent); color:#0b1020; font-weight:700; padding:10px 22px; border-radius:99px; font-size:14px } .cta:hover { filter:brightness(1.1) }
183
+ .btn { background:var(--panel2); border:1px solid var(--line); padding:6px 12px; border-radius:8px } .btn:hover { border-color:var(--accent) }
184
+ #menu { position:absolute; top:52px; right:10px; z-index:12; width:280px; background:var(--panel); border:1px solid var(--line); border-radius:12px; box-shadow:0 14px 40px rgba(0,0,0,.55); padding:6px; display:none }
185
+ .coffee-btn { color:#ffd24a !important; background:rgba(255,210,74,.12); box-shadow:inset 0 0 0 1px rgba(255,210,74,.35) } .coffee-btn:hover { background:rgba(255,210,74,.22) !important }
186
+ .glink { color:var(--dim); font-size:12.5px; text-decoration:underline; text-underline-offset:3px } .glink:hover { color:#ffd24a }
187
+ #mCoffee { background:rgba(255,210,74,.10); box-shadow:inset 0 0 0 1px rgba(255,210,74,.3); margin-top:4px } #mCoffee:hover { background:rgba(255,210,74,.2) }
188
+ #menu.on { display:block } #menu h4 { margin:8px 10px 4px; font-size:10.5px; letter-spacing:.08em; text-transform:uppercase; color:var(--dim); font-weight:600 }
189
+ .mi { display:flex; align-items:center; justify-content:space-between; gap:10px; width:100%; text-align:left; padding:8px 10px; border-radius:8px } .mi:hover { background:var(--panel2) }
190
+ .sw { width:30px; height:17px; border-radius:99px; background:#3a3e50; position:relative; flex:none; transition:background .15s } .sw::after { content:''; position:absolute; top:2px; left:2px; width:13px; height:13px; border-radius:50%; background:#fff; transition:transform .15s }
191
+ .sw.on { background:var(--accent) } .sw.on::after { transform:translateX(13px) }
192
+ #menu select { width:100%; background:var(--panel2); border:1px solid var(--line); border-radius:8px; padding:6px 8px; margin:2px 0 6px } #menu .box { padding:0 10px }
193
+ #menu .hint { font-size:11.5px; color:#ffb347; padding:0 10px 6px }
194
+ #card { position:fixed; inset:0; z-index:20; display:none; align-items:center; justify-content:center; text-align:center; background:radial-gradient(circle at 50% 40%,#2a3a66,#05060c); color:#fff }
195
+ #card.on { display:flex; animation:fadein .4s } @keyframes fadein { from { opacity:0 } }
196
+ #card h1 { font:900 clamp(34px,8vw,90px)/1 "Comic Sans MS","Chalkboard SE",sans-serif; margin:0; text-shadow:4px 4px 0 #d8372b, 8px 8px 0 #111 } #card p { font-size:clamp(14px,2.2vw,24px); color:#cdd6f4; max-width:70vw; margin:16px auto 0 }
197
+ #detail { position:absolute; top:0; right:0; bottom:0; width:min(420px,100%); z-index:7; background:var(--panel); border-left:1px solid var(--line); box-shadow:-8px 0 24px rgba(0,0,0,.4); display:none; flex-direction:column }
198
+ #detail.on { display:flex } #detail .hd { display:flex; justify-content:space-between; align-items:center; padding:10px 14px; border-bottom:1px solid var(--line) }
199
+ #dbody { overflow:auto; padding:6px 14px 14px } #dbody h3 { margin:14px 0 4px; font-size:10.5px; text-transform:uppercase; letter-spacing:.08em; color:var(--dim) }
200
+ #dbody pre { white-space:pre-wrap; word-break:break-word; background:var(--bg); padding:8px; border-radius:6px; margin:0; font:11.5px/1.4 ui-monospace,Menlo,monospace }
201
+ #recap { position:fixed; inset:0; z-index:15; background:rgba(5,6,12,.88); display:none; overflow:auto; padding:28px 16px } #recap.on { display:block }
202
+ #recap .box { max-width:860px; margin:0 auto; background:var(--panel); border:1px solid var(--line); border-radius:14px; padding:22px 26px }
203
+ #recap h1 { margin:0 0 14px; font:900 26px "Comic Sans MS","Chalkboard SE",sans-serif; text-shadow:2px 2px 0 #d8372b } #recap h3 { margin:20px 0 6px; font-size:10.5px; text-transform:uppercase; letter-spacing:.08em; color:var(--dim) }
204
+ .kpis { display:grid; grid-template-columns:repeat(auto-fit,minmax(110px,1fr)); gap:10px } .kpi { background:var(--bg); border-radius:10px; padding:10px 12px } .kpi b { display:block; font-size:22px } .kpi span { color:var(--dim); font-size:11.5px }
205
+ .coffee { display:flex; align-items:center; justify-content:space-between; gap:14px; margin-top:20px; padding:12px 14px; background:var(--bg); border:1px dashed #4a4f66; border-radius:10px; font-size:13px; color:#c3c6d4 }
206
+ #recap table { width:100%; border-collapse:collapse; font-size:12.5px } #recap td, #recap th { padding:4px 8px; border-bottom:1px solid var(--line); text-align:left } #recap th { color:var(--dim); font-weight:500 } #recap .f { font:12px ui-monospace,Menlo,monospace; word-break:break-all }
207
+
208
+ /* ---------- tree ---------- */
209
+ #tree ul { list-style:none; margin:0; padding-left:26px; position:relative } #tree > ul { padding-left:0 } #tree li { position:relative; padding:3px 0 }
210
+ #tree ul ul > li::before { content:''; position:absolute; left:-16px; top:0; bottom:0; border-left:2px solid #393d4f } #tree ul ul > li:last-child::before { bottom:auto; height:17px }
211
+ #tree ul ul > li::after { content:''; position:absolute; left:-16px; top:17px; width:14px; border-top:2px solid #393d4f }
212
+ .node { display:inline-flex; align-items:center; gap:8px; padding:3px 10px 3px 4px; border-radius:99px; background:var(--panel); border:2px solid var(--line); cursor:pointer; max-width:100% } .node:hover { border-color:var(--accent) }
213
+ .node .dot { width:22px; height:22px; border-radius:50%; flex:none; position:relative } .node .dot::after { content:''; position:absolute; left:6px; top:7px; width:3px; height:4.5px; border-radius:50%; background:#2a2233; box-shadow:7px 0 0 #2a2233 }
214
+ .node b { font-size:13px } .node .r { color:var(--dim); font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:300px }
215
+ .node .tl { color:#ffd24a; font:11px ui-monospace,Menlo,monospace; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:200px }
216
+ .node.working { border-color:var(--warn) } .node.done { opacity:.6 } .node.speaking { border-color:var(--accent); box-shadow:0 0 0 3px rgba(124,156,255,.3) }
217
+ .caret { cursor:pointer; color:var(--dim); width:16px; display:inline-block; user-select:none }
218
+ </style>
219
+ </head>
220
+ <body>
221
+ <header>
222
+ <div class="brand"><div class="logo">🎥</div><div><b>Agent Standup</b><small id="subtitle"></small></div></div>
223
+ <span id="pill">REPLAY</span><button id="tok" class="tok" style="display:none"></button><button id="ctxp" class="ctxp" style="display:none"></button><span id="board" class="board" aria-live="polite"></span><span id="meet" class="meet"></span>
224
+ <div class="sp"></div>
225
+ <div class="seg" id="viewSeg"><button data-v="call" class="on" data-fr="Appel" data-en="Call">Appel</button><button data-v="tree" data-fr="Arbre" data-en="Tree">Arbre</button><button data-v="board" data-fr="Sessions" data-en="Sessions">Sessions</button></div>
226
+ <button class="ic" id="bPlay" title="Pause / Lecture (espace)">⏸</button>
227
+ <button class="ic" id="bLog" title="Transcript">☰</button>
228
+ <button class="ic" id="bMute" title="Son (M)">🔊</button>
229
+ <button class="ic coffee-btn" id="bCoffee" style="display:none">☕</button>
230
+ <button class="ic" id="bMenu" title="Réglages">⚙</button>
231
+ </header>
232
+
233
+ <div id="menu">
234
+ <h4 data-fr="Langue" data-en="Language">Langue</h4>
235
+ <div class="box"><select id="langSel" aria-label="Language"></select></div>
236
+ <div class="hint" id="trHint" style="display:none"></div>
237
+ <h4 data-fr="Son" data-en="Sound">Son</h4>
238
+ <button class="mi" data-t="voice"><span data-fr="Voix des agents" data-en="Agent voices">Voix des agents</span><i class="sw"></i></button>
239
+ <button class="mi" data-t="office"><span data-fr="Ambiance de bureau (clavier, café…)" data-en="Office ambience (keys, coffee…)">Ambiance de bureau (clavier, café…)</span><i class="sw"></i></button>
240
+ <div class="box" id="officeBox" style="display:none"><input id="officeVol" type="range" min="0.1" max="1" step="0.05" style="width:100%" aria-label="Ambience volume"></div>
241
+ <button class="mi" data-t="notify"><span data-fr="Me prévenir quand les agents ont fini" data-en="Notify me when agents finish">Me prévenir quand les agents ont fini</span><i class="sw"></i></button>
242
+ <button class="mi" data-t="music"><span data-fr="Musique d’ambiance (radio FIP)" data-en="Office music (FIP radio)">Musique d’ambiance (radio FIP)</span><i class="sw"></i></button>
243
+ <div class="box" id="musicBox" style="display:none"><select id="station"></select><input id="musicVol" type="range" min="0" max="1" step="0.05" style="width:100%" aria-label="Music volume"></div>
244
+ <div class="box" id="vstyleBox"><select id="vstyle"></select></div>
245
+ <button class="mi" data-t="roster"><span data-fr="Liste des agents (à gauche)" data-en="Agents list (left)">Liste des agents (à gauche)</span><i class="sw"></i></button>
246
+ <button class="mi" data-t="reactions"><span data-fr="Barre de réactions (👍 👏 ☕)" data-en="Reactions bar (👍 👏 ☕)">Barre de réactions (👍 👏 ☕)</span><i class="sw"></i></button>
247
+ <button class="mi" data-t="banter"><span data-fr="Petites répliques et animations" data-en="Little lines & animations">Petites répliques et animations</span><i class="sw"></i></button>
248
+ <button class="mi" data-t="vanish"><span data-fr="Retirer les agents terminés (2 min)" data-en="Remove finished agents (2 min)">Retirer les agents terminés (2 min)</span><i class="sw"></i></button>
249
+ <button class="mi" data-t="epi"><span data-fr="Réactions et blagues" data-en="Reactions & jokes">Réactions et blagues</span><i class="sw"></i></button>
250
+ <h4 data-fr="Source" data-en="Source">Source</h4>
251
+ <div class="box"><div class="seg" style="width:100%" id="modeSeg"><button data-m="live" style="flex:1">Live</button><button data-m="replay" style="flex:1">Replay</button><button data-m="demo" style="flex:1">Demo</button></div>
252
+ <select id="liveProj" style="display:none;margin-top:6px" title="Project to follow"></select>
253
+ <select id="sessions" style="display:none"></select></div>
254
+ <h4 data-fr="Personnages" data-en="Characters">Personnages</h4>
255
+ <div class="box"><div id="charInfo" class="dim" style="font-size:11.5px;line-height:1.5"></div><div style="display:flex;gap:6px;margin-top:6px"><button class="btn" id="charReload" data-fr="Recharger" data-en="Reload">Recharger</button><button class="btn" id="charOpen" style="display:none" data-fr="Ouvrir le dossier" data-en="Open folder">Ouvrir le dossier</button></div></div>
256
+ <h4 data-fr="Outils" data-en="Tools">Outils</h4>
257
+ <button class="mi" id="mCoffee" style="display:none"><span id="mCoffeeT"></span></button>
258
+ <button class="mi" id="mRecap"><span data-fr="📋 Récapitulatif" data-en="📋 Recap">📋 Récapitulatif</span></button>
259
+ <button class="mi" id="mExport"><span data-fr="🎞 Exporter la vidéo" data-en="🎞 Export video">🎞 Exporter la vidéo</span></button>
260
+ <div class="keys">Space ⏯ · M 🔇 · T tree · R recap · L log · Esc ✕</div>
261
+ <button class="mi" id="mBrowser" style="display:none"><span data-fr="↗ Ouvrir dans le navigateur" data-en="↗ Open in browser">↗ Ouvrir dans le navigateur</span></button>
262
+ </div>
263
+
264
+ <main>
265
+ <nav id="roster" aria-label="Agents"></nav>
266
+ <section id="center">
267
+ <div id="present"><div class="pwin term"><div class="pbar"><i class="r"></i><i class="y"></i><i class="g"></i><span id="ptitle"></span><span id="pbadge"></span></div><div id="pbody"></div><div id="pstat"></div></div><div id="ptodo" style="display:none"></div><div id="pcap"></div><div id="pcam" class="tile working"></div></div>
268
+ <div id="stage"></div><div id="tree"></div><div id="boardv"></div>
269
+ <div id="reactBar" aria-label="Reactions"><button data-e="👍">👍</button><button data-e="👏">👏</button><button data-e="☕">☕</button><button data-e="🔥">🔥</button><button data-e="🙌">🙌</button></div>
270
+ <div id="hl"></div>
271
+ <canvas id="gantt" height="90"></canvas>
272
+ <div id="toasts" role="status" aria-live="polite"></div>
273
+ <div id="empty"><h2 id="eT"></h2><p id="eP"></p><button class="cta" id="eDemo"></button></div>
274
+ <div id="gate"><h2 id="gT"></h2><p id="gP"></p><button class="cta" id="gB"></button><button class="glink" id="gCoffee" style="display:none"></button></div>
275
+ <div id="detail"><div class="hd"><b id="dname"></b><button class="ic" id="dclose">✕</button></div><div id="dbody"></div></div>
276
+ </section>
277
+ <aside><h2 id="logH">Transcript</h2><input id="logQ" type="search" placeholder="Search…" aria-label="Search the transcript" style="margin:0 10px 8px;width:calc(100% - 20px)"><div id="log"></div></aside>
278
+ </main>
279
+ <footer>
280
+ <input id="seek" type="range" min="0" max="0" value="0"><span class="dim" id="pos">0 / 0</span>
281
+ <label class="dim"><span data-fr="Vitesse" data-en="Speed">Vitesse</span> <input id="speed" type="range" min="0.5" max="3" step="0.25" value="1.25" style="width:80px;vertical-align:middle"></label>
282
+ </footer>
283
+ <div id="card"></div><div id="recap"><div class="box"></div></div>
284
+
285
+ <script>
286
+ // =====================================================================
287
+ // helpers, prefs, i18n
288
+ // =====================================================================
289
+ const $ = (s) => document.querySelector(s);
290
+ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;' }[c]));
291
+ const hash = (s) => { let h = 2166136261; for (const c of s) h = Math.imul(h ^ c.charCodeAt(0), 16777619); return h >>> 0; };
292
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
293
+ const SUPPORT = { url: 'https://www.buymeacoffee.com/bored_dev_and_his_robots' }; // e.g. 'https://www.buymeacoffee.com/yourname' — the coffee button stays hidden while this is empty
294
+ const CID = Math.random().toString(36).slice(2, 12); // identifies this window for the audio-ownership handshake
295
+ const Q = new URLSearchParams(location.search);
296
+ const EMBED = Q.get('embed') === '1' || window.parent !== window;
297
+ if (Q.get('pin')) S.pin = Q.get('pin');
298
+
299
+ let prefs = { lang: 'en', voice: true, roster: true, banter: true, reactions: false, office: false, officeVol: 0.8, vstyle: 'cartoon', notify: false, epi: false, vanish: true, music: false, station: 'fip', musicVol: 0.35, view: 'call' };
300
+ try { Object.assign(prefs, JSON.parse(localStorage.getItem('cc.prefs') || '{}')); } catch {}
301
+ if (prefs.vstyle === 'babble') prefs.vstyle = 'animal', prefs.vstyleChosen = true;
302
+ if (!prefs.vstyleChosen && (prefs.vstyle === 'animal' || prefs.vstyle === 'sims')) prefs.vstyle = 'cartoon'; // the gibberish styles are opt-in: understandable voices by default
303
+ const savePrefs = () => { try { localStorage.setItem('cc.prefs', JSON.stringify(prefs)); } catch {} };
304
+ // Languages: French and English live in the code as pairs L(french, english); every other language is a catalogue public/locales/<code>.json mapping the English text to its translation.
305
+ // A missing string falls back to English, so a half-translated language still works. See public/locales/README.md to add one.
306
+ const LANGS = [['en', 'English', 'en'], ['fr', 'Français', 'fr'], ['zh-TW', '繁體中文', 'zh-Hant-TW'], ['zh-CN', '简体中文', 'zh-Hans-CN'], ['es', 'Español', 'es'], ['de', 'Deutsch', 'de'], ['pt-BR', 'Português (Brasil)', 'pt-BR'], ['ja', '日本語', 'ja']];
307
+ let LOC = {};
308
+ const FR = () => prefs.lang === 'fr';
309
+ const tr = (en) => (prefs.lang === 'en' || prefs.lang === 'fr' ? en : LOC[en] || en);
310
+ const L = (fr, en) => (FR() ? fr : tr(en));
311
+ async function loadLocale(code) { LOC = {}; if (code === 'en' || code === 'fr') return; try { const r = await fetch('/locales/' + encodeURIComponent(code) + '.json'); if (r.ok) LOC = await r.json(); } catch {} }
312
+ async function setLang(code, save) {
313
+ if (!LANGS.some((l) => l[0] === code)) code = 'en'; prefs.lang = code; await loadLocale(code); if (save) { prefs.langChosen = true; savePrefs(); }
314
+ if (FR()) fetch('/api/warm').catch(() => {}); AUD.stopAll(); synth && synth.cancel(); applyLang(); if (FR()) prefetchTr(S.idx);
315
+ }
316
+
317
+ // =====================================================================
318
+ // cast: soft mascots at their desks, in front of illustrated rooms
319
+ // =====================================================================
320
+ const NAMES = ['Ada','Linus','Grace','Alan','Margaret','Dennis','Barbara','Ken','Radia','Tim','Hedy','Guido','Frances','Bjarne','Katherine','Brian','Sophie','Niklaus','Annie','Donald','Joan','Edsger','Lynn','Vint','Mae','Rob','Zoe','Ivan','Nina','Otto','Lena','Hugo','Ines','Théo','Maya','Jules','Alma','Remy','Cleo','Noah','Iris','Leo','Mira','Axel','Nora','Eli','Lou','Gaby'];
321
+ const nameBook = {}; // agent id → unique first name within the session
322
+ function uniqueName(id, h) {
323
+ if (nameBook[id]) return nameBook[id]; const taken = new Set(Object.values(nameBook));
324
+ for (let k = 0; k < NAMES.length; k++) { const n = NAMES[(h + k) % NAMES.length]; if (!taken.has(n)) return (nameBook[id] = n); }
325
+ return (nameBook[id] = NAMES[h % NAMES.length] + ' ' + (taken.size + 1));
326
+ }
327
+ const PAL = ['#ff7a6b','#ffb347','#ffd84a','#4fd1a5','#4cc3ff','#7c8cff','#b583ff','#ff7eb6'];
328
+ const hexRGB = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
329
+ const mix = (a, b, t) => { const A = hexRGB(a), B = hexRGB(b); return '#' + A.map((v, i) => Math.round(v + (B[i] - v) * t).toString(16).padStart(2, '0')).join(''); };
330
+ // ---- custom characters: the person's own pixel art (folder served by /api/characters) replaces the drawn figure, scene and desk stay ----
331
+ let CHARS = { lead: null, you: null, pool: [], dir: '', n: 0 };
332
+ const toData = (url) => fetch(url).then((r) => r.blob()).then((b) => new Promise((res) => { const f = new FileReader(); f.onload = () => res(f.result); f.readAsDataURL(b); }));
333
+ async function loadChars() {
334
+ const next = { lead: null, you: null, pool: [], dir: '', n: 0 };
335
+ try {
336
+ const d = await (await fetch('/api/characters')).json(); next.dir = d.dir || '';
337
+ for (const c of d.chars || []) { try { const e = { name: c.stem, idle: await toData('/characters/' + encodeURIComponent(c.file)), talk: c.talk ? await toData('/characters/' + encodeURIComponent(c.talk)) : null };
338
+ if (c.role === 'lead') next.lead = e; else if (c.role === 'you') next.you = e; else next.pool.push(e); next.n++; } catch {} }
339
+ } catch {}
340
+ CHARS = next;
341
+ }
342
+ const customFor = (id) => (id === 'main' ? CHARS.lead : id === 'user' ? CHARS.you : CHARS.pool.length ? CHARS.pool.reduce((b, c) => (hash(id + '|' + (c.idle || '')) > hash(id + '|' + (b.idle || '')) ? c : b)) : null);
343
+ function renderCharInfo() {
344
+ const el = $('#charInfo'); if (!el) return;
345
+ el.innerHTML = CHARS.n ? `✓ ${CHARS.n} ${esc(L('personnage(s) personnalisé(s) chargé(s)', 'custom character(s) loaded'))}` : `${esc(L('Déposez vos images (PNG) dans ce dossier pour remplacer les personnages :', 'Drop your pixel art (PNG) in this folder to replace the characters:'))}<br><code style="word-break:break-all">${esc(CHARS.dir)}</code><br>${esc(L('lead.png = le chef · you.png = vous · les autres = les sous-agents · nom-talk.png = bouche ouverte', 'lead.png = the lead · you.png = you · others = the sub-agents · name-talk.png = open mouth'))}`;
346
+ $('#charOpen').style.display = EMBED ? '' : 'none';
347
+ }
348
+ function persona(a) { const p = persona0(a); p.custom = customFor(a.id); return p; }
349
+ function persona0(a) {
350
+ const h = hash(a.id), base = { ...a };
351
+ if (a.id === 'main') return { ...base, name: 'Claude', role: L('Chef', 'Lead'), shirt: '#e8845f', shape: 0, acc: 1, eye: 0, glasses: false, scene: 6, prop: 1, acc2: '#f5c04a', lead: true, vi: 0, gid: 'main' };
352
+ if (a.id === 'user') return { ...base, name: L('Vous', 'You'), role: L('Humain', 'Human'), shirt: '#8a94c9', shape: 1, acc: 2, eye: 1, glasses: false, scene: 7, prop: 1, acc2: '#ff7a6b', vi: 3, gid: 'user' };
353
+ return { ...base, name: a.name || uniqueName(a.id, h), roleEn: a.roleEn || a.role, role: (FR() && a.roleFr) || a.roleEn || a.role, shirt: PAL[(h >>> 6) % 8], shape: (h >>> 3) % 5, acc: (h >>> 9) % 6, eye: (h >>> 12) % 3, glasses: ((h >>> 15) % 4) === 0,
354
+ scene: (h >>> 18) % 6, prop: (h >>> 21) % 3, acc2: PAL[(h >>> 24) % 8], vi: h >>> 4, gid: h.toString(36) };
355
+ }
356
+
357
+ // ---------- illustrated rooms (viewBox 100×75) ----------
358
+ const star = (x, y, s, f = '#ffe58a') => `<path d="M${x} ${y - s}L${x + s * .3} ${y - s * .3}L${x + s} ${y}L${x + s * .3} ${y + s * .3}L${x} ${y + s}L${x - s * .3} ${y + s * .3}L${x - s} ${y}L${x - s * .3} ${y - s * .3}Z" fill="${f}"/>`;
359
+ const grad = (id, a, b, c) => `<linearGradient id="${id}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${a}"/>${c ? `<stop offset=".55" stop-color="${b}"/><stop offset="1" stop-color="${c}"/>` : `<stop offset="1" stop-color="${b}"/>`}</linearGradient>`;
360
+ const SCENES = [
361
+ { desk: ['#c98a5a', '#9a6238'], svg: () => `<defs>${grad('gs0', '#ffc2a8', '#ff93b4', '#9a7bff')}</defs><rect width="100" height="75" fill="url(#gs0)"/>
362
+ <circle cx="74" cy="31" r="19" fill="#fff3d0" opacity=".22"/><circle cx="74" cy="31" r="11" fill="#fff3d0"/>
363
+ <path d="M0 50Q24 34 48 48T100 44V75H0z" fill="#7b5fd6"/><path d="M0 57Q30 45 62 56T100 53V75H0z" fill="#5b46b8"/>
364
+ <path d="M18 22q2-3 4 0M26 17q2-3 4 0" stroke="#5a3d8a" stroke-width=".8" fill="none" stroke-linecap="round"/>` },
365
+ { desk: ['#6b6fa8', '#43467a'], svg: () => `<defs>${grad('gs1', '#0d1230', '#2a2e68')}</defs><rect width="100" height="75" fill="url(#gs1)"/>
366
+ ${[[12, 10, 1], [30, 18, .8], [58, 8, 1.1], [88, 22, .8], [72, 14, .7], [44, 24, .6], [8, 30, .7], [94, 8, .9]].map(([x, y, r], i) => `<circle class="tw" style="animation-delay:-${i * .4}s" cx="${x}" cy="${y}" r="${r * .7}" fill="#fff"/>`).join('')}
367
+ <circle cx="80" cy="18" r="7" fill="#fff4cf"/><circle cx="83" cy="16" r="6.2" fill="#1d2150"/>
368
+ <g fill="#161a45">${[[0, 40, 12], [12, 34, 9], [21, 44, 11], [32, 30, 8], [40, 41, 12], [52, 36, 9], [61, 45, 11], [72, 33, 10], [82, 42, 9], [91, 37, 10]].map(([x, y, w]) => `<rect x="${x}" y="${y}" width="${w}" height="${75 - y}"/>`).join('')}</g>
369
+ <g fill="#ffd36e">${Array.from({ length: 26 }, (_, i) => `<rect x="${3 + (i * 37) % 92}" y="${42 + (i * 13) % 14}" width="1.4" height="1.8" opacity="${.5 + (i % 3) * .2}"/>`).join('')}</g>` },
370
+ { desk: ['#caa27a', '#9c7650'], svg: () => `<defs>${grad('gs2', '#ffeadb', '#ffd2b5')}${grad('gw2', '#bfe9ff', '#eaf8ff')}</defs><rect width="100" height="75" fill="url(#gs2)"/>
371
+ <rect x="9" y="9" width="30" height="30" rx="3" fill="#fff" /><rect x="11" y="11" width="26" height="26" rx="2" fill="url(#gw2)"/><path d="M24 11v26M11 24h26" stroke="#fff" stroke-width="1.6"/>
372
+ <circle cx="30" cy="18" r="3.2" fill="#fff" opacity=".8"/>
373
+ <rect x="62" y="22" width="32" height="2.4" rx="1" fill="#b98a64"/><rect x="62" y="40" width="32" height="2.4" rx="1" fill="#b98a64"/>
374
+ ${[['#ff7a6b', 64, 12], ['#4cc3ff', 68, 14], ['#ffd84a', 72, 10], ['#7c8cff', 76, 13], ['#4fd1a5', 80, 11]].map(([c, x, h]) => `<rect x="${x}" y="${22 - h}" width="3.6" height="${h}" rx=".8" fill="${c}"/>`).join('')}
375
+ <path d="M70 40q-7-5-3-10q5 3 3 10zM74 40q3-8 9-6q-2 7-9 6z" fill="#46b97f"/><rect x="70" y="40" width="9" height="0"/>` },
376
+ { desk: ['#b9e0c4', '#86be9a'], svg: () => `<defs>${grad('gs3', '#bdf1ff', '#effff6')}</defs><rect width="100" height="75" fill="url(#gs3)"/>
377
+ <g fill="#fff" class="drift"><ellipse cx="22" cy="16" rx="9" ry="3.6"/><ellipse cx="28" cy="13.5" rx="6" ry="3.6"/><ellipse cx="74" cy="24" rx="8" ry="3"/><ellipse cx="79" cy="21.8" rx="5" ry="3"/></g>
378
+ <path d="M0 48Q25 36 52 47T100 42V75H0z" fill="#7ddc9a"/><path d="M0 56Q30 46 64 56T100 52V75H0z" fill="#4fc482"/>
379
+ <rect x="80" y="30" width="2.6" height="14" fill="#8a5a3a"/><circle cx="81.3" cy="27" r="8" fill="#3fae6c"/><circle cx="77" cy="30" r="5" fill="#4fc482"/>
380
+ ${[[14, 56, '#ff7eb6'], [24, 59, '#ffd84a'], [36, 55, '#fff'], [48, 58, '#ff7eb6']].map(([x, y, c]) => `<circle cx="${x}" cy="${y}" r="1.1" fill="${c}"/>`).join('')}` },
381
+ { desk: ['#e6c48f', '#bf9a63'], svg: () => `<defs>${grad('gs4', '#ffe9ae', '#a4e8ff')}</defs><rect width="100" height="75" fill="url(#gs4)"/>
382
+ <circle cx="22" cy="22" r="9" fill="#fff6d6"/><path d="M0 46Q12 41 24 46T48 46T72 46T100 46V75H0z" fill="#3cb7d9"/><path d="M0 52Q12 47 24 52T48 52T72 52T100 52V75H0z" fill="#2a95c4"/>
383
+ <path d="M70 42V26L82 42z" fill="#fff"/><path d="M69 43h14l-2 3H71z" fill="#c4705a"/><path d="M69 43V24" stroke="#8a5a3a" stroke-width=".8"/>` },
384
+ { desk: ['#7a5ea8', '#4d3a7a'], svg: () => `<defs>${grad('gs5', '#190f3d', '#4c2183')}</defs><rect width="100" height="75" fill="url(#gs5)"/>
385
+ ${Array.from({ length: 16 }, (_, i) => `<circle class="tw" style="animation-delay:-${i * .3}s" cx="${(i * 53) % 97 + 2}" cy="${(i * 29) % 50 + 3}" r=".7" fill="#fff"/>`).join('')}
386
+ <circle cx="22" cy="26" r="11" fill="#ff9bb8"/><ellipse cx="22" cy="26" rx="18" ry="3.6" fill="none" stroke="#ffe0a8" stroke-width="1.6" transform="rotate(-18 22 26)"/><path d="M13 22q9 4 18 1" stroke="#e86f98" stroke-width="1.4" fill="none"/>
387
+ <circle cx="80" cy="20" r="6" fill="#6fd3ff"/><circle cx="77.5" cy="18" r="1.4" fill="#4ab6e6"/>${star(60, 14, 3)}${star(90, 40, 2.2, '#cfc2ff')}` },
388
+ { desk: ['#506b73', '#2f4850'], svg: () => `<defs>${grad('gs6', '#10282f', '#18444f')}</defs><rect width="100" height="75" fill="url(#gs6)"/>
389
+ ${[[5, 8], [36, 6], [67, 8]].map(([x, y], i) => `<rect x="${x}" y="${y}" width="28" height="22" rx="2.5" fill="#0a1a1f" stroke="#2b6a78" stroke-width=".8"/>
390
+ <polyline points="${x + 3},${y + 17} ${x + 9},${y + 11} ${x + 14},${y + 14} ${x + 20},${y + 6} ${x + 25},${y + 9}" fill="none" stroke="${['#ff9a6b', '#4fd1a5', '#ffd84a'][i]}" stroke-width="1.3" stroke-linejoin="round"/>
391
+ <rect x="${x + 3}" y="${y + 3}" width="9" height="1.4" rx=".7" fill="#4cc3ff" opacity=".7"/>`).join('')}
392
+ <g fill="#ffe9a8" opacity=".7"><circle cx="20" cy="36" r="1" class="pulse"/><circle cx="50" cy="38" r="1" class="pulse" style="animation-delay:-.6s"/><circle cx="80" cy="36" r="1" class="pulse" style="animation-delay:-1.1s"/></g>` },
393
+ { desk: ['#a87a56', '#7c5438'], svg: () => `<defs>${grad('gs7', '#3a2b52', '#6b4a68')}<radialGradient id="gl7"><stop offset="0" stop-color="#ffd98a" stop-opacity=".75"/><stop offset="1" stop-color="#ffd98a" stop-opacity="0"/></radialGradient></defs>
394
+ <rect width="100" height="75" fill="url(#gs7)"/><circle cx="82" cy="30" r="26" fill="url(#gl7)"/>
395
+ <path d="M82 54V36" stroke="#2a2233" stroke-width="1.6"/><path d="M75 36q7-9 14 0z" fill="#ffd98a"/>
396
+ <rect x="12" y="10" width="22" height="16" rx="1.5" fill="#2a2233"/><rect x="14" y="12" width="18" height="12" rx="1" fill="#ff9a8b"/><path d="M14 24l6-6 5 4 4-3 3 5z" fill="#6b4a98"/><circle cx="27" cy="16" r="2" fill="#ffe9a8"/>` },
397
+ ];
398
+
399
+ // ---------- the characters ----------
400
+ const SHAPES = [{ top: 20, fy: 40, hw: 23 }, { top: 14, fy: 35, hw: 19 }, { top: 15, fy: 41, hw: 24 }, { top: 16, fy: 40, hw: 22 }, { top: 22, fy: 40, hw: 22 }];
401
+ const EYES = [{ rx: 3.1, ry: 4.3 }, { rx: 3.7, ry: 3.7 }, { rx: 2.5, ry: 5 }];
402
+ function bodyShape(i, fill, dark) {
403
+ return [`<rect x="27" y="20" width="46" height="56" rx="22" fill="${fill}"/>`, `<rect x="31" y="14" width="38" height="64" rx="19" fill="${fill}"/>`,
404
+ `<g fill="${fill}"><circle cx="38" cy="36" r="13"/><circle cx="50" cy="29" r="15"/><circle cx="62" cy="36" r="13"/><rect x="26" y="36" width="48" height="40" rx="12"/></g>`,
405
+ `<path d="M28 78V38a22 22 0 0 1 44 0V78z" fill="${fill}"/>`,
406
+ `<rect x="28" y="22" width="44" height="54" rx="11" fill="${fill}"/><rect x="23.5" y="36" width="4.4" height="11" rx="2.2" fill="${dark}"/><rect x="72.1" y="36" width="4.4" height="11" rx="2.2" fill="${dark}"/><path d="M50 22V13" stroke="${dark}" stroke-width="1.5" stroke-linecap="round"/><circle class="pulse" cx="50" cy="11.4" r="2.8" fill="#8ff0ff"/>`][i];
407
+ }
408
+ const ACC = [
409
+ (t, c) => `<path d="M50 ${t + 1}v-7" stroke="#3a9d68" stroke-width="1.6" stroke-linecap="round"/><path d="M50 ${t - 5}q-8-1-8-8q7 0 8 8z" fill="#53c98a"/><path d="M50 ${t - 5}q8-1 8-8q-7 0-8 8z" fill="#3fb677"/>`,
410
+ (t, c, fy, hw) => `<path d="M${50 - hw - .5} ${fy - 3}Q${50 - hw} ${t - 7} 50 ${t - 7}Q${50 + hw} ${t - 7} ${50 + hw + .5} ${fy - 3}" fill="none" stroke="#2c2f40" stroke-width="3" stroke-linecap="round"/>
411
+ <rect x="${50 - hw - 4}" y="${fy - 7}" width="7" height="14" rx="3.5" fill="${c}" stroke="#2c2f40" stroke-width="1.4"/><rect x="${50 + hw - 3}" y="${fy - 7}" width="7" height="14" rx="3.5" fill="${c}" stroke="#2c2f40" stroke-width="1.4"/>`,
412
+ (t, c) => `<path d="M30 ${t + 11}Q30 ${t - 9} 50 ${t - 9}Q70 ${t - 9} 70 ${t + 11}z" fill="${c}"/><rect x="29" y="${t + 7}" width="42" height="7" rx="3.5" fill="${mix(c, '#000000', .16)}"/><circle cx="50" cy="${t - 10}" r="4" fill="${mix(c, '#ffffff', .45)}"/>`,
413
+ (t, c) => `<path d="M31 ${t + 8}Q31 ${t - 8} 50 ${t - 8}Q69 ${t - 8} 69 ${t + 8}z" fill="${c}"/><path d="M45 ${t + 7}q22-3 30 3q-12 3-30-1z" fill="${mix(c, '#000000', .16)}"/>`,
414
+ (t, c) => `<g transform="translate(60 ${t + 3}) rotate(14)"><path d="M0 0L-9-5V5z" fill="${c}"/><path d="M0 0L9-5V5z" fill="${c}"/><circle r="2.6" fill="${mix(c, '#000000', .18)}"/></g>`,
415
+ (t, c) => `<path d="M44 ${t + 2}L40 ${t - 8}M56 ${t + 2}L60 ${t - 8}" stroke="#2c2f40" stroke-width="1.2" stroke-linecap="round"/><circle cx="40" cy="${t - 9}" r="2.4" fill="${c}"/><circle cx="60" cy="${t - 9}" r="2.4" fill="${c}"/>`,
416
+ ];
417
+ function avatarSVG(p, o, uid = '') { // uid keeps SVG ids unique per instance: duplicate ids made gradients vanish when the first copy was in a hidden element
418
+ const st = !!o, sh = SHAPES[p.shape], fy = sh.fy, top = sh.top, bot = p.shape === 4, c = p.shirt, c1 = mix(c, '#ffffff', .3), c2 = mix(c, '#000000', .2);
419
+ const ink = bot ? '#8ff0ff' : '#2a2233', gid = uid + 'b' + p.gid, sc = SCENES[p.scene], eg = EYES[p.eye], ex = p.shape === 1 ? [43, 57] : [41.5, 58.5];
420
+ const V = (cls, show, html) => (st ? (show ? html : '') : `<g class="${cls}">${html}</g>`);
421
+ const d0 = -((hash(p.id) % 45) / 10);
422
+ const eye = (x) => `<g class="eye"><ellipse cx="${x}" cy="${fy}" rx="${eg.rx}" ry="${eg.ry}" fill="${ink}"/><g class="pupil"><circle cx="${x + 1}" cy="${fy - 1.5}" r="1.1" fill="#fff"/><circle cx="${x - 1}" cy="${fy + 1.4}" r=".55" fill="#fff" opacity=".7"/></g></g>`;
423
+ const happy = (x) => `<path d="M${x - 3.6} ${fy + 1.4}Q${x} ${fy - 4.2} ${x + 3.6} ${fy + 1.4}" fill="none" stroke="${ink}" stroke-width="1.7" stroke-linecap="round"/>`;
424
+ const brow = (x, s) => `<path class="brow ${s}" d="M${x - 3.6} ${fy - eg.ry - 3}q3.6-2 7.2 0" fill="none" stroke="${ink}" stroke-width="1.4" stroke-linecap="round"/>`;
425
+ const my = fy + 10.5, working = st && o.working, done = st && o.done, speaking = st && o.m > 0, mopen = st && o.m === 2;
426
+ const face = `${bot ? '<rect x="33" y="29" width="34" height="26" rx="9" fill="#1d2138"/>' : `<ellipse cx="${ex[0] - 6}" cy="${fy + 6}" rx="3.3" ry="2" fill="#ff6f91" opacity=".34"/><ellipse cx="${ex[1] + 6}" cy="${fy + 6}" rx="3.3" ry="2" fill="#ff6f91" opacity=".34"/>`}
427
+ ${V('eyes-open', !done, eye(ex[0]) + eye(ex[1]))}${V('eyes-happy', done, happy(ex[0]) + happy(ex[1]))}${brow(ex[0], 'l')}${brow(ex[1], 'r')}
428
+ ${V('smile', !speaking && !done, `<path d="M${46} ${my}q4 3.6 8 0" fill="none" stroke="${ink}" stroke-width="1.6" stroke-linecap="round"/>`)}
429
+ ${V('grin', done && !speaking, `<path d="M44.5 ${my - .6}q5.5 8 11 0z" fill="${bot ? '#8ff0ff' : '#4b1c2c'}"/>`)}
430
+ ${V('mouth', speaking, `<ellipse class="mouthE" cx="50" cy="${my + 1}" rx="${mopen ? 4 : 4.6}" ry="${mopen ? 4.4 : .9}" fill="${bot ? '#8ff0ff' : '#4b1c2c'}"/>`)}
431
+ ${p.glasses ? `<g fill="rgba(255,255,255,.16)" stroke="#2c2f40" stroke-width="1.1"><circle cx="${ex[0]}" cy="${fy}" r="6.2"/><circle cx="${ex[1]}" cy="${fy}" r="6.2"/><path d="M${ex[0] + 6.2} ${fy}h${ex[1] - ex[0] - 12.4}" fill="none"/></g>` : ''}`;
432
+ const acc = ACC[p.acc](top, p.acc2, fy, sh.hw) + (p.lead ? `<path d="M${50 - sh.hw} ${fy + 5}q-2 11 13 12" fill="none" stroke="#2c2f40" stroke-width="1.2" stroke-linecap="round"/><circle cx="${50 - sh.hw + 13}" cy="${fy + 17}" r="1.9" fill="#2c2f40"/>` : '');
433
+ const arm = (side) => `<g class="arm ${side}"><ellipse cx="${side === 'l' ? 25 : 75}" cy="56" rx="4.6" ry="9" fill="${c2}" transform="rotate(${side === 'l' ? 12 : -12} ${side === 'l' ? 25 : 75} 56)"/></g>`;
434
+ const prop = [`<path d="M11 62.4l1.6-7.4h7.2l1.6 7.4z" fill="#e07a5f"/><path d="M16 55q-6-8-2-12q5 4 2 12z" fill="#3fbf7f"/><path d="M16 55q6-7 10-5q-1 6-10 5z" fill="#53c98a"/>`,
435
+ `<rect x="79" y="55.6" width="7.4" height="6.8" rx="1.6" fill="#f6f2ea"/><path d="M86.2 57q3.2 0 3.2 2.2t-3.2 2.2" fill="none" stroke="#f6f2ea" stroke-width="1.3"/><g class="steam" fill="none" stroke="#fff" stroke-width=".8" stroke-linecap="round" opacity=".7"><path d="M81.5 53q-1.4-1.8 0-3.4M84.3 53q-1.4-1.8 0-3.4"/></g>`,
436
+ `<rect x="76" y="58.2" width="14" height="4.2" rx="1" fill="#ff7a6b"/><rect x="77.5" y="54.4" width="12" height="3.8" rx="1" fill="#4cc3ff"/><rect x="76.5" y="50.8" width="11" height="3.6" rx="1" fill="#ffd84a"/>`][p.prop];
437
+ const lap = `<rect x="33" y="46" width="34" height="18" rx="2.8" fill="#d3d8e6"/><rect x="33" y="46" width="34" height="18" rx="2.8" fill="url(#${uid}lg)" opacity=".5"/><circle cx="50" cy="55" r="2.3" fill="${c1}"/><rect x="29.5" y="63.4" width="41" height="3" rx="1.5" fill="#aab1c2"/>`;
438
+ const hands = `<circle class="hand l" cx="38" cy="65.6" r="3.1" fill="${c2}"/><circle class="hand r" cx="62" cy="65.6" r="3.1" fill="${c2}"/>`;
439
+ const sparks = star(22, 28, 3) + star(78, 22, 2.6, '#ff9bb8') + star(70, 40, 1.9, '#9be7ff') + star(30, 46, 1.6, '#ffe58a');
440
+ const custFig = (c, talking) => { const base = 'x="20" y="6" width="60" height="58" preserveAspectRatio="xMidYMax meet"', px = 'image-rendering:pixelated;';
441
+ return `<image class="cimg" href="${c.idle}" ${base} style="${px}${talking && c.talk ? 'display:none' : ''}"/>` + (c.talk ? `<image class="cimg cimg-talk" href="${c.talk}" ${base} style="${px}${talking ? '' : 'display:none'}"/>` : ''); };
442
+ const fig = p.custom ? custFig(p.custom, st && o.m === 2) : `${arm('l')}${bodyShape(p.shape, `url(#${gid})`, c2)}${arm('r')}${face}${acc}`;
443
+ return `<svg viewBox="0 0 100 75" preserveAspectRatio="xMidYMid slice"${st ? ' xmlns="http://www.w3.org/2000/svg" width="400" height="300"' : ''}>
444
+ <defs><linearGradient id="${gid}" gradientUnits="userSpaceOnUse" x1="0" y1="12" x2="0" y2="72"><stop offset="0" stop-color="${c1}"/><stop offset="1" stop-color="${c}"/></linearGradient>
445
+ <linearGradient id="${uid}dk${p.scene}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${sc.desk[0]}"/><stop offset="1" stop-color="${sc.desk[1]}"/></linearGradient>
446
+ <linearGradient id="${uid}lg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fff"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient></defs>
447
+ ${sc.svg().replace(/id="(g[a-z]\d)"/g, `id="${uid}$1"`).replace(/url\(#(g[a-z]\d)\)/g, `url(#${uid}$1)`)}
448
+ <g class="fig" style="--d:${d0}s">
449
+ ${fig}
450
+ </g>
451
+ <rect y="62" width="100" height="13" fill="url(#${uid}dk${p.scene})"/><rect y="62" width="100" height="1.3" fill="#fff" opacity=".38"/><rect y="63.3" width="100" height="2.6" fill="#000" opacity=".1"/>
452
+ ${prop}${p.custom ? '' : V('laptop', working, lap) + V('hands', working, hands)}${V('sparks', done, `<g class="sparks">${sparks}</g>`)}
453
+ </svg>`;
454
+ }
455
+
456
+ // =====================================================================
457
+ // state
458
+ // =====================================================================
459
+ const S = { owner: null, agents: {}, events: [], idx: 0, playing: false, token: 0, status: {}, tc: {}, cur: null, live: true, userPaused: false, boost: 1, stopAt: 0,
460
+ speakers: new Set(), say: {}, lastTool: {}, level: {}, detail: null, collapsed: new Set(), title: '', idle: false, locked: true, logTo: 0, trOk: true,
461
+ clock: 0, doneAt: {}, gone: new Set(), leaving: new Set(), demo: false, hold: false };
462
+ const VANISH_MS = 120000; // a finished agent leaves the call after 2 minutes (playback time)
463
+ const spd = () => (S.live ? 1.12 : +$('#speed').value) * (S.boost || 1);
464
+ const nameOf = (id) => (S.agents[id] || {}).name || id;
465
+
466
+ // ---------- text prep ----------
467
+ const plain = (t) => String(t).replace(/```[\s\S]*?```/g, ' ').replace(/`([^`]*)`/g, '$1').replace(/https?:\/\/\S+/g, 'link').replace(/[#*_>|~\[\]]/g, ' ').replace(/\s+/g, ' ').trim();
468
+ function short(t, max = 230) {
469
+ const s = plain(t); if (s.length <= max) return s;
470
+ const cut = s.slice(0, max), end = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('! '), cut.lastIndexOf('? '));
471
+ return end > 80 ? cut.slice(0, end + 1) : cut.replace(/\s+\S*$/, '') + '…';
472
+ }
473
+ // make code-ish text pronounceable
474
+ const speakable = (t) => t.replace(/(?:[\w.-]+\/)+([\w.-]+)/g, (m, f) => f.replace(/[._-]/g, ' ')).replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[_`*#<>{}\[\]|\\=~^]/g, ' ')
475
+ .replace(/\s*[—–]\s*/g, ', ').replace(/\(([^)]{0,40})\)/g, ', $1,').replace(/\s+/g, ' ').trim();
476
+
477
+ // ---------- translation (local claude CLI) ----------
478
+ const trMem = new Map(), trPending = new Map();
479
+ const baseText = (ev, max) => short(ev.text, max || (S.live ? (ev.kind === 'order' ? 150 : 180) : (ev.kind === 'order' ? 200 : 230)));
480
+ function ensureTr(texts) {
481
+ texts = [...new Set(texts)].filter((t) => t && !trMem.has(t));
482
+ const waits = texts.filter((t) => trPending.has(t)).map((t) => trPending.get(t)), fresh = texts.filter((t) => !trPending.has(t));
483
+ if (fresh.length) {
484
+ const pr = fetch('/api/translate', { method: 'POST', body: JSON.stringify({ texts: fresh }) }).then((r) => r.json()).then((d) => {
485
+ (d.texts || []).forEach((fr, i) => trMem.set(fresh[i], fr)); S.trOk = d.ok !== false; if (!S.trOk) loadHealth(); else updateHint();
486
+ clearTimeout(S.relog); S.relog = setTimeout(() => renderLog(true), 500);
487
+ }).catch(() => { S.trOk = false; loadHealth(); }).finally(() => fresh.forEach((t) => trPending.delete(t)));
488
+ fresh.forEach((t) => trPending.set(t, pr)); waits.push(pr);
489
+ }
490
+ return Promise.all(waits);
491
+ }
492
+ function trNew(from) { // live: start translating every new line the moment it arrives (small parallel calls)
493
+ if (!FR()) return; for (let k = from; k < S.events.length; k++) if (!quiet(S.events[k])) ensureTr([baseText(S.events[k])]);
494
+ }
495
+ function prefetchTr(i) {
496
+ if (!FR() || S.live) return; const t = [];
497
+ for (let k = i; k < Math.min(S.events.length, i + 120) && t.length < 24; k++) if (!quiet(S.events[k])) t.push(baseText(S.events[k]));
498
+ ensureTr(t);
499
+ }
500
+ function spoken(ev) {
501
+ let t = baseText(ev); if (FR() && trMem.has(t)) t = trMem.get(t);
502
+ if (ev.kind === 'order') return `${nameOf(ev.to)}, ${t}`;
503
+ if (ev.kind === 'report') return L('Terminé. ', 'Done. ') + t;
504
+ return t;
505
+ }
506
+ async function loadHealth() { try { S.health = await (await fetch('/api/health')).json(); } catch {} updateHint(); }
507
+ function trProblem() { // why translation can't work, with what to do about it
508
+ const h = S.health || {};
509
+ if (h.claude === null && !h.apiKey) return L('Claude Code est introuvable : installez-le (npm i -g @anthropic-ai/claude-code) ou indiquez son chemin dans le réglage « agentStandup.claudePath ».', 'Claude Code not found: install it (npm i -g @anthropic-ai/claude-code) or set its path in the “agentStandup.claudePath” setting.');
510
+ if (/another Claude Code session|nested/i.test(h.trError || '')) return L('VS Code a été lancé depuis une session Claude Code : relancez VS Code normalement (Dock, Spotlight) pour activer la traduction.', 'VS Code was started from inside a Claude Code session: restart VS Code normally (Dock, Spotlight) to enable translation.');
511
+ if (/login|log in|auth|api key|401|credential|not logged/i.test(h.trError || '')) return L('Claude Code n’est pas connecté : lancez « claude » une fois dans un terminal pour vous connecter.', 'Claude Code is not logged in: run “claude” once in a terminal to sign in.');
512
+ return L('Traduction indisponible pour l’instant : vérifiez que « claude » fonctionne dans un terminal.', 'Translation unavailable right now: check that “claude” works in a terminal.');
513
+ }
514
+ function updateHint() {
515
+ const h = $('#trHint'), noFr = FR() && TTS.server && !TTS.pool.fr.length, noTts = prefs.voice && !TTS.server && TTS.platform !== '', noRadio = prefs.music && RADIO.err, other = elsewhere(), bad = (FR() && !S.trOk) || noFr || noTts || noRadio || other;
516
+ h.style.display = bad ? '' : 'none';
517
+ h.textContent = other ? L('Le son est joué dans une autre fenêtre Agent Standup. Cliquez sur 🔈 pour le reprendre ici.', 'Sound is playing in another Agent Standup window. Click 🔈 to take it over here.') : noRadio ? L('Radio injoignable : vérifiez votre connexion ou changez de station.', 'Radio unreachable: check your connection or pick another station.') : noFr ? L('Aucune voix française installée (Windows : Paramètres › Heure et langue › Voix).', 'No French voice installed (Windows: Settings › Time & language › Speech).') : noTts ? L('Aucune voix système trouvée : les agents marmonneront.', 'No system voice found: agents will mumble.') : trProblem();
518
+ }
519
+
520
+ // =====================================================================
521
+ // audio: sound effects + text-to-speech (server `say` → WebAudio)
522
+ // =====================================================================
523
+ const AUD = {
524
+ ctx: null, muted: false, live: new Set(), lastClick: 0,
525
+ off() { return this.muted || !!(S.owner && S.owner !== CID); }, // muted here, or another window holds the sound
526
+ init() {
527
+ if (this.ctx) { this.ctx.resume(); return; }
528
+ let c; try { c = this.ctx = new (window.AudioContext || window.webkitAudioContext)(); } catch { return; }
529
+ this.sfx = c.createGain(); this.amb = c.createGain(); this.amb.connect(this.sfx); this.voice = c.createGain(); this.music = c.createGain(); this.rec = c.createMediaStreamDestination();
530
+ [this.sfx, this.voice].forEach((g) => { g.connect(c.destination); g.connect(this.rec); });
531
+ this.music.gain.value = 0; this.music.connect(c.destination); // radio music is for your ears only: it is never part of video exports
532
+ const len = c.sampleRate * 4, buf = c.createBuffer(1, len, c.sampleRate), d = buf.getChannelData(0); let last = 0; // office room tone
533
+ for (let i = 0; i < len; i++) { last = (last + 0.02 * (Math.random() * 2 - 1)) / 1.02; d[i] = last * 3.5; }
534
+ const src = c.createBufferSource(); src.buffer = buf; src.loop = true; const lp = c.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 500;
535
+ const g = c.createGain(); g.gain.value = 0.3; src.connect(lp).connect(g).connect(this.sfx); src.start(); this.apply(); OFFICE.start();
536
+ },
537
+ apply() { if (!this.ctx) return; this.sfx.gain.value = prefs.office && !this.off() ? 1 : 0; this.voice.gain.value = prefs.voice && !this.off() ? 1 : 0; this.applyMusic(); RADIO.sync(); },
538
+ // Music fades down quickly when an agent starts talking and comes back slowly once the room is quiet.
539
+ applyMusic() { if (!this.ctx) return; this.amb.gain.setTargetAtTime((this.ducked ? 0.35 : 1) * (prefs.officeVol ?? 0.8), this.ctx.currentTime, 0.25); const v = prefs.music && !this.off() ? (prefs.musicVol ?? 0.35) * (this.ducked ? 0.16 : 1) : 0; this.music.gain.setTargetAtTime(v, this.ctx.currentTime, this.ducked ? 0.12 : 0.7); },
540
+ blip(f) { if (!this.ctx || this.off()) return; const c = this.ctx, t = c.currentTime, o = c.createOscillator(), g = c.createGain(); o.type = 'sine'; o.frequency.setValueAtTime(f, t); o.frequency.exponentialRampToValueAtTime(f * 1.5, t + 0.1); g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(0.12, t + 0.015); g.gain.exponentialRampToValueAtTime(0.0001, t + 0.22); o.connect(g).connect(c.destination); o.start(t); o.stop(t + 0.25); },
541
+ alert() { // a soft two-note chime for "your agents are done"; separate from the office sounds so it works with ambience off
542
+ if (!this.ctx || this.off()) return; const c = this.ctx;
543
+ [[784, 0], [1047, 0.16], [1319, 0.32]].forEach(([f, w]) => { const t = c.currentTime + w, o = c.createOscillator(), g = c.createGain(); o.type = 'sine'; o.frequency.value = f; g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(0.16, t + 0.02); g.gain.exponentialRampToValueAtTime(0.0001, t + 0.9); o.connect(g).connect(c.destination); o.start(t); o.stop(t + 0.95); });
544
+ },
545
+ duck(on) { clearTimeout(this.duckT); if (on) { this.ducked = true; this.applyMusic(); } else this.duckT = setTimeout(() => { this.ducked = false; this.applyMusic(); }, 900); },
546
+ tone(freq, dur, type = 'sine', vol = 0.12, when = 0) {
547
+ if (!this.ctx) return; const c = this.ctx, t = c.currentTime + when, o = c.createOscillator(), g = c.createGain(); o.type = type; o.frequency.value = freq;
548
+ g.gain.setValueAtTime(vol, t); g.gain.exponentialRampToValueAtTime(0.001, t + dur); o.connect(g).connect(this.sfx); o.start(t); o.stop(t + dur);
549
+ },
550
+ click() {
551
+ if (!this.ctx || !prefs.office || performance.now() - this.lastClick < 70) return; this.lastClick = performance.now();
552
+ const c = this.ctx, n = c.sampleRate * 0.03, b = c.createBuffer(1, n, c.sampleRate), d = b.getChannelData(0); for (let i = 0; i < n; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / n);
553
+ const s = c.createBufferSource(); s.buffer = b; const hp = c.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 1800 + Math.random() * 1500; const g = c.createGain(); g.gain.value = 0.18; s.connect(hp).connect(g).connect(this.sfx); s.start();
554
+ },
555
+ join() { this.tone(523, .14, 'triangle', .1); this.tone(784, .2, 'triangle', .1, .12); },
556
+ ding() { this.tone(988, .5, 'triangle', .14); this.tone(1318, .5, 'sine', .06, .02); },
557
+ murmur(pitch, dur) { // cartoon mumble, used when there is no real voice
558
+ if (!this.ctx || !prefs.voice) return; const c = this.ctx, t = c.currentTime, o = c.createOscillator(); o.type = 'sawtooth';
559
+ const bp = c.createBiquadFilter(); bp.type = 'bandpass'; bp.Q.value = 3; const g = c.createGain();
560
+ g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(0.08, t + 0.05); g.gain.setValueAtTime(0.08, t + dur - 0.1); g.gain.linearRampToValueAtTime(0, t + dur);
561
+ const base = 85 + pitch * 70; for (let x = 0; x < dur; x += 0.11) { o.frequency.setValueAtTime(base * (0.8 + Math.random() * 0.55), t + x); bp.frequency.setValueAtTime(500 + Math.random() * 900, t + x); }
562
+ o.connect(bp).connect(g).connect(this.voice); o.start(t); o.stop(t + dur + 0.05); this.live.add(o); o.onended = () => this.live.delete(o);
563
+ },
564
+ stopAll() { for (const s of this.live) { try { s.stop(); } catch {} } this.live.clear(); },
565
+ };
566
+
567
+ // Office ambience, all synthesised (no audio files): keyboards that follow real activity, a coffee machine, and small room noises.
568
+ const OFFICE = {
569
+ nb: null, ty: [0, 1, 2].map(() => ({ n: 0, at: 0, pan: 0 })), pt: 0, prev: 0, nextCoffee: 22000, nextMisc: 9000, timer: null,
570
+ ok() { return AUD.ctx && prefs.office && !AUD.off() && S.playing; },
571
+ noise() { const c = AUD.ctx; if (!this.nb) { this.nb = c.createBuffer(1, c.sampleRate * 2, c.sampleRate); const d = this.nb.getChannelData(0); for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1; } return this.nb; },
572
+ out(pan) { const c = AUD.ctx; if (!c.createStereoPanner) return AUD.amb; const p = c.createStereoPanner(); p.pan.value = pan; p.connect(AUD.amb); return p; },
573
+ hit(t, out, o) { // one short filtered noise burst (+ optional low thump): the building block of keys, clicks, doors, paper
574
+ const c = AUD.ctx, s = c.createBufferSource(), f = c.createBiquadFilter(), g = c.createGain(); s.buffer = this.noise(); f.type = o.type || 'bandpass'; f.frequency.value = o.f; f.Q.value = o.q || 1;
575
+ g.gain.setValueAtTime(o.v, t); g.gain.exponentialRampToValueAtTime(0.001, t + o.d); s.connect(f).connect(g).connect(out); s.start(t, Math.random() * 1.5, o.d + 0.02);
576
+ if (o.thump) { const x = c.createOscillator(), xg = c.createGain(); x.frequency.setValueAtTime(o.thump, t); x.frequency.exponentialRampToValueAtTime(o.thump * 0.4, t + 0.05); xg.gain.setValueAtTime(o.v * 0.7, t); xg.gain.exponentialRampToValueAtTime(0.001, t + 0.06); x.connect(xg).connect(out); x.start(t); x.stop(t + 0.07); }
577
+ },
578
+ key(heavy, pan) { const v = (0.6 + Math.random() * 0.6) * (heavy ? 0.3 : 0.15); this.hit(AUD.ctx.currentTime, this.out(pan), { f: (heavy ? 1500 : 2600) + Math.random() * 1400, q: 1.2, v, d: heavy ? 0.07 : 0.035, thump: heavy ? 170 : 240 }); },
579
+ mouse(pan) { const o = this.out(pan), t = AUD.ctx.currentTime; this.hit(t, o, { type: 'highpass', f: 3200, v: 0.14, d: 0.02 }); this.hit(t + 0.07, o, { type: 'highpass', f: 2600, v: 0.08, d: 0.02 }); },
580
+ burst(n, pan) { if (!this.ok()) return; for (let k = 0; k < n; k++) setTimeout(() => this.ok() && this.key(k === n - 1, pan), k * (65 + Math.random() * 70)); },
581
+ kick(tool) { // a real tool call just happened: somebody types it, clicks it or scrolls through it
582
+ if (!this.ok()) return; const pan = Math.random() * 1.6 - 0.8;
583
+ if (/^(Bash|Edit|Write|MultiEdit|NotebookEdit)/.test(tool || '')) this.burst(5 + Math.floor(Math.random() * 7), pan); else this.mouse(pan);
584
+ },
585
+ chip(text) { const t = document.createElement('div'); t.className = 'toast'; t.textContent = text; $('#toasts').appendChild(t); setTimeout(() => t.remove(), 4200); },
586
+ wob(freq, depth, base) { const c = AUD.ctx, g = c.createGain(), l = c.createOscillator(), lg = c.createGain(); g.gain.value = base; l.frequency.value = freq; lg.gain.value = depth; l.connect(lg).connect(g.gain); return { g, l }; },
587
+ coffee() {
588
+ const c = AUD.ctx, t = c.currentTime + 0.1, out = AUD.amb, nb = this.noise(), L = [];
589
+ const run = (node, a, b) => { node.start(a); node.stop(b); };
590
+ // grinder: growly motor + beans (0 – 2.4 s)
591
+ const gs = c.createBufferSource(); gs.buffer = nb; gs.loop = true; const bp = c.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 1100; bp.Q.value = 1.6;
592
+ const env = c.createGain(); env.gain.setValueAtTime(0, t); env.gain.linearRampToValueAtTime(0.2, t + 0.2); env.gain.setValueAtTime(0.2, t + 2.1); env.gain.linearRampToValueAtTime(0, t + 2.5);
593
+ const w = this.wob(42, 0.4, 0.6); gs.connect(bp).connect(env).connect(w.g).connect(out); run(gs, t, t + 2.6); run(w.l, t, t + 2.6);
594
+ const m = c.createOscillator(); m.type = 'sawtooth'; m.frequency.setValueAtTime(70, t); m.frequency.linearRampToValueAtTime(96, t + 0.4); const mf = c.createBiquadFilter(); mf.type = 'lowpass'; mf.frequency.value = 260; const mg = c.createGain(); mg.gain.setValueAtTime(0, t); mg.gain.linearRampToValueAtTime(0.1, t + 0.2); mg.gain.setValueAtTime(0.1, t + 2.1); mg.gain.linearRampToValueAtTime(0, t + 2.5); m.connect(mf).connect(mg).connect(out); run(m, t, t + 2.6);
595
+ // pour: gurgling water (3.0 – 6.4 s) with bubbles
596
+ const ps = c.createBufferSource(); ps.buffer = nb; ps.loop = true; const lp = c.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 700;
597
+ const pe = c.createGain(); pe.gain.setValueAtTime(0, t + 3); pe.gain.linearRampToValueAtTime(0.13, t + 3.4); pe.gain.setValueAtTime(0.13, t + 6); pe.gain.linearRampToValueAtTime(0, t + 6.5);
598
+ const pw = this.wob(7, 0.5, 0.6); ps.connect(lp).connect(pe).connect(pw.g).connect(out); run(ps, t + 3, t + 6.6); run(pw.l, t + 3, t + 6.6);
599
+ for (let k = 0; k < 18; k++) { const bt = t + 3.3 + Math.random() * 3, o = c.createOscillator(), bg = c.createGain(), f0 = 260 + Math.random() * 380; o.frequency.setValueAtTime(f0, bt); o.frequency.exponentialRampToValueAtTime(f0 * 1.9, bt + 0.07); bg.gain.setValueAtTime(0.05, bt); bg.gain.exponentialRampToValueAtTime(0.001, bt + 0.09); o.connect(bg).connect(out); run(o, bt, bt + 0.1); }
600
+ // steam hiss (6.3 – 7.8 s)
601
+ const ss = c.createBufferSource(); ss.buffer = nb; ss.loop = true; const hp = c.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 4200; const se = c.createGain(); se.gain.setValueAtTime(0, t + 6.3); se.gain.linearRampToValueAtTime(0.1, t + 6.6); se.gain.linearRampToValueAtTime(0, t + 7.8); ss.connect(hp).connect(se).connect(out); run(ss, t + 6.3, t + 7.9);
602
+ setTimeout(() => this.ok() && (AUD.tone(1760, 0.1, 'sine', 0.07), AUD.tone(1760, 0.1, 'sine', 0.07, 0.2)), 8000);
603
+ this.chip(L10('☕ Quelqu’un se fait un café…', '☕ Someone is making coffee…'));
604
+ },
605
+ misc() {
606
+ const c = AUD.ctx, t = c.currentTime + 0.05, pan = Math.random() * 1.8 - 0.9, o = this.out(pan), r = Math.floor(Math.random() * 5);
607
+ if (r === 0) { const x = c.createOscillator(), f = c.createBiquadFilter(), g = c.createGain(); x.type = 'sawtooth'; x.frequency.setValueAtTime(170, t); x.frequency.linearRampToValueAtTime(260, t + 0.35); f.type = 'bandpass'; f.frequency.value = 600; f.Q.value = 4; g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(0.07, t + 0.08); g.gain.linearRampToValueAtTime(0, t + 0.4); x.connect(f).connect(g).connect(o); x.start(t); x.stop(t + 0.45); } // chair creak
608
+ else if (r === 1) { for (let k = 0; k < 2; k++) { const x = c.createOscillator(), g = c.createGain(), w = this.wob(32, 0.5, 0.5), tt = t + k * 0.42; x.type = 'square'; x.frequency.value = 150; g.gain.setValueAtTime(0, tt); g.gain.linearRampToValueAtTime(0.07, tt + 0.02); g.gain.setValueAtTime(0.07, tt + 0.22); g.gain.linearRampToValueAtTime(0, tt + 0.26); x.connect(g).connect(w.g).connect(o); x.start(tt); x.stop(tt + 0.3); w.l.start(tt); w.l.stop(tt + 0.3); } } // phone buzzing on the desk
609
+ else if (r === 2) { this.hit(t, o, { type: 'lowpass', f: 380, v: 0.22, d: 0.2, thump: 90 }); this.hit(t + 0.5, o, { type: 'lowpass', f: 260, v: 0.12, d: 0.14, thump: 70 }); } // a door, far away
610
+ else if (r === 3) { this.hit(t, o, { type: 'highpass', f: 2500, v: 0.07, d: 0.45 }); } // paper shuffling
611
+ else { this.hit(t, o, { f: 900, q: 8, v: 0.18, d: 0.05, thump: 420 }); this.hit(t + 0.14, o, { f: 760, q: 8, v: 0.18, d: 0.05, thump: 360 }); } // knock-knock on a meeting-room door
612
+ },
613
+ tick() {
614
+ const now = performance.now(), dt = Math.min(500, now - (this.prev || now)); this.prev = now; if (!this.ok()) return;
615
+ this.pt += dt; const working = Object.keys(S.status).filter((id) => S.status[id] === 'working' && !S.gone.has(id)).length;
616
+ for (let i = 0; i < Math.min(3, working); i++) { // background typists, one per busy agent (max 3)
617
+ const y = this.ty[i]; if (this.pt < y.at) continue;
618
+ if (y.n <= 0) { if (Math.random() < 0.55) { y.n = 6 + Math.floor(Math.random() * 20); y.pan = Math.random() * 1.6 - 0.8; } else { y.at = this.pt + 500 + Math.random() * 2500; continue; } }
619
+ y.n--; this.key(y.n === 0, y.pan); y.at = this.pt + (y.n === 0 ? 900 + Math.random() * 2400 : 70 + Math.random() * 90 + (Math.random() < 0.1 ? 220 : 0));
620
+ }
621
+ if (this.pt > this.nextMisc) { this.misc(); this.nextMisc = this.pt + 18000 + Math.random() * 30000; }
622
+ if (this.pt > this.nextCoffee) { this.coffee(); this.nextCoffee = this.pt + 90000 + Math.random() * 90000; }
623
+ },
624
+ start() { if (!this.timer) this.timer = setInterval(() => this.tick(), 60); },
625
+ };
626
+ const L10 = (fr, en) => L(fr, en);
627
+ const STATIONS = [['fip', 'FIP', 'fip'], ['groove', 'FIP Groove', 'fipgroove'], ['jazz', 'FIP Jazz', 'fipjazz'], ['electro', 'FIP Electro', 'fipelectro'], ['reggae', 'FIP Reggae', 'fipreggae'], ['rock', 'FIP Rock', 'fiprock'], ['new', 'FIP Nouveautés', 'fipnouveautes'], ['pop', 'FIP Pop', 'fippop']]
628
+ .map(([id, name, slug]) => ({ id, name, url: `https://icecast.radiofrance.fr/${slug}-midfi.mp3` }));
629
+ const RADIO = { // live web radio through WebAudio (so it can be faded when agents talk)
630
+ el: null, cur: null, err: false,
631
+ sync() {
632
+ if (!AUD.ctx) return;
633
+ if (!this.el) {
634
+ const a = (this.el = new Audio()); a.crossOrigin = 'anonymous'; a.preload = 'none';
635
+ a.addEventListener('error', () => { this.err = !!this.cur; updateHint(); }); a.addEventListener('playing', () => { this.err = false; updateHint(); });
636
+ AUD.ctx.createMediaElementSource(a).connect(AUD.music);
637
+ }
638
+ const want = prefs.music && !AUD.off(), url = (STATIONS.find((s) => s.id === prefs.station) || STATIONS[0]).url;
639
+ if (want) { if (this.cur !== url) { this.cur = url; this.err = false; this.el.src = url; } if (this.el.paused) this.el.play().catch(() => {}); }
640
+ else if (this.cur) { this.cur = null; this.el.pause(); this.el.removeAttribute('src'); this.el.load(); } // stop downloading; it reconnects live next time
641
+ },
642
+ };
643
+ const synth = window.speechSynthesis;
644
+ const estMs = (text) => Math.min(9000, 900 + text.length * 42) / spd();
645
+
646
+ const NOVELTY = /grandma|grandpa|rocko|bad news|bahh|bells|boing|bubbles|cellos|good news|jester|organ|superstar|trinoids|whisper|wobble|zarvox|albert|fred|junior|kathy|ralph|hysterical|deranged|pipe/i;
647
+ const GOOD = /premium|enhanced|natural|siri|neural|améli|amelie|thomas|jacques|audrey|marie|eddy|flo\b|reed|sandy|shelley|samantha|daniel|karen|moira|serena|oliver|google|hortense|julie|paul|denise|henri|eloise|zira|david|hazel|mark|aria|guy/i;
648
+ const voiceScore = (n) => (/premium|enhanced|natural|neural|siri/i.test(n) ? 100 : 0) + (GOOD.test(n) ? 30 : 0) - (NOVELTY.test(n) ? 200 : 0);
649
+ function rankVoices(list) { // list of names → best distinct voices first
650
+ const seen = new Set(), out = [];
651
+ [...list].sort((a, b) => voiceScore(b) - voiceScore(a)).forEach((n) => { const base = n.replace(/\s*\(.*$/, ''); if (voiceScore(n) > -100 && !seen.has(base)) { seen.add(base); out.push(n); } });
652
+ return out.slice(0, 5);
653
+ }
654
+ const TTS = {
655
+ server: false, platform: '', pool: { fr: [], en: [] }, cache: new Map(),
656
+ async init() {
657
+ try { const d = await (await fetch('/api/voices')).json(); this.platform = d.platform || ''; if (!d.available) return; this.server = true;
658
+ for (const l of ['fr', 'en']) this.pool[l] = rankVoices(d.voices.filter((v) => v.locale.startsWith(l + '_')).map((v) => v.name)); } catch {}
659
+ },
660
+ voiceFor(p, fr = FR()) { const pool = this.pool[fr ? 'fr' : 'en']; return pool.length ? (p.id === 'main' ? pool[0] : pool[(p.vi >>> 2) % pool.length]) : null; },
661
+ rate() { return Math.round(Math.max(120, Math.min(330, 178 * (S.live ? 1.12 : +$('#speed').value))) / 10) * 10; },
662
+ buf(text, voice, rate) {
663
+ const key = voice + '|' + rate + '|' + text;
664
+ if (!this.cache.has(key)) {
665
+ this.cache.set(key, fetch(`/api/tts?voice=${encodeURIComponent(voice)}&rate=${rate}&text=${encodeURIComponent(text)}`).then((r) => { if (!r.ok) throw new Error('tts'); return r.arrayBuffer(); }).then((b) => AUD.ctx.decodeAudioData(b)));
666
+ if (this.cache.size > 60) this.cache.delete(this.cache.keys().next().value);
667
+ this.cache.get(key).catch(() => this.cache.delete(key));
668
+ }
669
+ return this.cache.get(key);
670
+ },
671
+ };
672
+ function warmTTS(i) { // synthesize the next few lines ahead of time
673
+ if (!TTS.server || !AUD.ctx || !prefs.voice || prefs.vstyle === 'animal' || prefs.vstyle === 'sims') return;
674
+ let n = 0; for (let k = i; k < S.events.length && n < 4; k++) {
675
+ const e = S.events[k]; if (quiet(e)) continue; n++;
676
+ const p = S.agents[e.agent], v = p && TTS.voiceFor(p, FR() && trMem.has(baseText(e))); if (v) TTS.buf(speakable(spoken(e)), v, TTS.rate()).catch(() => {});
677
+ }
678
+ }
679
+
680
+ // ---- Cartoon voices: the same system voice, given a character by a small WebAudio chain (pitch, vibrato, robot, radio…), or syllable "babble" in the spirit of Animal Crossing.
681
+ const VFX = [{ rate: 1.26 }, { rate: 0.86, low: 7 }, { ring: 75, rate: 1.02 }, { rate: 1.08, vib: [5.5, 0.006] }, { band: [420, 3200], drive: 1 }, { rate: 0.74, vib: [9, 0.003], lp: 2200 }, { rate: 1.16, vib: [7, 0.004] }];
682
+ const fxFor = (p) => (p.id === 'main' ? { rate: 0.95, low: 4 } : p.id === 'user' ? {} : VFX[(p.vi >>> 3) % VFX.length]);
683
+ function fxChain(c, src, fx) { // returns the last node; `stops` are oscillators to stop when the line ends
684
+ const stops = []; let node = src; const link = (n) => { node.connect(n); node = n; };
685
+ if (fx.rate) src.playbackRate.value = fx.rate;
686
+ if (fx.low) { const f = c.createBiquadFilter(); f.type = 'lowshelf'; f.frequency.value = 250; f.gain.value = fx.low; link(f); }
687
+ if (fx.band) { const a = c.createBiquadFilter(), b = c.createBiquadFilter(); a.type = 'highpass'; a.frequency.value = fx.band[0]; b.type = 'lowpass'; b.frequency.value = fx.band[1]; link(a); link(b); }
688
+ if (fx.lp) { const f = c.createBiquadFilter(); f.type = 'lowpass'; f.frequency.value = fx.lp; link(f); }
689
+ if (fx.drive) { const w = c.createWaveShaper(), k = new Float32Array(257); for (let i = 0; i < 257; i++) { const x = (i - 128) / 128; k[i] = Math.tanh(x * 4) * 0.8; } w.curve = k; link(w); }
690
+ if (fx.vib) { const d = c.createDelay(0.05), l = c.createOscillator(), lg = c.createGain(); d.delayTime.value = 0.012; l.frequency.value = fx.vib[0]; lg.gain.value = fx.vib[1]; l.connect(lg).connect(d.delayTime); l.start(); stops.push(l); link(d); }
691
+ if (fx.ring) { const o = c.createOscillator(), rm = c.createGain(), dry = c.createGain(), wet = c.createGain(), mix = c.createGain(); rm.gain.value = 0; o.frequency.value = fx.ring; o.connect(rm.gain); o.start(); stops.push(o);
692
+ dry.gain.value = 0.35; wet.gain.value = 0.9; node.connect(dry); node.connect(rm).connect(wet); dry.connect(mix); wet.connect(mix); node = mix; }
693
+ return { node, stops };
694
+ }
695
+ const FORM = { a: [800, 1200], e: [530, 1840], i: [300, 2300], o: [500, 900], u: [350, 800], y: [320, 2100] }; // vowel formants (F1, F2)
696
+ const VOW = 'aeiouyàâäéèêëîïôöùûü';
697
+ const vowelKey = (ch) => ({ à: 'a', â: 'a', ä: 'a', é: 'e', è: 'e', ê: 'e', ë: 'e', î: 'i', ï: 'i', ô: 'o', ö: 'o', ù: 'u', û: 'u', ü: 'u' }[ch] || ch);
698
+ // Gibberish "speech" instead of words, in the spirit of Animal Crossing (one blip per letter) and Simlish (flowing syllables with a sentence melody).
699
+ // It needs no system voice and no translation, so it sounds the same on every machine and in every language; the words stay on screen.
700
+ async function speakBabble(text, p, gain, token) {
701
+ const c = AUD.ctx, sims = prefs.vstyle === 'sims', clean = speakable(text).toLowerCase(), dur = estMs(text) / 1000, noise = OFFICE.noise();
702
+ const base = p.lead ? 150 : p.id === 'user' ? 190 : 200 + ((p.vi >>> 2) % 9) * 30, an = c.createAnalyser(); an.fftSize = 256;
703
+ const g = c.createGain(); g.gain.value = gain * (sims ? 0.4 : 0.6); an.connect(g); g.connect(AUD.voice);
704
+ const clauses = clean.match(/[^.!?;:]+[.!?;:]*/g) || [clean], nodes = []; let t = c.currentTime + 0.03; const end = t + dur;
705
+ const track = (o) => { AUD.live.add(o); nodes.push(o); };
706
+ const vowelGroups = (w) => w.match(new RegExp(`[^${VOW}]*[${VOW}]+(?:[^${VOW}]+(?![${VOW}]))?`, 'g')) || [w];
707
+ const nSyl = clauses.reduce((n, cl) => n + (cl.match(/[a-zà-ÿ0-9]+/g) || []).reduce((m, w) => m + vowelGroups(w).length, 0) + 1.5, 0) || 1, unit = Math.max(0.08, Math.min(0.2, dur / nSyl));
708
+ const letterStep = Math.max(0.045, Math.min(0.085, dur / Math.max(8, clean.length)));
709
+ const burst = (at, hp, len, v) => { const s = c.createBufferSource(), f = c.createBiquadFilter(), e = c.createGain(); s.buffer = noise; f.type = 'highpass'; f.frequency.value = hp; e.gain.setValueAtTime(v, at); e.gain.exponentialRampToValueAtTime(0.001, at + len); s.connect(f).connect(e).connect(an); s.start(at, Math.random(), len + 0.02); track(s); };
710
+ for (const cl of clauses) {
711
+ if (t >= end) break;
712
+ const q = /\?\s*$/.test(cl), ex = /!\s*$/.test(cl), words = cl.match(/[a-zà-ÿ0-9]+/g) || []; let wi = 0;
713
+ for (const w of words) {
714
+ if (t >= end) break; const prog = wi++ / Math.max(1, words.length), m = (q ? 0.95 + 0.32 * prog : 1.12 - 0.24 * prog) * (ex ? 1.08 : 1); // melody: falls through a statement, climbs through a question
715
+ if (!sims) {
716
+ for (const ch of w) {
717
+ const code = ch.charCodeAt(0), f = base * m * 2 ** (((code % 7) * 2 - 4 + (VOW.includes(ch) ? 3 : 0)) / 12), vw = [...'aeiou'][code % 5];
718
+ const o = c.createOscillator(), bp = c.createBiquadFilter(), env = c.createGain(); o.type = 'triangle'; o.frequency.setValueAtTime(f, t); o.frequency.linearRampToValueAtTime(f * (1 + ((code % 3) - 1) * 0.07), t + letterStep);
719
+ bp.type = 'bandpass'; bp.frequency.value = FORM[vw][0] * 1.3; bp.Q.value = 1.4; env.gain.setValueAtTime(0.0001, t); env.gain.exponentialRampToValueAtTime(1, t + 0.008); env.gain.exponentialRampToValueAtTime(0.0001, t + letterStep * 0.95);
720
+ o.connect(bp).connect(env).connect(an); o.start(t); o.stop(t + letterStep); track(o); t += letterStep;
721
+ }
722
+ t += letterStep * 0.8;
723
+ } else {
724
+ for (const [si, syl] of vowelGroups(w).entries()) {
725
+ if (t >= end) break; const v = syl.match(new RegExp(`[${VOW}]`)), vk = v ? vowelKey(v[0]) : 'e', on = syl[0], [F1, F2] = FORM[vk] || FORM.e, hash = (syl.charCodeAt(0) * 7 + syl.length * 3) % 11;
726
+ const f0 = base * m * (si === 0 ? 1.06 : 1) * (1 + (hash - 5) * 0.012), len = unit * (0.85 + (hash % 3) * 0.12), on0 = VOW.includes(on) ? 0 : /[sfzhxcjv]/.test(on) ? 1 : /[tkpbdgq]/.test(on) ? 2 : 3;
727
+ if (on0 === 1) { burst(t, /[sxzc]/.test(on) ? 4200 : 1800, 0.07, 0.35); t += 0.05; } else if (on0 === 2) { burst(t, 1400, 0.025, 0.5); t += 0.02; }
728
+ const o = c.createOscillator(), b1 = c.createBiquadFilter(), b2 = c.createBiquadFilter(), g2 = c.createGain(), env = c.createGain(), vib = c.createOscillator(), vg = c.createGain();
729
+ o.type = 'sawtooth'; o.frequency.setValueAtTime(f0 * 1.04, t); o.frequency.linearRampToValueAtTime(f0 * 0.97, t + len); vib.frequency.value = 5.5; vg.gain.value = f0 * 0.012; vib.connect(vg).connect(o.frequency);
730
+ b1.type = b2.type = 'bandpass'; b1.Q.value = 7; b2.Q.value = 9; const nasal = on0 === 3 ? 0.55 : 1; b1.frequency.setValueAtTime(F1 * nasal, t); b1.frequency.linearRampToValueAtTime(F1, t + 0.06); b2.frequency.setValueAtTime(F2 * (on0 === 3 ? 0.75 : 1), t); b2.frequency.linearRampToValueAtTime(F2, t + 0.07); g2.gain.value = 0.55;
731
+ env.gain.setValueAtTime(0.0001, t); env.gain.exponentialRampToValueAtTime(1.6, t + 0.02); env.gain.setValueAtTime(1.6, t + len * 0.6); env.gain.exponentialRampToValueAtTime(0.0001, t + len);
732
+ o.connect(b1).connect(env); o.connect(b2).connect(g2).connect(env); env.connect(an); o.start(t); vib.start(t); o.stop(t + len + 0.02); vib.stop(t + len + 0.02); track(o); track(vib); t += len * 0.92;
733
+ }
734
+ t += unit * 0.35;
735
+ }
736
+ }
737
+ t += unit * (sims ? 1.2 : 2.4);
738
+ }
739
+ const data = new Uint8Array(an.fftSize); S.level[p.id] = () => { an.getByteTimeDomainData(data); let s = 0; for (const v of data) { const x = (v - 128) / 128; s += x * x; } return Math.sqrt(s / data.length) * 2.2; };
740
+ await new Promise((res) => setTimeout(res, Math.max(100, (Math.min(t, end) - c.currentTime) * 1000 + 80)));
741
+ nodes.forEach((o) => AUD.live.delete(o)); delete S.level[p.id];
742
+ }
743
+ async function speakServer(text, p, gain, token, fr = FR()) {
744
+ const voice = TTS.voiceFor(p, fr); if (!voice) throw new Error('no voice');
745
+ const buf = await TTS.buf(speakable(text), voice, TTS.rate()); if (token !== S.token) return;
746
+ const c = AUD.ctx, src = c.createBufferSource(); src.buffer = buf;
747
+ const an = c.createAnalyser(); an.fftSize = 256; const g = c.createGain(); g.gain.value = gain; let stops = [];
748
+ if (prefs.vstyle === 'natural') src.connect(an); else { const ch = fxChain(c, src, fxFor(p)); stops = ch.stops; ch.node.connect(an); }
749
+ an.connect(g); g.connect(AUD.voice);
750
+ const data = new Uint8Array(an.fftSize);
751
+ S.level[p.id] = () => { an.getByteTimeDomainData(data); let s = 0; for (const v of data) { const x = (v - 128) / 128; s += x * x; } return Math.sqrt(s / data.length); }; // drives the mouth
752
+ AUD.live.add(src);
753
+ await new Promise((res) => { src.onended = res; setTimeout(res, buf.duration * 1000 + 800); src.start(); });
754
+ AUD.live.delete(src); delete S.level[p.id]; stops.forEach((o) => { try { o.stop(); } catch {} });
755
+ }
756
+ function speakBrowser(text, p, token, fr = FR()) { // fallback where no server voice exists (Windows/Linux)
757
+ return new Promise((resolve) => {
758
+ const vs = synth.getVoices().filter((v) => v.lang.toLowerCase().startsWith(fr ? 'fr' : 'en')); if (!vs.length) return resolve(false);
759
+ const ranked = rankVoices(vs.map((v) => v.name)), voice = vs.find((v) => v.name === (p.id === 'main' ? ranked[0] : ranked[(p.vi >>> 2) % ranked.length])) || vs[0];
760
+ const parts = speakable(text).match(/[^.!?;:]+[.!?;:]*/g) || [text]; synth.cancel(); let i = 0, done = false, started = false;
761
+ const fin = (ok) => { if (!done) { done = true; resolve(ok); } };
762
+ setTimeout(() => { if (!started) { synth.cancel(); fin(false); } }, 2500); // engine never started (e.g. inside a webview)
763
+ setTimeout(() => fin(true), 45000);
764
+ const next = () => {
765
+ if (i >= parts.length || done || token !== S.token) return fin(true);
766
+ const part = parts[i++].trim(); if (!part) return next();
767
+ const u = new SpeechSynthesisUtterance(part); u.lang = fr ? 'fr-FR' : 'en-US'; u.voice = voice; u.pitch = prefs.vstyle === 'natural' ? 0.95 + ((p.vi >>> 5) % 5) * 0.03 : p.id === 'main' ? 0.8 : [1.5, 0.6, 1.2, 1.7, 0.9, 0.5, 1.35][(p.vi >>> 3) % 7]; u.rate = Math.min(1.7, 0.97 * spd());
768
+ u.onstart = () => (started = true); u.onend = () => setTimeout(next, 120); u.onerror = () => fin(started); synth.speak(u);
769
+ };
770
+ next();
771
+ });
772
+ }
773
+ async function speakLine(text, p, primary, token, fr = FR()) {
774
+ if (AUD.off() || !prefs.voice) { if (!AUD.off() && prefs.office) AUD.murmur(0.5 + (p.vi % 5) / 10, estMs(text) / 1000); return sleep(estMs(text)); }
775
+ if (S.hurry) return sleep(estMs(text));
776
+ if ((prefs.vstyle === 'animal' || prefs.vstyle === 'sims') && AUD.ctx) { try { await speakBabble(text, p, primary ? 1 : 0.5, token); return; } catch {} }
777
+ if (TTS.server && AUD.ctx) { try { await speakServer(text, p, primary ? 1 : 0.5, token, fr); return; } catch {} }
778
+ if (primary && synth && !EMBED && (await speakBrowser(text, p, token, fr))) return;
779
+ AUD.murmur(0.5 + (p.vi % 5) / 10, estMs(text) / 1000); return sleep(estMs(text));
780
+ }
781
+
782
+ // =====================================================================
783
+ // rendering
784
+ // =====================================================================
785
+ function addTile(p) {
786
+ const d = document.createElement('div'); d.className = 'tile' + (p.lead ? ' lead' : ''); d.id = 't-' + p.id; d.tabIndex = 0; d.setAttribute('role', 'button'); d.setAttribute('aria-label', p.name + ' — ' + p.role); d.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openDetail(p.id); } }; d.style.display = 'none'; d.onclick = () => openDetail(p.id);
787
+ d.innerHTML = `${avatarSVG(p, undefined, 't' + p.gid + '_')}<div class="bubble"></div><div class="status"></div><div class="prop"></div><div class="thought"></div><div class="ticker"></div><div class="tbadge"></div><div class="tag"><b>${esc(p.name)}</b><span>${esc(p.role)}</span><span class="tk"></span><span class="cx"></span></div>`;
788
+ $('#stage').appendChild(d);
789
+ }
790
+ function buildStage() { $('#stage').innerHTML = ''; Object.values(S.agents).forEach(addTile); }
791
+ const layout = () => {
792
+ const st = $('#stage'), w = st.clientWidth - 28 || 600, nar = $('#center').clientWidth < 480, cols = nar ? (w >= 300 ? 2 : 1) : Math.max(1, Math.floor((w + 12) / 212)), colw = (w - 12 * (cols - 1)) / cols;
793
+ const rh = Math.round(colw * (nar ? 0.66 : 0.74)); st.style.setProperty('--cols', cols); st.style.setProperty('--rh', rh + 'px'); // Spotlight size: the biggest block (k columns × fr rows) that stays close to 3:2 and leaves room beside it for the others.
794
+ const maxRows = Math.max(cols > 1 ? 2 : 1, Math.floor((st.clientHeight * 0.7 + 12) / (rh + 12))); // the stage scrolls, so the spotlight may be a bit taller than the free space
795
+ let k = 1, fr = 1, best = -1;
796
+ for (let kk = 1; kk <= cols; kk++) for (let rr = 1; rr <= Math.min(4, maxRows); rr++) {
797
+ const fw = kk * colw + (kk - 1) * 12, fh = rr * rh + (rr - 1) * 12, ratio = fw / fh, area = fw * fh;
798
+ if ((cols > 2 && kk === cols) || ratio < 1.15 || ratio > 1.95) continue;
799
+ if (area > best) { best = area; k = kk; fr = rr; }
800
+ }
801
+ if (nar) { k = cols; fr = cols > 1 ? 2 : 1; } // side panel: small tiles, a banner-sized spotlight
802
+ else if (best < 0) { k = Math.min(2, cols); fr = Math.min(2, maxRows); }
803
+ st.style.setProperty('--fcols', k); st.style.setProperty('--fr', fr); st.classList.toggle('one', cols === 1);
804
+ document.body.classList.toggle('narrow', $('#center').clientWidth < 480); drawGantt();
805
+ };
806
+ new ResizeObserver(layout).observe($('#center'));
807
+
808
+ function renderLog(full) {
809
+ const log = $('#log'), from = S.live ? Math.max(0, S.events.length - 300) : 0;
810
+ if (full || S.logTo < from || S.logTo > S.events.length) { log.innerHTML = ''; S.logTo = from; }
811
+ const html = [];
812
+ for (let i = S.logTo; i < S.events.length; i++) {
813
+ if (S.events[i].kind === 'results' || S.events[i].kind === 'usage' || S.events[i].kind === 'finish' || S.events[i].kind === 'compact') continue;
814
+ const e = S.events[i], arrow = e.to ? ` <span class="arrow">→</span> <span class="who">${esc(nameOf(e.to))}</span>` : '';
815
+ const body = e.kind === 'tools' ? e.tools.map((t) => t.name).join(', ') : spoken(e), k = { order: L('tâche', 'task'), report: L('rapport', 'report'), say: '', user: L('brief', 'brief'), tools: L('outils', 'tools') }[e.kind];
816
+ html.push(`<div class="row ${e.kind}" data-i="${i}"><span class="who">${esc(nameOf(e.agent))}</span>${arrow} <span class="k">${k}</span><div class="txt">${esc(body)}</div></div>`);
817
+ }
818
+ log.insertAdjacentHTML('beforeend', html.join('')); S.logTo = S.events.length;
819
+ if (S.live) while (log.children.length > 320) log.firstChild.remove();
820
+ filterLog();
821
+ }
822
+ function filterLog() { const q = $('#logQ').value.trim().toLowerCase(); for (const r of $('#log').children) r.style.display = !q || r.textContent.toLowerCase().includes(q) ? '' : 'none'; }
823
+ $('#logQ').oninput = filterLog;
824
+ $('#log').onclick = (ev) => { const r = ev.target.closest('.row'); if (r && !S.live) seekTo(+r.dataset.i); };
825
+ function paintStatus() {
826
+ for (const p of Object.values(S.agents)) {
827
+ const t = $('#t-' + p.id); if (!t) continue; const s = S.status[p.id];
828
+ t.style.display = p.id === 'main' || p.id === 'user' || (s && (!S.gone.has(p.id) || S.leaving.has(p.id))) ? '' : 'none';
829
+ }
830
+ paintStates();
831
+ const prev = $('.row.now'); if (prev) prev.classList.remove('now');
832
+ const now = $(`.row[data-i="${S.idx}"]`); if (now) { now.classList.add('now'); if (document.body.classList.contains('log')) now.scrollIntoView({ block: 'nearest' }); }
833
+ later('detail', renderDetail); later('tree', renderTree); later('meet', renderMeet, 400); drawGantt();
834
+ $('#seek').value = S.idx; $('#pos').textContent = `${Math.min(S.idx + 1, S.events.length)} / ${S.events.length}`;
835
+ }
836
+ const applyStatus = (ev) => {
837
+ if (ev.kind === 'order') { (S.waitOn[ev.agent] = S.waitOn[ev.agent] || new Set()).add(ev.to); S.orderTs[ev.to] = ev.ts || 0; S.status[ev.to] = 'working'; delete S.doneAt[ev.to]; S.gone.delete(ev.to); S.leaving.delete(ev.to); if (!ev.bg && ev.toolId) S.run[ev.agent] = { id: ev.toolId, ts: ev.ts, start: performance.now(), name: 'Agent' }; const t = $('#t-' + ev.to); if (t) t.classList.remove('vanishing'); }
838
+ if (ev.kind === 'finish') { if (S.waitOn[ev.to]) S.waitOn[ev.to].delete(ev.agent); if (S.status[ev.agent] !== 'done') { S.status[ev.agent] = 'done'; S.doneAt[ev.agent] = S.clock; } delete S.run[ev.agent]; const lr = S.run[ev.to]; if (lr && /^(Task|Agent)$/.test(lr.name) && !(S.waitOn[ev.to] && S.waitOn[ev.to].size)) delete S.run[ev.to]; }
839
+ if (ev.kind === 'report') { if (S.waitOn[ev.to]) S.waitOn[ev.to].delete(ev.agent); S.status[ev.agent] = 'done'; S.doneAt[ev.agent] = S.clock; delete S.run[ev.agent]; }
840
+ };
841
+ function recomputeStatus(upTo) {
842
+ S.term = {}; S.todos = {}; S.run = {}; S.act = {}; S.usage = {}; S.errs = {}; S.lastAct = {}; S.waitOn = {}; S.orderTs = {}; S.prevState = {}; S.lastTool = {}; S.mainBusyAt = 0; S.ctx = {}; S.model = {}; S.compacts = {}; S.ctxWarned = {}; S.rep = {}; S.loops = {}; S.recomputing = true; document.querySelectorAll('.tile').forEach((t) => { clearTimeout(t._byeT); t.classList.remove('bye'); }); hidePresent();
843
+ S.status = {}; S.tc = {}; S.doneAt = {}; S.gone.clear(); S.leaving.clear(); document.querySelectorAll('.tile.vanishing').forEach((t) => t.classList.remove('vanishing')); const doneIdx = {}; document.querySelectorAll('.ticker').forEach((x) => { x.classList.remove('on'); x.innerHTML = ''; });
844
+ for (let i = 0; i < upTo; i++) {
845
+ const e = S.events[i]; applyStatus(e); tally(e); if (e.kind === 'tools') S.tc[e.agent] = (S.tc[e.agent] || 0) + e.tools.length;
846
+ if (i >= upTo - 160) { if (e.kind === 'tools') e.tools.forEach((t) => pushTerm(e.agent, t)); else if (e.kind === 'results') applyResults(e, true); }
847
+ if (e.kind === 'report' || e.kind === 'finish') doneIdx[e.agent] = i; if (e.kind === 'order') delete doneIdx[e.to];
848
+ }
849
+ S.recomputing = false; later('tok', renderTokens, 50); later('badges', renderBadges, 50);
850
+ const now = S.events[Math.max(0, upTo - 1)]; // agents that finished long before this point are already gone
851
+ if (prefs.vanish && now) for (const id in doneIdx) if (now.t - S.events[doneIdx[id]].t >= VANISH_MS) S.gone.add(id);
852
+ }
853
+ function syncUI() {
854
+ $('#bPlay').textContent = S.playing ? '⏸' : '▶';
855
+ const pill = $('#pill'); pill.className = '';
856
+ if (S.offline) { pill.textContent = L('HORS LIGNE', 'OFFLINE'); }
857
+ else if (!S.live) { pill.textContent = 'REPLAY'; } else if (S.locked) { pill.textContent = L('PRÊT', 'READY'); } else if (!S.playing) { pill.textContent = L('PAUSE', 'PAUSED'); } else if (S.idle) { pill.textContent = L('EN ATTENTE', 'WAITING'); pill.className = 'wait'; } else { pill.textContent = 'LIVE'; pill.className = 'live'; }
858
+ document.body.classList.toggle('replay', !S.live);
859
+ }
860
+
861
+ // ---------- tool ticker, shared screen, flying messages ----------
862
+ // ---------- what each agent is doing: tool calls with their real outputs ----------
863
+ S.term = {}; S.todos = {}; S.run = {}; S.act = {}; S.usage = {}; S.errs = {}; S.lastAct = {}; S.waitOn = {}; S.orderTs = {}; S.prevState = {}; S.ctx = {}; S.model = {}; S.compacts = {}; S.ctxWarned = {}; S.rep = {}; S.loops = {}; S.present = null; S.camFor = null; let presentTimer = null;
864
+ const later = (k, fn, ms = 160) => { later.p = later.p || {}; if (later.p[k]) return; later.p[k] = setTimeout(() => { later.p[k] = 0; fn(); }, ms); }; // coalesce bursts of repaints
865
+ const fmtTok = (n) => (n < 1000 ? String(n) : n < 1e6 ? (n / 1000).toFixed(n < 1e4 ? 1 : 0) + 'k' : (n / 1e6).toFixed(n < 1e7 ? 2 : 1) + 'M');
866
+ const tokTotal = (u) => (u ? u.i + u.o + u.w : 0); // headline = input + output + cache writes; cache reads are cheap and shown separately
867
+ // A loop is the same call repeated 3 times in a row, or 4 failures in a row. Polling and screenshot tools are legitimate repeaters and are ignored.
868
+ const LOOP_SKIP = /^(Monitor|TodoWrite|ToolSearch|Skill|Read|mcp__Claude_Browser__(computer|wait|screenshot)|mcp__terminal__read_terminal)$/, POLLY = /\b(sleep|watch|tail -f|gh run|status|poll)\b/;
869
+ function loopStep(R, ev) { // R: per-agent repetition state; returns a detection {kind,n,tool} or null
870
+ const a = ev.agent, st = (R[a] = R[a] || { key: '', n: 0, e: 0 });
871
+ if (ev.kind === 'tools') { for (const t of ev.tools) { const key = t.name + '|' + (t.d ? JSON.stringify(t.d) : (t.arg || '')); // the whole call, not just the file name: three different edits of one file are not a loop
872
+ if (LOOP_SKIP.test(t.name) || (t.name === 'Bash' && POLLY.test(t.arg || ''))) { st.key = ''; st.n = 0; continue; } if (key === st.key) st.n++; else { st.key = key; st.n = 1; } if (st.n >= 3) return { kind: 'calls', n: st.n, tool: t.name }; } }
873
+ else if (ev.kind === 'results') { if (ev.items.some((it) => it.err)) st.e++; else st.e = 0; if (st.e >= 4) return { kind: 'errors', n: st.e }; } // one batch of failures counts once
874
+ return null;
875
+ }
876
+ const WIN1M = /(opus-4-[78]|opus-5|sonnet-5|fable-5)/;
877
+ function ctxWin(model, ctx) { const base = WIN1M.test(model || '') ? 1e6 : 2e5; return ctx > base ? 1e6 : base; } // the transcript does not say which window is in use: infer it, a bigger observed context proves the 1M window
878
+ function tally(ev) { // numbers that pile up over a session: token usage, errors, last sign of life
879
+ if (ev.kind === 'compact') { S.compacts[ev.agent] = (S.compacts[ev.agent] || 0) + 1; delete S.ctxWarned[ev.agent]; }
880
+ const det = loopStep(S.rep, ev); if (det) { S.loops[ev.agent] = det; later('badges', renderBadges, 300); } else if (S.loops[ev.agent] && (ev.kind === 'results' ? (S.loops[ev.agent].kind === 'errors' && ev.items.every((it) => !it.err)) : (ev.kind === 'tools' && S.rep[ev.agent].n < 2))) { delete S.loops[ev.agent]; later('badges', renderBadges, 300); }
881
+ if (ev.kind === 'usage') { const c = ev.u.i + ev.u.r + ev.u.w; if (c > 0 && ev.model !== '<synthetic>') { S.ctx[ev.agent] = c; S.model[ev.agent] = ev.model; later('tok', renderCtx, 300); } const u = (S.usage[ev.agent] = S.usage[ev.agent] || { i: 0, o: 0, r: 0, w: 0 }); u.i += ev.u.i; u.o += ev.u.o; u.r += ev.u.r; u.w += ev.u.w; later('tok', renderTokens, 300); }
882
+ else if (ev.kind === 'results') ev.items.forEach((it) => { if (it.err) { S.errs[ev.agent] = (S.errs[ev.agent] || 0) + 1; later('badges', renderBadges, 300); oops(ev.agent); } });
883
+ else if (ev.kind === 'tools' || ev.kind === 'say') S.lastAct[ev.agent] = ev.ts;
884
+ }
885
+ const tokTip = (u) => `${L('Jetons enregistrés par Claude Code (comptés une fois par message, ce n’est pas une facture)', 'Tokens recorded by Claude Code (counted once per message, not a bill)')}\n${L('entrée', 'input')} ${fmtTok(u.i)} · ${L('sortie', 'output')} ${fmtTok(u.o)} · ${L('écriture cache', 'cache write')} ${fmtTok(u.w)} · ${L('lecture cache', 'cache read')} ${fmtTok(u.r)}`;
886
+ const ctxInfo = (id) => { const c = S.ctx[id]; if (!c) return null; const w = ctxWin(S.model[id], c), p = Math.round((c / w) * 100); return { c, w, p, lvl: p >= 88 ? 'hi' : p >= 70 ? 'mid' : 'lo' }; };
887
+ function renderCtx() { // "context N% full": per agent chip (from 50%), a header chip for the lead, and one warning before auto-compaction
888
+ for (const id in S.agents) { const t = $('#t-' + id); if (!t) continue; const el = t.querySelector('.cx'), x = ctxInfo(id);
889
+ if (el) { if (x && x.p >= 50) { el.textContent = `ctx ${x.p}%`; el.className = 'cx ' + x.lvl; el.title = `${L('Contexte', 'Context')}: ${fmtTok(x.c)} / ${fmtTok(x.w)}`; } else { el.textContent = ''; el.className = 'cx'; } }
890
+ if (x && x.p >= 85 && !S.ctxWarned[id] && S.live && S.playing) { S.ctxWarned[id] = 1; toast(`${nameOf(id)}: ${L('contexte plein à ', 'context ')}${x.p}%${L(' — compactage proche', ' full — compaction soon')} 🗜`); if (prefs.notify) AUD.alert(); }
891
+ else if (x && x.p < 60) delete S.ctxWarned[id]; }
892
+ const lead = ctxInfo('main'), cp = $('#ctxp'); if (lead) { cp.style.display = ''; cp.textContent = `ctx ${lead.p}%`; cp.className = 'ctxp ' + lead.lvl; cp.title = `${L('Contexte du chef', 'Lead context')}: ${fmtTok(lead.c)} / ${fmtTok(lead.w)}${S.compacts.main ? ' · ' + S.compacts.main + '×🗜' : ''}`; } else cp.style.display = 'none';
893
+ }
894
+ function renderTokens() {
895
+ renderCtx();
896
+ const all = { i: 0, o: 0, r: 0, w: 0 }; for (const id in S.usage) for (const k in all) all[k] += S.usage[id][k];
897
+ const b = $('#tok'), tot = tokTotal(all); b.style.display = tot ? '' : 'none';
898
+ if (tot) { b.textContent = `⚡ ${fmtTok(tot)} ${L('jetons', 'tokens')}`; b.title = tokTip(all); }
899
+ for (const id in S.agents) { const t = $('#t-' + id); if (!t) continue; const u = S.usage[id], el = t.querySelector('.tk'); el.textContent = u && tokTotal(u) ? `⚡${fmtTok(tokTotal(u))}` : ''; if (u) t.title = tokTip(u); }
900
+ }
901
+ // One boring signal per tile: working · waiting on another agent · may need you · stalled · done. Stalled hand-offs are what really cost time.
902
+ // file edits are instant, so one that stays pending is almost surely an approval prompt; a Bash command may legitimately run long (⏳ badge instead)
903
+ const NEEDS_OK = /^(Edit|Write|MultiEdit|NotebookEdit|AskUserQuestion|ExitPlanMode)/, ASKS = /^(AskUserQuestion|ExitPlanMode)/;
904
+ const atTail = () => S.idx >= S.events.length - 3; // only trust wall-clock ages when playback has caught up with the live end
905
+ function agentState(id, now) {
906
+ let s = S.status[id]; const r = S.run[id], w = S.waitOn[id], la = S.lastAct[id];
907
+ if (id === 'main' && !s) { if (r || (w && w.size) || (S.live && la && now - la < 25000)) s = 'working'; else if (S.live && la) return { k: 'done', txt: '✓ ' + L('à vous', 'your turn') }; }
908
+ if (s === 'done') return { k: 'done', txt: '✓ ' + L('terminé', 'done') };
909
+ if (!s) return { k: 'idle', txt: '' };
910
+ if (S.live && atTail() && r && r.ts && r.name && NEEDS_OK.test(r.name)) { const age = now - r.ts; if (age > (ASKS.test(r.name) ? 4000 : 15000)) return { k: 'blocked', txt: '✋ ' + L('attend vous ? ', 'needs you? ') + fmtShort(age) }; }
911
+ if (w && w.size) { // really waiting only while the lead has a sub-agent call pending, or while those agents are still active (background agents never block the lead)
912
+ const pendingSub = r && /^(Task|Agent)$/.test(r.name), open = [...w].filter((c) => S.status[c] !== 'done' && (pendingSub || !S.live || now - Math.max(S.lastAct[c] || 0, S.orderTs[c] || 0) < 120000));
913
+ if (open.length) {
914
+ const names = open.map(nameOf); let stale = S.live && pendingSub; for (const c of open) if (!(now - Math.max(S.lastAct[c] || 0, S.orderTs[c] || 0) > 120000)) stale = false;
915
+ return { k: stale ? 'stalled' : 'waiting', txt: (stale ? '⚠ ' + L('bloqué · ', 'stalled · ') : '🔗 ') + L('attend ', 'waiting on ') + names.slice(0, 2).join(', ') + (names.length > 2 ? ' +' + (names.length - 2) : '') };
916
+ }
917
+ }
918
+ return { k: 'working', txt: '⚙ ' + (S.act[id] || L('au travail', 'working')) };
919
+ }
920
+ const STATE_CLASSES = ['working', 'waiting', 'blocked', 'stalled', 'done'];
921
+ function paintStates() {
922
+ const now = Date.now(), n = { working: 0, waiting: 0, blocked: 0, stalled: 0, done: 0 }, ros = [];
923
+ for (const p of Object.values(S.agents)) {
924
+ if (p.id === 'user') continue; const t = $('#t-' + p.id); if (!t) continue;
925
+ const st = agentState(p.id, now), shown = t.style.display !== 'none'; if (shown && n[st.k] !== undefined && !S.gone.has(p.id)) n[st.k]++;
926
+ STATE_CLASSES.forEach((c) => t.classList.toggle(c, st.k === c));
927
+ const el = t.querySelector('.status'); if (el.textContent !== st.txt) el.textContent = st.txt; el.className = 'status ' + st.k;
928
+ const pr = t.querySelector('.prop'), want = { blocked: '✋', stalled: '😰', waiting: '⌛' }[st.k] || '', was = S.prevState[p.id]; // a raised hand, a sweat drop, an hourglass: the call metaphor for "needs you / stuck / waiting"
929
+ if (st.k === 'done' && was && was !== 'done' && was !== 'idle') { pr.textContent = '🎉'; pr.className = 'prop cheer'; setTimeout(() => { if (pr.classList.contains('cheer')) { pr.textContent = ''; pr.className = 'prop'; } }, 1200); }
930
+ else if (!pr.classList.contains('cheer')) { if (pr.textContent !== want) pr.textContent = want; pr.className = 'prop ' + (want ? st.k : ''); }
931
+ if (shown && !S.gone.has(p.id)) ros.push({ p, st });
932
+ if (p.id === 'main' && st.k === 'done' && was && was !== 'done' && was !== 'idle' && S.live && S.playing && Date.now() - (S.mainBusyAt || 0) > 45000) confetti();
933
+ if (p.id === 'main') { if (st.k === 'working' || st.k === 'waiting') S.mainBusyAt = S.mainBusyAt || Date.now(); else if (st.k === 'done') S.mainBusyAt = 0; }
934
+ S.prevState[p.id] = st.k; const tip = `${p.name} — ${p.role}${st.txt ? '\n' + st.txt : ''}${S.lastTool[p.id] ? '\n' + S.lastTool[p.id] : ''}`; if (t.title !== tip) t.title = tip;
935
+ }
936
+ const b = $('#board'), parts = [['working', '⚙', L('au travail', 'working')], ['waiting', '🔗', L('en attente', 'waiting')], ['blocked', '✋', L('vous attendent', 'need you')], ['stalled', '⚠', L('bloqués', 'stalled')], ['done', '✓', L('terminés', 'done')]]
937
+ .filter(([k]) => n[k]).map(([k, i, l]) => `<span class="${k}" title="${n[k]} ${l}">${i} ${n[k]}<em> ${l}</em></span>`).join('');
938
+ if (b.innerHTML !== parts) b.innerHTML = parts; S.nBlocked = n.blocked + n.stalled; paintRoster(ros); return n;
939
+ }
940
+ const RORDER = { blocked: 0, stalled: 1, working: 2, waiting: 3, idle: 4, done: 5 };
941
+ function paintRoster(ros) { // a fixed list beside the call: who is doing what, urgent first, readable even with dozens of agents
942
+ const box = $('#roster'); if (!box || !document.body.classList.contains('rost')) return;
943
+ ros.sort((a, b) => (a.p.id === 'main' ? -1 : b.p.id === 'main' ? 1 : (RORDER[a.st.k] - RORDER[b.st.k]) || (S.lastAct[b.p.id] || 0) - (S.lastAct[a.p.id] || 0)));
944
+ const rows = ros.slice(0, 60).map(({ p, st }) => `<div class="rr k-${st.k}${S.focus === p.id ? ' sel' : ''}${S.speakers.has(p.id) ? ' speaking' : ''}" data-id="${esc(p.id)}" tabindex="0" role="button" aria-label="${esc(p.name)}, ${esc(st.txt || '')}"><div class="n"><span class="dot" style="background:${p.shirt}"></span><b>${esc(p.name)}</b><span class="ro">${esc(p.role)}</span></div><div class="st">${esc(st.txt || L('en veille', 'standing by'))}${S.loops[p.id] ? ' 🔁' : ''}</div></div>`).join('');
945
+ const html = `<h3>${L('Agents', 'Agents')} · ${ros.length}</h3>${rows}${ros.length > 60 ? `<div class="ro" style="padding:4px 8px">+${ros.length - 60}</div>` : ''}`;
946
+ if (box.dataset.h !== html) { box.dataset.h = html; box.innerHTML = html; }
947
+ }
948
+ $('#roster').onclick = (e) => { const r = e.target.closest('.rr'); if (!r) return; S.focusLock = Date.now() + 25000; setFocus(r.dataset.id); paintStates(); };
949
+ $('#roster').onkeydown = (e) => { const r = e.target.closest('.rr'); if (r && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); S.focusLock = Date.now() + 25000; setFocus(r.dataset.id); paintStates(); } else if (r && e.key === 'i') openDetail(r.dataset.id); };
950
+ $('#roster').ondblclick = (e) => { const r = e.target.closest('.rr'); if (r) openDetail(r.dataset.id); };
951
+ function renderBadges() { // per tile: failed tool calls, a command that has been running a long time, an agent that went quiet
952
+ const now = Date.now();
953
+ for (const id in S.agents) {
954
+ const t = $('#t-' + id); if (!t) continue; const b = t.querySelector('.tbadge'), parts = []; let slow = false, quiet = false;
955
+ if (S.errs[id]) parts.push(`<span class="e">✗ ${S.errs[id]}</span>`);
956
+ if (S.loops[id]) parts.push(`<span class="l" title="${esc(S.loops[id].kind === 'errors' ? L('Échecs répétés', 'Repeated failures') : L('Même appel répété', 'Same call repeated'))}">🔁 ×${S.loops[id].n}</span>`);
957
+ if (S.live && S.status[id] === 'working') {
958
+ const r = S.run[id];
959
+ if (r && r.ts && atTail() && now - r.ts > 60000) { slow = true; parts.push(`<span class="s">⏳ ${fmtShort(now - r.ts)}</span>`); }
960
+ else if (!r && S.lastAct[id] && now - S.lastAct[id] > 180000) { quiet = true; parts.push(`<span class="q">💤 ${fmtShort(now - S.lastAct[id])}</span>`); }
961
+ }
962
+ t.classList.toggle('slow', slow); t.classList.toggle('quiet', quiet); const h = parts.join(''); if (b.innerHTML !== h) b.innerHTML = h;
963
+ }
964
+ }
965
+ const fmtShort = (ms) => { const s = Math.round(ms / 1000); return s < 90 ? `${s}s` : `${Math.round(s / 60)} min`; };
966
+ function askNotify() { try { if (!EMBED && 'Notification' in window && Notification.permission === 'default') Notification.requestPermission(); } catch {} AUD.init(); AUD.alert(); }
967
+ function watchDone(n) { // live: the team was busy for a while and has now been quiet for a few seconds → tell the person
968
+ if (!S.live || !S.playing) { S.busySince = S.idleAt = 0; return; }
969
+ const now = Date.now(); if (n > 0) { S.busySince = S.busySince || now; S.idleAt = 0; return; } if (!S.busySince) return;
970
+ S.idleAt = S.idleAt || now; if (now - S.idleAt < 6000) return;
971
+ const long = S.idleAt - S.busySince >= 20000; S.busySince = S.idleAt = 0; if (!long || !prefs.notify) return;
972
+ const msg = L('Les agents ont fini 🎉', 'Your agents are done 🎉'); S.doneUntil = now + 60000; AUD.alert();
973
+ if (EMBED) window.parent.postMessage({ agentStandup: 'notify', text: msg }, '*');
974
+ else { try { if (document.hidden && 'Notification' in window && Notification.permission === 'granted') new Notification('Agent Standup', { body: msg }); } catch {} }
975
+ }
976
+ const FAV0 = (document.querySelector('link[rel="icon"]') || {}).href;
977
+ function setFav(color) {
978
+ const l = document.querySelector('link[rel="icon"]'); if (!l || setFav.cur === color) return; setFav.cur = color;
979
+ if (!color) { l.href = FAV0; return; }
980
+ const c = document.createElement('canvas'); c.width = c.height = 32; const g = c.getContext('2d'); g.font = '26px serif'; g.fillText('🎥', 0, 26); g.fillStyle = color; g.beginPath(); g.arc(24, 8, 8, 0, 7); g.fill(); g.strokeStyle = '#12131a'; g.lineWidth = 2; g.stroke(); l.href = c.toDataURL();
981
+ }
982
+ function updateTitle() {
983
+ paintStates(); if (S.live && S.nBlocked && !S.wasBlocked && prefs.notify) { AUD.alert(); const msg = L('Un agent attend peut-être votre feu vert ✋', 'An agent may be waiting for you ✋'); if (EMBED) window.parent.postMessage({ agentStandup: 'notify', text: msg }, '*'); else { try { if (document.hidden && 'Notification' in window && Notification.permission === 'granted') new Notification('Agent Standup', { body: msg }); } catch {} } } S.wasBlocked = S.nBlocked > 0;
984
+ let n = 0; for (const id in S.status) if (S.status[id] === 'working' && !S.gone.has(id)) n++;
985
+ watchDone(n); setFav(S.live && S.nBlocked ? '#ff4d4d' : null);
986
+ document.title = S.live && S.nBlocked ? `✋ ${S.nBlocked} ${L('à voir', 'need you')} · Agent Standup` : S.doneUntil > Date.now() && !n ? '✓ ' + L('Terminé', 'Done') + ' · Agent Standup' : S.live && S.playing && n ? `● ${n} ${L('au travail', 'working')} · Agent Standup` : 'Agent Standup';
987
+ }
988
+ setInterval(() => { if (!S.agents || !S.live) return; renderBadges(); updateTitle(); if (document.body.classList.contains('tree')) renderTree(); }, 2000);
989
+ const base = (f) => String(f || '').split('/').pop();
990
+ const fmtDur = (ms) => (ms < 950 ? `${Math.max(1, Math.round(ms))} ms` : ms < 60000 ? `${(ms / 1000).toFixed(1)} s` : `${Math.floor(ms / 60000)} min ${Math.round((ms % 60000) / 1000)} s`);
991
+ const quiet = (e) => e.kind === 'tools' || e.kind === 'results' || e.kind === 'usage' || e.kind === 'finish' || e.kind === 'compact';
992
+ function activityLabel(t) { // the short "what is on their screen" tag
993
+ const d = t.d || {}, f = base(d.file || t.arg);
994
+ switch (d.kind) {
995
+ case 'read': return L('Lit ', 'Reading ') + f;
996
+ case 'edit': return L('Modifie ', 'Editing ') + f;
997
+ case 'write': return L('Crée ', 'Writing ') + f;
998
+ case 'cmd': { const n = (d.note || '').trim(); if (n) return n.slice(0, 36); return L('Exécute ', 'Running ') + d.text.trim().split(/\s+/).slice(0, 2).join(' ').slice(0, 26); }
999
+ case 'grep': return L('Cherche « ', 'Searching “') + String(d.pattern || '').slice(0, 22) + L(' »', '”');
1000
+ case 'glob': return L('Liste ', 'Listing ') + String(d.pattern || '').slice(0, 24);
1001
+ case 'todo': return L('Met à jour sa todo', 'Updating todo list');
1002
+ case 'web': return d.url ? L('Consulte ', 'Fetching ') + d.url.replace(/^https?:\/\//, '').slice(0, 26) : L('Recherche sur le web', 'Web search');
1003
+ case 'text': { const w = d.what; if (w === 'question') return L('Pose une question', 'Asking a question'); if (w === 'skill') return L('Utilise ', 'Using skill ') + (d.v || ''); if (w === 'mcp') return d.v || t.name; if (w === 'toolsearch') return L('Cherche un outil', 'Looking for a tool'); return t.name; }
1004
+ default: return t.name;
1005
+ }
1006
+ }
1007
+ function pushTerm(id, tool) {
1008
+ const d = tool.d || {}, buf = (S.term[id] = S.term[id] || []);
1009
+ buf.push({ k: d.kind === 'text' ? 'tool' : d.kind, id: tool.id, ts: tool.ts, tool, out: null, err: false, end: null });
1010
+ if (d.kind === 'todo') S.todos[id] = d.items;
1011
+ if (buf.length > 80) buf.shift();
1012
+ S.act[id] = activityLabel(tool);
1013
+ }
1014
+ function applyResults(ev, silent) { // the output of a tool call arrives
1015
+ const buf = S.term[ev.agent] || []; let last = null;
1016
+ for (const it of ev.items) for (let i = buf.length - 1; i >= 0; i--) if (buf[i].id === it.id) { Object.assign(buf[i], { out: it.out, err: it.err, end: ev.ts }); last = buf[i]; break; }
1017
+ if (S.run[ev.agent] && ev.items.some((it) => it.id === S.run[ev.agent].id)) delete S.run[ev.agent];
1018
+ if (silent || !last) return;
1019
+ setTicker(ev.agent, last); if (S.present === ev.agent) { renderPresent(); armPresent(); }
1020
+ }
1021
+ function setTicker(id, e) { // the strip on the tile: spinner while running, then ✓/✗ and the real duration
1022
+ const t = $('#t-' + id); if (!t) return; const tk = t.querySelector('.ticker'), tool = e.tool, running = e.end == null;
1023
+ tk.innerHTML = `${running ? '<span class="spin"></span>' : ''}<i>${esc(tool.name)}</i> ${esc(tool.arg || '')}${running ? ' <em class="el"></em>' : ` <em class="${e.err ? 'bad' : 'ok'}">${e.err ? '✗' : '✓'} ${fmtDur(e.end - e.ts)}</em>`}`;
1024
+ tk.classList.add('on'); if (running) { tk.classList.remove('flash'); void tk.offsetWidth; tk.classList.add('flash'); }
1025
+ }
1026
+ function tickTools(ev) {
1027
+ const t = $('#t-' + ev.agent); if (!t) return; t.style.display = '';
1028
+ ev.tools.forEach((tool) => pushTerm(ev.agent, tool));
1029
+ const buf = S.term[ev.agent], k = ev.tools[ev.tools.length - 1];
1030
+ S.tc[ev.agent] = (S.tc[ev.agent] || 0) + ev.tools.length; S.lastTool[ev.agent] = k.name + ' ' + (k.arg || '').slice(-38);
1031
+ S.run[ev.agent] = { id: k.id, start: performance.now(), ts: k.ts, name: k.name }; setTicker(ev.agent, buf[buf.length - 1]); showPresent(ev.agent);
1032
+ }
1033
+ setInterval(() => { // live "running…" counters
1034
+ if (!S.playing) return;
1035
+ for (const id in S.run) { const el = performance.now() - S.run[id].start, tag = $('#t-' + id + ' .ticker .el'); if (tag) tag.textContent = el > 1500 ? fmtDur(el) : ''; }
1036
+ if (S.present && S.run[S.present]) { const m = termModel(S.present); $('#pstat').textContent = m.stat; }
1037
+ }, 250);
1038
+
1039
+ // What the shared screen shows for an agent: terminal history with outputs, or an editor (file read / diff).
1040
+ function statOf(e, id) {
1041
+ if (!e) return '';
1042
+ if (e.end == null) { const r = S.run[id], el = r ? performance.now() - r.start : 0; return `⠋ ${e.tool.name} · ${L('en cours', 'running')}${el > 1500 ? ' ' + fmtDur(el) : ''}`; }
1043
+ return `${e.err ? '✗' : '✓'} ${e.tool.name} · ${fmtDur(e.end - e.ts)}`;
1044
+ }
1045
+ function termModel(id) {
1046
+ const buf = S.term[id] || [], last = buf[buf.length - 1], lines = [], more = (n) => ({ c: 'dim', t: `… +${n} ${L('lignes', 'lines')}` });
1047
+ if (last && (last.k === 'edit' || last.k === 'write' || last.k === 'read')) {
1048
+ const d = last.tool.d, rows = (txt, c) => String(txt || '').split('\n').slice(0, 14).map((t) => ({ c, t }));
1049
+ if (last.k === 'read') {
1050
+ if (last.out == null) lines.push({ c: 'run', t: L('Ouverture du fichier…', 'Opening file…') });
1051
+ else { const r = last.out.split('\n'); r.slice(0, 26).forEach((x) => { const m = x.match(/^\s*(\d+)\t(.*)$/); lines.push({ c: 'ctx', n: m ? m[1] : '', t: m ? m[2] : x }); }); if (r.length > 26) lines.push(more(r.length - 26)); }
1052
+ } else {
1053
+ if (last.k === 'edit') lines.push(...rows(d.old, 'del'), ...rows(d.new, 'add')); else lines.push(...rows(d.text, 'add'));
1054
+ if (last.out) lines.push({ c: last.err ? 'err' : 'dim', t: (last.err ? '✗ ' : '✓ ') + last.out.split('\n')[0].slice(0, 140) });
1055
+ }
1056
+ return { view: 'editor', title: `${base(d.file)} — ${last.k === 'read' ? L('lecture', 'read-only') : L('Éditeur', 'Editor')}`, tab: { file: base(d.file), path: (d.file || '').split('/').slice(-4, -1).join(' › ') }, lines, stat: statOf(last, id) };
1057
+ }
1058
+ for (const e of buf.slice(-10)) {
1059
+ const d = e.tool.d || {}, out = (n) => { if (e.out == null) return; const r = e.out.split('\n'); r.slice(0, n).forEach((t) => lines.push({ c: e.err ? 'err' : 'out', t })); if (r.length > n) lines.push(more(r.length - n)); };
1060
+ if (e.k === 'cmd') { lines.push({ c: 'cmd', t: d.text.split('\n').slice(0, 3).join('\n') }); if (d.note) lines.push({ c: 'dim', t: '# ' + d.note }); out(12); if (e.end == null) lines.push({ c: 'run', t: L('en cours…', 'running…') }); }
1061
+ else { lines.push({ c: 'tool', t: `${e.tool.name}(${e.k === 'read' || e.k === 'edit' || e.k === 'write' ? base(d.file) : (e.tool.arg || '').slice(-70)})` }); if (e.k === 'grep' || e.k === 'glob' || e.k === 'web') out(6); if (e.end == null && e.k !== 'todo') lines.push({ c: 'run', t: '…' }); }
1062
+ }
1063
+ return { view: 'term', lines: lines.slice(-60), stat: statOf(last, id) };
1064
+ }
1065
+ const lineHtml = (l, view) => view === 'editor'
1066
+ ? `<div class="ln ${l.c}"><b>${esc(l.n != null ? l.n : l.c === 'add' ? '+' : l.c === 'del' ? '−' : '')}</b>${esc(l.t) || ' '}</div>`
1067
+ : `<div class="tl ${l.c}">${l.c === 'cmd' ? '<span class="ps">❯</span> ' : l.c === 'tool' ? '<span class="dotg">●</span> ' : l.c === 'run' ? '<span class="spin"></span>' : ''}${esc(l.t)}</div>`;
1068
+
1069
+ // ---------- screen sharing: a real video-call presentation (window + presenter camera) ----------
1070
+ function renderPresent() {
1071
+ const id = S.present, p = S.agents[id]; if (!p) return; const m = termModel(id);
1072
+ $('#present .pwin').className = 'pwin ' + (m.view === 'editor' ? 'editor' : 'term');
1073
+ $('#ptitle').textContent = m.view === 'editor' ? m.title : `${p.name} — zsh — 96×24`;
1074
+ const pb = $('#pbody');
1075
+ pb.innerHTML = (m.view === 'editor' ? `<div class="ptab"><span>${esc(m.tab.file)}</span><em>${esc(m.tab.path)}</em></div>` : '') + m.lines.map((l) => lineHtml(l, m.view)).join('') + (m.view === 'term' ? '<div class="tl"><span class="ps">❯</span> <span class="cursor"></span></div>' : '');
1076
+ pb.scrollTop = m.view === 'term' ? pb.scrollHeight : 0; $('#pstat').textContent = m.stat;
1077
+ const td = S.todos[id], el = $('#ptodo'); el.style.display = td && td.length ? '' : 'none'; $('#present').classList.toggle('has-todo', !!(td && td.length));
1078
+ if (td) el.innerHTML = `<h5>${L('Tâches', 'Tasks')} · ${td.filter((x) => x.s === 'completed').length}/${td.length}</h5>` + td.map((x) => `<div class="td ${x.s}"><i>${x.s === 'completed' ? '☑' : x.s === 'in_progress' ? '◐' : '☐'}</i>${esc(x.c)}</div>`).join('');
1079
+ $('#pbadge').textContent = `🖥 ${p.name} ${L('partage son écran', 'is sharing their screen')}`;
1080
+ if (S.camFor !== id) { $('#pcam').innerHTML = avatarSVG(p, undefined, 'c' + p.gid + '_') + `<div class="tag"><b>${esc(p.name)}</b></div>`; S.camFor = id; }
1081
+ $('#pcam').classList.toggle('speaking', S.speakers.has(id));
1082
+ }
1083
+ function armPresent() { // stays up while a command is still running, then ~4 s after the last action
1084
+ clearTimeout(presentTimer); if (S.hold) return;
1085
+ presentTimer = setTimeout(() => { if (S.present && S.run[S.present] && S.playing) armPresent(); else hidePresent(); }, 4200 / Math.max(1, spd() / 1.2));
1086
+ }
1087
+ function showPresent(id) {
1088
+ if (S.boost > 1 || (S.speakers.size && !S.speakers.has(id))) return; // catching up, or someone else has the floor
1089
+ const first = S.present !== id; S.present = id; $('#present').classList.add('on'); document.body.classList.add('presenting'); renderPresent(); if (first) { layout(); AUD.tone(880, .12, 'sine', .05); }
1090
+ armPresent();
1091
+ }
1092
+ function hidePresent() {
1093
+ clearTimeout(presentTimer); if (!S.present) return; S.present = null; $('#present').classList.remove('on'); document.body.classList.remove('presenting'); $('#pcap').textContent = ''; layout();
1094
+ }
1095
+ // the speaker always gets the spotlight (and keeps it until someone else talks)
1096
+ function setFocus(id) {
1097
+ if (S.focus === id) return; const o = $('#t-' + S.focus); if (o) o.classList.remove('focus'); S.focus = id; const t = $('#t-' + id);
1098
+ if (t) { t.classList.add('focus'); $('#stage').scrollTo({ top: 0, behavior: 'smooth' }); }
1099
+ }
1100
+ function fly(fromId, toId, emoji) {
1101
+ const a = $('#t-' + fromId), b = $('#t-' + toId); if (!a || !b || document.body.classList.contains('tree')) return;
1102
+ const r1 = a.getBoundingClientRect(), r2 = b.getBoundingClientRect(); if (!r1.width || !r2.width) return;
1103
+ const x1 = r1.left + r1.width / 2 - 14, y1 = r1.top + r1.height / 2 - 14, x2 = r2.left + r2.width / 2 - 14, y2 = r2.top + r2.height / 2 - 14;
1104
+ const el = document.createElement('div'); el.className = 'fly'; el.textContent = emoji; document.body.appendChild(el);
1105
+ el.animate([{ transform: `translate(${x1}px,${y1}px) scale(.6)`, opacity: 1 }, { transform: `translate(${(x1 + x2) / 2}px,${Math.min(y1, y2) - 60}px) scale(1.6) rotate(-12deg)`, opacity: 1, offset: .5 }, { transform: `translate(${x2}px,${y2}px) scale(.8)`, opacity: .15 }],
1106
+ { duration: 900 / Math.min(2, spd()), easing: 'ease-in-out' }).onfinish = () => el.remove();
1107
+ }
1108
+ function popTile(id) { const t = $('#t-' + id); if (!t) return; t.classList.remove('pop'); void t.offsetWidth; t.classList.add('pop'); }
1109
+
1110
+ // ---------- finished agents leave the call ----------
1111
+ function poof(tile, color) {
1112
+ const r = tile.getBoundingClientRect(); if (!r.width) return; const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
1113
+ const ring = document.createElement('div'); ring.className = 'puff'; Object.assign(ring.style, { width: '40px', height: '40px', border: `3px solid ${color}`, background: 'none' }); document.body.appendChild(ring);
1114
+ ring.animate([{ transform: `translate(${cx - 20}px,${cy - 20}px) scale(.3)`, opacity: .9 }, { transform: `translate(${cx - 20}px,${cy - 20}px) scale(${Math.max(3, r.width / 40)})`, opacity: 0 }], { duration: 800, easing: 'ease-out' }).onfinish = () => ring.remove();
1115
+ for (let i = 0; i < 18; i++) {
1116
+ const d = document.createElement('div'), a = (i / 18) * Math.PI * 2 + Math.random() * .5, dist = 50 + Math.random() * Math.min(110, r.width / 2), sz = 5 + Math.random() * 9;
1117
+ d.className = 'puff'; Object.assign(d.style, { width: sz + 'px', height: sz + 'px', background: i % 3 ? color : '#fff' }); document.body.appendChild(d);
1118
+ d.animate([{ transform: `translate(${cx}px,${cy}px) scale(1)`, opacity: 1 }, { transform: `translate(${cx + Math.cos(a) * dist}px,${cy + Math.sin(a) * dist - 20}px) scale(0)`, opacity: 0 }],
1119
+ { duration: 650 + Math.random() * 450, easing: 'cubic-bezier(.2,.7,.4,1)', delay: 120 }).onfinish = () => d.remove();
1120
+ }
1121
+ }
1122
+ function toast(msg) { const t = document.createElement('div'); t.className = 'toast'; t.textContent = msg; const c = $('#toasts'); c.appendChild(t); while (c.children.length > 3) c.firstChild.remove(); setTimeout(() => t.remove(), 3100); }
1123
+ const BYE = { en: ['Bye, everyone!', 'Off I go. 👋', 'Thanks, see you!', 'My work here is done.', 'Catch you later!', 'Logging off, bye!', 'That’s a wrap!', 'Good luck with the rest!', 'Mic drop. 🎤', 'Leaving the call… ☕'],
1124
+ fr: ['Salut tout le monde !', 'Je file. 👋', 'Merci, à plus !', 'Ma mission est terminée.', 'À tout à l’heure !', 'Je me déconnecte, ciao !', 'C’est dans la boîte !', 'Bon courage pour la suite !', 'Mic drop. 🎤', 'Je quitte l’appel… ☕'] };
1125
+ function byeLine(id) { const P = BYE[FR() ? 'fr' : 'en'], seed = hash(id + String(S.clock | 0)); let l = P[seed % P.length]; if (l === S.lastBye) l = P[(seed + 1) % P.length]; return (S.lastBye = l); }
1126
+ function vanish(id) {
1127
+ S.gone.add(id); const t = $('#t-' + id), p = S.agents[id]; if (!t || !p) return;
1128
+ toast(`${p.name} ${L('a quitté l’appel', 'left the call')} 👋`);
1129
+ if (t.style.display === 'none' || document.body.classList.contains('tree')) return;
1130
+ S.leaving.add(id); const say = prefs.banter && !S.hurry && !S.speakers.has(id), go = () => { t.classList.remove('bye'); if (!S.leaving.has(id)) return; t.classList.add('vanishing'); poof(t, p.shirt); AUD.tone(660, .35, 'sine', .07); AUD.tone(330, .5, 'sine', .06, .12);
1131
+ setTimeout(() => { S.leaving.delete(id); t.classList.remove('vanishing'); if (S.gone.has(id)) t.style.display = 'none'; }, 1250); };
1132
+ if (say) { const b = t.querySelector('.bubble'), line = byeLine(id); b.innerHTML = `<small>👋</small>${esc(line)}`; t.classList.add('bye'); t._byeT = setTimeout(go, 1500); } else go(); // a short goodbye before the poof
1133
+ }
1134
+ setInterval(() => {
1135
+ if (!S.playing) return; S.clock += 500 * spd();
1136
+ if (!prefs.vanish) return;
1137
+ for (const id in S.doneAt) if (S.status[id] === 'done' && !S.gone.has(id) && S.clock - S.doneAt[id] >= VANISH_MS) vanish(id);
1138
+ }, 500);
1139
+
1140
+ // ---------- speaking tiles ----------
1141
+ const mouths = new Map();
1142
+ function startTalk(id, text, label) {
1143
+ const t = $('#t-' + id); if (!t) return;
1144
+ if (S.present && S.present !== id) hidePresent();
1145
+ if (!S.speakers.size && !(S.focusLock > Date.now())) setFocus(id); S.gone.delete(id); if (S.doneAt[id] != null) S.doneAt[id] = S.clock; t.style.display = ''; t.classList.add('speaking'); S.speakers.add(id); S.say[id] = { text, label }; AUD.duck(true);
1146
+ const b = t.querySelector('.bubble'); b.innerHTML = (label ? `<small>${esc(label)}</small>` : '') + esc(text);
1147
+ if (S.present === id) { $('#pcap').textContent = text; $('#pcam').classList.add('speaking'); }
1148
+ const m = t.querySelector('.mouthE'); let open = false; clearInterval(mouths.get(id));
1149
+ mouths.set(id, setInterval(() => {
1150
+ if (!m) { const ct = t.querySelector('.cimg-talk'); if (ct) { const lv0 = S.level[id], op = lv0 ? lv0() > 0.05 : (open = !open); ct.style.display = op ? '' : 'none'; t.querySelector('.cimg').style.display = op ? 'none' : ''; if (S.present === id) { const pc = $('#pcam .cimg-talk'), pi = $('#pcam .cimg'); if (pc && pi) { pc.style.display = op ? '' : 'none'; pi.style.display = op ? 'none' : ''; } } } return; } // custom character: swap between its two frames
1151
+ const pm = S.present === id ? $('#pcam .mouthE') : null, lv = S.level[id]; if (lv) { const v = lv(); const ry = (.9 + Math.min(4.2, v * 28)).toFixed(1), rx = (4.8 - Math.min(1.1, v * 6)).toFixed(1); m.setAttribute('ry', ry); m.setAttribute('rx', rx); if (pm) { pm.setAttribute('ry', ry); pm.setAttribute('rx', rx); } }
1152
+ else { open = !open; m.setAttribute('ry', open ? 4.2 : 1.1); m.setAttribute('rx', open ? 4 : 4.7); if (pm) { pm.setAttribute('ry', open ? 4.2 : 1.1); pm.setAttribute('rx', open ? 4 : 4.7); } }
1153
+ }, 70));
1154
+ if (document.body.classList.contains('tree')) renderTree();
1155
+ }
1156
+
1157
+ function thought(id, em) { const t = $('#t-' + id); if (!t || t.style.display === 'none') return; const el = t.querySelector('.thought'); el.textContent = em; el.classList.remove('go'); void el.offsetWidth; el.classList.add('go'); }
1158
+ setInterval(() => { // now and then a busy agent has a little thought; never while someone is talking
1159
+ if (!prefs.banter || !S.playing || S.speakers.size || document.hidden) return; const busy = Object.keys(S.agents).filter((id) => id !== 'user' && agentState(id, Date.now()).k === 'working' && $('#t-' + id) && $('#t-' + id).style.display !== 'none');
1160
+ if (busy.length && Math.random() < 0.5) { const id = busy[Math.floor(Math.random() * busy.length)], tl = S.lastTool[id] || ''; thought(id, /^Bash/.test(tl) ? '⌨️' : /^(Grep|Glob|Read)/.test(tl) ? '🔍' : ['💭', '🎧', '🧠', '☕', '🎵'][Math.floor(Math.random() * 5)]); }
1161
+ }, 7000);
1162
+ function oops(id) { if (!prefs.banter || !S.playing || S.recomputing) return; const t = $('#t-' + id); if (!t || t.style.display === 'none' || (t._oops && Date.now() - t._oops < 4000)) return; t._oops = Date.now(); t.classList.remove('oops'); void t.offsetWidth; t.classList.add('oops'); thought(id, '😬'); }
1163
+ function confetti() {
1164
+ if (!prefs.banter || document.hidden) return; const box = $('#center'), cols = ['#f5c04a', '#7c9cff', '#ff7a6b', '#5ad19a', '#c58bff'];
1165
+ for (let i = 0; i < 26; i++) { const c = document.createElement('i'); c.className = 'confetti'; c.style.left = Math.random() * 100 + '%'; c.style.background = cols[i % cols.length]; c.style.animationDelay = Math.random() * 0.6 + 's'; c.style.animationDuration = 1.8 + Math.random() * 1.4 + 's'; box.appendChild(c); setTimeout(() => c.remove(), 4200); }
1166
+ }
1167
+ function stopTalk(id) {
1168
+ clearInterval(mouths.get(id)); mouths.delete(id); S.speakers.delete(id); if (!S.speakers.size) AUD.duck(false); if (S.focus === id && S.speakers.size) setFocus([...S.speakers][0]);
1169
+ if (S.present === id) { $('#pcap').textContent = ''; $('#pcam').classList.remove('speaking'); const pm = $('#pcam .mouthE'); if (pm) { pm.setAttribute('ry', .9); pm.setAttribute('rx', 4.6); } }
1170
+ const t = $('#t-' + id); if (!t) return;
1171
+ t.classList.remove('speaking'); const m = t.querySelector('.mouthE'); if (m) { m.setAttribute('ry', .9); m.setAttribute('rx', 4.6); }
1172
+ const ct = t.querySelector('.cimg-talk'); if (ct) { ct.style.display = 'none'; t.querySelector('.cimg').style.display = ''; }
1173
+ const pc = $('#pcam .cimg-talk'), pi = $('#pcam .cimg'); if (pc && pi && S.present === id) { pc.style.display = 'none'; pi.style.display = ''; }
1174
+ }
1175
+ const clearSpeaking = () => [...S.speakers].forEach(stopTalk);
1176
+
1177
+ // ---------- hierarchy tree ----------
1178
+ function renderTree() {
1179
+ if (!document.body.classList.contains('tree')) return;
1180
+ const kids = {}, parent = {}, last = {}, cnt = {};
1181
+ for (let i = 0; i <= S.idx && i < S.events.length; i++) {
1182
+ const e = S.events[i];
1183
+ if (e.kind === 'order' && !parent[e.to]) { parent[e.to] = e.agent; (kids[e.agent] = kids[e.agent] || []).push(e.to); }
1184
+ if (e.kind === 'tools') { cnt[e.agent] = (cnt[e.agent] || 0) + e.tools.length; last[e.agent] = e.tools[e.tools.length - 1]; }
1185
+ }
1186
+ const node = (id) => {
1187
+ const p = S.agents[id], st = S.status[id], k = kids[id] || [], open = !S.collapsed.has(id), lt = last[id]; if (!p) return '';
1188
+ return `<li><span class="caret" data-c="${id}">${k.length ? (open ? '▾' : '▸') : ''}</span><span class="node ${st || ''} ${S.speakers.has(id) ? 'speaking' : ''}" data-n="${id}">
1189
+ <span class="dot" style="background:${p.shirt}"></span><b>${esc(p.name)}</b><span class="r">${esc(p.role)}</span>${(() => { const a = agentState(id, Date.now()); return a.k !== 'idle' ? `<span class="stc ${a.k}">${esc(a.txt)}</span>` : ''; })()}
1190
+ ${cnt[id] ? `<span class="r">🔧${cnt[id]}</span>` : ''}${k.length ? `<span class="r">👥${k.length}</span>` : ''}${lt && st === 'working' ? `<span class="tl">${esc(lt.name)} ${esc(lt.arg || '')}</span>` : ''}</span>
1191
+ ${k.length && open ? `<ul>${k.map(node).join('')}</ul>` : ''}</li>`;
1192
+ };
1193
+ $('#tree').innerHTML = `<ul>${node('user').replace(/<\/li>\s*$/, `<ul>${node('main')}</ul></li>`)}</ul>`;
1194
+ }
1195
+ $('#tree').onclick = (e) => {
1196
+ const c = e.target.closest('[data-c]'), n = e.target.closest('[data-n]');
1197
+ if (c) { S.collapsed.has(c.dataset.c) ? S.collapsed.delete(c.dataset.c) : S.collapsed.add(c.dataset.c); renderTree(); } else if (n) openDetail(n.dataset.n);
1198
+ };
1199
+
1200
+ // ---------- agent detail ----------
1201
+ function openDetail(id) { const p = S.agents[id]; if (!p) return; S.detail = id; $('#dname').textContent = `${p.name} · ${p.role}`; renderDetail(); $('#detail').classList.add('on'); }
1202
+ function renderDetail() {
1203
+ if (!S.detail) return; const id = S.detail, evs = S.events.slice(0, S.idx + 1).filter((e) => e.agent === id || e.to === id);
1204
+ const order = evs.find((e) => e.kind === 'order' && e.to === id), tools = evs.filter((e) => e.agent === id && e.kind === 'tools').flatMap((e) => e.tools);
1205
+ const counts = {}; tools.forEach((t) => (counts[t.name] = (counts[t.name] || 0) + 1));
1206
+ const u = S.usage[id], tokHtml = u && tokTotal(u) ? `<h3>${L('Jetons', 'Tokens')} · ⚡ ${fmtTok(tokTotal(u))}</h3><div class="dim">${L('entrée', 'input')} ${fmtTok(u.i)} · ${L('sortie', 'output')} ${fmtTok(u.o)} · ${L('écriture cache', 'cache write')} ${fmtTok(u.w)} · ${L('lecture cache', 'cache read')} ${fmtTok(u.r)}</div>` : '';
1207
+ $('#dbody').innerHTML = tokHtml + (order ? `<h3>${L('Consigne de', 'Prompt from')} ${esc(nameOf(order.agent))}</h3><pre>${esc(order.text)}</pre>` : '') +
1208
+ `<h3>${L('Outils', 'Tools')} (${tools.length})</h3><div>${Object.entries(counts).map(([n, c]) => `${esc(n)} ×${c}`).join(' · ') || '—'}</div>` +
1209
+ `<h3>${L('Activité récente', 'Recent activity')}</h3>` + (S.term[id] || []).slice(-30).reverse().map((e) => `<details><summary>${e.end == null ? '⠋' : e.err ? '✗' : '✓'} ${esc(e.tool.name)} <span class="dim">${e.end != null ? fmtDur(e.end - e.ts) : L('en cours', 'running')}</span> — ${esc((e.tool.arg || '').slice(-60))}</summary><pre>${esc((e.tool.d && e.tool.d.kind === 'cmd' ? '$ ' + e.tool.d.text + '\n' : '') + (e.out || ''))}</pre></details>`).join('') +
1210
+ evs.filter((e) => e.agent === id && (e.kind === 'say' || e.kind === 'report')).map((e) => `<h3>${e.kind === 'report' ? L('Rapport final', 'Final report') : L('A dit', 'Said')}</h3><pre>${esc(e.text)}</pre>`).join('');
1211
+ }
1212
+ $('#dclose').onclick = () => { S.detail = null; $('#detail').classList.remove('on'); };
1213
+
1214
+ // ---------- timeline (replay only) ----------
1215
+ let gantt = { rows: [], n: 0 }, ganttQ = false;
1216
+ function computeGantt() {
1217
+ const rows = {}, order = [];
1218
+ S.events.forEach((e, i) => { if (e.kind === 'order') { rows[e.to] = rows[e.to] || { id: e.to, start: i, end: S.events.length, ticks: [], talks: [] }; order.push(e.to); } });
1219
+ S.events.forEach((e, i) => { const r = rows[e.agent]; if (!r) return; if (e.kind === 'report') r.end = i; if (e.kind === 'tools') r.ticks.push(i); if (e.kind === 'say' || e.kind === 'report') r.talks.push(i); });
1220
+ gantt = { rows: [...new Set(order)].map((id) => rows[id]), n: S.events.length };
1221
+ const cv = $('#gantt'); cv.height = Math.max(56, Math.min(110, gantt.rows.length * 4 + 10)); cv.style.height = cv.height + 'px';
1222
+ }
1223
+ function drawGantt() {
1224
+ if (S.live || ganttQ) return; ganttQ = true;
1225
+ requestAnimationFrame(() => {
1226
+ ganttQ = false; const cv = $('#gantt'), W = cv.clientWidth, H = +cv.height, dpr = devicePixelRatio || 1; if (!W) return;
1227
+ cv.width = W * dpr; const c = cv.getContext('2d'); c.scale(dpr, dpr); if (!gantt.n) return;
1228
+ const rh = Math.max(2, (H - 8) / Math.max(1, gantt.rows.length)), X = (i) => (i / gantt.n) * W;
1229
+ gantt.rows.forEach((r, k) => {
1230
+ const y = 4 + k * rh, p = S.agents[r.id]; if (!p) return; const live = S.idx >= r.start;
1231
+ c.globalAlpha = live ? (S.status[r.id] === 'done' ? .55 : .95) : .3; c.fillStyle = p.shirt;
1232
+ c.fillRect(X(r.start), y, Math.max(2, X(Math.min(r.end, live ? Math.max(S.idx, r.start) : r.end)) - X(r.start)), Math.max(1.5, rh - 1)); c.globalAlpha = 1;
1233
+ c.fillStyle = '#fff'; r.talks.forEach((t) => c.fillRect(X(t) - 1, y, 2.5, Math.max(1.5, rh - 1)));
1234
+ });
1235
+ c.fillStyle = '#ffd24a'; c.fillRect(X(S.idx), 0, 2, H);
1236
+ });
1237
+ }
1238
+ $('#gantt').onclick = (e) => { const r = e.currentTarget.getBoundingClientRect(); seekTo(Math.floor(((e.clientX - r.left) / r.width) * S.events.length)); };
1239
+
1240
+ // =====================================================================
1241
+ // support the dev
1242
+ // =====================================================================
1243
+ const coffeeMsg = () => L('Ce développeur a été remplacé par ses propres agents. Un café pour sa reconversion ? ☕', 'This dev was replaced by his own agents. Spare a coffee for his career change? ☕');
1244
+ function openExternal(url) { if (EMBED) window.parent.postMessage({ agentStandup: 'openExternal', url }, '*'); else window.open(url, '_blank', 'noopener'); } // VS Code webviews must ask the extension
1245
+ function renderCoffee() {
1246
+ const on = !!SUPPORT.url, b = $('#mCoffee'); b.style.display = on ? '' : 'none'; $('#mCoffeeT').textContent = L('☕ Offrir un café au développeur', '☕ Buy the (replaced) dev a coffee'); b.title = coffeeMsg();
1247
+ const h = $('#bCoffee'); h.style.display = on ? '' : 'none'; h.title = coffeeMsg(); h.setAttribute('aria-label', coffeeMsg());
1248
+ const g = $('#gCoffee'); g.style.display = on ? '' : 'none'; g.textContent = L('☕ Ce développeur a été remplacé par ses agents — lui offrir un café', '☕ This dev was replaced by his agents — buy him a coffee');
1249
+ }
1250
+ $('#bCoffee').onclick = $('#gCoffee').onclick = () => openExternal(SUPPORT.url);
1251
+ $('#mCoffee').onclick = () => { $('#menu').classList.remove('on'); openExternal(SUPPORT.url); };
1252
+
1253
+ // =====================================================================
1254
+ // recap
1255
+ // =====================================================================
1256
+ const fmt = (ms) => { const s = Math.round(ms / 1000), m = Math.floor(s / 60), h = Math.floor(m / 60), d = Math.floor(h / 24); return d ? `${d}j ${h % 24}h` : h ? `${h}h${String(m % 60).padStart(2, '0')}` : `${m}:${String(s % 60).padStart(2, '0')}`; };
1257
+
1258
+ // ---------- highlights: jump to the moments that matter when replaying ----------
1259
+ function computeHighlights() {
1260
+ const out = [], ev = S.events; let firstErr = -1, gap = { ms: 0, i: -1 }, prevT = null, comp = [], R = {}, loops = [];
1261
+ ev.forEach((e, i) => {
1262
+ if (e.kind === 'usage' || e.kind === 'finish') return;
1263
+ if (e.kind === 'results' && firstErr < 0 && e.items.some((it) => it.err)) firstErr = i;
1264
+ if (e.kind === 'compact') comp.push(i);
1265
+ if (prevT != null && e.kind !== 'user' && e.t - prevT <= 3600000 && e.t - prevT > gap.ms) gap = { ms: e.t - prevT, i }; // a wait for the human (the next event is their prompt) is not an agent wait
1266
+ if (e.kind !== 'compact') prevT = e.t;
1267
+ const d = loopStep(R, e); if (d && loops.length < 3 && !loops.some((l) => l.agent === e.agent)) loops.push({ i, agent: e.agent, d });
1268
+ });
1269
+ if (firstErr >= 0) out.push({ i: firstErr, t: '✗ ' + L('première erreur', 'first error') });
1270
+ if (gap.ms >= 30000) out.push({ i: gap.i, t: '⏱ ' + L('plus longue attente ', 'longest wait ') + fmtDur(gap.ms) });
1271
+ if (comp.length) out.push({ i: comp[0], t: `🗜 ${L('compactage', 'compaction')}${comp.length > 1 ? ' ×' + comp.length : ''}` });
1272
+ loops.forEach((l) => out.push({ i: l.i, t: `🔁 ${nameOf(l.agent)} ${l.d.kind === 'errors' ? L('échoue en boucle ?', 'failing in a loop?') : L('boucle ?', 'looping?')}` }));
1273
+ out.sort((a, b) => a.i - b.i); return out;
1274
+ }
1275
+ function renderHighlights() {
1276
+ const box = $('#hl'); if (!box) return; const hs = S.live ? [] : computeHighlights();
1277
+ box.classList.toggle('has', hs.length > 0); box.innerHTML = hs.length ? `<span class="dim" style="font-size:11.5px">${L('Moments clés', 'Highlights')}</span>` + hs.map((h) => `<button class="hlc" data-i="${h.i}">${esc(h.t)}</button>`).join('') : '';
1278
+ }
1279
+ $('#hl').onclick = (e) => { const b = e.target.closest('.hlc'); if (b && !S.live) seekTo(+b.dataset.i); };
1280
+ function openRecap() {
1281
+ const per = {}, files = {}; let tools = 0, cmds = 0, errs = 0; const tk = {}, A = (id) => (per[id] = per[id] || { id, tools: 0, files: new Set(), t0: null, t1: null });
1282
+ S.events.forEach((e) => {
1283
+ if (e.kind === 'usage') { const u = (tk[e.agent] = tk[e.agent] || { i: 0, o: 0, r: 0, w: 0 }); u.i += e.u.i; u.o += e.u.o; u.r += e.u.r; u.w += e.u.w; }
1284
+ if (e.kind === 'results') e.items.forEach((it) => { if (it.err) errs++; });
1285
+ if (e.kind === 'order') A(e.to).t0 = e.t; if (e.kind === 'report') A(e.agent).t1 = e.t;
1286
+ if (e.kind === 'tools') e.tools.forEach((t) => { tools++; A(e.agent).tools++; if (t.name === 'Bash') cmds++; const d = t.d || {};
1287
+ if ((d.kind === 'edit' || d.kind === 'write') && d.file) { A(e.agent).files.add(d.file); (files[d.file] = files[d.file] || { n: 0, who: new Set() }).n++; files[d.file].who.add(nameOf(e.agent)); } });
1288
+ });
1289
+ const last = S.events.length ? S.events[S.events.length - 1].t : 0, rows = Object.values(per).filter((a) => a.id !== 'user' && S.agents[a.id]).sort((a, b) => b.tools - a.tools).slice(0, 18), fl = Object.entries(files).sort((a, b) => b[1].n - a[1].n).slice(0, 25);
1290
+ const k = (v, l) => `<div class="kpi"><b>${v}</b><span>${l}</span></div>`, totTok = Object.values(tk).reduce((n, u) => n + tokTotal(u), 0);
1291
+ $('#recap .box').innerHTML = `<h1>${L('Récap de l’épisode', 'Episode recap')}</h1>
1292
+ <div class="kpis">${k(Math.max(0, Object.keys(S.agents).length - 2), 'agents')}${k(tools, L('appels d’outils', 'tool calls'))}${k(cmds, L('commandes', 'commands'))}${k(errs, L('outils en erreur', 'tool errors'))}${totTok ? k('⚡ ' + fmtTok(totTok), L('jetons', 'tokens')) : ''}${k(Object.keys(files).length, L('fichiers modifiés', 'files changed'))}${k(fmt(last), L('durée', 'duration'))}</div>
1293
+ <h3>${L('Agents les plus actifs', 'Busiest agents')}</h3><table><tr><th></th><th>${L('Rôle', 'Role')}</th><th>${L('Outils', 'Tools')}</th><th>${L('Fichiers', 'Files')}</th><th>${L('Jetons', 'Tokens')}</th><th>${L('Durée', 'Time')}</th></tr>
1294
+ ${rows.map((a) => `<tr><td><b>${esc(nameOf(a.id))}</b></td><td>${esc(S.agents[a.id].role)}</td><td>${a.tools}</td><td>${a.files.size || ''}</td><td title="${tk[a.id] ? esc(tokTip(tk[a.id])) : ''}">${tk[a.id] ? '⚡ ' + fmtTok(tokTotal(tk[a.id])) : ''}</td><td>${a.t0 != null && a.t1 != null ? fmt(a.t1 - a.t0) : ''}</td></tr>`).join('')}</table>
1295
+ <h3>${L('Fichiers touchés', 'Files touched')}</h3>${fl.length ? `<table>${fl.map(([f, v]) => `<tr><td class="f">${esc(f)}</td><td>×${v.n}</td><td>${esc([...v.who].join(', '))}</td></tr>`).join('')}</table>` : `<div class="dim">${L('Aucune modification.', 'No edits.')}</div>`}
1296
+ ${SUPPORT.url ? `<div class="coffee"><span>${esc(coffeeMsg())}</span><button class="btn" id="recapCoffee">☕ ${L('Offrir un café', 'Buy a coffee')}</button></div>` : ''}
1297
+ <p style="text-align:right;margin:18px 0 0"><button class="btn" id="recapMd">📋 ${L('Copier en Markdown', 'Copy as Markdown')}</button> <button class="cta" id="recapx">${L('Fermer', 'Close')}</button></p>`;
1298
+ const md = [`# Agent Standup — ${S.title || L('épisode', 'session')}`, `_${new Date().toLocaleString()}_ · ${Math.max(0, Object.keys(S.agents).length - 2)} agents · ${tools} ${L('appels d’outils', 'tool calls')} · ${cmds} ${L('commandes', 'commands')} · ${errs} ${L('erreurs', 'errors')}${totTok ? ' · ⚡ ' + fmtTok(totTok) + ' tokens' : ''}`, '', '## Standup'];
1299
+ rows.forEach((a) => { let rep = ''; for (let i = S.events.length - 1; i >= 0; i--) { const e = S.events[i]; if (e.agent === a.id && (e.kind === 'report' || (a.id === 'main' && e.kind === 'say'))) { rep = spoken(e).replace(/\s+/g, ' ').trim().slice(0, 240); break; } }
1300
+ md.push(`- **${nameOf(a.id)}** (${S.agents[a.id].role}) — ${a.tools} ${L('outils', 'tools')}${a.files.size ? `, ${a.files.size} ${L('fichiers', 'files')}` : ''}${tk[a.id] ? `, ⚡ ${fmtTok(tokTotal(tk[a.id]))}` : ''}${rep ? `\n > ${rep}` : ''}`); });
1301
+ if (fl.length) { md.push('', '## ' + L('Fichiers touchés', 'Files touched')); fl.forEach(([f, v]) => md.push(`- \`${f}\` ×${v.n} (${[...v.who].join(', ')})`)); }
1302
+ md.push('', '_Made with Agent Standup — https://atoll92.github.io/agent-standup/_');
1303
+ $('#recapMd').onclick = async () => { const txt = md.join('\n'); let ok = false; try { await navigator.clipboard.writeText(txt); ok = true; } catch { try { const ta = document.createElement('textarea'); ta.value = txt; ta.style.cssText = 'position:fixed;opacity:0'; document.body.appendChild(ta); ta.select(); ok = document.execCommand('copy'); ta.remove(); } catch {} } $('#recapMd').textContent = ok ? '✓ ' + L('Copié', 'Copied') : L('Copie impossible', 'Copy failed'); setTimeout(() => ($('#recapMd').textContent = '📋 ' + L('Copier en Markdown', 'Copy as Markdown')), 2000); };
1304
+ if ($('#recapCoffee')) $('#recapCoffee').onclick = () => openExternal(SUPPORT.url);
1305
+ $('#recapx').onclick = () => $('#recap').classList.remove('on'); $('#recap').classList.add('on');
1306
+ }
1307
+
1308
+ // =====================================================================
1309
+ // episode mode: title cards and reactions
1310
+ // =====================================================================
1311
+ const REACT = { fr: { ack: ['Compris, chef.', 'C’est parti.', 'Encore du boulot…', 'Je m’en occupe.', 'Ok ok ok.'], done: ['Ça compile chez moi.', 'Oh là là, encore des bugs.', 'Qui a écrit ce code ?', 'Super, on a fini.', 'Classique.'] },
1312
+ en: { ack: ['Got it, boss.', 'On it.', 'More work, great.', 'I’ll handle it.', 'Okay okay okay.'], done: ['Works on my machine.', 'Oh man, more bugs.', 'Who wrote this code?', 'Nice, we are done.', 'Classic.'] } };
1313
+ const quipMem = new Map();
1314
+ function prefetchQuips(i) {
1315
+ if (!prefs.epi) return; const texts = [];
1316
+ for (let k = i; k < Math.min(S.events.length, i + 60) && texts.length < 6; k++) if (S.events[k].kind === 'report') texts.push(baseText(S.events[k], 160));
1317
+ const need = texts.filter((t) => !quipMem.has(t)); if (!need.length) return; need.forEach((t) => quipMem.set(t, null));
1318
+ fetch('/api/quips', { method: 'POST', body: JSON.stringify({ texts: need, lang: FR() ? 'fr' : 'en' }) }).then((r) => r.json()).then((d) => (d.texts || []).forEach((q, i) => d.ok && q && q !== need[i] && quipMem.set(need[i], q))).catch(() => {});
1319
+ }
1320
+
1321
+ // Little lines that make the call feel alive. They never appear in the transcript and never replace real content: they are short, local (no API) and can be turned off in ⚙.
1322
+ const ACK = {
1323
+ en: { tests: ['Running the tests, as usual.', 'Tests? Always.', 'Let’s see what breaks.', 'Green or red, I’ll tell you.'], review: ['Putting on my reviewer glasses.', 'Reading everything twice.', 'I’ll be nice. Maybe.', 'Auditing, brb.'], explore: ['Off to explore the codebase.', 'Grepping like there’s no tomorrow.', 'Time to dig.', 'I’ll find it.'],
1324
+ fix: ['Bug hunting time.', 'I smell a bug.', 'Debugger mode: on.', 'Who broke this?'], docs: ['Writing it up.', 'Documentation, my favorite.', 'Words incoming.'], ui: ['Pixels, here I come.', 'Making it pretty.', 'Let me look at the layout.'], cleanup: ['Cleaning up the mess.', 'Tidying time.', 'Deleting things. Carefully.'],
1325
+ perf: ['Let’s make it fast.', 'Profiling, one moment.', 'Chasing milliseconds.'], any: ['On it!', 'Got it, boss.', 'Reading the brief.', 'Consider it done. Probably.', 'Okay okay okay.', 'Say no more.', 'Already on my way.', 'Roger that.', 'Coffee first. Then this.'] },
1326
+ fr: { tests: ['Je lance les tests, comme d’hab.', 'Des tests ? Toujours.', 'On va voir ce qui casse.', 'Vert ou rouge, je te dirai.'], review: ['Je mets mes lunettes de relecteur.', 'Je relis tout deux fois.', 'Je serai gentil. Peut-être.', 'Audit en cours, je reviens.'], explore: ['Je pars explorer le code.', 'Je grep comme si c’était la fin du monde.', 'Allez, on creuse.', 'Je vais le trouver.'],
1327
+ fix: ['L’heure de la chasse aux bugs.', 'Je sens un bug.', 'Mode débogueur : activé.', 'Qui a cassé ça ?'], docs: ['Je rédige ça.', 'La doc, mon truc préféré.', 'Des mots arrivent.'], ui: ['Pixels, me voilà.', 'On va rendre ça joli.', 'Je regarde la mise en page.'], cleanup: ['Je nettoie ce désordre.', 'Séance de rangement.', 'Je supprime. Prudemment.'],
1328
+ perf: ['On va rendre ça rapide.', 'Je profile, un instant.', 'À la chasse aux millisecondes.'], any: ['Je m’en occupe !', 'Compris, chef.', 'Je lis le brief.', 'C’est comme fait. Sûrement.', 'Ok ok ok.', 'Pas un mot de plus.', 'Je suis déjà parti.', 'Reçu cinq sur cinq.', 'D’abord un café. Ensuite ça.'] },
1329
+ };
1330
+ const ACK_RE = [['tests', /test|qa\b|spec|jest|vitest|coverage/], ['review', /review|audit|check|inspect|lint|critique/], ['explore', /explor|search|research|find|investigat|look|map|scan/], ['fix', /fix|bug|debug|error|crash|broken/], ['docs', /\bdoc|readme|write|changelog|spec/], ['ui', /\bui\b|design|css|style|layout|front|page|component/], ['cleanup', /refactor|clean|tidy|dead code/], ['perf', /perf|speed|slow|optimi|latenc|profil/]];
1331
+ function ackLine(ev) {
1332
+ // classify on role and title only: the prompt text is too noisy
1333
+ const R = ACK[FR() ? 'fr' : 'en'], text = `${ev.title || ''} ${(S.agents[ev.to] || {}).role || ''}`.toLowerCase(), cat = (ACK_RE.find(([, re]) => re.test(text)) || [])[0];
1334
+ const pool = cat && hash(String(ev.ts)) % 4 ? R[cat] : R.any, seed = hash(String(ev.ts) + ev.to); let line = pool[seed % pool.length];
1335
+ if (line === S.lastAck) line = pool[(seed + 1) % pool.length]; S.lastAck = line; return line;
1336
+ }
1337
+ async function react(ev, token) {
1338
+ if ((!prefs.epi && !prefs.banter) || S.boost > 1 || S.hurry) return; const seed = hash(String(ev.ts) + ev.agent), R = REACT[FR() ? 'fr' : 'en']; let who, line;
1339
+ if (ev.kind === 'order') { if (!prefs.banter && seed % 100 > 55) return; who = ev.to; line = prefs.banter ? ackLine(ev) : R.ack[seed % R.ack.length]; }
1340
+ else if (ev.kind === 'report') { if (!prefs.epi && seed % 100 > 35) return; if (seed % 100 > 60) return; const others = Object.keys(S.status).concat(['main']).filter((x) => x !== ev.agent && S.agents[x] && !S.gone.has(x)); who = others[(seed >>> 3) % others.length]; line = quipMem.get(baseText(ev, 160)) || R.done[seed % R.done.length]; }
1341
+ else return;
1342
+ if (!S.agents[who]) return; startTalk(who, line, ev.kind === 'order' && prefs.banter ? '💬' : '🎬'); await speakLine(line, S.agents[who], true, token); stopTalk(who);
1343
+ }
1344
+ async function card(text, sub, ms = 2500) {
1345
+ const c = $('#card'); c.innerHTML = `<div><h1>${esc(text)}</h1><p>${esc(sub || '')}</p></div>`; c.classList.add('on'); AUD.tone(392, .5, 'triangle', .12); AUD.tone(523, .6, 'triangle', .12, .25);
1346
+ await sleep(ms / Math.min(2, spd())); c.classList.remove('on');
1347
+ }
1348
+
1349
+ // =====================================================================
1350
+ // playback loop
1351
+ // =====================================================================
1352
+ const SPK = new Set(['say', 'report', 'order', 'user']);
1353
+ function aheadSpeakable(i, limit) { let n = 0; for (let k = i, m = Math.min(S.events.length, i + 800); k < m; k++) if (SPK.has(S.events[k].kind) && ++n > limit) break; return n; }
1354
+ const isSub = (id) => id !== 'main' && id !== 'user';
1355
+ async function run(i) {
1356
+ const token = ++S.token; S.playing = true; S.idle = false; S.hurry = false; S.boost = 1; syncUI(); AUD.init();
1357
+ if (i === 0 && prefs.epi && !S.live) { await card(L('ÉPISODE 1', 'EPISODE 1'), S.title); if (token !== S.token) return; }
1358
+ while (token === S.token) {
1359
+ const lim = S.stopAt || S.events.length;
1360
+ if (i >= lim) { if (S.live && !S.stopAt) { if (!S.idle) { S.idle = true; syncUI(); } await sleep(350); continue; } break; }
1361
+ if (S.idle) { S.idle = false; syncUI(); }
1362
+ if (S.live) { // way behind: skip ahead to stay current, but only count what is actually said: sub-agents produce thousands of silent tool events
1363
+ const rem = S.events.length - i;
1364
+ if (rem > 300 || aheadSpeakable(i, 16) > 16) { // only a really big backlog is skipped; before that, lines are shortened instead
1365
+ let k = S.events.length, back = 0; while (k > i && back < 8) { k--; if (SPK.has(S.events[k].kind)) back++; }
1366
+ const to = rem > 300 ? Math.max(k, S.events.length - 14) : k; if (to > i) { i = to; recomputeStatus(i); paintStatus(); }
1367
+ }
1368
+ }
1369
+ const q = S.live ? aheadSpeakable(i, 14) : 0; // lines waiting to be said: the more of them, the shorter each one is read, but it is always read
1370
+ S.pace = !S.live ? 0 : q > 7 ? 70 : q > 3 ? 130 : 260; S.hurry = q > 12; // voices are only skipped when more than a dozen lines are queued
1371
+ S.boost = S.hurry || (S.live && S.events.length - i > 60) ? 3 : 1;
1372
+ const ev = S.events[i]; S.idx = i; tally(ev);
1373
+ if (ev.kind === 'usage' || ev.kind === 'compact') { if (ev.kind === 'compact' && S.live && prefs.banter) toast(`${nameOf(ev.agent)}: ${L('contexte compacté 🗜', 'context compacted 🗜')}`); i++; continue; }
1374
+ if (ev.kind === 'finish') { applyStatus(ev); paintStatus(); i++; continue; }
1375
+ if (ev.kind === 'results') { applyResults(ev, S.boost > 1); await sleep((S.demo ? 900 : S.live ? 30 : 80) / spd()); i++; continue; }
1376
+ if (ev.kind === 'tools') { applyStatus(ev); tickTools(ev); AUD.click(); OFFICE.kick(ev.tools[ev.tools.length - 1].name); paintStatus(); await sleep((S.demo ? 1100 : S.live ? 110 : 250) / spd()); i++; continue; }
1377
+ const group = [ev]; let j = i + 1; // sub-agents talking back-to-back were running in parallel: they talk over each other
1378
+ if (isSub(ev.agent) && ev.kind !== 'order') {
1379
+ while (j < Math.min(S.events.length, i + 28) && group.length < 3) {
1380
+ const e2 = S.events[j];
1381
+ if (e2.kind === 'usage' || e2.kind === 'compact') { tally(e2); j++; continue; }
1382
+ if (e2.kind === 'finish') { applyStatus(e2); j++; continue; }
1383
+ if (e2.kind === 'tools') { tally(e2); tickTools(e2); applyStatus(e2); j++; continue; }
1384
+ if (e2.kind === 'results') { tally(e2); applyResults(e2, true); j++; continue; }
1385
+ if (e2.kind !== 'order' && isSub(e2.agent) && !group.some((g) => g.agent === e2.agent)) { group.push(e2); j++; } else break;
1386
+ }
1387
+ }
1388
+ if (FR() && !S.hurry) { prefetchTr(i); await Promise.race([ensureTr(group.map((g) => baseText(g))), sleep(S.live ? 7000 : 14000)]); if (token !== S.token) return; }
1389
+ prefetchQuips(i); warmTTS(i);
1390
+ const playOne = (g) => {
1391
+ applyStatus(g);
1392
+ const label = g.kind === 'order' ? `${L('pour', 'to')} ${nameOf(g.to)}` : g.kind === 'report' ? `${L('rapport à', 'report to')} ${nameOf(g.to)}` : '', line = spoken(g);
1393
+ startTalk(g.agent, line, label);
1394
+ if (g.kind === 'order') { S.status[g.to] = 'working'; AUD.join(); setTimeout(() => { paintStatus(); fly(g.agent, g.to, '📨'); popTile(g.to); }, 30); }
1395
+ if (g.kind === 'report') { AUD.ding(); setTimeout(() => { fly(g.agent, g.to, '✅'); popTile(g.agent); }, 30); }
1396
+ return speakLine(clipSpeech(line, S.pace), S.agents[g.agent], true, token, FR() && trMem.has(baseText(g))).then(() => stopTalk(g.agent));
1397
+ };
1398
+ let plays; if (S.live) { plays = []; let chain = Promise.resolve(); for (const g of group) { chain = chain.then(() => (token === S.token ? playOne(g) : null)); plays.push(chain); } } else plays = group.map(playOne); // live: one voice at a time so each can be heard; replay keeps the overlapping chatter
1399
+ S.idx = j - 1; paintStatus(); await Promise.all(plays); if (token !== S.token) return;
1400
+ for (const g of group) { await react(g, token); if (token !== S.token) return; }
1401
+ i = j;
1402
+ }
1403
+ if (token === S.token) {
1404
+ S.playing = false; clearSpeaking(); syncUI();
1405
+ if (!S.exporting && !S.stopAt && !S.live) { S.idx = S.events.length - 1; paintStatus(); if (prefs.epi) await card(L('FIN', 'THE END'), L('Aucun agent n’a été maltraité durant ce tournage.', 'No agents were harmed.'), 2600); openRecap(); }
1406
+ }
1407
+ }
1408
+ function clipSpeech(t, cap) { // the bubble keeps the full text; the voice reads the first sentence(s) up to cap characters
1409
+ t = String(t).replace(/\s+/g, ' ').trim(); if (!cap || t.length <= cap) return t;
1410
+ const m = t.slice(0, cap + 40).match(/^.{20,}?[.!?](?=\s|$)/); if (m && m[0].length <= cap + 30) return m[0];
1411
+ return t.slice(0, cap).replace(/\s+\S*$/, '') + '…';
1412
+ }
1413
+ function pause() { S.playing = false; S.hurry = false; S.boost = 1; S.token++; synth && synth.cancel(); AUD.stopAll(); clearSpeaking(); $('#card').classList.remove('on'); syncUI(); }
1414
+ function togglePlay() {
1415
+ if (S.locked) return;
1416
+ if (S.playing) { S.userPaused = true; pause(); } else { claimAudio(); S.userPaused = false; run(S.idx >= S.events.length - 1 && !S.live ? (recomputeStatus(0), 0) : S.idx); }
1417
+ }
1418
+ function seekTo(i) {
1419
+ pause(); i = Math.max(0, Math.min(S.events.length - 1, i)); S.idx = i; recomputeStatus(i); paintStatus(); S.userPaused = false; run(i);
1420
+ }
1421
+
1422
+ // Freeze the call at one exact moment (used for screenshots: ?demo=1&at=<event index>).
1423
+ function showMoment(i) {
1424
+ S.hold = true; pause(); recomputeStatus(i); S.idx = i; const e = S.events[i];
1425
+ if (e.kind === 'tools') {
1426
+ tickTools(e);
1427
+ for (let k = i + 1; k < Math.min(S.events.length, i + 10); k++) { const r = S.events[k]; if (r.kind === 'results' && r.agent === e.agent && r.items.some((it) => e.tools.some((t) => t.id === it.id))) applyResults(r); }
1428
+ } else if (!quiet(e)) startTalk(e.agent, spoken(e), '');
1429
+ paintStatus();
1430
+ }
1431
+
1432
+ // =====================================================================
1433
+ // data: live follow + replay
1434
+ // =====================================================================
1435
+ async function loadSession(proj, id) {
1436
+ pause(); const mine = (S.loadN = (S.loadN || 0) + 1);
1437
+ const data = await (await fetch(`/api/session?proj=${encodeURIComponent(proj)}&id=${id}`)).json();
1438
+ if (mine !== S.loadN || data.error) return false;
1439
+ S.term = {}; S.camFor = null; S.focus = null; S.agents = {}; for (const k in nameBook) delete nameBook[k]; [{ id: 'user' }, ...data.agents].forEach((a) => (S.agents[a.id] = persona(a)));
1440
+ S.events = data.events; S.joinAt = Date.now(); S.idx = 0; S.status = {}; S.tc = {}; S.cur = { proj, id }; S.logTo = 0; S.detail = null; $('#detail').classList.remove('on');
1441
+ const opt = $('#sessions').selectedOptions[0]; S.title = (S.live ? niceProj(proj) : opt && opt.dataset.title) || '';
1442
+ if (proj === '_demo') S.events.forEach((e) => { if (e.fr) trMem.set(baseText(e), e.fr); }); // the demo ships its own French
1443
+ $('#subtitle').textContent = S.title; recomputeStatus(0); buildStage(); renderHighlights(); setFocus('main'); renderLog(true); computeGantt(); layout(); paintStatus(); prefetchTr(0); return true;
1444
+ }
1445
+ const niceProj = (p) => (p === '_demo' ? '' : String(p).replace(/^-+/, '').replace(/-/g, '/').split('/').slice(-2).join('/'));
1446
+ async function fillProjects() { // live view: follow the latest activity anywhere, or pin one project
1447
+ const sel = $('#liveProj'); let rows = []; try { rows = await (await fetch('/api/projects')).json(); } catch {}
1448
+ sel.innerHTML = `<option value="">${L('Tous les projets (le plus récent)', 'All projects (most recent)')}</option>` + rows.map((r) => `<option value="${esc(r.proj)}">${esc(niceProj(r.proj))}</option>`).join('');
1449
+ sel.value = prefs.proj && rows.some((r) => r.proj === prefs.proj) ? prefs.proj : ''; sel.style.display = rows.length > 1 ? '' : 'none';
1450
+ }
1451
+ $('#liveProj').onchange = () => { Q.delete('proj'); prefs.proj = $('#liveProj').value; S.pin = ''; savePrefs(); S.cur = null; followCurrent(); };
1452
+ async function followCurrent() {
1453
+ const cur = await (await fetch('/api/current?proj=' + encodeURIComponent(Q.get('proj') || prefs.proj || '') + (S.pin ? '&id=' + encodeURIComponent(S.pin) : ''))).json();
1454
+ if (!cur.id) { $('#empty').classList.add('on'); return false; } $('#empty').classList.remove('on');
1455
+ if (S.pin && cur.id !== S.pin) S.pin = ''; // the pinned session is gone: follow the latest one instead of staying silently pinned
1456
+ if (S.cur && S.cur.id === cur.id && S.cur.proj === cur.proj) return false;
1457
+ if (!(await loadSession(cur.proj, cur.id))) return false;
1458
+ const start = Math.max(0, S.events.length - 16); recomputeStatus(start); S.idx = start; trNew(start); paintStatus();
1459
+ if (!S.locked && !S.userPaused) run(start); return true;
1460
+ }
1461
+ let polling = false;
1462
+ setInterval(async () => {
1463
+ if (!S.live || polling) return; polling = true;
1464
+ try {
1465
+ const switched = await followCurrent(); if (S.offline) { S.offline = false; syncUI(); }
1466
+ if (switched || !S.cur) return;
1467
+ const d = await (await fetch(`/api/session?proj=${encodeURIComponent(S.cur.proj)}&id=${S.cur.id}&after=${S.events.length}`)).json();
1468
+ if (d.error) return; if (d.total < S.events.length) { S.cur = null; return; } // transcript was rewritten: reload next tick
1469
+ if (!d.events || !d.events.length) return;
1470
+ for (const a of d.agents) if (!S.agents[a.id]) { S.agents[a.id] = persona(a); addTile(S.agents[a.id]); }
1471
+ const from = S.events.length; S.events = S.events.concat(d.events); $('#seek').max = S.events.length - 1; trNew(from); renderLog(false); paintStatus();
1472
+ } catch { if (!S.offline) { S.offline = true; syncUI(); } } finally { polling = false; }
1473
+ }, 700);
1474
+
1475
+
1476
+ window.addEventListener('message', (e) => { // the VS Code status bar asks to follow a given session
1477
+ const d = e.data; if (!EMBED || e.source !== window.parent || !d || d.agentStandup !== 'pin' || typeof d.id !== 'string') return;
1478
+ prefs.proj = typeof d.proj === 'string' ? d.proj : ''; S.pin = d.id; S.cur = null; setView('call'); if (S.live) { fillProjects(); followCurrent(); } else setMode('live');
1479
+ });
1480
+ // ---------- meeting chrome: participants and a clock ----------
1481
+ function renderMeet() {
1482
+ const m = $('#meet'); if (!m) return; const n = Object.keys(S.agents).filter((id) => id !== 'user' && !S.gone.has(id) && ((S.status[id] && S.status[id] !== 'done') || id === 'main')).length;
1483
+ const e0 = S.events[0], cur = S.events[Math.min(S.idx, S.events.length - 1)]; let ms = 0;
1484
+ if (S.live) ms = Date.now() - (S.joinAt || (S.joinAt = Date.now())); else if (cur) ms = cur.t || 0;
1485
+ const s = Math.max(0, Math.floor(ms / 1000)), hh = Math.floor(s / 3600), mm = String(Math.floor((s % 3600) / 60)).padStart(2, '0'), ss = String(s % 60).padStart(2, '0');
1486
+ const html = `${S.live ? '<i title="live"></i>' : ''}<span>👥 ${n}</span><span>${hh ? hh + ':' : ''}${mm}:${ss}</span>`; if (m.innerHTML !== html) m.innerHTML = html;
1487
+ }
1488
+ setInterval(() => { if (!document.hidden && S.live && S.events && S.events.length) renderMeet(); }, 1000);
1489
+
1490
+ // ---------- reactions: send a little emoji to whoever has the floor ----------
1491
+ $('#reactBar').onclick = (e) => {
1492
+ const b = e.target.closest('button'); if (!b) return; const em = b.dataset.e, ids = Object.keys(S.agents).filter((id) => id !== 'user' && $('#t-' + id) && $('#t-' + id).style.display !== 'none');
1493
+ const to = [...S.speakers].find((id) => id !== 'user' && S.agents[id]) || ids.find((id) => S.status[id] === 'working') || ids[0]; if (!to) return;
1494
+ fly('user', to, em); setTimeout(() => { popTile(to); AUD.blip(em === '☕' ? 520 : em === '🔥' ? 760 : 660); }, 380); if (em !== '🔥') setTimeout(() => fly(to, 'user', em === '🙌' ? '🙌' : '👍'), 900);
1495
+ };
1496
+
1497
+ // ---------- board: every recent session at a glance ----------
1498
+ const BSTATE = { blocked: ['✋', 'needs you?', 'attend vous ?'], working: ['⚙', 'working', 'au travail'], waiting: ['🔗', 'waiting on sub-agents', 'attend ses sous-agents'], turn: ['✓', 'your turn', 'à vous'], idle: ['💤', 'idle', 'inactif'] };
1499
+ const BORDER = ['blocked', 'working', 'waiting', 'turn', 'idle'];
1500
+ const agoStr = (ms) => (ms < 90e3 ? Math.max(1, Math.round(ms / 1000)) + 's' : ms < 5400e3 ? Math.round(ms / 60e3) + ' min' : Math.round(ms / 3600e3) + ' h');
1501
+ let boardBusy = false;
1502
+ async function renderBoardView() {
1503
+ if (!document.body.classList.contains('board') || boardBusy) return; boardBusy = true;
1504
+ try {
1505
+ const rows = (await (await fetch('/api/board')).json()).sort((a, b) => BORDER.indexOf(a.state) - BORDER.indexOf(b.state) || b.mtime - a.mtime), now = Date.now();
1506
+ if (!document.body.classList.contains('board')) return;
1507
+ const cnt = {}; rows.forEach((r) => (cnt[r.state] = (cnt[r.state] || 0) + 1));
1508
+ const sum = BORDER.filter((k) => cnt[k]).map((k) => `<span class="stc ${k}">${BSTATE[k][0]} ${cnt[k]} ${L(BSTATE[k][2], BSTATE[k][1])}</span>`).join('');
1509
+ $('#boardv').innerHTML = `<div class="bsum">${sum || `<span>${L('Aucune session récente', 'No recent session')}</span>`}</div><div class="bgrid">${rows.map((r) => {
1510
+ const [ic, en, fr] = BSTATE[r.state], extra = r.state === 'blocked' ? ` · ${esc(r.tool)} ${agoStr(r.ageMs)}` : r.state === 'working' && r.tool ? ` · ${esc(r.tool)}` : r.state === 'idle' || r.state === 'turn' ? ` · ${agoStr(now - r.mtime)}` : '';
1511
+ return `<div class="bcard ${r.state}" tabindex="0" role="button" data-p="${esc(r.proj)}" data-i="${esc(r.id)}"><div><span class="stc ${r.state}">${ic} ${L(fr, en)}${extra}</span></div><div class="bt">${esc(/^[0-9a-f]{8}-[0-9a-f-]{27}$/.test(r.title) ? L('Session sans titre', 'Untitled session') : r.title)}</div><div class="bp">${esc(niceProj(r.proj))}</div>${r.subTotal ? `<div class="bm">👥 ${r.subActive}/${r.subTotal} ${L('sous-agents actifs', 'sub-agents active')}</div>` : ''}</div>`; }).join('')}</div>`;
1512
+ } catch {} finally { boardBusy = false; }
1513
+ }
1514
+ $('#boardv').onkeydown = (e) => { if ((e.key === 'Enter' || e.key === ' ') && e.target.classList.contains('bcard')) { e.preventDefault(); e.target.click(); } };
1515
+ $('#boardv').onclick = async (e) => {
1516
+ const c = e.target.closest('.bcard'); if (!c) return; prefs.proj = c.dataset.p; S.pin = c.dataset.i; savePrefs(); setView('call');
1517
+ if (S.live || S.demo) { if (S.demo) await setMode('live'); S.cur = null; fillProjects(); await followCurrent(); } else await setMode('live');
1518
+ };
1519
+ setInterval(() => { if (!document.hidden) renderBoardView(); }, 2500);
1520
+ async function setMode(m) {
1521
+ if (m === 'demo') { // a built-in fictional session: nothing to install, nothing private on screen
1522
+ pause(); S.live = false; S.demo = true; S.userPaused = false; S.cur = null; $('#empty').classList.remove('on');
1523
+ document.querySelectorAll('#modeSeg button').forEach((b) => b.classList.toggle('on', b.dataset.m === 'demo')); $('#sessions').style.display = 'none';
1524
+ if (await loadSession('_demo', 'demo')) { S.title = L('Démo — une équipe corrige un login lent', 'Demo — a team fixes a slow login'); $('#subtitle').textContent = S.title; S.locked = false; $('#gate').classList.remove('on'); syncUI(); }
1525
+ return;
1526
+ }
1527
+ S.demo = false;
1528
+ const live = m === 'live'; if (live === S.live && S.cur) return; pause(); S.live = live; S.userPaused = false;
1529
+ document.querySelectorAll('#modeSeg button').forEach((b) => b.classList.toggle('on', b.dataset.m === m)); $('#sessions').style.display = live ? 'none' : '';
1530
+ S.cur = null; syncUI();
1531
+ if (live) { fillProjects(); await followCurrent(); return; }
1532
+ $('#liveProj').style.display = 'none';
1533
+ const rows = await (await fetch('/api/sessions')).json();
1534
+ $('#sessions').innerHTML = rows.map((r) => { const d = new Date(r.mtime).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
1535
+ return `<option value="${esc(r.proj)}|${r.id}" data-title="${esc(r.title)}">${r.agents ? '👥' + r.agents + ' · ' : ''}${esc(r.title.slice(0, 40))} (${d})</option>`; }).join('');
1536
+ $('#empty').classList.remove('on'); const pick = rows.find((r) => r.agents) || rows[0]; if (!pick) { $('#empty').classList.add('on'); return; }
1537
+ $('#sessions').value = `${pick.proj}|${pick.id}`; await openSelected();
1538
+ }
1539
+ async function openSelected() { const [p, i] = $('#sessions').value.split('|'); if (await loadSession(p, i)) { S.locked = false; $('#gate').classList.remove('on'); } }
1540
+ $('#sessions').onchange = openSelected;
1541
+
1542
+ // =====================================================================
1543
+ // video export (own canvas compositor + recorded WebAudio mix)
1544
+ // =====================================================================
1545
+ const imgCache = {};
1546
+ function avatarImg(p, m, st) { // m: 0 silent, 1 speaking (closed), 2 speaking (open)
1547
+ const k = p.id + m + (st || ''); if (imgCache[k]) return imgCache[k];
1548
+ const im = new Image(); im.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(avatarSVG(p, { m, working: st === 'working', done: st === 'done' })); return (imgCache[k] = im);
1549
+ }
1550
+ function wrap(c, text, maxW, maxLines) {
1551
+ const words = String(text).split(/\s+/), lines = []; let cur = '';
1552
+ for (const w of words) { const t = cur ? cur + ' ' + w : w; if (c.measureText(t).width > maxW && cur) { lines.push(cur); cur = w; if (lines.length === maxLines) break; } else cur = t; }
1553
+ if (lines.length < maxLines && cur) lines.push(cur);
1554
+ if (lines.length === maxLines && words.join(' ').length > lines.join(' ').length) lines[maxLines - 1] = lines[maxLines - 1].replace(/\s*\S*$/, '…'); return lines;
1555
+ }
1556
+ function tileOnCanvas(c, id, x, y, w, h, big) {
1557
+ const p = S.agents[id], sp = S.speakers.has(id), st = S.status[id], open = sp && Math.floor(performance.now() / 120) % 2, im = avatarImg(p, sp ? (open ? 2 : 1) : 0, st);
1558
+ if (im.complete && im.naturalWidth) c.drawImage(im, x, y, w, h);
1559
+ c.lineWidth = sp ? 4 : 2; c.strokeStyle = sp ? (p.lead ? '#f5c04a' : '#7c9cff') : st === 'working' ? '#f0a020' : 'rgba(255,255,255,.14)'; c.strokeRect(x, y, w, h);
1560
+ c.font = `bold ${big ? 16 : 12}px sans-serif`; const nm = p.name + (st === 'done' ? ' ✓' : ''); c.fillStyle = 'rgba(0,0,0,.7)'; c.fillRect(x + 6, y + h - (big ? 30 : 22), c.measureText(nm).width + 12, big ? 24 : 18); c.fillStyle = '#fff'; c.fillText(nm, x + 12, y + h - (big ? 12 : 8));
1561
+ if (!big && S.lastTool[id] && st === 'working') { c.font = '10px monospace'; c.fillStyle = 'rgba(15,20,35,.85)'; c.fillRect(x, y, w, 15); c.fillStyle = '#ffd24a'; c.fillText(S.lastTool[id].slice(0, Math.floor(w / 6)), x + 4, y + 11); }
1562
+ if (big && sp && S.say[id]) { c.font = '15px sans-serif'; const Ls = wrap(c, S.say[id].text, w - 40, 4), bh = Ls.length * 19 + 14; c.fillStyle = '#fff'; c.beginPath(); c.roundRect(x + 10, y + 10, w - 20, bh, 10); c.fill(); c.fillStyle = '#1b1d27'; Ls.forEach((l, i) => c.fillText(l, x + 20, y + 30 + i * 19)); }
1563
+ }
1564
+ function drawFrame(cv) {
1565
+ const c = cv.getContext('2d'), W = cv.width, H = cv.height; c.fillStyle = '#12131a'; c.fillRect(0, 0, W, H);
1566
+ c.font = 'bold 20px "Comic Sans MS",sans-serif'; c.fillStyle = '#e9eaf0'; c.fillText('🎥 ' + (S.title || 'Agent Standup'), 20, 32);
1567
+ const ids = Object.keys(S.agents).filter((id) => id === 'main' || id === 'user' || (S.status[id] && !S.gone.has(id))), sp = [...S.speakers].filter((id) => S.agents[id]).slice(0, 3), big = sp.length ? sp : ['main', 'user'];
1568
+ const pres = S.present && S.agents[S.present], bw = Math.min(480, (W - 40) / big.length - 12), bh = pres ? 330 : bw * 0.75;
1569
+ if (pres) {
1570
+ const m = S.present && termModel(S.present), x = 20, y = 50, w = W - 40, ed = m.view === 'editor', col = { cmd: '#e6edf3', out: '#aab2c5', err: '#ff7b72', tool: '#8b95b0', dim: '#6b7089', run: '#ffd24a', add: '#9be9a8', del: '#ffa198', ctx: '#d6dae6' };
1571
+ c.fillStyle = ed ? '#1a1b26' : '#0d1117'; c.beginPath(); c.roundRect(x, y, w, bh, 12); c.fill(); c.fillStyle = '#1c1f2b'; c.fillRect(x, y, w, 28);
1572
+ ['#ff5f57', '#febc2e', '#28c840'].forEach((cl, i) => { c.fillStyle = cl; c.beginPath(); c.arc(x + 16 + i * 18, y + 14, 5, 0, 7); c.fill(); });
1573
+ c.font = '13px sans-serif'; c.fillStyle = '#9aa0b4'; c.fillText(ed ? m.title : S.agents[S.present].name + ' — zsh', x + 90, y + 19); c.font = '14px monospace';
1574
+ const rows = ed ? m.lines.slice(0, 13) : m.lines.slice(-13);
1575
+ rows.forEach((l, i) => { const yy = y + 52 + i * 21; if (l.c === 'add' || l.c === 'del') { c.fillStyle = l.c === 'add' ? 'rgba(63,185,80,.16)' : 'rgba(248,81,73,.16)'; c.fillRect(x, yy - 15, w, 21); } c.fillStyle = col[l.c] || '#d6dae6'; c.fillText(((l.c === 'cmd' ? '❯ ' : l.c === 'tool' ? '● ' : ed && l.n ? String(l.n).padStart(4) + ' ' : l.c === 'add' ? '+ ' : l.c === 'del' ? '− ' : '') + String(l.t).split('\n')[0]).slice(0, 112), x + 14, yy); });
1576
+ c.font = '12px monospace'; c.fillStyle = '#8b95b0'; c.fillText(m.stat, x + 14, y + bh - 8);
1577
+ c.fillStyle = 'rgba(240,160,32,.94)'; c.beginPath(); c.roundRect(x + 12, y + bh - 34, 300, 24, 12); c.fill(); c.fillStyle = '#201400'; c.font = 'bold 13px sans-serif'; c.fillText('🖥 ' + S.agents[S.present].name + ' ' + L('partage son écran', 'is sharing their screen'), x + 24, y + bh - 17);
1578
+ tileOnCanvas(c, S.present, x + w - 210, y + bh - 160, 196, 147, false);
1579
+ } else big.forEach((id, i) => tileOnCanvas(c, id, 20 + i * (bw + 12), 50, bw, bh, true));
1580
+ const others = ids.filter((id) => (pres ? id !== S.present : !big.includes(id))).sort((a, b) => (S.status[b] === 'working') - (S.status[a] === 'working')).slice(0, 14), tw = 160, th = 120, cols = Math.floor((W - 40) / (tw + 8)), ty = 50 + bh + 14;
1581
+ others.forEach((id, i) => tileOnCanvas(c, id, 20 + (i % cols) * (tw + 8), ty + Math.floor(i / cols) * (th + 8), tw, th, false));
1582
+ const lead = sp[0] && S.say[sp[0]];
1583
+ if (lead) { c.font = '22px sans-serif'; const Ls = wrap(c, `${nameOf(sp[0])} : ${lead.text}`, W - 80, 2); c.fillStyle = 'rgba(0,0,0,.75)'; c.fillRect(0, H - 22 - Ls.length * 28, W, Ls.length * 28 + 22); c.fillStyle = '#fff'; Ls.forEach((l, i) => c.fillText(l, 40, H - 12 - (Ls.length - 1 - i) * 28 - 6)); }
1584
+ const cd = $('#card'); if (cd.classList.contains('on')) { c.fillStyle = '#05060c'; c.fillRect(0, 0, W, H); c.textAlign = 'center'; c.font = '900 90px "Comic Sans MS",sans-serif'; c.fillStyle = '#d8372b'; c.fillText(cd.querySelector('h1').textContent, W / 2 + 5, H / 2 + 5); c.fillStyle = '#fff'; c.fillText(cd.querySelector('h1').textContent, W / 2, H / 2); c.font = '26px sans-serif'; c.fillStyle = '#cdd6f4'; c.fillText(cd.querySelector('p').textContent.slice(0, 80), W / 2, H / 2 + 60); c.textAlign = 'start'; }
1585
+ }
1586
+ let recorder = null;
1587
+ async function exportVideo(from = 0, to = 0) {
1588
+ if (recorder) { recorder.stop(); return; }
1589
+ const wasLive = S.live; if (wasLive) await setMode('replay'); pause(); claimAudio(); AUD.init(); await AUD.ctx.resume(); AUD.muted = false; AUD.apply();
1590
+ const cv = document.createElement('canvas'); cv.width = 1280; cv.height = 720, stream = new MediaStream([...cv.captureStream(30).getTracks(), ...AUD.rec.stream.getTracks()]);
1591
+ const mime = ['video/webm;codecs=vp9,opus', 'video/webm'].find((m) => MediaRecorder.isTypeSupported(m)), chunks = [];
1592
+ recorder = new MediaRecorder(stream, { mimeType: mime, videoBitsPerSecond: 4e6 }); recorder.ondataavailable = (e) => e.data.size && chunks.push(e.data);
1593
+ const timer = setInterval(() => { drawFrame(cv); $('#pill').textContent = `⏹ ${Math.max(0, Math.round(((S.idx - from) / ((to || S.events.length) - from)) * 100))}%`; }, 33), done = new Promise((res) => (recorder.onstop = res));
1594
+ S.exporting = true; S.stopAt = to || 0; S.idx = from; recomputeStatus(from); recorder.start(500); run(from);
1595
+ const poll = setInterval(() => { if (!S.playing && recorder) { setTimeout(() => recorder && recorder.stop(), 900); clearInterval(poll); } }, 300);
1596
+ await done; clearInterval(timer); clearInterval(poll); S.exporting = false; S.stopAt = 0; recorder = null; syncUI();
1597
+ const blob = new Blob(chunks, { type: 'video/webm' }); window.__lastExport = blob.size; const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'agent-standup.webm'; a.click();
1598
+ }
1599
+
1600
+ // =====================================================================
1601
+ // UI wiring
1602
+ // =====================================================================
1603
+ function applyLang() {
1604
+ renderCharInfo();
1605
+ document.documentElement.lang = (LANGS.find((l) => l[0] === prefs.lang) || LANGS[0])[2];
1606
+ document.querySelectorAll('[data-fr]').forEach((el) => (el.textContent = FR() ? el.dataset.fr : tr(el.dataset.en)));
1607
+ const sel = $('#langSel'); if (!sel.options.length) sel.innerHTML = LANGS.map(([c, n]) => `<option value="${c}">${n}</option>`).join(''); sel.value = prefs.lang;
1608
+ [['#bPlay', L('Pause / Lecture (espace)', 'Pause / Play (space)')], ['#bLog', 'Transcript'], ['#bMute', L('Son (touche M)', 'Sound (M key)')], ['#bMenu', L('Réglages', 'Settings')], ['#dclose', L('Fermer', 'Close')], ['#officeVol', L('Volume de l’ambiance', 'Ambience volume')], ['#musicVol', L('Volume de la musique', 'Music volume')], ['#liveProj', L('Projet à suivre', 'Project to follow')], ['#reactBar', L('Réactions', 'Reactions')], ['#logQ', L('Rechercher dans la transcription', 'Search the transcript')], ['#roster', L('Agents', 'Agents')]].forEach(([q, t]) => { const el = $(q); if (!el) return; el.title = t; el.setAttribute('aria-label', t); }); $('#logQ').placeholder = L('Rechercher…', 'Search…'); document.querySelectorAll('#reactBar button').forEach((b) => b.setAttribute('aria-label', b.dataset.e));
1609
+ $('#gT').textContent = L('Rejoindre l’appel', 'Join the call'); $('#gP').textContent = L('Cliquez pour activer le son et suivre votre session Claude Code en direct.', 'Click to enable sound and follow your Claude Code session live.'); $('#gB').textContent = L('▶ Rejoindre', '▶ Join');
1610
+ $('#eDemo').textContent = L('▶ Voir la démo', '▶ Watch the demo'); $('#eT').textContent = L('En attente d’une session', 'Waiting for a session'); $('#eP').textContent = L('Lancez Claude Code dans ce dossier : l’appel démarrera tout seul.', 'Start Claude Code in this folder — the call will start by itself.');
1611
+ for (const id in S.agents) { const p = persona(S.agents[id]); Object.assign(S.agents[id], { name: p.name, role: p.role }); const t = $('#t-' + id); if (t) { t.querySelector('.tag span').textContent = p.role; t.querySelector('.tag b').textContent = p.name; if (id === 'main' || id === 'user') t.querySelector('.tag span').textContent = p.role; } }
1612
+ document.querySelectorAll('#menu .mi[data-t]').forEach((b) => b.querySelector('.sw').classList.toggle('on', !!prefs[b.dataset.t])); renderCoffee(); renderTokens(); updateHint(); renderLog(true); syncUI();
1613
+ }
1614
+ const elsewhere = () => !!(S.owner && S.owner !== CID);
1615
+ function applyMute() {
1616
+ const b = $('#bMute'); b.textContent = AUD.muted ? '🔇' : elsewhere() ? '🔈' : '🔊';
1617
+ b.title = elsewhere() ? L('Le son est joué dans une autre fenêtre — cliquer pour le reprendre ici', 'Sound is playing in another window — click to take it over here') : L('Son (touche M)', 'Sound (M key)');
1618
+ AUD.apply(); if (AUD.off()) { synth && synth.cancel(); AUD.stopAll(); }
1619
+ }
1620
+ async function audioSync(claim) { // heartbeat + ownership: avoids two Agent Standup windows talking at the same time
1621
+ try {
1622
+ const d = await (await fetch(`/api/audio?cid=${CID}${claim ? '&claim=1' : ''}`)).json(), before = S.owner;
1623
+ S.owner = d.owner; if (before !== S.owner) { applyMute(); updateHint(); }
1624
+ } catch {}
1625
+ }
1626
+ const claimAudio = () => { S.owner = CID; applyMute(); audioSync(true); };
1627
+ setInterval(() => audioSync(false), 1500);
1628
+ function toggleMute() { if (elsewhere()) { AUD.muted = false; claimAudio(); return; } AUD.muted = !AUD.muted; applyMute(); }
1629
+ function setView(v) { prefs.view = v; savePrefs(); document.body.classList.toggle('tree', v === 'tree'); document.body.classList.toggle('board', v === 'board'); if (v === 'board') renderBoardView(); document.querySelectorAll('#viewSeg button').forEach((b) => b.classList.toggle('on', b.dataset.v === v)); renderTree(); }
1630
+
1631
+ $('#viewSeg').onclick = (e) => { const b = e.target.closest('button'); if (b) setView(b.dataset.v); };
1632
+ $('#langSel').onchange = () => setLang($('#langSel').value, true);
1633
+ $('#charReload').onclick = async () => { await loadChars(); for (const k in imgCache) delete imgCache[k]; for (const id in S.agents) S.agents[id].custom = customFor(id); buildStage(); paintStatus(); renderCharInfo(); };
1634
+ $('#charOpen').onclick = () => window.parent.postMessage({ agentStandup: 'openCharacters' }, '*');
1635
+ $('#modeSeg').onclick = (e) => { const b = e.target.closest('button'); if (b) setMode(b.dataset.m); };
1636
+ $('#menu').onclick = (e) => { const b = e.target.closest('.mi[data-t]'); if (!b) return; prefs[b.dataset.t] = !prefs[b.dataset.t]; savePrefs(); if (prefs[b.dataset.t] && ['voice', 'office', 'music', 'notify'].includes(b.dataset.t)) claimAudio(); b.querySelector('.sw').classList.toggle('on', prefs[b.dataset.t]); AUD.apply(); if (b.dataset.t === 'office') $('#officeBox').style.display = prefs.office ? '' : 'none'; if (b.dataset.t === 'reactions') document.body.classList.toggle('react', prefs.reactions); if (b.dataset.t === 'roster') { document.body.classList.toggle('rost', prefs.roster); paintStates(); } if (b.dataset.t === 'notify' && prefs.notify) askNotify(); if (b.dataset.t === 'vanish' && !prefs.vanish) { S.gone.clear(); paintStatus(); }
1637
+ if (b.dataset.t === 'music') { $('#musicBox').style.display = prefs.music ? '' : 'none'; if (prefs.music) { AUD.init(); AUD.apply(); } } };
1638
+ $('#station').innerHTML = STATIONS.map((s) => `<option value="${s.id}">📻 ${s.name}</option>`).join('');
1639
+ $('#station').value = prefs.station; $('#musicVol').value = prefs.musicVol; $('#musicBox').style.display = prefs.music ? '' : 'none';
1640
+ $('#station').onchange = (e) => { prefs.station = e.target.value; savePrefs(); AUD.init(); RADIO.sync(); };
1641
+ $('#vstyle').innerHTML = [['animal', 'Animal Crossing (babillage)', 'Animal Crossing (babble)'], ['sims', 'Simlish (babillage)', 'Simlish (babble)'], ['cartoon', 'Voix cartoon (voix réelle)', 'Cartoon voices (real voice)'], ['natural', 'Voix naturelles', 'Natural voices']].map(([v, fr, en]) => `<option value="${v}" data-fr="${fr}" data-en="${en}">${L(fr, en)}</option>`).join(''); $('#vstyle').value = prefs.vstyle; $('#vstyle').onchange = () => { prefs.vstyle = $('#vstyle').value; prefs.vstyleChosen = true; savePrefs(); };
1642
+ $('#officeVol').value = prefs.officeVol; $('#officeBox').style.display = prefs.office ? '' : 'none';
1643
+ $('#officeVol').oninput = (e) => { prefs.officeVol = +e.target.value; savePrefs(); AUD.applyMusic(); };
1644
+ $('#musicVol').oninput = (e) => { prefs.musicVol = +e.target.value; savePrefs(); AUD.applyMusic(); };
1645
+ $('#tok').onclick = () => openRecap();
1646
+ $('#bMenu').onclick = (e) => { e.stopPropagation(); $('#menu').classList.toggle('on'); };
1647
+ document.addEventListener('click', (e) => { if (!e.target.closest('#menu') && !e.target.closest('#bMenu')) $('#menu').classList.remove('on'); });
1648
+ $('#bLog').onclick = () => { document.body.classList.toggle('log'); $('#bLog').classList.toggle('on'); };
1649
+ $('#bMute').onclick = toggleMute;
1650
+ $('#bPlay').onclick = togglePlay;
1651
+ $('#mRecap').onclick = () => { $('#menu').classList.remove('on'); openRecap(); };
1652
+ $('#mExport').onclick = () => { $('#menu').classList.remove('on'); exportVideo(); };
1653
+ $('#mBrowser').onclick = () => { $('#menu').classList.remove('on'); window.parent.postMessage({ agentStandup: 'openBrowser' }, '*'); };
1654
+ $('#seek').oninput = (e) => seekTo(+e.target.value);
1655
+ $('#eDemo').onclick = () => setMode('demo');
1656
+ $('#gB').onclick = () => {
1657
+ claimAudio(); AUD.init(); AUD.apply(); S.locked = false; $('#gate').classList.remove('on'); syncUI();
1658
+ if (synth && !EMBED) { try { synth.speak(new SpeechSynthesisUtterance('')); } catch {} } // unlock browser speech
1659
+ if (S.live && S.events.length) run(S.idx);
1660
+ };
1661
+ addEventListener('keydown', (e) => {
1662
+ if (/INPUT|SELECT|TEXTAREA/.test(e.target.tagName)) return;
1663
+ if (e.key === ' ') { e.preventDefault(); togglePlay(); } else if (e.key.toLowerCase() === 'm') toggleMute(); else if (e.key.toLowerCase() === 't') setView(document.body.classList.contains('tree') ? 'call' : 'tree'); else if (e.key.toLowerCase() === 'r') openRecap(); else if (e.key.toLowerCase() === 'l') $('#bLog').click(); else if (e.key === 'Escape') { $('#recap').classList.remove('on'); $('#menu').classList.remove('on'); }
1664
+ });
1665
+ if (EMBED) $('#mBrowser').style.display = '';
1666
+
1667
+ (async function init() {
1668
+ if (!LANGS.some((l) => l[0] === prefs.lang)) prefs.lang = 'en'; await loadLocale(prefs.lang); await loadChars();
1669
+ document.body.classList.toggle('rost', prefs.roster !== false); document.body.classList.toggle('react', !!prefs.reactions); setView(prefs.view); applyLang(); applyMute(); TTS.init().then(updateHint);
1670
+ loadHealth().then(() => { // French by default only if translation can actually work; otherwise stay in English without a warning
1671
+ const h = S.health || {};
1672
+ if (!prefs.langChosen && FR() && h.claude === null && !h.apiKey) { prefs.lang = 'en'; applyLang(); }
1673
+ if (FR()) fetch('/api/warm').catch(() => {});
1674
+ });
1675
+ if (Q.get('lang')) { await setLang(Q.get('lang'), false); } // not saved: lets a link force a language
1676
+ if (Q.get('demo')) {
1677
+ if (Q.get('view') === 'tree') setView('tree');
1678
+ await setMode('demo'); if (Q.get('speed')) $('#speed').value = Q.get('speed');
1679
+ if (Q.get('at') != null) { showMoment(+Q.get('at')); if (Q.get('recap') === '1') openRecap(); } else if (Q.get('play') === '1') run(0);
1680
+ return;
1681
+ }
1682
+ S.live = Q.get('live') !== '0'; document.querySelectorAll('#modeSeg button').forEach((b) => b.classList.toggle('on', b.dataset.m === (S.live ? 'live' : 'replay')));
1683
+ if (S.live) { S.locked = true; $('#gate').classList.add('on'); S.cur = null; syncUI(); fillProjects(); await followCurrent(); } else { S.live = true; await setMode('replay'); }
1684
+ syncUI();
1685
+ })();
1686
+ </script>
1687
+ </body>
1688
+ </html>