@kolbo/mcp 1.103.0 → 1.104.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolbo/mcp",
3
- "version": "1.103.0",
3
+ "version": "1.104.0",
4
4
  "description": "Kolbo AI MCP Server - Generate images, videos, music, speech, and sound effects from Claude Code",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/apps/html.js CHANGED
@@ -62,7 +62,8 @@ var ICONS = {
62
62
  chevronDown: _svg('<path d="M6 9l6 6 6-6"/>'),
63
63
  chevronLeft: _svg('<path d="M15 18l-6-6 6-6"/>'),
64
64
  chevronRight: _svg('<path d="M9 18l6-6-6-6"/>'),
65
- chevronUp: _svg('<path d="M18 15l-6-6-6 6"/>')
65
+ chevronUp: _svg('<path d="M18 15l-6-6-6 6"/>'),
66
+ expand: _svg('<path d="M15 3h6v6"/><path d="M9 21H3v-6"/><path d="M21 3l-7 7"/><path d="M3 21l7-7"/>')
66
67
  };
67
68
  // Media-kind → icon (accepts model kind / media_type strings).
68
69
  function kindIcon(kind) {
@@ -141,9 +142,12 @@ function monogram(name) {
141
142
  var ch = String(name || '').replace(/^[@#]+/, '').trim().charAt(0) || '?';
142
143
  return '<span class="k-mono-icon">' + esc(ch.toUpperCase()) + '</span>';
143
144
  }
144
- // In-widget preview overlay — every card (generation chips, media grid, list
145
- // rows) uses this so a DNA / reference / library thumb opens a popup in Kolbo
146
- // Code, Claude Code, Codex, and any other host. Host fullscreen is not required.
145
+ // In-widget lightbox — every card (generation results, chips, media grid, list
146
+ // rows) opens media here. It shows inside the card at once, then asks the host
147
+ // for fullscreen: granted, it fills the host viewport; refused or unanswered,
148
+ // the in-card popup still works (a click never depends on the host answering).
149
+ // Any [data-peek] node opens it through ONE delegated listener, and the
150
+ // lightbox pages through the other [data-peek] items in the same #stage.
147
151
  function ensurePeek() {
148
152
  if (el('peek')) return el('peek');
149
153
  var card = document.querySelector('.k-card') || document.body;
@@ -151,55 +155,116 @@ function ensurePeek() {
151
155
  box.id = 'peek';
152
156
  box.className = 'k-peek';
153
157
  box.hidden = true;
154
- box.innerHTML = '<button type="button" class="k-peek-close" id="peek-close" aria-label="Close"></button>'
155
- + '<img id="peek-img" alt="">'
156
- + '<video id="peek-video" muted playsinline controls></video>'
157
- + '<div class="k-peek-cap" id="peek-cap"></div>';
158
+ box.innerHTML = '<div class="k-peek-bar"><span class="k-peek-cap" id="peek-cap"></span>'
159
+ + '<span class="k-peek-count" id="peek-count"></span>'
160
+ + '<button type="button" class="k-peek-btn" id="peek-dl" aria-label="Download"></button>'
161
+ + '<button type="button" class="k-peek-btn" id="peek-close" aria-label="Close"></button></div>'
162
+ + '<div class="k-peek-stage" id="peek-stage"><img id="peek-img" alt="">'
163
+ // nofullscreen: host iframes are never granted allow="fullscreen", so the
164
+ // native button is always greyed out. The lightbox IS the fullscreen view.
165
+ + '<video id="peek-video" playsinline controls controlslist="nofullscreen"></video></div>'
166
+ + '<button type="button" class="k-peek-nav prev" id="peek-prev" aria-label="Previous"></button>'
167
+ + '<button type="button" class="k-peek-nav next" id="peek-next" aria-label="Next"></button>';
158
168
  card.appendChild(box);
159
169
  el('peek-close').innerHTML = ICONS.x;
170
+ el('peek-dl').innerHTML = ICONS.download;
171
+ el('peek-prev').innerHTML = ICONS.chevronLeft;
172
+ el('peek-next').innerHTML = ICONS.chevronRight;
160
173
  el('peek-close').onclick = function (e) { e.stopPropagation(); closePeek(); };
161
- box.onclick = function (e) { if (e.target === box) closePeek(); };
174
+ el('peek-prev').onclick = function (e) { e.stopPropagation(); stepPeek(-1); };
175
+ el('peek-next').onclick = function (e) { e.stopPropagation(); stepPeek(1); };
176
+ el('peek-dl').onclick = function (e) {
177
+ e.stopPropagation();
178
+ var it = peekList[peekIndex];
179
+ if (it) window.kolbo.openLink(downloadUrl(it.dl || it.url));
180
+ };
181
+ box.onclick = function (e) { if (e.target === box || e.target === el('peek-stage')) closePeek(); };
162
182
  return box;
163
183
  }
164
184
  function peekAttrs(url, kind, cap) {
165
185
  if (!url) return '';
166
186
  return ' data-peek="' + esc(url) + '" data-peek-kind="' + esc(kind || 'image') + '" data-peek-cap="' + esc(cap || '') + '"';
167
187
  }
168
- function bindPeekHits(root) {
169
- if (!root) return;
170
- Array.prototype.forEach.call(root.querySelectorAll('[data-peek]'), function (node) {
171
- node.onclick = function (e) {
172
- e.preventDefault();
173
- e.stopPropagation();
174
- openPeek(node.getAttribute('data-peek'), node.getAttribute('data-peek-kind'), node.getAttribute('data-peek-cap'));
175
- };
176
- });
188
+ function peekItem(node) {
189
+ return {
190
+ url: node.getAttribute('data-peek'),
191
+ kind: node.getAttribute('data-peek-kind') || 'image',
192
+ cap: node.getAttribute('data-peek-cap') || '',
193
+ dl: node.getAttribute('data-peek-dl') || ''
194
+ };
177
195
  }
178
- // Whether THIS peek took the iframe fullscreen (vs. opening inside a card the
179
- // user had already fullscreened) — only then does closing it hand the mode back.
196
+ document.addEventListener('click', function (e) {
197
+ var node = e.target && e.target.closest && e.target.closest('[data-peek]');
198
+ if (!node || node.closest('#peek')) return;
199
+ // A control INSIDE a previewable tile (download, use, a player) keeps its own job.
200
+ var ctl = e.target.closest('button, a, audio, video[controls]');
201
+ if (ctl && ctl !== node && node.contains(ctl)) return;
202
+ e.preventDefault();
203
+ e.stopPropagation();
204
+ var scope = node.closest('#stage') || node.parentNode;
205
+ var seen = {};
206
+ var list = [];
207
+ Array.prototype.forEach.call(scope.querySelectorAll('[data-peek]'), function (n) {
208
+ var it = peekItem(n);
209
+ if (!it.url || seen[it.url]) return;
210
+ seen[it.url] = 1;
211
+ list.push(it);
212
+ });
213
+ var at = peekItem(node);
214
+ openPeek(at.url, at.kind, at.cap, { list: list, time: +node.getAttribute('data-peek-time') || 0 });
215
+ }, true);
216
+ var peekList = [];
217
+ var peekIndex = 0;
218
+ // Whether THIS peek took the iframe fullscreen — only then does closing it
219
+ // hand the display mode back.
180
220
  var peekWentFullscreen = false;
181
- function openPeek(url, kind, cap) {
182
- if (!url) return;
183
- var box = ensurePeek();
221
+ function showPeekItem(time) {
222
+ var it = peekList[peekIndex];
184
223
  var img = el('peek-img');
185
224
  var vid = el('peek-video');
186
- var video = kind === 'video';
187
- if (video) { vid.setAttribute('src', url); img.removeAttribute('src'); }
188
- else { img.setAttribute('src', url); vid.removeAttribute('src'); }
189
- el('peek-cap').textContent = cap || '';
225
+ if (it.kind === 'video') {
226
+ img.removeAttribute('src');
227
+ vid.setAttribute('src', it.url);
228
+ if (time) vid.addEventListener('loadedmetadata', function () { try { vid.currentTime = time; } catch (e) {} }, { once: true });
229
+ var p = vid.play && vid.play();
230
+ if (p && p.catch) p.catch(function () {});
231
+ } else {
232
+ try { vid.pause(); } catch (e) {}
233
+ vid.removeAttribute('src');
234
+ img.setAttribute('src', it.url);
235
+ }
236
+ el('peek-cap').textContent = it.cap || '';
237
+ var many = peekList.length > 1;
238
+ el('peek-count').textContent = many ? (peekIndex + 1) + ' / ' + peekList.length : '';
239
+ el('peek-prev').hidden = !many;
240
+ el('peek-next').hidden = !many;
241
+ }
242
+ function stepPeek(d) {
243
+ var box = el('peek');
244
+ if (!box || box.hidden || peekList.length < 2) return;
245
+ peekIndex = (peekIndex + d + peekList.length) % peekList.length;
246
+ showPeekItem(0);
247
+ }
248
+ function openPeek(url, kind, cap, opts) {
249
+ if (!url) return;
250
+ opts = opts || {};
251
+ var box = ensurePeek();
252
+ peekList = [{ url: url, kind: kind || 'image', cap: cap || '' }];
253
+ peekIndex = 0;
254
+ (opts.list || []).forEach(function (it, i) { if (it.url === url) { peekList = opts.list; peekIndex = i; } });
255
+ // Pause inline players so two soundtracks never overlap.
256
+ Array.prototype.forEach.call(document.querySelectorAll('video'), function (v) {
257
+ if (v.id !== 'peek-video') { try { v.pause(); } catch (e) {} }
258
+ });
259
+ showPeekItem(opts.time || 0);
190
260
  box.hidden = false;
261
+ document.documentElement.classList.add('k-peek-open');
191
262
  if (window.kolbo && window.kolbo.notifySize) window.kolbo.notifySize();
192
- // Go big when the host allows it: the overlay is ALREADY visible inside the
193
- // card, so a host that refuses fullscreen (or never answers the request)
194
- // still shows the preview — the failure mode is "smaller", never "nothing".
195
- // Skip the request when the card itself is fullscreen; the overlay covers it.
196
- if (!window.kolbo || !window.kolbo.requestDisplayMode) return;
197
- if (document.documentElement.classList.contains('k-fullscreen')) return;
263
+ if (peekWentFullscreen || !window.kolbo || !window.kolbo.requestDisplayMode) return;
198
264
  try {
199
265
  window.kolbo.requestDisplayMode('fullscreen').then(function (res) {
200
- // The user may have closed the peek before the host answered — hand the
201
- // grant straight back instead of fullscreening an empty card.
202
266
  if (!(res && res.mode === 'fullscreen')) return;
267
+ // Closed before the host answered — hand the grant straight back.
203
268
  if (box.hidden) { window.kolbo.requestDisplayMode('inline').catch(function () {}); return; }
204
269
  peekWentFullscreen = true;
205
270
  document.documentElement.classList.add('k-peek-fs');
@@ -207,22 +272,69 @@ function openPeek(url, kind, cap) {
207
272
  }).catch(function () {});
208
273
  } catch (e) {}
209
274
  }
210
- function closePeek() {
275
+ function closePeek(hostExited) {
211
276
  var box = el('peek');
212
277
  if (!box || box.hidden) return;
213
278
  box.hidden = true;
279
+ document.documentElement.classList.remove('k-peek-open');
214
280
  el('peek-img').removeAttribute('src');
215
281
  var vid = el('peek-video');
216
282
  if (vid) { try { vid.pause(); } catch (e) {} vid.removeAttribute('src'); }
217
283
  if (peekWentFullscreen) {
218
284
  peekWentFullscreen = false;
219
285
  document.documentElement.classList.remove('k-peek-fs');
220
- try { window.kolbo.requestDisplayMode('inline').catch(function () {}); } catch (e) {}
286
+ if (hostExited !== true) { try { window.kolbo.requestDisplayMode('inline').catch(function () {}); } catch (e) {} }
221
287
  if (window.kolbo.setFullscreen) window.kolbo.setFullscreen(false);
222
288
  }
223
289
  if (window.kolbo && window.kolbo.notifySize) window.kolbo.notifySize();
224
290
  }
225
- document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closePeek(); });
291
+ // The host's own exit control (claude.ai's fullscreen X / Esc) drops the card
292
+ // back to inline without asking us — close the lightbox along with it.
293
+ window.kolbo.onThemeChange(function (ctx) {
294
+ if (peekWentFullscreen && ctx && ctx.displayMode && ctx.displayMode !== 'fullscreen') closePeek(true);
295
+ });
296
+ document.addEventListener('keydown', function (e) {
297
+ if (e.key === 'Escape') closePeek();
298
+ else if (e.key === 'ArrowLeft') stepPeek(-1);
299
+ else if (e.key === 'ArrowRight') stepPeek(1);
300
+ });
301
+ // Every inline <video controls> gets an Expand button (and double-click) that
302
+ // opens the lightbox at the current time; the native fullscreen button is
303
+ // hidden because the host iframe can never honour it. A MutationObserver
304
+ // covers every render path (results, batch/status grids, scenes, media-grid
305
+ // play swap) with no per-widget wiring.
306
+ function wireVideoExpand(root) {
307
+ if (!root || !root.querySelectorAll) return;
308
+ var vids = root.tagName === 'VIDEO' ? [root] : root.querySelectorAll('video[controls]');
309
+ Array.prototype.forEach.call(vids, function (v) {
310
+ if (v.id === 'peek-video' || !v.controls || v.getAttribute('data-expand') || !v.parentNode) return;
311
+ var src = v.getAttribute('src');
312
+ if (!src) return;
313
+ v.setAttribute('data-expand', '1');
314
+ v.setAttribute('controlslist', 'nofullscreen');
315
+ var b = document.createElement('button');
316
+ b.type = 'button';
317
+ b.className = 'k-vexpand';
318
+ b.title = 'Expand';
319
+ b.setAttribute('aria-label', 'Expand video');
320
+ b.innerHTML = ICONS.expand;
321
+ b.setAttribute('data-peek', src);
322
+ b.setAttribute('data-peek-kind', 'video');
323
+ var holder = v.parentNode.closest && v.parentNode.closest('[title]');
324
+ if (holder) b.setAttribute('data-peek-cap', holder.getAttribute('title'));
325
+ // Resume where the inline player was.
326
+ function mark() { b.setAttribute('data-peek-time', String(v.currentTime || 0)); }
327
+ b.addEventListener('pointerdown', mark);
328
+ b.addEventListener('keydown', mark);
329
+ v.addEventListener('dblclick', function (e) { e.preventDefault(); mark(); b.click(); });
330
+ v.parentNode.insertBefore(b, v.nextSibling);
331
+ });
332
+ }
333
+ if (window.MutationObserver) {
334
+ new MutationObserver(function (muts) {
335
+ muts.forEach(function (m) { Array.prototype.forEach.call(m.addedNodes, wireVideoExpand); });
336
+ }).observe(document.documentElement, { childList: true, subtree: true });
337
+ }
226
338
  // Pull structuredContent out of a tools/call result (host bridge shape).
227
339
  function structured(res) {
228
340
  if (!res) return null;
package/src/apps/theme.js CHANGED
@@ -85,7 +85,7 @@ body {
85
85
  .k-prompt { color: var(--text-muted); font-size: 12.5px; margin-bottom: 4px; word-break: break-word;
86
86
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
87
87
  user-select: text; -webkit-user-select: text; cursor: text; }
88
- .k-prompt.expanded { -webkit-line-clamp: unset; white-space: pre-wrap; }
88
+ .k-prompt.expanded { -webkit-line-clamp: unset; white-space: pre-wrap; }
89
89
  .k-text-tools { display: flex; gap: 2px; justify-content: flex-end; margin: 0 0 10px; }
90
90
  .k-text-btn {
91
91
  display: inline-flex; align-items: center; gap: 4px;
@@ -138,27 +138,58 @@ body {
138
138
  .k-peek-hit { cursor: zoom-in; }
139
139
  .k-peek {
140
140
  position: absolute; inset: 0; z-index: 20;
141
- display: flex; flex-direction: column; align-items: center; justify-content: center;
142
- gap: 10px; padding: 28px 16px 16px;
143
- background: rgba(8, 10, 16, 0.92);
141
+ display: flex; flex-direction: column;
142
+ background: rgba(6, 8, 12, 0.96);
144
143
  }
145
144
  .k-peek[hidden] { display: none !important; }
146
- .k-peek img, .k-peek video {
147
- display: none; max-width: 100%; max-height: min(360px, 70vh);
148
- border-radius: 10px; object-fit: contain; background: #000;
145
+ /* An inline lightbox needs room: a short card (one list row, a 3-tile grid)
146
+ used to clip the media to a sliver. The card grows while it is open and
147
+ notifySize() asks the host for the height. */
148
+ html.k-peek-open .k-card { min-height: 460px; }
149
+ .k-peek-bar { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px 8px 8px 14px; min-height: 44px; }
150
+ .k-peek-cap { flex: 1; min-width: 0; font-size: 12.5px; color: rgba(255,255,255,0.78);
151
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
152
+ .k-peek-count { flex: none; font-size: 11.5px; color: rgba(255,255,255,0.55); font-family: 'JetBrains Mono', ui-monospace, monospace; }
153
+ .k-peek-btn {
154
+ flex: none; width: 32px; height: 32px; border: 1px solid rgba(255,255,255,0.14); border-radius: 999px;
155
+ background: rgba(255,255,255,0.08); color: #fff; cursor: pointer; font-size: 15px;
156
+ display: inline-flex; align-items: center; justify-content: center;
157
+ }
158
+ .k-peek-btn:hover { background: var(--brand); border-color: var(--brand); }
159
+ /* min-height:0 + max 100% on BOTH axes: the media is contained in whatever box
160
+ the lightbox has, never cropped and never overflowing it. */
161
+ .k-peek-stage { flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; padding: 0 12px 12px; }
162
+ .k-peek-stage img, .k-peek-stage video {
163
+ display: none; max-width: 100%; max-height: 100%; width: auto; height: auto;
164
+ object-fit: contain; border-radius: 8px; background: #000;
149
165
  }
150
- .k-peek img[src], .k-peek video[src] { display: block; }
151
- .k-peek-close {
152
- position: absolute; top: 8px; right: 8px;
153
- width: 28px; height: 28px; border: 0; border-radius: 999px;
154
- background: var(--surface-2); color: var(--text); cursor: pointer;
166
+ .k-peek-stage img[src], .k-peek-stage video[src] { display: block; }
167
+ .k-peek-stage video[src] { width: 100%; height: 100%; }
168
+ .k-peek-nav {
169
+ position: absolute; top: 50%; transform: translateY(-50%); z-index: 2;
170
+ width: 40px; height: 40px; border: 1px solid rgba(255,255,255,0.14); border-radius: 999px;
171
+ background: rgba(0,0,0,0.55); color: #fff; cursor: pointer; font-size: 20px;
155
172
  display: inline-flex; align-items: center; justify-content: center;
156
173
  }
157
- .k-peek-cap { font-size: 12px; color: var(--text-muted); text-align: center; max-width: 100%; word-break: break-word; }
158
- /* Peek granted host fullscreen: the overlay owns the whole viewport, not the card. */
174
+ .k-peek-nav[hidden] { display: none; }
175
+ .k-peek-nav:hover { background: var(--brand); border-color: var(--brand); }
176
+ .k-peek-nav.prev { left: 10px; }
177
+ .k-peek-nav.next { right: 10px; }
178
+ /* Host granted fullscreen: the lightbox owns the whole viewport, not the card. */
159
179
  html.k-peek-fs, html.k-peek-fs body { height: 100%; overflow: hidden; }
160
180
  html.k-peek-fs .k-peek { position: fixed; inset: 0; z-index: 50; }
161
- html.k-peek-fs .k-peek img, html.k-peek-fs .k-peek video { max-height: calc(100vh - 72px); }
181
+ html.k-peek-fs .k-card { min-height: 0; }
182
+ /* Expand button on every inline video — replaces the native fullscreen button,
183
+ which a host iframe without allow="fullscreen" always greys out. */
184
+ .k-vexpand {
185
+ position: absolute; top: 8px; left: 8px; z-index: 3;
186
+ width: 30px; height: 30px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.18);
187
+ background: rgba(0, 0, 0, 0.62); color: #fff; font-size: 14px; cursor: pointer;
188
+ display: inline-flex; align-items: center; justify-content: center;
189
+ }
190
+ .k-vexpand:hover { background: var(--brand); border-color: var(--brand); }
191
+ /* Recent Chromium still draws the native button despite controlslist. */
192
+ video::-webkit-media-controls-fullscreen-button { display: none !important; }
162
193
  /* ---- Plans / upgrade card ---- */
163
194
  .k-plan-toggle { display: inline-flex; gap: 2px; padding: 3px; margin-bottom: 12px;
164
195
  background: var(--surface-2); border: 1px solid var(--border); border-radius: 999px; }
@@ -293,21 +324,6 @@ html.k-peek-fs .k-peek img, html.k-peek-fs .k-peek video { max-height: calc(100v
293
324
  border-radius: 12px; background: #000; border: 1px solid var(--border); cursor: zoom-in; }
294
325
  .k-viewer video { cursor: default; }
295
326
 
296
- /* ---- Fullscreen (ui/request-display-mode granted) ----
297
- position:fixed pins the card to the iframe viewport regardless of document
298
- flow/host sizing quirks — the media physically cannot exceed the screen. */
299
- html.k-fullscreen, html.k-fullscreen body { height: 100%; overflow: hidden; }
300
- html.k-fullscreen .k-card { position: fixed; inset: 0; display: flex; flex-direction: column; border-radius: 0; }
301
- html.k-fullscreen .k-body { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
302
- html.k-fullscreen .k-prompt, html.k-fullscreen .k-chips { flex: none; }
303
- html.k-fullscreen #stage { flex: 1; min-height: 0; display: flex; flex-direction: column; }
304
- html.k-fullscreen .k-viewer { flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; margin-bottom: 6px; }
305
- html.k-fullscreen .k-viewer img, html.k-fullscreen .k-viewer video {
306
- max-height: 100%; max-width: 100%; height: auto;
307
- width: auto; margin: 0 auto; cursor: zoom-out; object-fit: contain; }
308
- html.k-fullscreen .k-thumbs { flex: none; }
309
- html.k-fullscreen .k-thumbs .k-thumb { width: 56px; height: 56px; }
310
- html.k-fullscreen .k-actions { flex: none; padding-top: 8px; }
311
327
  .k-expand-hint { display: none; }
312
328
  .k-thumbs { display: flex; gap: 6px; margin: 10px 0 2px; }
313
329
  .k-thumbs .k-thumb { width: 48px; height: 48px; border-radius: 8px; overflow: hidden; cursor: pointer;
@@ -335,7 +335,6 @@ function renderChips(sc) {
335
335
  if (sc.count > 1) h += chip('×' + sc.count);
336
336
  h += referenceHTML(sc);
337
337
  el('chips').innerHTML = h;
338
- bindPeekHits(el('chips'));
339
338
  }
340
339
 
341
340
  var REF_VIDEO_RE = /\\.(mp4|mov|webm|mkv|avi|m4v)(\\?|#|$)/i;
@@ -920,13 +919,6 @@ function renderImages(sc, urls) {
920
919
  else renderLinks(urls);
921
920
  window.kolbo.notifySize();
922
921
  };
923
- // Click → expand into an in-Claude fullscreen viewer (all actions stay
924
- // available); click again (or Exit) collapses back. Hosts that refuse
925
- // fullscreen fall back to opening the original in a new tab.
926
- setTimeout(function () {
927
- var img = el('main-img');
928
- if (img) img.onclick = toggleFullscreen;
929
- }, 0);
930
922
  var thumbs = '';
931
923
  if (urls.length > 1) {
932
924
  thumbs = '<div class="k-thumbs">' + urls.map(function (u, i) {
@@ -935,6 +927,10 @@ function renderImages(sc, urls) {
935
927
  }
936
928
  el('stage').innerHTML = viewer + thumbs;
937
929
  wireDlButtons(el('stage'));
930
+ // Click → lightbox (host fullscreen when granted), paging through every result.
931
+ el('main-img').onclick = function () {
932
+ openPeek(state.urls[selected], 'image', '', { list: state.urls.map(function (u) { return { url: u, kind: 'image' }; }) });
933
+ };
938
934
  Array.prototype.forEach.call(el('stage').querySelectorAll('.k-thumb'), function (t) {
939
935
  t.onclick = function () {
940
936
  selected = +t.getAttribute('data-i');
@@ -1094,20 +1090,11 @@ function renderBatchGrid(sc) {
1094
1090
  (it.label ? ' title="' + esc(it.label) + '"' : '') + '>' +
1095
1091
  (it.type === 'video'
1096
1092
  ? '<video class="k-cell-fill" src="' + esc(it.url) + '" controls playsinline preload="metadata"></video>'
1097
- : '<img class="k-cell-fill" src="' + esc(it.url) + '" alt="" loading="lazy" style="cursor:zoom-in">') +
1093
+ : '<img class="k-cell-fill k-peek-hit" src="' + esc(it.url) + '" alt="" loading="lazy"' + peekAttrs(it.url, 'image', it.label) + '>') +
1098
1094
  (it.label ? '<span class="k-skel-cap" title="' + esc(it.label) + '">' + esc(it.label) + '</span>' : '') +
1099
1095
  dlBtnHTML(it.url) + '</div>';
1100
1096
  }).join('') + '</div>';
1101
1097
  wireDlButtons(el('stage'));
1102
- // In-widget popup, not the host round-trip focusMedia() uses — a batch grid
1103
- // tile has no visible full-size image otherwise, so a host that never resolves
1104
- // requestDisplayMode() (or drops window.open after the async round trip eats
1105
- // the click's user-activation window) leaves the click doing nothing at all.
1106
- Array.prototype.forEach.call(el('stage').querySelectorAll('[data-focus]'), function (cell) {
1107
- var it = items[+cell.getAttribute('data-focus')];
1108
- if (it.type !== 'image') return; // <video controls> owns its own clicks
1109
- cell.onclick = function () { openPeek(it.url, 'image', it.label); };
1110
- });
1111
1098
  renderActions(sc);
1112
1099
  window.kolbo.notifySize();
1113
1100
  }
@@ -1142,7 +1129,7 @@ function renderStatusGrid(sc) {
1142
1129
  ? '<video class="k-cell-fill" src="' + esc(it.url) + '" controls playsinline preload="metadata"></video>'
1143
1130
  : it.kind === 'audio'
1144
1131
  ? '<div class="k-cell-fill" style="display:flex;align-items:center;justify-content:center">' + ICONS.sound + '</div>'
1145
- : '<img class="k-cell-fill" src="' + esc(it.url) + '" alt="" loading="lazy" style="cursor:zoom-in">') +
1132
+ : '<img class="k-cell-fill k-peek-hit" src="' + esc(it.url) + '" alt="" loading="lazy"' + peekAttrs(it.url, 'image', it.title) + '>') +
1146
1133
  cap + dlBtnHTML(it.url) + '</div>';
1147
1134
  }
1148
1135
  var failed = it.state === 'failed' || it.state === 'cancelled';
@@ -1152,11 +1139,6 @@ function renderStatusGrid(sc) {
1152
1139
  return '<div class="k-skel square">' + badge + cap + '</div>';
1153
1140
  }).join('') + '</div>';
1154
1141
  wireDlButtons(el('stage'));
1155
- Array.prototype.forEach.call(el('stage').querySelectorAll('[data-focus]'), function (cell) {
1156
- var it = items[+cell.getAttribute('data-focus')];
1157
- if (it.kind !== 'image') return; // <video controls> owns its own clicks
1158
- cell.onclick = function () { openPeek(it.url, 'image', it.title); };
1159
- });
1160
1142
  renderActions(sc);
1161
1143
  window.kolbo.notifySize();
1162
1144
  }
@@ -1182,8 +1164,9 @@ function renderScenes(sc) {
1182
1164
  makeExpandable(el('scene-cap'), it.label);
1183
1165
  wireDlButtons(el('stage'));
1184
1166
  if (it.type === 'image') {
1185
- var main = el('scene-main');
1186
- if (main) main.onclick = function () { focusMedia(it.url); };
1167
+ el('scene-main').onclick = function () {
1168
+ openPeek(it.url, 'image', it.label, { list: items.map(function (x) { return { url: x.url, kind: x.type === 'video' ? 'video' : 'image', cap: x.label }; }) });
1169
+ };
1187
1170
  }
1188
1171
  Array.prototype.forEach.call(el('stage').querySelectorAll('.k-thumb'), function (t) {
1189
1172
  t.onclick = function () { selected = +t.getAttribute('data-i'); renderScenes(sc); window.kolbo.notifySize(); };
@@ -1191,27 +1174,6 @@ function renderScenes(sc) {
1191
1174
  renderActions(sc);
1192
1175
  }
1193
1176
 
1194
- // Fullscreen a single item out of a multi-item grid (Creative Director
1195
- // scenes). Exit restores the grid.
1196
- function focusMedia(url) {
1197
- window.kolbo.requestDisplayMode('fullscreen').then(function (res) {
1198
- if (!(res && res.mode === 'fullscreen')) return window.kolbo.openLink(url);
1199
- isFullscreen = true;
1200
- el('stage').innerHTML = '<div class="k-viewer"><img id="focus-img" src="' + esc(url) + '" alt="" style="cursor:zoom-out">' + dlBtnHTML(url) + '</div>';
1201
- wireDlButtons(el('stage'));
1202
- el('focus-img').onclick = exitFocus;
1203
- applyFullscreen(true, exitFocus);
1204
- window.kolbo.notifySize();
1205
- }).catch(function () { window.kolbo.openLink(url); });
1206
- }
1207
- function exitFocus() {
1208
- window.kolbo.requestDisplayMode('inline').catch(function () {});
1209
- isFullscreen = false;
1210
- applyFullscreen(false);
1211
- renderResult(state); // restore whichever multi-item view we came from
1212
- window.kolbo.notifySize();
1213
- }
1214
-
1215
1177
  function renderError(msg) {
1216
1178
  clearTimeout(pollTimer);
1217
1179
 
@@ -1241,38 +1203,6 @@ function renderTrackingIssue(msg) {
1241
1203
  window.kolbo.notifySize();
1242
1204
  }
1243
1205
 
1244
- /* ---------- fullscreen viewer ---------- */
1245
- var isFullscreen = false;
1246
- function toggleFullscreen() {
1247
- var want = isFullscreen ? 'inline' : 'fullscreen';
1248
- window.kolbo.requestDisplayMode(want).then(function (res) {
1249
- var granted = res && res.mode;
1250
- if (granted === 'fullscreen') { isFullscreen = true; applyFullscreen(true); }
1251
- else if (granted === 'inline' || isFullscreen) { isFullscreen = false; applyFullscreen(false); }
1252
- else if (!isFullscreen) {
1253
- // Host refused fullscreen — degrade to opening the original file.
1254
- window.kolbo.openLink(state.urls && state.urls[selected]);
1255
- }
1256
- }).catch(function () {
1257
- if (!isFullscreen) window.kolbo.openLink(state.urls && state.urls[selected]);
1258
- });
1259
- }
1260
- function applyFullscreen(on, exitHandler) {
1261
- document.documentElement.classList.toggle('k-fullscreen', on);
1262
- if (window.kolbo.setFullscreen) window.kolbo.setFullscreen(on);
1263
- var c = el('phase-chip');
1264
- if (on) {
1265
- c.style.display = '';
1266
- c.innerHTML = ICONS.x + ' ' + esc('Exit');
1267
- c.style.cursor = 'pointer';
1268
- c.onclick = exitHandler || toggleFullscreen;
1269
- } else {
1270
- c.style.display = 'none';
1271
- c.onclick = null;
1272
- c.style.cursor = '';
1273
- }
1274
- window.kolbo.notifySize();
1275
- }
1276
1206
 
1277
1207
  function setPhaseChip(text, spinning) {
1278
1208
  var c = el('phase-chip');
@@ -151,7 +151,6 @@ function itemHTML(item) {
151
151
 
152
152
  function wire() {
153
153
  var stage = el('stage');
154
- bindPeekHits(stage);
155
154
  Array.prototype.forEach.call(stage.querySelectorAll('[data-open]'), function (b) {
156
155
  b.onclick = function (e) {
157
156
  e.stopPropagation();
@@ -180,7 +180,6 @@ function audioRowHTML(item) {
180
180
 
181
181
  function wire() {
182
182
  var stage = el('stage');
183
- bindPeekHits(stage);
184
183
  Array.prototype.forEach.call(stage.querySelectorAll('.k-tile, .k-audio-row'), function (c) {
185
184
  c.onclick = function (e) {
186
185
  if (e.target && e.target.closest && e.target.closest('[data-peek],[data-use],[data-dl],[data-video-play]')) return;