@kolbo/mcp 1.79.7 → 1.80.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.79.7",
3
+ "version": "1.80.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": {
@@ -1,6 +1,6 @@
1
1
  # AUTO-GENERATED — do not edit
2
2
 
3
- This tree is mirrored from kolbo-code@fccda44, the single source of truth.
3
+ This tree is mirrored from kolbo-code@b39d555, the single source of truth.
4
4
  Canonical source: packages/opencode/skills/kolbo/
5
5
  Distribution: .github/workflows/sync-skill-to-plugin.yml
6
6
 
@@ -23,6 +23,7 @@
23
23
  * .callTool(name, args) — Promise<CallToolResult>
24
24
  * .sendMessage(text) — append a user chat message (returns Promise)
25
25
  * .openLink(url) — open external URL
26
+ * .copyText(text) — copy via the host clipboard (ui/copy-text)
26
27
  * .notifySize() — report content size to host
27
28
  */
28
29
 
@@ -183,6 +184,7 @@ const BRIDGE_JS = `
183
184
  return request('ui/message', { role: 'user', content: [{ type: 'text', text: text }] });
184
185
  },
185
186
  openLink: function (url) { return request('ui/open-link', { url: url }); },
187
+ copyText: function (text) { return request('ui/copy-text', { text: text }); },
186
188
  // Hand a piece of this widget's media to the host's composer. Dragging it
187
189
  // out cannot work: a widget is a sandboxed cross-origin iframe, so a native
188
190
  // HTML5 drag started in here never delivers its dataTransfer to the host
package/src/apps/html.js CHANGED
@@ -57,7 +57,10 @@ var ICONS = {
57
57
  audio: _svg('<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>'),
58
58
  document: _svg('<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8"/><path d="M8 17h8"/>'),
59
59
  cube: _svg('<path d="M21 8l-9-5-9 5 9 5z"/><path d="M3 8v8l9 5 9-5V8"/><path d="M12 13v8"/>'),
60
- file: _svg('<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/>')
60
+ file: _svg('<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/>'),
61
+ copy: _svg('<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>'),
62
+ chevronDown: _svg('<path d="M6 9l6 6 6-6"/>'),
63
+ chevronUp: _svg('<path d="M18 15l-6-6-6 6"/>')
61
64
  };
62
65
  // Media-kind → icon (accepts model kind / media_type strings).
63
66
  function kindIcon(kind) {
@@ -82,6 +85,36 @@ function downloadUrl(u) {
82
85
  if (!u) return u;
83
86
  return 'https://api.kolbo.ai/mcp/download?url=' + encodeURIComponent(u);
84
87
  }
88
+ // Copy from a sandboxed widget iframe. Order: Clipboard API (Kolbo Code
89
+ // grants clipboard-write) → execCommand → host ui/copy-text (parent clipboard).
90
+ function writeClipboard(text) {
91
+ text = String(text || '');
92
+ function fallback() {
93
+ if (!document.body) return false;
94
+ var ta = document.createElement('textarea');
95
+ ta.value = text;
96
+ ta.setAttribute('readonly', '');
97
+ ta.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0';
98
+ document.body.appendChild(ta);
99
+ ta.focus();
100
+ ta.select();
101
+ var ok = false;
102
+ try { ok = document.execCommand('copy'); } catch (e) {}
103
+ document.body.removeChild(ta);
104
+ return ok;
105
+ }
106
+ function host() {
107
+ if (!window.kolbo || !window.kolbo.copyText) return Promise.resolve(false);
108
+ return window.kolbo.copyText(text).then(function () { return true; }).catch(function () { return false; });
109
+ }
110
+ if (navigator.clipboard && navigator.clipboard.writeText) {
111
+ return navigator.clipboard.writeText(text).then(function () { return true; }).catch(function () {
112
+ return fallback() ? Promise.resolve(true) : host();
113
+ });
114
+ }
115
+ if (fallback()) return Promise.resolve(true);
116
+ return host();
117
+ }
85
118
  function fmtCredits(n) { return (n == null) ? '' : (Math.round(n * 100) / 100) + ' cr'; }
86
119
  function fmtDur(s) { if (s == null) return ''; s = Math.round(s); return s >= 60 ? Math.floor(s/60) + 'm ' + (s%60) + 's' : s + 's'; }
87
120
  function applyTheme(ctx) {
@@ -105,6 +138,62 @@ function modelChipHTML(name, iconUrl) {
105
138
  function monogram(name) {
106
139
  return '<span class="k-mono-icon">' + esc(String(name).trim().charAt(0).toUpperCase()) + '</span>';
107
140
  }
141
+ // In-widget preview overlay — every card (generation chips, media grid, list
142
+ // rows) uses this so a DNA / reference / library thumb opens a popup in Kolbo
143
+ // Code, Claude Code, Codex, and any other host. Host fullscreen is not required.
144
+ function ensurePeek() {
145
+ if (el('peek')) return el('peek');
146
+ var card = document.querySelector('.k-card') || document.body;
147
+ var box = document.createElement('div');
148
+ box.id = 'peek';
149
+ box.className = 'k-peek';
150
+ box.hidden = true;
151
+ box.innerHTML = '<button type="button" class="k-peek-close" id="peek-close" aria-label="Close"></button>'
152
+ + '<img id="peek-img" alt="">'
153
+ + '<video id="peek-video" muted playsinline controls></video>'
154
+ + '<div class="k-peek-cap" id="peek-cap"></div>';
155
+ card.appendChild(box);
156
+ el('peek-close').innerHTML = ICONS.x;
157
+ el('peek-close').onclick = function (e) { e.stopPropagation(); closePeek(); };
158
+ box.onclick = function (e) { if (e.target === box) closePeek(); };
159
+ return box;
160
+ }
161
+ function peekAttrs(url, kind, cap) {
162
+ if (!url) return '';
163
+ return ' data-peek="' + esc(url) + '" data-peek-kind="' + esc(kind || 'image') + '" data-peek-cap="' + esc(cap || '') + '"';
164
+ }
165
+ function bindPeekHits(root) {
166
+ if (!root) return;
167
+ Array.prototype.forEach.call(root.querySelectorAll('[data-peek]'), function (node) {
168
+ node.onclick = function (e) {
169
+ e.preventDefault();
170
+ e.stopPropagation();
171
+ openPeek(node.getAttribute('data-peek'), node.getAttribute('data-peek-kind'), node.getAttribute('data-peek-cap'));
172
+ };
173
+ });
174
+ }
175
+ function openPeek(url, kind, cap) {
176
+ if (!url) return;
177
+ var box = ensurePeek();
178
+ var img = el('peek-img');
179
+ var vid = el('peek-video');
180
+ var video = kind === 'video';
181
+ if (video) { vid.setAttribute('src', url); img.removeAttribute('src'); }
182
+ else { img.setAttribute('src', url); vid.removeAttribute('src'); }
183
+ el('peek-cap').textContent = cap || '';
184
+ box.hidden = false;
185
+ if (window.kolbo && window.kolbo.notifySize) window.kolbo.notifySize();
186
+ }
187
+ function closePeek() {
188
+ var box = el('peek');
189
+ if (!box || box.hidden) return;
190
+ box.hidden = true;
191
+ el('peek-img').removeAttribute('src');
192
+ var vid = el('peek-video');
193
+ if (vid) { try { vid.pause(); } catch (e) {} vid.removeAttribute('src'); }
194
+ if (window.kolbo && window.kolbo.notifySize) window.kolbo.notifySize();
195
+ }
196
+ document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closePeek(); });
108
197
  // Pull structuredContent out of a tools/call result (host bridge shape).
109
198
  function structured(res) {
110
199
  if (!res) return null;
package/src/apps/index.js CHANGED
@@ -193,8 +193,19 @@ const ICON_CDN_BASE = 'https://kolbo-general-media.fra1.cdn.digitaloceanspaces.c
193
193
 
194
194
  function resolveAvatarUrl(avatar) {
195
195
  if (!avatar) return null;
196
- if (/^https?:\/\//i.test(avatar)) return avatar;
197
- return `${ICON_CDN_BASE}/${encodeURIComponent(avatar)}`;
196
+ if (/^https?:\/\//i.test(avatar)) {
197
+ try {
198
+ const parsed = new URL(avatar);
199
+ const file = parsed.pathname.match(/\/(?:models_icons|assets)\/([^/]+)$/);
200
+ // api.kolbo.ai / app.kolbo.ai avatars 404 or get bot-blocked inside
201
+ // sandboxed widget iframes. The CDN copy is the same file.
202
+ if (file && /(?:^|\.)kolbo\.ai$|digitaloceanspaces\.com$/i.test(parsed.hostname)) {
203
+ return `${ICON_CDN_BASE}/${file[1]}`;
204
+ }
205
+ } catch (_) { /* keep the original absolute URL */ }
206
+ return avatar;
207
+ }
208
+ return `${ICON_CDN_BASE}/${encodeURIComponent(String(avatar).replace(/^\/+/, ''))}`;
198
209
  }
199
210
 
200
211
  async function modelCatalog(client) {
@@ -219,7 +230,11 @@ async function modelCatalog(client) {
219
230
  // the ONLY thing that tells two same-named variants apart. See canonicalModelId.
220
231
  const raw = m.types !== undefined ? m.types : m.type;
221
232
  const types = (Array.isArray(raw) ? raw : [raw]).filter(Boolean).map(String);
222
- const info = { icon, eta, id: m.identifier || null, name: m.name || null, types };
233
+ const aspects = Array.isArray(m.supported_aspect_ratios)
234
+ ? m.supported_aspect_ratios
235
+ : (Array.isArray(m.supportedAspectRatios) ? m.supportedAspectRatios : []);
236
+ const aspectsByType = m.supported_aspect_ratios_by_type || m.supportedAspectRatiosByType || null;
237
+ const info = { icon, eta, id: m.identifier || null, name: m.name || null, types, aspects, aspectsByType };
223
238
  all.push(info);
224
239
  // Display names collide across variants ("Nano Banana 2" names both the
225
240
  // t2i model and its editing sibling) — on collision keep the model with
@@ -251,8 +266,138 @@ async function modelInfoMap(client) {
251
266
  /** Resolve one model's { icon, eta, name }; missing → all null. */
252
267
  async function modelInfo(client, modelName) {
253
268
  if (!modelName) return { icon: null, eta: null, name: null };
254
- const map = await modelInfoMap(client);
255
- return map.get(String(modelName).toLowerCase()) || { icon: null, eta: null, name: null };
269
+ const catalog = await modelCatalog(client);
270
+ const exact = catalog.byKey.get(String(modelName).toLowerCase());
271
+ if (exact) return exact;
272
+ // Same separator-insensitive / prefix leniency as canonicalModelId — the
273
+ // chip used to miss "gpt-image-2" when the catalog only keyed the editor
274
+ // sibling, and the widget fell back to a first-letter monogram.
275
+ const want = normId(modelName);
276
+ if (!want) return { icon: null, eta: null, name: null };
277
+ const rows = catalog.all || [];
278
+ return rows.find((row) => normId(row.id) === want || normId(row.name) === want)
279
+ || rows.find((row) => normId(row.id).startsWith(want) || normId(row.name).startsWith(want))
280
+ || { icon: null, eta: null, name: null };
281
+ }
282
+
283
+ /**
284
+ * Snap a requested aspect ratio onto the catalog enum for that model.
285
+ * Every image/video generate_* tool must call this after canonicalModelId —
286
+ * list_models already prints supported_aspect_ratios, but the LLM still
287
+ * invents 21:9 / "widescreen" / "21/9". Fail open only when the catalog is
288
+ * empty or the model is unpublished (hidden ids still reach the API).
289
+ */
290
+ const ASPECT_ALIASES = {
291
+ square: '1:1',
292
+ landscape: '16:9',
293
+ widescreen: '16:9',
294
+ horizontal: '16:9',
295
+ portrait: '9:16',
296
+ vertical: '9:16',
297
+ story: '9:16',
298
+ stories: '9:16',
299
+ reel: '9:16',
300
+ reels: '9:16',
301
+ ultrawide: '21:9',
302
+ cinema: '21:9',
303
+ cinematic: '21:9',
304
+ };
305
+
306
+ function normalizeAspectRatio(requested) {
307
+ if (requested == null || requested === '') return requested;
308
+ const raw = String(requested).trim();
309
+ const lower = raw.toLowerCase();
310
+ if (lower === 'auto' || lower === 'adaptive') return lower;
311
+ if (ASPECT_ALIASES[lower]) return ASPECT_ALIASES[lower];
312
+ const pair = lower.match(/^(\d+(?:\.\d+)?)\s*[:x×/\-]\s*(\d+(?:\.\d+)?)$/);
313
+ if (!pair) return raw;
314
+ const a = Number(pair[1]);
315
+ const b = Number(pair[2]);
316
+ if (!Number.isFinite(a) || !Number.isFinite(b) || a <= 0 || b <= 0) return raw;
317
+ return Number.isInteger(a) && Number.isInteger(b) ? `${a}:${b}` : `${a}:${b}`;
318
+ }
319
+
320
+ function parseAspectDecimal(ratio) {
321
+ const normalized = normalizeAspectRatio(ratio);
322
+ const parts = String(normalized || '').split(':').map(Number);
323
+ if (parts.length !== 2 || parts.some((n) => !Number.isFinite(n) || n <= 0)) return 1;
324
+ return parts[0] / parts[1];
325
+ }
326
+
327
+ function aspectKey(ratio) {
328
+ return String(normalizeAspectRatio(ratio) || '').toLowerCase();
329
+ }
330
+
331
+ function closestAspectRatio(requested, supported) {
332
+ const normalized = normalizeAspectRatio(requested);
333
+ if (!normalized) return requested;
334
+ if (normalized === 'auto' || normalized === 'adaptive') return normalized;
335
+ if (!Array.isArray(supported) || supported.length === 0) return normalized;
336
+ const exact = supported.find((ratio) => aspectKey(ratio) === aspectKey(normalized));
337
+ if (exact) return exact;
338
+ const concrete = supported.filter((ratio) => {
339
+ const key = aspectKey(ratio);
340
+ return key && key !== 'auto' && key !== 'adaptive';
341
+ });
342
+ if (!concrete.length) return normalized;
343
+ const target = parseAspectDecimal(normalized);
344
+ let best = concrete[0];
345
+ let bestDist = Infinity;
346
+ for (const ratio of concrete) {
347
+ const dist = Math.abs(parseAspectDecimal(ratio) - target);
348
+ if (dist < bestDist) {
349
+ bestDist = dist;
350
+ best = ratio;
351
+ }
352
+ }
353
+ return best;
354
+ }
355
+
356
+ function findCatalogModel(catalog, modelId, type) {
357
+ if (!catalog || !modelId) return null;
358
+ const key = String(modelId).toLowerCase().trim();
359
+ const want = normId(key);
360
+ if (!want) return null;
361
+ const byKey = catalog.byKey && catalog.byKey.get(key);
362
+ if (byKey) return byKey;
363
+ const all = catalog.all || [];
364
+ const types = (Array.isArray(type) ? type : [type]).filter(Boolean);
365
+ const candidates = all.filter((info) =>
366
+ (info.id && (info.id.toLowerCase() === key || normId(info.id) === want))
367
+ || (info.name && (info.name.toLowerCase() === key || normId(info.name) === want))
368
+ );
369
+ if (!candidates.length) return null;
370
+ if (types.length) {
371
+ const typed = candidates.filter((info) => Array.isArray(info.types) && info.types.some((t) => types.includes(t)));
372
+ if (typed.length) return typed[0];
373
+ }
374
+ return candidates[0];
375
+ }
376
+
377
+ function supportedAspectsFor(info, type) {
378
+ if (!info) return [];
379
+ const byType = type && info.aspectsByType;
380
+ if (byType && typeof byType === 'object') {
381
+ const typed = Array.isArray(type)
382
+ ? type.map((t) => byType[t]).find((arr) => Array.isArray(arr) && arr.length)
383
+ : byType[type];
384
+ if (Array.isArray(typed) && typed.length) return typed;
385
+ }
386
+ return Array.isArray(info.aspects) ? info.aspects : [];
387
+ }
388
+
389
+ async function resolveCatalogAspectRatio(client, modelId, requested, type) {
390
+ if (!requested) return requested;
391
+ const normalized = normalizeAspectRatio(requested);
392
+ if (!modelId) return normalized;
393
+ try {
394
+ const catalog = await modelCatalog(client);
395
+ const info = findCatalogModel(catalog, modelId, type);
396
+ if (!info) return normalized;
397
+ return closestAspectRatio(normalized, supportedAspectsFor(info, type));
398
+ } catch (_) {
399
+ return normalized;
400
+ }
256
401
  }
257
402
 
258
403
  /* ------------------------------------------------------------------ */
@@ -512,6 +657,9 @@ module.exports = {
512
657
  modelInfoMap,
513
658
  voiceInfo,
514
659
  canonicalModelId,
660
+ normalizeAspectRatio,
661
+ closestAspectRatio,
662
+ resolveCatalogAspectRatio,
515
663
  resolveAvatarUrl,
516
664
  widgetHtml, // exported for smoke tests
517
665
  };
package/src/apps/theme.js CHANGED
@@ -59,6 +59,7 @@ body {
59
59
 
60
60
  /* ---- Card shell ---- */
61
61
  .k-card {
62
+ position: relative;
62
63
  background: var(--card);
63
64
  backdrop-filter: blur(20px) saturate(180%);
64
65
  -webkit-backdrop-filter: blur(20px) saturate(180%);
@@ -81,10 +82,20 @@ body {
81
82
  .k-head .k-spacer { flex: 1; }
82
83
 
83
84
  .k-body { padding: 14px 16px; }
84
- .k-prompt { color: var(--text-muted); font-size: 12.5px; margin-bottom: 10px; word-break: break-word;
85
- display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
86
- .k-prompt.k-clamped, .k-caption.k-clamped { cursor: pointer; }
85
+ .k-prompt { color: var(--text-muted); font-size: 12.5px; margin-bottom: 4px; word-break: break-word;
86
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
87
+ user-select: text; -webkit-user-select: text; cursor: text; }
87
88
  .k-prompt.expanded { -webkit-line-clamp: unset; }
89
+ .k-text-tools { display: flex; gap: 2px; justify-content: flex-end; margin: 0 0 10px; }
90
+ .k-text-btn {
91
+ display: inline-flex; align-items: center; gap: 4px;
92
+ padding: 3px 8px; border: 0; border-radius: 6px;
93
+ background: transparent; color: var(--text-faint);
94
+ font-size: 11px; font-weight: 600; font-family: inherit;
95
+ cursor: pointer;
96
+ }
97
+ .k-text-btn:hover { color: var(--text); background: var(--surface-2); }
98
+ .k-text-btn svg { width: 12px; height: 12px; }
88
99
  /* @VisualDNA / #Moodboard mentions are load-bearing prompt syntax, not prose —
89
100
  the server resolves them to the actual asset. Mark them so a glance at the
90
101
  prompt shows which references it pulls in. */
@@ -96,8 +107,10 @@ body {
96
107
  }
97
108
  /* Single-line media caption (scene / batch prompt under the viewer) */
98
109
  .k-caption { font-size: 11px; color: var(--text-faint); margin: 2px 2px 0;
99
- white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
110
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
111
+ user-select: text; -webkit-user-select: text; cursor: text; }
100
112
  .k-caption.expanded { white-space: normal; word-break: break-word; }
113
+ .k-caption + .k-text-tools { margin: 0 2px 6px; justify-content: flex-start; }
101
114
 
102
115
  /* ---- Chips ---- */
103
116
  .k-chips { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-bottom: 12px; }
@@ -122,6 +135,26 @@ body {
122
135
  }
123
136
  .k-chip img.k-voice-thumb { width: 18px; height: 18px; border-radius: 999px; margin-left: -3px; }
124
137
  .k-ref-thumb { width: 26px; height: 26px; border-radius: 6px; object-fit: cover; border: 1px solid var(--border-strong); }
138
+ .k-peek-hit { cursor: zoom-in; }
139
+ .k-peek {
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);
144
+ }
145
+ .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;
149
+ }
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;
155
+ display: inline-flex; align-items: center; justify-content: center;
156
+ }
157
+ .k-peek-cap { font-size: 12px; color: var(--text-muted); text-align: center; max-width: 100%; word-break: break-word; }
125
158
  /* Visual DNA chips: the character's face, so you can see WHICH DNA is locked in. */
126
159
  .k-dna-face { width: 18px; height: 18px; border-radius: 999px; object-fit: cover; margin-left: -3px; background: var(--border-strong); }
127
160
  .k-dna-stack .k-dna-stack-item { display: inline-flex; }
@@ -264,6 +297,10 @@ html.k-fullscreen .k-actions { flex: none; padding-top: 8px; }
264
297
  box-shadow: 0 2px 12px rgba(59, 130, 246, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.25); }
265
298
  .k-btn.primary:hover { background: #2f74e8; }
266
299
  .k-btn.ghost { background: transparent; box-shadow: none; border-color: transparent; color: var(--text-muted); }
300
+ .k-btn.danger { background: var(--error); border-color: var(--error); color: #fff;
301
+ box-shadow: 0 2px 12px rgba(239, 68, 68, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.25); }
302
+ .k-btn.danger:hover { background: #dc2626; }
303
+ .k-stop-ask { color: var(--text-muted); font-size: 12px; font-weight: 500; margin-right: 4px; }
267
304
  .k-btn.ghost:hover { color: var(--text); background: var(--surface-2); }
268
305
  .k-btn svg { width: 13px; height: 13px; }
269
306
  /* Inline SVG icons (replace emoji) — align with text, inherit color, never shrink. */
@@ -333,6 +370,7 @@ html.k-fullscreen .k-actions { flex: none; padding-top: 8px; }
333
370
  .k-prompt-row { flex-wrap: wrap; }
334
371
  .k-prompt-row .k-input { min-width: 0; flex: 1 1 100%; }
335
372
  .k-prompt-row .k-btn { flex: 1 1 auto; }
373
+ .k-text-btn { min-height: 32px; padding: 6px 10px; }
336
374
  }
337
375
  .k-play {
338
376
  width: 32px; height: 32px; border-radius: 50%; flex: none; border: 1px solid rgba(255,255,255,0.18);