polats commited on
Commit
d47c2ea
·
verified ·
1 Parent(s): 65b4e2c

Deploy Karate Wiener (kimodo kata maker)

Browse files
Files changed (3) hide show
  1. tabs/whoami.drawer.html +6 -2
  2. tabs/whoami.tab.css +12 -2
  3. tabs/whoami.tab.js +132 -118
tabs/whoami.drawer.html CHANGED
@@ -1,7 +1,11 @@
1
  <aside id="kimodo-whoami-drawer" class="kimodo-drawer" aria-label="Who is Karate Wiener">
2
 
3
- <!-- Hero portrait (src set by whoami.tab.js from the dataset). -->
4
- <img id="kimodo-whoami-hero-img" class="kimodo-whoami-hero-img" alt="Karate Wiener" />
 
 
 
 
5
 
6
  <p class="kimodo-whoami-lead">Welcome, my student.</p>
7
 
 
1
  <aside id="kimodo-whoami-drawer" class="kimodo-drawer" aria-label="Who is Karate Wiener">
2
 
3
+ <!-- Hero portrait + a play/stop button (right of the picture) for the welcome voice.
4
+ src + wiring set by whoami.tab.js from the dataset. -->
5
+ <div class="kimodo-whoami-herorow">
6
+ <img id="kimodo-whoami-hero-img" class="kimodo-whoami-hero-img" alt="Karate Wiener" />
7
+ <button id="kimodo-whoami-wspeak" class="kw-speak kimodo-whoami-wspeak" type="button" aria-label="Play welcome"></button>
8
+ </div>
9
 
10
  <p class="kimodo-whoami-lead">Welcome, my student.</p>
11
 
tabs/whoami.tab.css CHANGED
@@ -45,13 +45,15 @@
45
  }
46
 
47
  /* Who Am I panel content */
48
- /* Hero portrait of Karate Wiener. */
 
49
  .kimodo-whoami-hero-img {
50
- display: block; width: 70%; max-width: 240px; margin: 4px auto 12px;
51
  border-radius: 14px; border: 1px solid #5a3a7a;
52
  background: linear-gradient(135deg, #2a1840 0%, #3a1d52 50%, #122a4a 100%);
53
  box-shadow: 0 6px 18px rgba(0,0,0,0.45);
54
  }
 
55
  .kimodo-whoami-lead { font-size: 15px; font-weight: 600; color: #ffe6fb; margin: 8px 0; line-height: 1.45; }
56
  /* Swipable testimonial strip: horizontal scroll-snap, tap a panel to open the lightbox. */
57
  .kimodo-whoami-gallery {
@@ -80,6 +82,14 @@
80
  background: rgba(40,24,64,0.9); color: #ffe6fb; border: 1px solid #c065e0; font-size: 20px;
81
  line-height: 1; cursor: pointer; display: grid; place-items: center;
82
  }
 
 
 
 
 
 
 
 
83
 
84
  /* ---- Chat trigger button (end of the panel) ---- */
85
  .kimodo-whoami-chatbtn {
 
45
  }
46
 
47
  /* Who Am I panel content */
48
+ /* Hero portrait of Karate Wiener + the welcome play/stop button to its right. */
49
+ .kimodo-whoami-herorow { display: flex; align-items: center; justify-content: center; gap: 12px; margin: 4px 0 12px; }
50
  .kimodo-whoami-hero-img {
51
+ display: block; width: 56%; max-width: 200px; margin: 0;
52
  border-radius: 14px; border: 1px solid #5a3a7a;
53
  background: linear-gradient(135deg, #2a1840 0%, #3a1d52 50%, #122a4a 100%);
54
  box-shadow: 0 6px 18px rgba(0,0,0,0.45);
55
  }
56
+ .kimodo-whoami-wspeak { flex: 0 0 auto; align-self: center; width: 44px; height: 44px; font-size: 16px; }
57
  .kimodo-whoami-lead { font-size: 15px; font-weight: 600; color: #ffe6fb; margin: 8px 0; line-height: 1.45; }
58
  /* Swipable testimonial strip: horizontal scroll-snap, tap a panel to open the lightbox. */
59
  .kimodo-whoami-gallery {
 
82
  background: rgba(40,24,64,0.9); color: #ffe6fb; border: 1px solid #c065e0; font-size: 20px;
83
  line-height: 1; cursor: pointer; display: grid; place-items: center;
84
  }
85
+ /* Prev / next arrows (also swipe on touch). */
86
+ .kimodo-whoami-lightbox .kimodo-whoami-lbnav {
87
+ position: absolute; top: 50%; transform: translateY(-50%); width: 44px; height: 44px; border-radius: 50%;
88
+ background: rgba(40,24,64,0.85); color: #ffe6fb; border: 1px solid #7c5cdc; font-size: 26px; line-height: 1;
89
+ cursor: pointer; display: grid; place-items: center;
90
+ }
91
+ .kimodo-whoami-lightbox .kw-prev { left: 12px; }
92
+ .kimodo-whoami-lightbox .kw-next { right: 12px; }
93
 
94
  /* ---- Chat trigger button (end of the panel) ---- */
95
  .kimodo-whoami-chatbtn {
tabs/whoami.tab.js CHANGED
@@ -1,6 +1,5 @@
1
- // WHO AM I tab — info on Karate Wiener, and tutorial STEP 1: the rail tab pulses
2
- // (vaporwave) on first start to invite the user in, stops once they open it, and can be
3
- // re-armed from the account drawer's "Restart tutorial" (kimodo-tutorial-restart event).
4
  (function () {
5
  var TKEY = 'kimodoTutorial';
6
  function load() { try { return JSON.parse(localStorage.getItem(TKEY) || '{}') || {}; } catch (e) { return {}; } }
@@ -8,35 +7,34 @@
8
  function tab() { return document.getElementById('kimodo-whoami-toggle'); }
9
  function setPulse(on) { var t = tab(); if (t) t.classList.toggle('kimodo-whoami-pulse', !!on); }
10
  function seen() { return !!load().whoamiSeen; }
11
-
12
- // Pulse the tab until the user has opened it once (step 1 of the tutorial).
13
  function applyState() { setPulse(!seen()); }
 
14
 
15
- function markSeen() {
16
- var o = load();
17
- if (!o.whoamiSeen) { o.whoamiSeen = true; save(o); }
18
- setPulse(false);
19
- }
20
-
21
- // ---- Hero portrait + swipable testimonial gallery (hosted in the dataset) ----
22
  function whoamiBase() {
23
  var b = (typeof DATASET_BASE !== 'undefined' && DATASET_BASE) ? DATASET_BASE : '';
24
  return b + '/viewer/whoami/';
25
  }
26
  var TESTIMONIALS = 4;
27
  var built = false;
 
 
 
28
  function buildWhoami() {
29
  if (built) return;
30
  var base = whoamiBase();
31
  var hero = document.getElementById('kimodo-whoami-hero-img');
32
  if (hero && !hero.getAttribute('src')) hero.src = base + 'weiner.webp';
 
 
33
  var gal = document.getElementById('kimodo-whoami-gallery');
34
  if (gal && !gal.childNodes.length) {
 
35
  for (var i = 1; i <= TESTIMONIALS; i++) {
36
  var src = base + 'testimonial-' + i + '.webp';
 
37
  var img = document.createElement('img');
38
  img.src = src; img.alt = 'Student testimonial ' + i; img.loading = 'lazy';
39
- img.addEventListener('click', (function (s) { return function () { openLightbox(s); }; })(src));
40
  gal.appendChild(img);
41
  }
42
  }
@@ -47,32 +45,82 @@
47
  if (av && !av.getAttribute('src')) av.src = base + 'wiener-meditate.webp';
48
  chatBtn.addEventListener('click', openChat);
49
  }
50
- ensureWelcome();
51
  built = true;
52
  }
53
- // Wiener greets the student out loud when they open WHO AM I. Mirrors the intro sound: a
54
- // PRELOADED <audio> element (created early, buffering in the background) so play() in the
55
- // tab-tap gesture fires instantly iOS blocks playing a brand-new, not-yet-fetched audio.
56
- var welcomeAudio = null;
57
- function ensureWelcome() {
58
- if (welcomeAudio) return welcomeAudio;
59
- var base = whoamiBase(); if (!base) return null;
60
- welcomeAudio = document.createElement('audio');
61
- welcomeAudio.preload = 'auto'; welcomeAudio.setAttribute('playsinline', '');
62
- welcomeAudio.src = base + 'wiener-welcome.wav';
63
- welcomeAudio.style.display = 'none';
64
- document.body.appendChild(welcomeAudio);
65
- try { welcomeAudio.load(); } catch (e) {}
66
- return welcomeAudio;
 
 
 
 
 
 
 
 
 
67
  }
68
- function playWelcome() {
69
- var a = ensureWelcome(); if (!a) return;
70
- try { a.currentTime = 0; var pr = a.play(); if (pr && pr.catch) pr.catch(function () {}); } catch (e) {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  }
72
 
73
  // ---- Chat with Karate Wiener (phone-messaging modal) ----------------------
74
- // Text reply via the #wiener-chat-* bridge; voice is on-demand per message via the 🔊
75
- // speak button (#wiener-speak-*). Both are hidden gradio triggers (mirror voteKata).
76
  var chatHistory = [];
77
  var modal = null, msgsEl = null, textEl = null, sendBtn = null, chatBusy = false;
78
  function wienerTrigger(prefix, payloadObj, timeoutMs, cb) {
@@ -111,6 +159,8 @@
111
  sendBtn = modal.querySelector('#kw-chat-send');
112
  modal.querySelector('.kw-chat-close').addEventListener('click', closeChat);
113
  modal.addEventListener('click', function (e) { if (e.target === modal) closeChat(); });
 
 
114
  sendBtn.addEventListener('click', sendMessage);
115
  textEl.addEventListener('keydown', function (e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } });
116
  textEl.addEventListener('input', autoGrow);
@@ -119,11 +169,11 @@
119
  function openChat() {
120
  buildModal();
121
  modal.classList.add('open');
122
- // Opening the chat takes over from the panel welcome voice (don't talk over each other).
123
- if (welcomeAudio) { try { welcomeAudio.pause(); } catch (e) {} }
124
  // First message uses the fixed chat-intro recording (no TTS — it's always the same, so
125
- // we save generation cost) and plays it right away.
 
126
  if (!chatHistory.length) addWiener('Welcome, my student. Ask me anything — karate, the spirit, or the law. Osu!', true, whoamiBase() + 'chat-intro.wav');
 
127
  setTimeout(function () { if (textEl) textEl.focus(); }, 80);
128
  }
129
  function closeChat() { if (modal) modal.classList.remove('open'); }
@@ -133,69 +183,17 @@
133
  row.appendChild(b); msgsEl.appendChild(row); scrollBottom();
134
  chatHistory.push({ role: 'user', content: text });
135
  }
136
- // Wiener message: avatar, the bubble, then a small play/stop ICON to the right of the
137
- // message. `autospeak` (true) loads + plays the voice at once. `fixedUrl` voices the
138
- // message from a static file (the greeting) instead of generating TTS.
139
  function addWiener(text, autospeak, fixedUrl) {
140
  var row = document.createElement('div'); row.className = 'kw-msg kw-wiener';
141
  var av = document.createElement('img'); av.className = 'kw-msgav'; av.src = whoamiBase() + 'wiener-meditate.webp'; av.alt = '';
142
  var spk = document.createElement('button'); spk.type = 'button'; spk.className = 'kw-speak';
143
  var b = document.createElement('div'); b.className = 'kw-bubble'; b.textContent = text;
144
- row.appendChild(av); row.appendChild(b); row.appendChild(spk); // avatar → bubble → speak (right of the message)
145
  msgsEl.appendChild(row); scrollBottom();
146
  chatHistory.push({ role: 'wiener', content: text });
147
  wireSpeak(spk, text, autospeak !== false, fixedUrl);
148
  }
149
- // wiener-speak shares ONE hidden gradio trigger/result, so concurrent requests would
150
- // cross (a message could play another message's audio). Serialize them: one TTS in flight
151
- // at a time, each tagged to its own button via the callback — never the shared textbox race.
152
- var speakQueue = [], speakActive = false;
153
- function enqueueSpeak(text, cb) { speakQueue.push({ text: text, cb: cb }); pumpSpeak(); }
154
- function pumpSpeak() {
155
- if (speakActive || !speakQueue.length) return;
156
- speakActive = true;
157
- var job = speakQueue.shift();
158
- wienerTrigger('wiener-speak', { text: job.text }, 120000, function (r) {
159
- speakActive = false;
160
- try { job.cb(r); } finally { pumpSpeak(); }
161
- });
162
- }
163
- // Icon speak button: ▶ play / ■ stop, with a spinner while the voice loads/buffers. The
164
- // voice (fixed file or generated TTS) plays the moment it's ready; tap ■ to stop, ▶ to
165
- // replay the cached audio. Each button owns its own `audio`, so it always plays ITS text.
166
- function wireSpeak(btn, text, autostart, fixedUrl) {
167
- var audio = null;
168
- function set(state) {
169
- btn.dataset.state = state;
170
- btn.innerHTML = state === 'loading' ? '<span class="kw-ld" aria-label="loading"></span>' : (state === 'playing' ? '■' : '▶');
171
- btn.title = state === 'loading' ? 'loading voice…' : (state === 'playing' ? 'stop' : 'play voice');
172
- btn.classList.toggle('loading', state === 'loading');
173
- btn.classList.toggle('playing', state === 'playing');
174
- }
175
- function startPlay(src) {
176
- audio = new Audio(src);
177
- audio.addEventListener('ended', function () { set('idle'); });
178
- var pr = audio.play();
179
- if (pr && pr.then) pr.then(function () { set('playing'); }).catch(function () { set('idle'); });
180
- else set('playing');
181
- }
182
- function load() {
183
- set('loading'); // spinner while buffering / generating
184
- if (fixedUrl) { startPlay(fixedUrl); return; } // static greeting voice — no TTS
185
- enqueueSpeak(text, function (r) {
186
- if (r && r.ok && r.audio) startPlay(r.audio);
187
- else set('idle'); // failed → ▶ (tap to retry)
188
- });
189
- }
190
- btn.addEventListener('click', function () {
191
- var s = btn.dataset.state;
192
- if (s === 'loading') return;
193
- if (s === 'playing') { try { audio.pause(); audio.currentTime = 0; } catch (e) {} set('idle'); return; }
194
- if (audio) { try { audio.currentTime = 0; var pr = audio.play(); if (pr && pr.catch) pr.catch(function () {}); set('playing'); } catch (e) {} }
195
- else load(); // first manual play, or retry after a failed auto-load
196
- });
197
- if (autostart) load(); else set('idle');
198
- }
199
  function sendMessage() {
200
  if (chatBusy || !textEl) return;
201
  var text = (textEl.value || '').trim();
@@ -209,64 +207,80 @@
209
  wienerTrigger('wiener-chat', { history: hist, message: text }, 120000, function (r) {
210
  if (typing.parentNode) typing.parentNode.removeChild(typing);
211
  chatBusy = false; if (sendBtn) sendBtn.disabled = false;
212
- if (r && r.ok && r.reply) addWiener(r.reply, true); // auto-load + speak the reply
213
  else addWiener('The path is quiet right now, my student. Ask me again. Osu!', true);
214
  });
215
  }
216
 
217
- // Full-screen lightbox (one shared element at <body> level). Tap anywhere to close.
218
- var lb = null;
219
  function ensureLightbox() {
220
  if (lb) return lb;
221
  lb = document.createElement('div'); lb.className = 'kimodo-whoami-lightbox';
222
- var img = document.createElement('img'); img.className = 'kimodo-whoami-lbimg'; img.alt = 'Testimonial';
223
  var close = document.createElement('button'); close.type = 'button'; close.className = 'kimodo-whoami-lbclose'; close.setAttribute('aria-label', 'Close'); close.textContent = '✕';
224
- lb.appendChild(img); lb.appendChild(close);
225
- lb.addEventListener('click', closeLightbox);
 
226
  document.body.appendChild(lb);
 
 
 
 
 
 
 
 
 
 
 
227
  return lb;
228
  }
229
- function openLightbox(src) {
230
- var el = ensureLightbox();
231
- el.querySelector('.kimodo-whoami-lbimg').src = src;
232
- el.classList.add('open');
233
  }
 
234
  function closeLightbox() { if (lb) lb.classList.remove('open'); }
235
- window.addEventListener('keydown', function (e) { if (e.key === 'Escape') { closeLightbox(); if (typeof closeChat === 'function') closeChat(); } });
236
 
237
- // Opening the WHO AM I drawer completes step 1 + builds the hero/gallery (lazily, so
238
- // DATASET_BASE is resolved by the time it runs).
 
 
 
 
 
 
 
239
  window.addEventListener('kimodo-drawer-open', function (e) {
240
- if (e.detail === 'kimodo-whoami-drawer') { markSeen(); buildWhoami(); playWelcome(); }
241
  });
242
 
243
  // Restart the tutorial (from the account drawer): re-arm step 1's pulse.
244
  window.addEventListener('kimodo-tutorial-restart', function () {
245
- var o = load(); o.whoamiSeen = false; save(o);
246
- setPulse(true);
247
  });
248
 
249
- // Don't start the first-run pulse until the intro TITLE overlay has finished. The intro
250
- // (#kw-title-overlay) is added just after this script and REMOVED from the DOM when it
251
- // ends; it covers the whole screen meanwhile. So: wait until it has appeared and gone,
252
- // or — if no intro exists (no audio asset) — proceed after a short grace window.
253
  function gateInitialPulse() {
254
  var present = function () { return !!document.getElementById('kw-title-overlay'); };
255
  var sawIntro = present();
256
  var iv = setInterval(function () {
257
- if (present()) { sawIntro = true; return; } // intro still on screen
258
- if (sawIntro) { clearInterval(iv); applyState(); } // appeared then gone = intro done
259
  }, 250);
260
- // No intro showed up within the grace window => nothing to wait for.
261
  setTimeout(function () { if (!sawIntro && !present()) { clearInterval(iv); applyState(); } }, 1300);
262
- // Hard safety: never wait forever.
263
  setTimeout(function () { clearInterval(iv); applyState(); }, 15000);
264
  }
265
  gateInitialPulse();
266
- // Pre-buffer the welcome voice (preloaded <audio>) + warm the chat-intro file cache once
267
- // DATASET_BASE is ready, so the first WHO AM I tap / chat open plays instantly.
268
  setTimeout(function () {
269
- ensureWelcome();
270
- try { var a = document.createElement('audio'); a.preload = 'auto'; a.src = whoamiBase() + 'chat-intro.wav'; a.style.display = 'none'; document.body.appendChild(a); a.load(); } catch (e) {}
 
 
 
271
  }, 1500);
272
  })();
 
1
+ // WHO AM I tab — info on Karate Wiener, voiced welcome + testimonial gallery + a chat
2
+ // modal. Tutorial STEP 1: the rail tab pulses on first start until opened.
 
3
  (function () {
4
  var TKEY = 'kimodoTutorial';
5
  function load() { try { return JSON.parse(localStorage.getItem(TKEY) || '{}') || {}; } catch (e) { return {}; } }
 
7
  function tab() { return document.getElementById('kimodo-whoami-toggle'); }
8
  function setPulse(on) { var t = tab(); if (t) t.classList.toggle('kimodo-whoami-pulse', !!on); }
9
  function seen() { return !!load().whoamiSeen; }
 
 
10
  function applyState() { setPulse(!seen()); }
11
+ function markSeen() { var o = load(); if (!o.whoamiSeen) { o.whoamiSeen = true; save(o); } setPulse(false); }
12
 
 
 
 
 
 
 
 
13
  function whoamiBase() {
14
  var b = (typeof DATASET_BASE !== 'undefined' && DATASET_BASE) ? DATASET_BASE : '';
15
  return b + '/viewer/whoami/';
16
  }
17
  var TESTIMONIALS = 4;
18
  var built = false;
19
+ var galleryImages = []; // testimonial srcs (lightbox swipes through these)
20
+ var welcomeBtn = null; // the welcome speak/stop button (right of the hero image)
21
+
22
  function buildWhoami() {
23
  if (built) return;
24
  var base = whoamiBase();
25
  var hero = document.getElementById('kimodo-whoami-hero-img');
26
  if (hero && !hero.getAttribute('src')) hero.src = base + 'weiner.webp';
27
+ welcomeBtn = document.getElementById('kimodo-whoami-wspeak');
28
+ if (welcomeBtn && !welcomeBtn._wired) { welcomeBtn._wired = true; wireSpeak(welcomeBtn, '', false, base + 'wiener-welcome.wav'); }
29
  var gal = document.getElementById('kimodo-whoami-gallery');
30
  if (gal && !gal.childNodes.length) {
31
+ galleryImages = [];
32
  for (var i = 1; i <= TESTIMONIALS; i++) {
33
  var src = base + 'testimonial-' + i + '.webp';
34
+ galleryImages.push(src);
35
  var img = document.createElement('img');
36
  img.src = src; img.alt = 'Student testimonial ' + i; img.loading = 'lazy';
37
+ img.addEventListener('click', (function (idx) { return function () { openLightbox(idx); }; })(i - 1));
38
  gal.appendChild(img);
39
  }
40
  }
 
45
  if (av && !av.getAttribute('src')) av.src = base + 'wiener-meditate.webp';
46
  chatBtn.addEventListener('click', openChat);
47
  }
 
48
  built = true;
49
  }
50
+
51
+ // ---- Shared voice player ---------------------------------------------------
52
+ // ONE <audio> element for every voice (welcome + chat). iOS only auto-plays audio that
53
+ // was first started inside a user gesture; reusing a single element that a gesture-play
54
+ // (the welcome on tab tap / the chat-intro on open) has "unlocked" lets later replies
55
+ // auto-play without their own gesture. Only one voice plays at a time.
56
+ var voiceEl = null, curBtn = null;
57
+ function voiceElement() {
58
+ if (voiceEl) return voiceEl;
59
+ voiceEl = document.createElement('audio');
60
+ voiceEl.setAttribute('playsinline', ''); voiceEl.style.display = 'none';
61
+ document.body.appendChild(voiceEl);
62
+ voiceEl.addEventListener('ended', function () { if (curBtn && curBtn._set) curBtn._set('idle'); curBtn = null; });
63
+ return voiceEl;
64
+ }
65
+ function playVoice(src, btn) {
66
+ var a = voiceElement();
67
+ if (curBtn && curBtn !== btn && curBtn._set) curBtn._set('idle'); // switch: reset the previous button
68
+ curBtn = btn;
69
+ try { a.src = src; a.currentTime = 0; } catch (e) {}
70
+ var pr = a.play();
71
+ if (pr && pr.then) pr.then(function () { if (btn._set) btn._set('playing'); }).catch(function () { if (btn._set) btn._set('idle'); });
72
+ else if (btn._set) btn._set('playing');
73
  }
74
+ function stopVoice() {
75
+ var a = voiceElement();
76
+ try { a.pause(); a.currentTime = 0; } catch (e) {}
77
+ if (curBtn && curBtn._set) curBtn._set('idle');
78
+ curBtn = null;
79
+ }
80
+
81
+ // wiener-speak shares ONE hidden gradio trigger/result → serialize so concurrent TTS
82
+ // requests never cross (a message playing another's audio).
83
+ var speakQueue = [], speakActive = false;
84
+ function enqueueSpeak(text, cb) { speakQueue.push({ text: text, cb: cb }); pumpSpeak(); }
85
+ function pumpSpeak() {
86
+ if (speakActive || !speakQueue.length) return;
87
+ speakActive = true;
88
+ var job = speakQueue.shift();
89
+ wienerTrigger('wiener-speak', { text: job.text }, 120000, function (r) {
90
+ speakActive = false;
91
+ try { job.cb(r); } finally { pumpSpeak(); }
92
+ });
93
+ }
94
+ // Icon speak button: ▶ play / ■ stop, spinner while the voice loads. `fixedUrl` voices it
95
+ // from a static file (welcome / chat-intro — no TTS). Plays the moment it's ready; the
96
+ // cached src means replays are instant and always play THIS message's audio.
97
+ function wireSpeak(btn, text, autostart, fixedUrl) {
98
+ var src = fixedUrl || null;
99
+ btn._set = function (state) {
100
+ btn.dataset.state = state;
101
+ btn.innerHTML = state === 'loading' ? '<span class="kw-ld" aria-label="loading"></span>' : (state === 'playing' ? '■' : '▶');
102
+ btn.title = state === 'loading' ? 'loading voice…' : (state === 'playing' ? 'stop' : 'play voice');
103
+ btn.classList.toggle('loading', state === 'loading');
104
+ btn.classList.toggle('playing', state === 'playing');
105
+ };
106
+ btn._play = function () {
107
+ if (src) { playVoice(src, btn); return; }
108
+ btn._set('loading');
109
+ enqueueSpeak(text, function (r) {
110
+ if (r && r.ok && r.audio) { src = r.audio; playVoice(src, btn); }
111
+ else btn._set('idle'); // failed → ▶ (tap to retry)
112
+ });
113
+ };
114
+ btn.addEventListener('click', function () {
115
+ var s = btn.dataset.state;
116
+ if (s === 'loading') return;
117
+ if (s === 'playing') { stopVoice(); return; }
118
+ btn._play();
119
+ });
120
+ if (autostart) btn._play(); else btn._set('idle');
121
  }
122
 
123
  // ---- Chat with Karate Wiener (phone-messaging modal) ----------------------
 
 
124
  var chatHistory = [];
125
  var modal = null, msgsEl = null, textEl = null, sendBtn = null, chatBusy = false;
126
  function wienerTrigger(prefix, payloadObj, timeoutMs, cb) {
 
159
  sendBtn = modal.querySelector('#kw-chat-send');
160
  modal.querySelector('.kw-chat-close').addEventListener('click', closeChat);
161
  modal.addEventListener('click', function (e) { if (e.target === modal) closeChat(); });
162
+ // Don't let taps inside the modal reach the document handler that closes the drawer.
163
+ modal.addEventListener('pointerdown', function (e) { e.stopPropagation(); });
164
  sendBtn.addEventListener('click', sendMessage);
165
  textEl.addEventListener('keydown', function (e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } });
166
  textEl.addEventListener('input', autoGrow);
 
169
  function openChat() {
170
  buildModal();
171
  modal.classList.add('open');
 
 
172
  // First message uses the fixed chat-intro recording (no TTS — it's always the same, so
173
+ // we save generation cost). Playing it here (inside the chat-button tap) unlocks the
174
+ // shared voice element, so later replies can auto-play without their own gesture.
175
  if (!chatHistory.length) addWiener('Welcome, my student. Ask me anything — karate, the spirit, or the law. Osu!', true, whoamiBase() + 'chat-intro.wav');
176
+ else stopVoice();
177
  setTimeout(function () { if (textEl) textEl.focus(); }, 80);
178
  }
179
  function closeChat() { if (modal) modal.classList.remove('open'); }
 
183
  row.appendChild(b); msgsEl.appendChild(row); scrollBottom();
184
  chatHistory.push({ role: 'user', content: text });
185
  }
186
+ // Wiener message: avatar, bubble, then the play/stop ICON to the right of the message.
 
 
187
  function addWiener(text, autospeak, fixedUrl) {
188
  var row = document.createElement('div'); row.className = 'kw-msg kw-wiener';
189
  var av = document.createElement('img'); av.className = 'kw-msgav'; av.src = whoamiBase() + 'wiener-meditate.webp'; av.alt = '';
190
  var spk = document.createElement('button'); spk.type = 'button'; spk.className = 'kw-speak';
191
  var b = document.createElement('div'); b.className = 'kw-bubble'; b.textContent = text;
192
+ row.appendChild(av); row.appendChild(b); row.appendChild(spk);
193
  msgsEl.appendChild(row); scrollBottom();
194
  chatHistory.push({ role: 'wiener', content: text });
195
  wireSpeak(spk, text, autospeak !== false, fixedUrl);
196
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  function sendMessage() {
198
  if (chatBusy || !textEl) return;
199
  var text = (textEl.value || '').trim();
 
207
  wienerTrigger('wiener-chat', { history: hist, message: text }, 120000, function (r) {
208
  if (typing.parentNode) typing.parentNode.removeChild(typing);
209
  chatBusy = false; if (sendBtn) sendBtn.disabled = false;
210
+ if (r && r.ok && r.reply) addWiener(r.reply, true);
211
  else addWiener('The path is quiet right now, my student. Ask me again. Osu!', true);
212
  });
213
  }
214
 
215
+ // ---- Testimonial lightbox (swipable) --------------------------------------
216
+ var lb = null, lbImg = null, lbIdx = 0;
217
  function ensureLightbox() {
218
  if (lb) return lb;
219
  lb = document.createElement('div'); lb.className = 'kimodo-whoami-lightbox';
220
+ lbImg = document.createElement('img'); lbImg.className = 'kimodo-whoami-lbimg'; lbImg.alt = 'Testimonial';
221
  var close = document.createElement('button'); close.type = 'button'; close.className = 'kimodo-whoami-lbclose'; close.setAttribute('aria-label', 'Close'); close.textContent = '✕';
222
+ var prev = document.createElement('button'); prev.type = 'button'; prev.className = 'kimodo-whoami-lbnav kw-prev'; prev.setAttribute('aria-label', 'Previous'); prev.textContent = '‹';
223
+ var next = document.createElement('button'); next.type = 'button'; next.className = 'kimodo-whoami-lbnav kw-next'; next.setAttribute('aria-label', 'Next'); next.textContent = '›';
224
+ lb.appendChild(lbImg); lb.appendChild(close); lb.appendChild(prev); lb.appendChild(next);
225
  document.body.appendChild(lb);
226
+ // Keep clicks/taps inside the lightbox from reaching the document handler that dismisses
227
+ // the WHO AM I drawer — closing the lightbox must leave the tab open.
228
+ lb.addEventListener('pointerdown', function (e) { e.stopPropagation(); });
229
+ lb.addEventListener('click', function (e) { if (e.target === lb) closeLightbox(); }); // backdrop only
230
+ close.addEventListener('click', function (e) { e.stopPropagation(); closeLightbox(); });
231
+ prev.addEventListener('click', function (e) { e.stopPropagation(); showLb(lbIdx - 1); });
232
+ next.addEventListener('click', function (e) { e.stopPropagation(); showLb(lbIdx + 1); });
233
+ // Swipe left/right to move between testimonials.
234
+ var sx = 0, sy = 0;
235
+ lb.addEventListener('touchstart', function (e) { var t = e.changedTouches[0]; sx = t.clientX; sy = t.clientY; }, { passive: true });
236
+ lb.addEventListener('touchend', function (e) { var t = e.changedTouches[0]; var dx = t.clientX - sx, dy = t.clientY - sy; if (Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy)) showLb(lbIdx + (dx < 0 ? 1 : -1)); }, { passive: true });
237
  return lb;
238
  }
239
+ function showLb(idx) {
240
+ if (!galleryImages.length) return;
241
+ lbIdx = (idx + galleryImages.length) % galleryImages.length; // wrap around
242
+ ensureLightbox(); lbImg.src = galleryImages[lbIdx];
243
  }
244
+ function openLightbox(idx) { ensureLightbox(); showLb(idx || 0); lb.classList.add('open'); }
245
  function closeLightbox() { if (lb) lb.classList.remove('open'); }
 
246
 
247
+ window.addEventListener('keydown', function (e) {
248
+ var lbOpen = lb && lb.classList.contains('open');
249
+ if (e.key === 'Escape') { closeLightbox(); closeChat(); }
250
+ else if (lbOpen && e.key === 'ArrowLeft') showLb(lbIdx - 1);
251
+ else if (lbOpen && e.key === 'ArrowRight') showLb(lbIdx + 1);
252
+ });
253
+
254
+ // Opening the WHO AM I drawer completes step 1, builds the panel, and plays the welcome
255
+ // voice (inside the tab-tap gesture, so it actually auto-plays + unlocks the voice element).
256
  window.addEventListener('kimodo-drawer-open', function (e) {
257
+ if (e.detail === 'kimodo-whoami-drawer') { markSeen(); buildWhoami(); if (welcomeBtn) welcomeBtn._play(); }
258
  });
259
 
260
  // Restart the tutorial (from the account drawer): re-arm step 1's pulse.
261
  window.addEventListener('kimodo-tutorial-restart', function () {
262
+ var o = load(); o.whoamiSeen = false; save(o); setPulse(true);
 
263
  });
264
 
265
+ // Don't start the first-run pulse until the intro TITLE overlay has finished.
 
 
 
266
  function gateInitialPulse() {
267
  var present = function () { return !!document.getElementById('kw-title-overlay'); };
268
  var sawIntro = present();
269
  var iv = setInterval(function () {
270
+ if (present()) { sawIntro = true; return; }
271
+ if (sawIntro) { clearInterval(iv); applyState(); }
272
  }, 250);
 
273
  setTimeout(function () { if (!sawIntro && !present()) { clearInterval(iv); applyState(); } }, 1300);
 
274
  setTimeout(function () { clearInterval(iv); applyState(); }, 15000);
275
  }
276
  gateInitialPulse();
277
+ // Warm the voice file caches once DATASET_BASE is ready so the first welcome / chat-intro
278
+ // plays instantly.
279
  setTimeout(function () {
280
+ try {
281
+ ['wiener-welcome.wav', 'chat-intro.wav'].forEach(function (f) {
282
+ var a = document.createElement('audio'); a.preload = 'auto'; a.src = whoamiBase() + f; a.style.display = 'none'; document.body.appendChild(a); a.load();
283
+ });
284
+ } catch (e) {}
285
  }, 1500);
286
  })();