@kolbo/mcp
Version:
Kolbo AI MCP Server - Generate images, videos, music, speech, and sound effects from Claude Code
553 lines (532 loc) • 32.2 kB
JavaScript
'use strict';
/**
* Kolbo Liquid Glass design system for MCP App widgets.
* Tokens extracted from kolbo-map (tailwind.config + index.css + liquid-glass.css):
* dark bg #0F0F0F, card #262626/90 + blur, brand #3b82f6, text #F5F5F2, Inter,
* specular top-edge highlight, spring easing cubic-bezier(.34,1.56,.64,1),
* skeleton-sweep shimmer, liquid-press buttons.
*/
const KOLBO_CSS = `
:root {
--bg: #0f0f0f;
/* A translucent lift, not a fixed colour: every host paints its own dark grey
behind the iframe (claude.ai, ChatGPT, Codex, Kolbo Code all differ), and a
fixed near-black card read as a hole cut into the chat. */
--card: rgba(255, 255, 255, 0.04);
--card-solid: #161619;
--panel: rgba(255, 255, 255, 0.035);
--surface: rgba(255, 255, 255, 0.03);
--surface-2: rgba(255, 255, 255, 0.06);
--border: rgba(255, 255, 255, 0.08);
--border-strong: rgba(255, 255, 255, 0.14);
--text: #f5f5f2;
--text-muted: rgba(245, 245, 242, 0.62);
--text-faint: rgba(245, 245, 242, 0.40);
--brand: #3b82f6;
--brand-soft: rgba(59, 130, 246, 0.16);
--success: #22c55e;
--error: #ef4444;
--warning: #f59e0b;
--radius-card: 18px;
--radius-btn: 10px;
--spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--smooth: cubic-bezier(0.25, 0.46, 0.45, 0.94);
--specular: inset 0 1px 0 rgba(255, 255, 255, 0.18);
/* "light dark" on the ROOT, never a fixed scheme. When an iframe's root
scheme differs from the host frame's, the browser paints an OPAQUE canvas
behind the page: a fixed "dark" here drew a dark square around every card
in hosts whose frame is light/normal (Codex). "light dark" adopts the host
frame's scheme, so the canvas stays transparent everywhere. The card
carries the real theme for its native controls below. */
color-scheme: light dark;
}
[data-theme="light"] {
--bg: #f5f5f2;
--card: rgba(255, 255, 255, 0.85);
--card-solid: #ffffff;
--panel: rgba(0, 0, 0, 0.035);
--surface: rgba(0, 0, 0, 0.02);
--surface-2: rgba(0, 0, 0, 0.04);
--border: rgba(0, 0, 0, 0.08);
--border-strong: rgba(0, 0, 0, 0.14);
--text: #0a0a0c;
--text-muted: rgba(10, 10, 12, 0.62);
--text-faint: rgba(10, 10, 12, 0.40);
--brand-soft: rgba(59, 130, 246, 0.10);
--specular: inset 0 1px 0 rgba(255, 255, 255, 0.65);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { background: transparent; }
.k-card { color-scheme: dark; }
[data-theme="light"] .k-card { color-scheme: light; }
body {
font-family: 'Poppins', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Arial, sans-serif;
color: var(--text);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
/* ---- Card shell ---- */
.k-card {
position: relative;
background: var(--card);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid var(--border);
border-radius: var(--radius-card);
/* No drop shadow: on a dark host it only adds a muddy halo. */
box-shadow: var(--specular);
overflow: hidden;
animation: k-in 400ms var(--spring);
}
@keyframes k-in { from { opacity: 0; transform: translateY(6px) scale(0.985); } to { opacity: 1; transform: none; } }
.k-head {
display: flex; align-items: center; gap: 8px;
padding: 12px 14px 10px;
}
/* The mark sits at the far right and the card is named by what it shows:
the brand word next to the title was chrome, not information. */
.k-logo { order: 99; margin-left: auto; display: flex; align-items: center; cursor: pointer; opacity: 0.9; }
.k-logo:hover { opacity: 1; }
.k-logo > span { display: none; }
.k-logo svg { display: block; }
.k-head .k-title { color: var(--text); font-size: 13.5px; font-weight: 650; letter-spacing: -0.01em; }
.k-head .k-spacer { flex: 1; }
.k-body { padding: 0 12px 12px; }
.k-prompt { color: var(--text-muted); font-size: 12.5px; margin: 0 2px 2px; word-break: break-word;
display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden;
user-select: text; -webkit-user-select: text; cursor: text; }
.k-prompt.expanded { -webkit-line-clamp: unset; white-space: pre-wrap; }
.k-text-tools { display: flex; gap: 2px; justify-content: flex-end; margin: -2px 0 6px; }
/* The prompt's Copy / Expand sit on the prompt's own line, not a row of their own. */
#prompt { padding-right: 132px; }
#prompt + .k-text-tools { margin: -24px 0 8px; }
.k-text-btn {
display: inline-flex; align-items: center; gap: 4px;
padding: 3px 8px; border: 0; border-radius: 6px;
background: transparent; color: var(--text-faint);
font-size: 11px; font-weight: 600; font-family: inherit;
cursor: pointer;
}
.k-text-btn:hover { color: var(--text); background: var(--surface-2); }
.k-text-btn svg { width: 12px; height: 12px; }
/* @VisualDNA / #Moodboard mentions are load-bearing prompt syntax, not prose —
the server resolves them to the actual asset. Mark them so a glance at the
prompt shows which references it pulls in. */
.k-mention {
display: inline; padding: 1px 5px; border-radius: 5px;
background: var(--brand-soft); color: var(--brand);
font-size: 0.94em; font-weight: 500;
}
/* Single-line media caption (scene / batch prompt under the viewer) */
.k-caption { font-size: 11px; color: var(--text-faint); margin: 2px 2px 0;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
user-select: text; -webkit-user-select: text; cursor: text; }
.k-caption.expanded { white-space: normal; word-break: break-word; }
.k-caption + .k-text-tools { margin: 0 2px 6px; justify-content: flex-start; }
/* ---- Chips ---- */
.k-chips { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; margin: 0 0 10px; }
.k-chip {
display: inline-flex; align-items: center; gap: 5px;
padding: 3px 8px; border-radius: 999px;
background: var(--surface-2); border: 0;
font-size: 11.5px; font-weight: 500; color: var(--text-muted);
white-space: nowrap;
/* UI font, not a code font: tags are labels people read ("7 images",
"Seedance 2.5"), and monospace made the whole row look like a log line. */
font-variant-numeric: tabular-nums;
animation: k-chip-in 200ms var(--spring);
}
@keyframes k-chip-in { from { opacity: 0; transform: translateX(-6px); } to { opacity: 1; transform: none; } }
.k-chip.brand { background: var(--brand-soft); color: var(--brand); }
.k-chip img { width: 14px; height: 14px; border-radius: 4px; object-fit: cover; }
.k-chip .k-mono-icon {
width: 14px; height: 14px; border-radius: 4px; background: var(--brand);
color: #fff; font-size: 9px; font-weight: 700; display: inline-flex;
align-items: center; justify-content: center; font-family: inherit;
}
.k-chip img.k-voice-thumb { width: 18px; height: 18px; border-radius: 999px; margin-left: -3px; }
.k-ref-thumb { width: 26px; height: 26px; border-radius: 6px; object-fit: cover; border: 1px solid var(--border-strong); }
.k-peek-hit { cursor: zoom-in; }
.k-peek {
position: absolute; inset: 0; z-index: 20;
display: flex; flex-direction: column;
background: #0b0b0d;
}
.k-peek[hidden] { display: none !important; }
/* An inline lightbox needs room: a short card (one list row, a 3-tile grid)
used to clip the media to a sliver. The card grows while it is open and
notifySize() asks the host for the height. */
html.k-peek-open .k-card { min-height: 460px; }
.k-peek-bar { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px 8px 8px 14px; min-height: 44px; }
.k-peek-cap { flex: 1; min-width: 0; font-size: 12.5px; color: rgba(255,255,255,0.78);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.k-peek-count { flex: none; font-size: 11.5px; color: rgba(255,255,255,0.55); font-variant-numeric: tabular-nums; }
.k-peek-btn {
flex: none; width: 32px; height: 32px; border: 1px solid rgba(255,255,255,0.14); border-radius: 999px;
background: rgba(255,255,255,0.08); color: #fff; cursor: pointer; font-size: 15px;
display: inline-flex; align-items: center; justify-content: center;
}
.k-peek-btn:hover { background: var(--brand); border-color: var(--brand); }
/* min-height:0 + max 100% on BOTH axes: the media is contained in whatever box
the lightbox has, never cropped and never overflowing it. */
.k-peek-stage { flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; padding: 0 12px 12px; }
.k-peek-stage img, .k-peek-stage video {
display: none; max-width: 100%; max-height: 100%; width: auto; height: auto;
object-fit: contain; border-radius: 8px; background: #000;
}
.k-peek-stage img[src], .k-peek-stage video[src] { display: block; }
.k-peek-stage video[src] { width: 100%; height: 100%; }
.k-peek-nav {
position: absolute; top: 50%; transform: translateY(-50%); z-index: 2;
width: 40px; height: 40px; border: 1px solid rgba(255,255,255,0.14); border-radius: 999px;
background: rgba(0,0,0,0.55); color: #fff; cursor: pointer; font-size: 20px;
display: inline-flex; align-items: center; justify-content: center;
}
.k-peek-nav[hidden] { display: none; }
.k-peek-strip { flex: none; display: flex; gap: 6px; padding: 0 12px 12px;
overflow-x: auto; scrollbar-width: none; }
.k-peek-strip > :first-child { margin-left: auto; }
.k-peek-strip > :last-child { margin-right: auto; }
.k-peek-strip[hidden] { display: none; }
.k-peek-strip::-webkit-scrollbar { display: none; }
.k-peek-thumb { flex: none; width: 44px; height: 44px; padding: 0; border-radius: 8px; overflow: hidden; cursor: pointer;
border: 2px solid transparent; background: #1c1c20; color: #fff; opacity: 0.55;
display: inline-flex; align-items: center; justify-content: center;
transition: opacity 150ms var(--smooth), border-color 150ms var(--smooth); }
.k-peek-thumb:hover { opacity: 0.9; }
.k-peek-thumb.on { border-color: var(--brand); opacity: 1; }
.k-peek-thumb img, .k-peek-thumb video { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; }
.k-peek-nav:hover { background: var(--brand); border-color: var(--brand); }
.k-peek-nav.prev { left: 10px; }
.k-peek-nav.next { right: 10px; }
/* Host granted fullscreen: the lightbox owns the whole viewport, not the card. */
html.k-peek-fs, html.k-peek-fs body { height: 100%; overflow: hidden; }
html.k-peek-fs .k-peek { position: fixed; inset: 0; z-index: 50; }
/* backdrop-filter (and any transform) makes the card the containing block for
position:fixed descendants, so a GRANTED fullscreen still showed the lightbox
card-sized. Drop them while the lightbox owns the viewport. */
html.k-peek-fs .k-card { min-height: 0; backdrop-filter: none; -webkit-backdrop-filter: none; transform: none; animation: none; }
/* Expand button on every inline video — replaces the native fullscreen button,
which a host iframe without allow="fullscreen" always greys out. */
.k-vexpand {
position: absolute; top: 8px; left: 8px; z-index: 3;
width: 30px; height: 30px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.18);
background: rgba(0, 0, 0, 0.62); color: #fff; font-size: 14px; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center;
}
.k-vexpand:hover { background: var(--brand); border-color: var(--brand); }
/* Recent Chromium still draws the native button despite controlslist. */
video::-webkit-media-controls-fullscreen-button { display: none !important; }
/* ---- Plans / upgrade card ---- */
.k-plan-toggle { display: inline-flex; gap: 2px; padding: 3px; margin-bottom: 12px;
background: var(--surface-2); border: 1px solid var(--border); border-radius: 999px; }
.k-toggle-btn { border: 0; border-radius: 999px; padding: 5px 14px; cursor: pointer;
background: transparent; color: var(--text-muted); font-family: inherit; font-size: 12px; font-weight: 600; }
.k-toggle-btn.active { background: var(--brand); color: #fff; }
.k-plan-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 10px; }
.k-plan { display: flex; flex-direction: column; gap: 7px; padding: 14px;
background: var(--surface); border: 1px solid var(--border); border-radius: 12px; box-shadow: var(--specular); }
.k-plan.current { border-color: var(--brand); }
.k-plan-top { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.k-plan-name { font-size: 15px; font-weight: 700; letter-spacing: -0.01em; }
.k-plan-badge { padding: 2px 7px; border-radius: 999px; background: var(--brand-soft); color: var(--brand);
font-size: 10px; font-weight: 700; }
.k-plan-badge.current { background: var(--surface-2); color: var(--text-faint); }
.k-plan-credits { display: flex; align-items: center; gap: 5px; font-size: 12px; color: var(--text-muted); }
.k-plan-pricing { display: flex; align-items: baseline; gap: 6px; margin-top: 2px; }
.k-plan-price { font-size: 22px; font-weight: 700; letter-spacing: -0.02em; }
.k-plan-was { font-size: 13px; color: var(--text-faint); text-decoration: line-through; }
.k-plan-note { font-size: 11px; color: var(--text-faint); }
.k-plan .k-btn { margin-top: auto; justify-content: center; }
.k-pack-head { margin: 14px 0 6px; font-size: 11px; font-weight: 700; letter-spacing: 0.04em;
text-transform: uppercase; color: var(--text-faint); }
.k-pack-row { margin-bottom: 6px; }
/* Visual DNA chips: the character's face, so you can see WHICH DNA is locked in. */
.k-dna-face { width: 18px; height: 18px; border-radius: 999px; object-fit: cover; margin-left: -3px; background: var(--border-strong); }
.k-dna-stack .k-dna-stack-item { display: inline-flex; }
.k-dna-stack .k-dna-stack-item + .k-dna-stack-item .k-dna-face { margin-left: -9px; }
.k-dna-stack .k-dna-face { box-shadow: 0 0 0 1.5px var(--card-solid); }
.k-dna-stack { cursor: default; }
/* ---- Generating state ---- */
.k-gen-grid { display: grid; gap: 8px; }
.k-gen-grid.n1 { grid-template-columns: 1fr; }
.k-gen-grid.n2 { grid-template-columns: 1fr 1fr; }
.k-gen-grid.n3, .k-gen-grid.n4 { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }
.k-skel {
position: relative; border-radius: 12px; overflow: hidden;
background: var(--surface-2);
min-height: 120px; max-height: 300px;
/* width:100% is load-bearing, not cosmetic. Without a definite width the
aspect-ratio transfers the OTHER way: a 16/9 cell in a 196px auto-fill
column wants 110px of height, min-height:120px clamps it up, and the ratio
then back-computes the WIDTH as 120*16/9 = 213px. The item overflows its
196px track and lands on top of the next tile — which is exactly what a
3-up video grid looked like. Pinning the width makes the ratio size the
height only, and min-height just letterboxes a slightly tall cell. */
width: 100%;
}
.k-skel.video { aspect-ratio: 16 / 9; }
.k-skel.square { aspect-ratio: 1; }
.k-skel.portrait { aspect-ratio: 3 / 4; }
.k-skel::after {
content: ''; position: absolute; inset: 0;
/* The shimmer covers the WHOLE cell and outlives the skeleton — .k-skel.done
only clears its paint, not its box. A pseudo-element hit-tests as its
originating element, so every click on a finished cell landed on the cell
instead of the media inside it: image cells still worked (their handler is
ON the cell), but a <video>'s native controls never saw a single event —
play, scrub, volume and fullscreen were all dead. It is decoration; it must
never take a click. */
pointer-events: none;
background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.06) 40%, rgba(255,255,255,0.10) 50%, rgba(255,255,255,0.06) 60%, transparent 100%);
background-size: 200% 100%;
animation: k-sweep 1.6s ease-in-out infinite;
}
@keyframes k-sweep { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
/* Batch grid: per-cell prompt caption + a cell that already finished */
/* bottom: 0 puts this exactly where a <video> draws its control bar, so it has
to be click-through too — the caption is a label, not a target. The full text
stays reachable: the title tooltip moved onto the cell. */
.k-skel-cap { position: absolute; left: 0; right: 0; bottom: 0; z-index: 2; pointer-events: none;
padding: 12px 8px 6px; font-size: 10.5px; color: #fff;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.65));
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.k-skel.done::after { animation: none; background: none; }
.k-cell-fill { width: 100%; height: 100%; object-fit: contain; display: block; background: #000; }
.k-gen-badge {
position: absolute; top: 10px; left: 10px; z-index: 2; pointer-events: none;
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px; border-radius: 999px;
background: rgba(0, 0, 0, 0.65); /* no backdrop-filter: nested blur over the
animating k-sweep skeleton forced per-frame backdrop re-sampling on phones;
0.65 black over the shimmer reads identically without it */
border: 1px solid rgba(255, 255, 255, 0.14);
font-size: 11px; font-weight: 600; color: #fff; text-transform: capitalize;
}
.k-spin {
width: 12px; height: 12px; border-radius: 50%;
border: 2px solid rgba(255,255,255,0.25); border-top-color: var(--brand);
animation: k-rot 0.8s linear infinite;
}
@keyframes k-rot { to { transform: rotate(360deg); } }
.k-progress { position: relative; height: 3px; border-radius: 2px; overflow: hidden;
background: var(--surface-2); margin-top: 12px; }
.k-progress > i { position: absolute; inset: 0 auto 0 0; width: 0%; border-radius: 2px;
background: linear-gradient(90deg, var(--brand), #60a5fa);
transition: width 600ms var(--smooth); }
.k-status-line { display: flex; align-items: center; justify-content: space-between;
margin-top: 8px; font-size: 11px; color: var(--text-faint); }
/* ---- Results ---- */
.k-media { position: relative; border-radius: 10px; overflow: hidden; border: 1px solid var(--border);
background: #000; cursor: pointer; transition: transform 300ms var(--spring), box-shadow 300ms var(--smooth); }
.k-media:hover { transform: scale(1.015); box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45); }
.k-media img, .k-media video { display: block; width: 100%; height: 100%; object-fit: contain; }
.k-media.selected { outline: 2px solid var(--brand); outline-offset: 1px; }
/* ---- Per-item hover download button (multi-image grids, CD scenes, viewer) ---- */
.k-dl {
position: absolute; top: 8px; right: 8px; z-index: 3;
width: 30px; height: 30px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.18);
background: rgba(0, 0, 0, 0.62); color: #fff; font-size: 14px; line-height: 1;
display: inline-flex; align-items: center; justify-content: center;
cursor: pointer; opacity: 0; transition: opacity 150ms var(--smooth), background 150ms var(--smooth);
}
.k-media:hover .k-dl, .k-viewer:hover .k-dl, .k-skel:hover .k-dl, .k-dl:focus-visible { opacity: 1; }
/* Same contract as .k-tile-acts: on a touch host there is no hover, and the
hover-only Download / Attach buttons were simply invisible. */
@media (hover: none) { .k-dl { opacity: 1; } }
.k-dl:hover { background: var(--brand); border-color: var(--brand); }
.k-viewer { position: relative; }
/* Keep the whole completed card under the host's iframe height cap (~800px):
header + prompt + chips + viewer + thumbs + actions + footer must all fit,
or claude.ai adds an inner scrollbar. Click the image to expand in-Claude. */
/* Media sits in an inset panel. The image is sized to itself (width:auto) so a
portrait result no longer floats in a wide black letterbox. */
.k-viewer { margin-bottom: 0; padding: 8px; border-radius: 16px; background: var(--panel);
display: flex; justify-content: center; }
.k-viewer img, .k-viewer video { display: block; max-width: 100%;
/* Fixed px, no vh: vh inside an iframe is the height the host granted, so a
vh-based cap grew as the iframe grew and the card chased its own tail. */
max-height: 400px; object-fit: contain;
border-radius: 10px; cursor: zoom-in; }
.k-viewer img { width: auto; height: auto; }
.k-viewer video { width: 100%; background: #000; cursor: default; }
#stage > .k-gen-grid, #stage > .k-grid { padding: 8px; border-radius: 16px; background: var(--panel); }
.k-expand-hint { display: none; }
.k-thumbs { display: flex; gap: 6px; margin: 8px 0 0; justify-content: center; }
.k-thumbs .k-thumb { width: 44px; height: 44px; border-radius: 9px; overflow: hidden; cursor: pointer;
border: 2px solid transparent; opacity: 0.6; transition: all 150ms var(--smooth); flex: none; }
.k-thumbs .k-thumb:hover { opacity: 1; }
.k-thumbs .k-thumb.active { border-color: var(--brand); opacity: 1; }
.k-thumbs .k-thumb img { width: 100%; height: 100%; object-fit: cover; }
/* ---- Buttons ---- */
.k-actions { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; padding-top: 10px; }
.k-btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 7px 13px; border-radius: var(--radius-btn);
border: 1px solid transparent; background: var(--surface-2);
color: var(--text); font-size: 12px; font-weight: 600; font-family: inherit;
cursor: pointer; transition: all 150ms var(--smooth);
text-decoration: none; user-select: none;
}
.k-btn:hover { background: var(--border-strong); }
.k-btn:active { transform: scale(0.97); }
.k-btn.primary { background: var(--brand); border-color: var(--brand); color: #fff;
box-shadow: 0 2px 12px rgba(59, 130, 246, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.25); }
.k-btn.primary:hover { background: #2f74e8; }
.k-btn.ghost { background: transparent; box-shadow: none; border-color: transparent; color: var(--text-muted); }
.k-btn.danger { background: var(--error); border-color: var(--error); color: #fff;
box-shadow: 0 2px 12px rgba(239, 68, 68, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.25); }
.k-btn.danger:hover { background: #dc2626; }
.k-stop-ask { color: var(--text-muted); font-size: 12px; font-weight: 500; margin-right: 4px; }
.k-btn.ghost:hover { color: var(--text); background: var(--surface-2); }
.k-btn svg { width: 13px; height: 13px; }
/* Inline SVG icons (replace emoji) — align with text, inherit color, never shrink. */
.k-ic { width: 1em; height: 1em; vertical-align: -0.14em; flex: none; }
.k-btn:disabled { opacity: 0.5; cursor: default; }
/* ---- Inline prompt input (Animate / Edit flows) ---- */
.k-prompt-row { display: none; gap: 8px; margin-top: 10px; }
.k-prompt-row.open { display: flex; animation: k-in 250ms var(--spring); }
.k-input {
flex: 1; padding: 8px 12px; border-radius: var(--radius-btn);
border: 1px solid var(--border-strong); background: var(--surface);
color: var(--text); font-size: 12.5px; font-family: inherit; outline: none;
}
.k-input:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-soft); }
.k-input::placeholder { color: var(--text-faint); }
/* ---- Paged media grid (library / stock / presets / DNAs / moodboards) ----
Media-first tiles: the artwork IS the row. A title rides a gradient scrim
INSIDE the tile instead of a text block under it, so a page of results reads
as a contact sheet rather than a list of captions. No backdrop-filter on the
scrim for the same reason .k-gen-badge has none — a dozen blurred strips over
a dozen images forces a per-frame backdrop re-sample on phones. */
.k-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
/* Narrow hosts (Kolbo Code sidebar, phone): step down instead of shrinking
tiles below a thumbnail worth looking at. The 520px block further down wins
under 520 and takes it to 2. */
@media (max-width: 620px) { .k-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
.k-tile { position: relative; aspect-ratio: 1; border-radius: 10px; overflow: hidden;
background: var(--surface-2); cursor: pointer; }
.k-tile img, .k-tile video { display: block; width: 100%; height: 100%; object-fit: cover;
transition: transform 350ms var(--smooth), filter 200ms var(--smooth); }
.k-tile:hover img { transform: scale(1.03); filter: brightness(1.06); }
.k-tile-fb { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
color: var(--text-faint); font-size: 24px; }
.k-tile-cap { position: absolute; left: 0; right: 0; bottom: 0; z-index: 2; pointer-events: none;
padding: 20px 8px 7px; background: linear-gradient(transparent, rgba(0, 0, 0, 0.78));
opacity: 0; transition: opacity 150ms var(--smooth); }
.k-tile:hover .k-tile-cap, .k-tile:focus-within .k-tile-cap { opacity: 1; }
@media (hover: none) { .k-tile-cap { opacity: 1; } }
.k-tile-t { font-size: 11px; font-weight: 600; color: #fff;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.k-tile-s { font-size: 10px; color: rgba(255, 255, 255, 0.66);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* Hover actions, revealed on pointer devices and always-on where there is no
hover — a touch host must never hide the only Use affordance. */
.k-tile-acts { position: absolute; top: 6px; right: 6px; z-index: 3; display: flex; gap: 4px;
opacity: 0; transition: opacity 150ms var(--smooth); }
.k-tile:hover .k-tile-acts, .k-tile:focus-within .k-tile-acts { opacity: 1; }
@media (hover: none) { .k-tile-acts { opacity: 1; } }
.k-tile-act { width: 26px; height: 26px; padding: 0; border-radius: 7px;
border: 1px solid rgba(255, 255, 255, 0.18); background: rgba(0, 0, 0, 0.62); color: #fff;
display: inline-flex; align-items: center; justify-content: center; cursor: pointer;
transition: background 150ms var(--smooth); }
.k-tile-act:hover { background: var(--brand); border-color: var(--brand); }
.k-tile-play { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 2;
width: 46px; height: 38px; border-radius: 12px; border: 0; background: rgba(40, 44, 50, 0.62); font-size: 15px; }
/* ---- Pager (media grid + list) ----
A page of results at a fixed card height, instead of a card that grows a row
every time you ask for more. Forward past the last loaded page still goes
through the server's page_tool + next_args contract. */
.k-pager { display: flex; align-items: center; justify-content: center; gap: 12px; margin-top: 12px; }
.k-pager-btn { width: 28px; height: 28px; padding: 0; border-radius: 8px;
border: 1px solid var(--border); background: var(--surface-2); color: var(--text);
box-shadow: var(--specular); display: inline-flex; align-items: center; justify-content: center;
cursor: pointer; transition: background 150ms var(--smooth); }
.k-pager-btn:hover:not(:disabled) { background: var(--border-strong); }
.k-pager-btn:disabled { opacity: 0.3; cursor: default; }
.k-dots { display: flex; align-items: center; gap: 6px; }
.k-dot { width: 7px; height: 7px; padding: 0; border: 0; border-radius: 999px; cursor: pointer;
background: var(--text); opacity: 0.28;
transition: width 220ms var(--spring), opacity 150ms var(--smooth); }
.k-dot:hover { opacity: 0.6; }
.k-dot.on { width: 22px; opacity: 0.95; cursor: default; }
.k-dot.ghost { cursor: default; opacity: 0.14; }
.k-pager-label { font-size: 11px; color: var(--text-faint);
font-variant-numeric: tabular-nums; min-width: 58px; text-align: center; }
/* ---- Audio rows ---- */
.k-audio-row { display: flex; align-items: center; gap: 10px; padding: 9px 10px;
border-radius: 12px; background: var(--panel);
margin-bottom: 6px; transition: background 150ms var(--smooth); }
.k-audio-row:hover { background: var(--surface-2); }
.k-audio-art { width: 40px; height: 40px; border-radius: 8px; object-fit: cover; flex: none;
background: var(--brand-soft); }
/* Stand-in when a voice/track has no artwork, or has one the host refuses to
load — a blank tile and the browser's broken-image glyph both read as "this
row is broken" rather than "this one has no picture". */
.k-audio-art-fallback { display: flex; align-items: center; justify-content: center;
color: var(--brand); font-size: 18px; }
.k-audio-meta { flex: 1; min-width: 0; }
.k-audio-title { font-size: 12.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.k-audio-sub { font-size: 11px; color: var(--text-faint);
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.k-generated-audio {
display: grid; grid-template-columns: 40px minmax(0, 1fr) auto;
align-items: center; gap: 7px 10px;
}
.k-generated-audio .k-audio-placeholder {
display: flex; align-items: center; justify-content: center; color: var(--brand);
}
.k-generated-audio .k-audio-placeholder svg { width: 19px; height: 19px; }
.k-generated-audio .k-audio-download { white-space: nowrap; }
.k-generated-audio .k-audio-player {
grid-column: 2 / -1; display: block; width: 100%; min-width: 0; height: 34px;
}
@media (max-width: 520px) {
.k-generated-audio { grid-template-columns: 36px minmax(0, 1fr); }
.k-generated-audio .k-audio-art { width: 36px; height: 36px; }
.k-generated-audio .k-btn { grid-column: 2; justify-self: start; }
.k-generated-audio .k-audio-player { grid-column: 1 / -1; }
/* Touch-friendly MCP App layout (Claude iOS/Android iframe). */
.k-head { padding: 10px 12px; gap: 8px; }
.k-body { padding: 0 10px 10px; }
.k-footer { padding: 0 12px 10px; }
.k-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.k-tile-act { width: 30px; height: 30px; }
.k-pager-btn { width: 34px; height: 34px; }
.k-actions { flex-direction: column; align-items: stretch; }
.k-actions .k-btn { width: 100%; min-height: 44px; justify-content: center; }
.k-btn { min-height: 40px; padding: 10px 14px; }
.k-title { font-size: 13.5px; }
.k-prompt-row { flex-wrap: wrap; }
.k-prompt-row .k-input { min-width: 0; flex: 1 1 100%; }
.k-prompt-row .k-btn { flex: 1 1 auto; }
.k-text-btn { min-height: 32px; padding: 6px 10px; }
}
.k-play {
width: 32px; height: 32px; border-radius: 50%; flex: none; border: 1px solid rgba(255,255,255,0.18);
background: rgba(0,0,0,0.5); color: #fff; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center;
transition: all 150ms var(--smooth);
}
.k-play:hover { background: var(--brand); border-color: var(--brand); }
/* ---- Misc ---- */
.k-error { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-radius: 10px;
background: rgba(239, 68, 68, 0.08); border: 1px solid rgba(239, 68, 68, 0.25);
color: #fca5a5; font-size: 12.5px; }
.k-empty { padding: 22px; text-align: center; color: var(--text-faint); font-size: 12.5px; }
.k-footer { display: flex; align-items: center; gap: 6px; padding: 0 14px 10px;
font-size: 10.5px; color: var(--text-faint); }
.k-footer > span:not(.k-credits) { display: none; }
.k-footer:not(:has(.k-credits:not(:empty))) { display: none; }
.k-footer a { color: var(--text-faint); text-decoration: none; }
.k-footer a:hover { color: var(--brand); }
.k-credits { margin-left: auto; font-variant-numeric: tabular-nums; }
`;
/**
* Kolbo mark — the real app icon from the CDN (the host CSP allows it), with
* the simplified inline SVG only as the onerror fallback.
*/
const KOLBO_LOGO_SVG = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="24" rx="6" fill="#3b82f6"/><path d="M7 5.5v13M7 12l6.5-6.5M7 12l7 6.5" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
const KOLBO_LOGO_IMG = `<img src="https://kolbo-general-media.fra1.cdn.digitaloceanspaces.com/models_icons/kolbo-ai.png" alt="Kolbo" style="width:18px;height:18px;border-radius:5px;display:block" onerror="this.outerHTML=KOLBO_LOGO_FALLBACK">`;
module.exports = { KOLBO_CSS, KOLBO_LOGO_SVG, KOLBO_LOGO_IMG };