Compare commits

...
2 Commits
Author SHA1 Message Date
Ku6epXBOCTuK 2448815113 feat: update logos, add logo background 2026-09-05 12:04:05 +05:00
Ku6epXBOCTuK af990e1b32 feat: add main page 2026-09-05 10:25:18 +05:00
19 changed files with 1654 additions and 3 deletions
+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><title>rubber-duck</title><circle cx="32" cy="30" r="24" fill="#ffd93b" stroke="#d9a413" stroke-width="3"/><circle cx="24" cy="25" r="5" fill="#fff"/><circle cx="40" cy="25" r="5" fill="#fff"/><circle cx="25.5" cy="26" r="2.6" fill="#111"/><circle cx="41.5" cy="26" r="2.6" fill="#111"/><ellipse cx="32" cy="41" rx="15" ry="9" fill="#f5883c" stroke="#d9701c" stroke-width="2.5"/><path d="M19 40q13 7 26 0" fill="none" stroke="#b35410" stroke-width="2" stroke-linecap="round"/></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 543 B

@@ -0,0 +1,231 @@
<script lang="ts">
import { outfit, duckState } from '$lib/duck/state.svelte';
import { simulateMcpQuack, randomTip, playQuack, moodLabel, type Mood } from '$lib/duck/quack';
type Msg = {
id: number;
role: 'user' | 'duck';
text: string;
mood?: Mood;
};
let messages = $state<Msg[]>([
{
id: 0,
role: 'duck',
text: 'Кря! Я твоя rubber duck. Расскажи, что строишь, — я выслушаю и подскажу.'
}
]);
let draft = $state('');
let busy = $state(false);
let listEl: HTMLElement | undefined = $state();
let nextId = $state(1);
const CHIPS = ['Застрял на баге 🌀', 'Почему не работает?', 'Ура, получилось! 🎉', 'Я устал...'];
$effect(() => {
void messages.length;
listEl?.scrollTo({ top: listEl.scrollHeight, behavior: 'smooth' });
});
async function send(raw: string) {
const text = raw.trim();
if (!text || busy) return;
busy = true;
draft = '';
messages.push({ id: nextId++, role: 'user', text });
duckState.speaking = true;
const sim = await simulateMcpQuack(text);
messages.push({
id: nextId++,
role: 'duck',
text: `${sim.content[0].text}\nКстати: ${randomTip()}`,
mood: sim.arguments.mood
});
duckState.speaking = false;
busy = false;
if (outfit.sound) playQuack();
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void send(draft);
}
}
</script>
<div class="chat">
<div class="list" bind:this={listEl}>
{#each messages as msg (msg.id)}
<div class="msg {msg.role}">
<div class="bubble">
{#if msg.role === 'duck'}<span class="who">🦆</span>{/if}
<div class="text">{msg.text}</div>
</div>
{#if msg.role === 'duck' && msg.mood}
<div class="meta mono">tools/call(quack, mood={msg.mood}) · {moodLabel(msg.mood)} · симуляция</div>
{/if}
</div>
{/each}
{#if busy}
<div class="msg duck">
<div class="bubble thinking"><span class="who">🦆</span><span class="dots">кря-кря<i>.</i><i>.</i><i>.</i></span></div>
</div>
{/if}
</div>
<div class="chips">
{#each CHIPS as chip}
<button class="chip" disabled={busy} onclick={() => void send(chip)}>{chip}</button>
{/each}
</div>
<div class="input-row">
<textarea
rows="2"
placeholder="Расскажи утке о проблеме…"
bind:value={draft}
onkeydown={onKeydown}
disabled={busy}
></textarea>
<button class="send" onclick={() => void send(draft)} disabled={busy || !draft.trim()}>
Крякнуть
</button>
</div>
</div>
<style>
.chat {
display: flex;
flex-direction: column;
height: 100%;
gap: 10px;
}
.list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 10px;
padding: 4px 2px;
min-height: 0;
}
.msg {
display: flex;
flex-direction: column;
max-width: 88%;
}
.msg.user {
align-self: flex-end;
align-items: flex-end;
}
.msg.duck {
align-self: flex-start;
align-items: flex-start;
}
.bubble {
padding: 8px 12px;
border-radius: 12px;
font-size: 14px;
line-height: 1.45;
white-space: pre-line;
word-break: break-word;
}
.msg.user .bubble {
background: #2563eb;
color: #fff;
border-bottom-right-radius: 4px;
}
.msg.duck .bubble {
background: var(--panel2);
border: 1px solid var(--border);
border-bottom-left-radius: 4px;
display: flex;
gap: 8px;
align-items: flex-start;
}
.who {
flex: none;
}
.meta {
font-size: 10.5px;
color: var(--muted);
margin-top: 3px;
}
.dots i {
animation: blink 1.2s infinite;
font-style: normal;
}
.dots i:nth-child(2) {
animation-delay: 0.2s;
}
.dots i:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes blink {
0%,
60%,
100% {
opacity: 0.2;
}
30% {
opacity: 1;
}
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.chip {
background: var(--panel2);
border: 1px solid var(--border);
color: var(--muted);
border-radius: 999px;
padding: 4px 10px;
font-size: 12px;
cursor: pointer;
}
.chip:hover:not(:disabled) {
color: var(--text);
border-color: #3b82f6;
}
.chip:disabled {
opacity: 0.5;
cursor: default;
}
.input-row {
display: flex;
gap: 8px;
align-items: flex-end;
}
textarea {
flex: 1;
resize: none;
background: var(--panel2);
border: 1px solid var(--border);
border-radius: 10px;
color: var(--text);
padding: 8px 10px;
font: inherit;
font-size: 14px;
}
textarea:focus {
outline: 1px solid #3b82f6;
}
.send {
background: #2563eb;
color: #fff;
border: none;
border-radius: 10px;
padding: 9px 14px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.send:disabled {
opacity: 0.45;
cursor: default;
}
</style>
@@ -0,0 +1,146 @@
<script lang="ts">
import { outfit, duckState } from '$lib/duck/state.svelte';
import { capById, teeById, TEE_LOGO } from '$lib/duck/wardrobe';
import { playQuack } from '$lib/duck/quack';
import { logoById, logoBackdrop } from '$lib/duck/logos';
import Logo from './Logo.svelte';
const cap = $derived(capById(outfit.cap));
const tee = $derived(teeById(outfit.tee));
// фактический цвет логотипа и подложка-«стикер», если он сливается с тканью
const teeLogoDef = $derived(logoById(outfit.teeLogo));
const capLogoDef = $derived(logoById(outfit.capLogo));
const capMonoColor = $derived(cap.dark ? '#f8fafc' : '#0f172a');
const teeBackdrop = $derived.by(() => {
if (!tee.img || !teeLogoDef) return null;
const colors = teeLogoDef.mono ? [tee.mono] : teeLogoDef.tints;
return logoBackdrop(tee.color, colors, TEE_LOGO.x, TEE_LOGO.y, TEE_LOGO.s);
});
const capBackdrop = $derived.by(() => {
if (cap.id === 'none' || !capLogoDef) return null;
const colors = capLogoDef.mono ? [capMonoColor] : capLogoDef.tints;
return logoBackdrop(cap.color, colors, cap.logo.x, cap.logo.y, cap.logo.s);
});
let quacking = $state(false);
function poke() {
if (quacking) return;
quacking = true;
playQuack();
setTimeout(() => (quacking = false), 650);
}
</script>
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div
class="duck"
class:bob={outfit.bob}
class:speaking={duckState.speaking || quacking}
onclick={poke}
title="Крякнуть!"
>
<img class="base" src="/duck/duck.png" alt="Резиновая утка" draggable="false" />
{#if tee.img}
<img class="layer" src={tee.img} alt="" draggable="false" />
{/if}
<svg class="layer" viewBox="0 0 800 920" aria-hidden="true">
{#if outfit.teeLogo && tee.img}
{#if teeBackdrop}
<rect
x={teeBackdrop.x}
y={teeBackdrop.y}
width={teeBackdrop.w}
height={teeBackdrop.h}
rx={teeBackdrop.rx}
fill={teeBackdrop.fill}
stroke={teeBackdrop.stroke}
stroke-width="2"
/>
{/if}
<Logo
id={outfit.teeLogo}
size={TEE_LOGO.s}
x={TEE_LOGO.x - TEE_LOGO.s / 2}
y={TEE_LOGO.y - TEE_LOGO.s / 2}
color={tee.mono}
/>
{/if}
{#if cap.id !== 'none'}
<g class="cap">{@html cap.svg}</g>
{#if outfit.capLogo}
{#if capBackdrop}
<rect
x={capBackdrop.x}
y={capBackdrop.y}
width={capBackdrop.w}
height={capBackdrop.h}
rx={capBackdrop.rx}
fill={capBackdrop.fill}
stroke={capBackdrop.stroke}
stroke-width="2"
/>
{/if}
<Logo
id={outfit.capLogo}
size={cap.logo.s}
x={cap.logo.x - cap.logo.s / 2}
y={cap.logo.y - cap.logo.s / 2}
color={capMonoColor}
/>
{/if}
{/if}
</svg>
</div>
<style>
.duck {
position: relative;
width: 100%;
max-width: 430px;
margin: 0 auto;
aspect-ratio: 800 / 920;
cursor: pointer;
user-select: none;
-webkit-user-drag: none;
}
.duck img,
.duck svg {
display: block;
width: 100%;
height: 100%;
pointer-events: none;
}
.layer {
position: absolute;
inset: 0;
}
.duck.bob {
animation: bob 4.5s ease-in-out infinite;
}
.duck.speaking {
animation: wiggle 0.35s ease-in-out infinite;
}
@keyframes bob {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-8px);
}
}
@keyframes wiggle {
0%,
100% {
transform: rotate(0deg);
}
25% {
transform: rotate(-2.5deg);
}
75% {
transform: rotate(2.5deg);
}
}
</style>
@@ -0,0 +1,26 @@
<script lang="ts">
import { logoById } from '$lib/duck/logos';
let {
id,
size = 24,
x = undefined,
y = undefined,
color = 'currentColor'
}: { id: string; size?: number; x?: number; y?: number; color?: string } = $props();
const def = $derived(logoById(id));
</script>
{#if def}
<svg
viewBox="0 0 24 24"
width={size}
height={size}
x={x}
y={y}
style:color={def.mono ? color : undefined}
role="img"
aria-label={def.label}
>{@html def.svg}</svg>
{/if}
@@ -0,0 +1,159 @@
<script lang="ts">
import { outfit, resetOutfit } from '$lib/duck/state.svelte';
let copied = $state(false);
const outfitJson = $derived(
JSON.stringify(
{
cap: outfit.cap,
cap_logo: outfit.capLogo,
tee: outfit.tee,
tee_logo: outfit.teeLogo,
animation: outfit.bob,
sound: outfit.sound
},
null,
2
)
);
async function copyOutfit() {
try {
await navigator.clipboard.writeText(outfitJson);
copied = true;
setTimeout(() => (copied = false), 1400);
} catch {
// нет доступа к буферу — просто игнорируем
}
}
</script>
<div class="settings">
<section>
<h3>Поведение</h3>
<label class="toggle">
<input type="checkbox" bind:checked={outfit.bob} />
<span class="switch" aria-hidden="true"></span>
<span>Покачивание утки</span>
</label>
<label class="toggle">
<input type="checkbox" bind:checked={outfit.sound} />
<span class="switch" aria-hidden="true"></span>
<span>Крякать при ответе</span>
</label>
</section>
<section>
<h3>Текущий образ</h3>
<pre class="json mono">{outfitJson}</pre>
<div class="row">
<button class="btn" onclick={copyOutfit}>{copied ? 'Скопировано ✓' : 'Копировать JSON'}</button>
<button class="btn danger" onclick={resetOutfit}>Сбросить образ</button>
</div>
</section>
<section>
<h3>О проекте</h3>
<p class="about">
Утка-отладчик слушает и крякает. Вызов <code>quack</code> в чате —
<b>локальная симуляция</b> MCP-инструмента: без сети, с той же логикой ответа.
Настоящий MCP-эндпоинт живёт отдельно — <a href="/mcp">/mcp</a>.
Все логотипы принадлежат их правообладателям и используются
в иллюстративных целях в рамках добросовестного использования (fair use).
</p>
</section>
</div>
<style>
.settings {
display: flex;
flex-direction: column;
gap: 20px;
}
section h3 {
margin: 0 0 10px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.toggle {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 0;
cursor: pointer;
font-size: 14px;
}
.toggle input {
display: none;
}
.switch {
width: 36px;
height: 20px;
border-radius: 999px;
background: var(--panel2);
border: 1px solid var(--border);
position: relative;
transition: background 0.15s;
flex: none;
}
.switch::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--muted);
transition: transform 0.15s, background 0.15s;
}
.toggle input:checked + .switch {
background: #2563eb;
border-color: #2563eb;
}
.toggle input:checked + .switch::after {
transform: translateX(16px);
background: #fff;
}
.json {
margin: 0;
background: var(--panel2);
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 12px;
font-size: 12px;
line-height: 1.5;
overflow-x: auto;
}
.row {
display: flex;
gap: 8px;
margin-top: 10px;
}
.btn {
background: var(--panel2);
border: 1px solid var(--border);
color: var(--text);
border-radius: 8px;
padding: 7px 12px;
font-size: 13px;
cursor: pointer;
}
.btn:hover {
border-color: #3b82f6;
}
.btn.danger:hover {
border-color: var(--no);
color: var(--no);
}
.about {
margin: 0;
font-size: 13px;
color: var(--muted);
line-height: 1.55;
}
</style>
@@ -0,0 +1,285 @@
<script lang="ts">
import { outfit } from '$lib/duck/state.svelte';
import { CAPS, TEES } from '$lib/duck/wardrobe';
import { LOGOS, LOGO_CATEGORIES, logoById } from '$lib/duck/logos';
import Logo from './Logo.svelte';
function capLabel() {
return CAPS.find((c) => c.id === outfit.cap)?.label ?? '';
}
function teeLabel() {
return TEES.find((t) => t.id === outfit.tee)?.label ?? '';
}
function capLogoLabel() {
const l = logoById(outfit.capLogo);
return l ? `лого на кепке: ${l.label}` : '';
}
function teeLogoLabel() {
const l = logoById(outfit.teeLogo);
return l ? ` · лого на футболке: ${l.label}` : ' · без логотипов';
}
</script>
<div class="wardrobe">
<section>
<h3>Кепка</h3>
<div class="items caps">
{#each CAPS as cap (cap.id)}
<button
class="item"
class:selected={outfit.cap === cap.id}
title={cap.label}
onclick={() => {
outfit.cap = cap.id;
if (cap.id === 'none') outfit.capLogo = null;
}}
>
<span class="thumb cap-thumb">
{#if cap.id === 'none'}
<span class="none-mark"></span>
{:else}
<svg viewBox="150 0 500 186" aria-hidden="true">{@html cap.svg}</svg>
{/if}
</span>
<span class="label">{cap.label}</span>
</button>
{/each}
</div>
</section>
<section>
<h3>Футболка</h3>
<div class="items tees">
{#each TEES as tee (tee.id)}
<button
class="item"
class:selected={outfit.tee === tee.id}
title={tee.label}
onclick={() => {
outfit.tee = tee.id;
if (tee.id === 'none') outfit.teeLogo = null;
}}
>
<span class="thumb tee-thumb">
{#if !tee.img}
<span class="none-mark"></span>
{:else}
<img src={tee.img} alt={tee.label} draggable="false" />
{/if}
</span>
<span class="label">{tee.label}</span>
</button>
{/each}
</div>
</section>
<section>
<h3>Логотип на кепке</h3>
{#if outfit.cap === 'none'}
<p class="hint">Сначала наденьте кепку — печатать не на чем.</p>
{:else}
<div class="logos">
{#each LOGO_CATEGORIES as cat (cat.id)}
<div class="logo-group">
<span class="group-label">{cat.label}</span>
<div class="logo-grid">
<button
class="logo-item"
class:selected={outfit.capLogo === null}
title="Без логотипа"
onclick={() => (outfit.capLogo = null)}
>
<span class="empty-logo"></span>
</button>
{#each LOGOS.filter((l) => l.category === cat.id) as logo (logo.id)}
<button
class="logo-item"
class:selected={outfit.capLogo === logo.id}
title={logo.label}
onclick={() => (outfit.capLogo = logo.id)}
>
<Logo id={logo.id} size={26} />
</button>
{/each}
</div>
</div>
{/each}
</div>
{/if}
</section>
<section>
<h3>Логотип на футболке</h3>
{#if outfit.tee === 'none'}
<p class="hint">Сначала наденьте футболку — печатать не на чем.</p>
{:else}
<div class="logos">
{#each LOGO_CATEGORIES as cat (cat.id)}
<div class="logo-group">
<span class="group-label">{cat.label}</span>
<div class="logo-grid">
<button
class="logo-item"
class:selected={outfit.teeLogo === null}
title="Без логотипа"
onclick={() => (outfit.teeLogo = null)}
>
<span class="empty-logo"></span>
</button>
{#each LOGOS.filter((l) => l.category === cat.id) as logo (logo.id)}
<button
class="logo-item"
class:selected={outfit.teeLogo === logo.id}
title={logo.label}
onclick={() => (outfit.teeLogo = logo.id)}
>
<Logo id={logo.id} size={26} />
</button>
{/each}
</div>
</div>
{/each}
</div>
{/if}
</section>
<p class="legal">
Все логотипы принадлежат их правообладателям. Используются на этой странице
в иллюстративных целях в рамках добросовестного использования (fair use).
</p>
<p class="current mono">
{outfit.cap !== 'none' ? `кепка: ${capLabel()}` : 'без кепки'} ·
{outfit.tee !== 'none' ? `футболка: ${teeLabel()}` : 'без футболки'} ·
{capLogoLabel()}{teeLogoLabel()}
</p>
</div>
<style>
.wardrobe {
display: flex;
flex-direction: column;
gap: 18px;
}
section h3 {
margin: 0 0 8px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.items {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
background: var(--panel2);
border: 1px solid var(--border);
border-radius: 10px;
padding: 8px;
cursor: pointer;
color: var(--muted);
}
.item:hover {
border-color: #3b82f6;
}
.item.selected {
border-color: #3b82f6;
box-shadow: 0 0 0 1px #3b82f6;
color: var(--text);
}
.thumb {
display: flex;
align-items: center;
justify-content: center;
background: rgba(15, 23, 42, 0.55);
border-radius: 6px;
}
.cap-thumb {
width: 72px;
height: 40px;
}
.cap-thumb svg {
width: 100%;
height: 100%;
}
.tee-thumb {
width: 52px;
height: 52px;
}
.tee-thumb img {
width: 100%;
height: 100%;
object-fit: contain;
}
.none-mark {
font-size: 20px;
color: var(--muted);
}
.label {
font-size: 11px;
line-height: 1.2;
}
.logos {
display: flex;
flex-direction: column;
gap: 10px;
}
.group-label {
font-size: 11px;
color: var(--muted);
}
.logo-grid {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
}
.logo-item {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: var(--panel2);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
padding: 0;
}
.logo-item:hover {
border-color: #3b82f6;
}
.logo-item.selected {
border-color: #3b82f6;
box-shadow: 0 0 0 1px #3b82f6;
}
.empty-logo {
color: var(--muted);
font-size: 14px;
}
.hint {
margin: 0;
font-size: 13px;
color: var(--muted);
}
.current {
margin: 0;
font-size: 11px;
color: var(--muted);
}
.legal {
margin: 0;
font-size: 11px;
line-height: 1.5;
color: var(--muted);
border-top: 1px solid var(--border);
padding-top: 10px;
}
</style>
+200
View File
@@ -0,0 +1,200 @@
export const LOGO_CATEGORIES = [
{ id: 'lang', label: 'Языки' },
{ id: 'tech', label: 'Технологии' },
{ id: 'org', label: 'Организации' }
] as const;
/** категория логотипа — выводится из LOGO_CATEGORIES, отдельно не правится */
export type LogoCategory = (typeof LOGO_CATEGORIES)[number]['id'];
export interface LogoDef {
id: string;
label: string;
category: LogoCategory;
/** mono = рисуется цветом currentColor (подстраивается под тёмную/светлую поверхность) */
mono?: boolean;
/** доминирующие цвета марки (можно несколько) — для авто-подложки,
* когда любой из них сливается с кепкой/футболкой */
tints: string[];
/** разметка под viewBox 0 0 24 24 */
svg: string;
}
const badge = (fill: string, label: string, fg: string, fontSize = 10) =>
`<rect x="1" y="1" width="22" height="22" rx="4" fill="${fill}"/>` +
`<text x="12" y="16.4" text-anchor="middle" font-family="system-ui, 'Segoe UI', sans-serif" font-weight="800" font-size="${fontSize}" fill="${fg}">${label}</text>`;
export const LOGOS: LogoDef[] = [
// --- языки ---
{
id: 'js',
label: 'JavaScript',
category: 'lang',
tints: ['#f7df1e'],
svg: badge('#f7df1e', 'JS', '#1a1a1a')
},
{
id: 'ts',
label: 'TypeScript',
category: 'lang',
tints: ['#3178c6'],
svg: badge('#3178c6', 'TS', '#ffffff')
},
{
id: 'go',
label: 'Go',
category: 'lang',
tints: ['#00add8'],
svg: badge('#00add8', 'GO', '#ffffff', 9)
},
{
id: 'rust',
label: 'Rust',
category: 'lang',
tints: ['#000000'],
svg: `<path d="M23.8346 11.7033l-1.0073-.6236a13.7268 13.7268 0 00-.0283-.2936l.8656-.8069a.3483.3483 0 00-.1154-.578l-1.1066-.414a8.4958 8.4958 0 00-.087-.2856l.6904-.9587a.3462.3462 0 00-.2257-.5446l-1.1663-.1894a9.3574 9.3574 0 00-.1407-.2622l.49-1.0761a.3437.3437 0 00-.0274-.3361.3486.3486 0 00-.3006-.154l-1.1845.0416a6.7444 6.7444 0 00-.1873-.2268l.2723-1.153a.3472.3472 0 00-.417-.4172l-1.1532.2724a14.0183 14.0183 0 00-.2278-.1873l.0415-1.1845a.3442.3442 0 00-.49-.328l-1.076.491c-.0872-.0476-.1742-.0952-.2623-.1407l-.1903-1.1673A.3483.3483 0 0016.256.955l-.9597.6905a8.4867 8.4867 0 00-.2855-.086l-.414-1.1066a.3483.3483 0 00-.5781-.1154l-.8069.8666a9.2936 9.2936 0 00-.2936-.0284L12.2946.1683a.3462.3462 0 00-.5892 0l-.6236 1.0073a13.7383 13.7383 0 00-.2936.0284L9.9803.3374a.3462.3462 0 00-.578.1154l-.4141 1.1065c-.0962.0274-.1903.0567-.2855.086L7.744.955a.3483.3483 0 00-.5447.2258L7.009 2.348a9.3574 9.3574 0 00-.2622.1407l-1.0762-.491a.3462.3462 0 00-.49.328l.0416 1.1845a7.9826 7.9826 0 00-.2278.1873L3.8413 3.425a.3472.3472 0 00-.4171.4171l.2713 1.1531c-.0628.075-.1255.1509-.1863.2268l-1.1845-.0415a.3462.3462 0 00-.328.49l.491 1.0761a9.167 9.167 0 00-.1407.2622l-1.1662.1894a.3483.3483 0 00-.2258.5446l.6904.9587a13.303 13.303 0 00-.087.2855l-1.1065.414a.3483.3483 0 00-.1155.5781l.8656.807a9.2936 9.2936 0 00-.0283.2935l-1.0073.6236a.3442.3442 0 000 .5892l1.0073.6236c.008.0982.0182.1964.0283.2936l-.8656.8079a.3462.3462 0 00.1155.578l1.1065.4141c.0273.0962.0567.1914.087.2855l-.6904.9587a.3452.3452 0 00.2268.5447l1.1662.1893c.0456.088.0922.1751.1408.2622l-.491 1.0762a.3462.3462 0 00.328.49l1.1834-.0415c.0618.0769.1235.1528.1873.2277l-.2713 1.1541a.3462.3462 0 00.4171.4161l1.153-.2713c.075.0638.151.1255.2279.1863l-.0415 1.1845a.3442.3442 0 00.49.327l1.0761-.49c.087.0486.1741.0951.2622.1407l.1903 1.1662a.3483.3483 0 00.5447.2268l.9587-.6904a9.299 9.299 0 00.2855.087l.414 1.1066a.3452.3452 0 00.5781.1154l.8079-.8656c.0972.0111.1954.0203.2936.0294l.6236 1.0073a.3472.3472 0 00.5892 0l.6236-1.0073c.0982-.0091.1964-.0183.2936-.0294l.8069.8656a.3483.3483 0 00.578-.1154l.4141-1.1066a8.4626 8.4626 0 00.2855-.087l.9587.6904a.3452.3452 0 00.5447-.2268l.1903-1.1662c.088-.0456.1751-.0931.2622-.1407l1.0762.49a.3472.3472 0 00.49-.327l-.0415-1.1845a6.7267 6.7267 0 00.2267-.1863l1.1531.2713a.3472.3472 0 00.4171-.416l-.2713-1.1542c.0628-.0749.1255-.1508.1863-.2278l1.1845.0415a.3442.3442 0 00.328-.49l-.49-1.076c.0475-.0872.0951-.1742.1407-.2623l1.1662-.1893a.3483.3483 0 00.2258-.5447l-.6904-.9587.087-.2855 1.1066-.414a.3462.3462 0 00.1154-.5781l-.8656-.8079c.0101-.0972.0202-.1954.0283-.2936l1.0073-.6236a.3442.3442 0 000-.5892zm-6.7413 8.3551a.7138.7138 0 01.2986-1.396.714.714 0 11-.2997 1.396zm-.3422-2.3142a.649.649 0 00-.7715.5l-.3573 1.6685c-1.1035.501-2.3285.7795-3.6193.7795a8.7368 8.7368 0 01-3.6951-.814l-.3574-1.6684a.648.648 0 00-.7714-.499l-1.473.3158a8.7216 8.7216 0 01-.7613-.898h7.1676c.081 0 .1356-.0141.1356-.088v-2.536c0-.074-.0536-.0881-.1356-.0881h-2.0966v-1.6077h2.2677c.2065 0 1.1065.0587 1.394 1.2088.0901.3533.2875 1.5044.4232 1.8729.1346.413.6833 1.2381 1.2685 1.2381h3.5716a.7492.7492 0 00.1296-.0131 8.7874 8.7874 0 01-.8119.9526zM6.8369 20.024a.714.714 0 11-.2997-1.396.714.714 0 01.2997 1.396zM4.1177 8.9972a.7137.7137 0 11-1.304.5791.7137.7137 0 011.304-.579zm-.8352 1.9813l1.5347-.6824a.65.65 0 00.33-.8585l-.3158-.7147h1.2432v5.6025H3.5669a8.7753 8.7753 0 01-.2834-3.348zm6.7343-.5437V8.7836h2.9601c.153 0 1.0792.1772 1.0792.8697 0 .575-.7107.7815-1.2948.7815zm10.7574 1.4862c0 .2187-.008.4363-.0243.651h-.9c-.09 0-.1265.0586-.1265.1477v.413c0 .973-.5487 1.1846-1.0296 1.2382-.4576.0517-.9648-.1913-1.0275-.4717-.2704-1.5186-.7198-1.8436-1.4305-2.4034.8817-.5599 1.799-1.386 1.799-2.4915 0-1.1936-.819-1.9458-1.3769-2.3153-.7825-.5163-1.6491-.6195-1.883-.6195H5.4682a8.7651 8.7651 0 014.907-2.7699l1.0974 1.151a.648.648 0 00.9182.0213l1.227-1.1743a8.7753 8.7753 0 016.0044 4.2762l-.8403 1.8982a.652.652 0 00.33.8585l1.6178.7188c.0283.2875.0425.577.0425.8717zm-9.3006-9.5993a.7128.7128 0 11.984 1.0316.7137.7137 0 01-.984-1.0316zm8.3389 6.71a.7107.7107 0 01.9395-.3625.7137.7137 0 11-.9405.3635z"/>`
},
{
id: 'python',
label: 'Python',
category: 'lang',
tints: ['#387eb8', '#ffe052'],
svg:
`<defs><linearGradient id="pyGradA" gradientUnits="userSpaceOnUse" x1="63.8159" y1="56.6829" x2="118.4934" y2="1.8225" gradientTransform="matrix(1 0 0 -1 -53.2974 66.4321)"> <stop offset="0" stop-color="#387eb8"/> <stop offset="1" stop-color="#366994"/></linearGradient><linearGradient id="pyGradB" gradientUnits="userSpaceOnUse" x1="97.0444" y1="21.6321" x2="155.6665" y2="-34.5308" gradientTransform="matrix(1 0 0 -1 -53.2974 66.4321)"> <stop offset="0" stop-color="#ffe052"/> <stop offset="1" stop-color="#ffc331"/></linearGradient></defs>` +
`<g transform="scale(0.2175)"><path fill="url(#pyGradA)" d="M55.023-0.077c-25.971,0-26.25,10.081-26.25,12.156c0,3.148,0,12.594,0,12.594h26.75v3.781 c0,0-27.852,0-37.375,0c-7.949,0-17.938,4.833-17.938,26.25c0,19.673,7.792,27.281,15.656,27.281c2.335,0,9.344,0,9.344,0 s0-9.765,0-13.125c0-5.491,2.721-15.656,15.406-15.656c15.91,0,19.971,0,26.531,0c3.902,0,14.906-1.696,14.906-14.406 c0-13.452,0-17.89,0-24.219C82.054,11.426,81.515-0.077,55.023-0.077z M40.273,8.392c2.662,0,4.813,2.15,4.813,4.813 c0,2.661-2.151,4.813-4.813,4.813s-4.813-2.151-4.813-4.813C35.46,10.542,37.611,8.392,40.273,8.392z"/><path fill="url(#pyGradB)" d="M55.397,109.923c25.959,0,26.282-10.271,26.282-12.156c0-3.148,0-12.594,0-12.594H54.897v-3.781 c0,0,28.032,0,37.375,0c8.009,0,17.938-4.954,17.938-26.25c0-23.322-10.538-27.281-15.656-27.281c-2.336,0-9.344,0-9.344,0 s0,10.216,0,13.125c0,5.491-2.631,15.656-15.406,15.656c-15.91,0-19.476,0-26.532,0c-3.892,0-14.906,1.896-14.906,14.406 c0,14.475,0,18.265,0,24.219C28.366,100.497,31.562,109.923,55.397,109.923z M70.148,101.454c-2.662,0-4.813-2.151-4.813-4.813 s2.15-4.813,4.813-4.813c2.661,0,4.813,2.151,4.813,4.813S72.809,101.454,70.148,101.454z"/></g>`
},
// --- технологии ---
{
id: 'nginx',
label: 'NGINX',
category: 'tech',
tints: ['#009639'],
svg: badge('#009639', 'n', '#ffffff', 13)
},
{
id: 'kafka',
label: 'Kafka',
category: 'tech',
tints: ['#231F20'],
svg: `<path d="M9.71 2.136a1.43 1.43 0 0 0-2.047 0h-.007a1.48 1.48 0 0 0-.421 1.042c0 .41.161.777.422 1.039l.007.007c.257.264.616.426 1.019.426.404 0 .766-.162 1.027-.426l.003-.007c.261-.262.421-.629.421-1.039 0-.408-.159-.777-.421-1.042H9.71zM8.683 22.295c.404 0 .766-.167 1.027-.429l.003-.008c.261-.261.421-.631.421-1.036 0-.41-.159-.778-.421-1.044H9.71a1.42 1.42 0 0 0-1.027-.432 1.4 1.4 0 0 0-1.02.432h-.007c-.26.266-.422.634-.422 1.044 0 .406.161.775.422 1.036l.007.008c.258.262.617.429 1.02.429zm7.89-4.462c.359-.096.683-.33.882-.684l.027-.052a1.47 1.47 0 0 0 .114-1.067 1.454 1.454 0 0 0-.675-.896l-.021-.014a1.425 1.425 0 0 0-1.078-.132c-.36.091-.684.335-.881.686-.2.349-.241.75-.146 1.119.099.363.33.691.675.896h.002c.346.203.737.239 1.101.144zm-6.405-7.342a2.083 2.083 0 0 0-1.485-.627c-.58 0-1.103.242-1.482.627-.378.385-.612.916-.612 1.507s.233 1.124.612 1.514a2.08 2.08 0 0 0 2.967 0c.379-.39.612-.923.612-1.514s-.233-1.122-.612-1.507zm-.835-2.51c.843.141 1.6.552 2.178 1.144h.004c.092.093.182.196.265.299l1.446-.851a3.176 3.176 0 0 1-.047-1.808 3.149 3.149 0 0 1 1.456-1.926l.025-.016a3.062 3.062 0 0 1 2.345-.306c.77.21 1.465.721 1.898 1.482v.002c.431.757.518 1.626.313 2.408a3.145 3.145 0 0 1-1.456 1.928l-.198.118h-.02a3.095 3.095 0 0 1-2.154.201 3.127 3.127 0 0 1-1.514-.944l-1.444.848a4.162 4.162 0 0 1 0 2.879l1.444.846c.413-.47.939-.789 1.514-.944a3.041 3.041 0 0 1 2.371.319l.048.023v.002a3.17 3.17 0 0 1 1.408 1.906 3.215 3.215 0 0 1-.313 2.405l-.026.053-.003-.005a3.147 3.147 0 0 1-1.867 1.436 3.096 3.096 0 0 1-2.371-.318v-.006a3.156 3.156 0 0 1-1.456-1.927 3.175 3.175 0 0 1 .047-1.805l-1.446-.848a3.905 3.905 0 0 1-.265.294l-.004.005a3.938 3.938 0 0 1-2.178 1.138v1.699a3.09 3.09 0 0 1 1.56.862l.002.004c.565.572.914 1.368.914 2.243 0 .873-.35 1.664-.914 2.239l-.002.009a3.1 3.1 0 0 1-2.21.931 3.1 3.1 0 0 1-2.206-.93h-.002v-.009a3.186 3.186 0 0 1-.916-2.239c0-.875.35-1.672.916-2.243v-.004h.002a3.1 3.1 0 0 1 1.558-.862v-1.699a3.926 3.926 0 0 1-2.176-1.138l-.006-.005a4.098 4.098 0 0 1-1.173-2.874c0-1.122.452-2.136 1.173-2.872h.006a3.947 3.947 0 0 1 2.176-1.144V6.289a3.137 3.137 0 0 1-1.558-.864h-.002v-.004a3.192 3.192 0 0 1-.916-2.243c0-.871.35-1.669.916-2.243l.002-.002A3.084 3.084 0 0 1 8.683 0c.861 0 1.641.355 2.21.932v.002h.002c.565.574.914 1.372.914 2.243 0 .876-.35 1.667-.914 2.243l-.002.005a3.142 3.142 0 0 1-1.56.864v1.692zm8.121-1.129l-.012-.019a1.452 1.452 0 0 0-.87-.668 1.43 1.43 0 0 0-1.103.146h.002c-.347.2-.58.529-.677.896-.095.365-.054.768.146 1.119l.007.009c.2.347.519.579.874.673.357.103.755.059 1.098-.144l.019-.009a1.47 1.47 0 0 0 .657-.885 1.493 1.493 0 0 0-.141-1.118"/>`
},
{
id: 'docker',
label: 'Docker',
category: 'tech',
tints: ['#2496ED'],
svg: `<path d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"/>`
},
{
id: 'kubernetes',
label: 'Kubernetes',
category: 'tech',
tints: ['#326CE5'],
svg: `<path d="M10.204 14.35l.007.01-.999 2.413a5.171 5.171 0 0 1-2.075-2.597l2.578-.437.004.005a.44.44 0 0 1 .484.606zm-.833-2.129a.44.44 0 0 0 .173-.756l.002-.011L7.585 9.7a5.143 5.143 0 0 0-.73 3.255l2.514-.725.002-.009zm1.145-1.98a.44.44 0 0 0 .699-.337l.01-.005.15-2.62a5.144 5.144 0 0 0-3.01 1.442l2.147 1.523.004-.002zm.76 2.75l.723.349.722-.347.18-.78-.5-.623h-.804l-.5.623.179.779zm1.5-3.095a.44.44 0 0 0 .7.336l.008.003 2.134-1.513a5.188 5.188 0 0 0-2.992-1.442l.148 2.615.002.001zm10.876 5.97l-5.773 7.181a1.6 1.6 0 0 1-1.248.594l-9.261.003a1.6 1.6 0 0 1-1.247-.596l-5.776-7.18a1.583 1.583 0 0 1-.307-1.34L2.1 5.573c.108-.47.425-.864.863-1.073L11.305.513a1.606 1.606 0 0 1 1.385 0l8.345 3.985c.438.209.755.604.863 1.073l2.062 8.955c.108.47-.005.963-.308 1.34zm-3.289-2.057c-.042-.01-.103-.026-.145-.034-.174-.033-.315-.025-.479-.038-.35-.037-.638-.067-.895-.148-.105-.04-.18-.165-.216-.216l-.201-.059a6.45 6.45 0 0 0-.105-2.332 6.465 6.465 0 0 0-.936-2.163c.052-.047.15-.133.177-.159.008-.09.001-.183.094-.282.197-.185.444-.338.743-.522.142-.084.273-.137.415-.242.032-.024.076-.062.11-.089.24-.191.295-.52.123-.736-.172-.216-.506-.236-.745-.045-.034.027-.08.062-.111.088-.134.116-.217.23-.33.35-.246.25-.45.458-.673.609-.097.056-.239.037-.303.033l-.19.135a6.545 6.545 0 0 0-4.146-2.003l-.012-.223c-.065-.062-.143-.115-.163-.25-.022-.268.015-.557.057-.905.023-.163.061-.298.068-.475.001-.04-.001-.099-.001-.142 0-.306-.224-.555-.5-.555-.275 0-.499.249-.499.555l.001.014c0 .041-.002.092 0 .128.006.177.044.312.067.475.042.348.078.637.056.906a.545.545 0 0 1-.162.258l-.012.211a6.424 6.424 0 0 0-4.166 2.003 8.373 8.373 0 0 1-.18-.128c-.09.012-.18.04-.297-.029-.223-.15-.427-.358-.673-.608-.113-.12-.195-.234-.329-.349-.03-.026-.077-.062-.111-.088a.594.594 0 0 0-.348-.132.481.481 0 0 0-.398.176c-.172.216-.117.546.123.737l.007.005.104.083c.142.105.272.159.414.242.299.185.546.338.743.522.076.082.09.226.1.288l.16.143a6.462 6.462 0 0 0-1.02 4.506l-.208.06c-.055.072-.133.184-.215.217-.257.081-.546.11-.895.147-.164.014-.305.006-.48.039-.037.007-.09.02-.133.03l-.004.002-.007.002c-.295.071-.484.342-.423.608.061.267.349.429.645.365l.007-.001.01-.003.129-.029c.17-.046.294-.113.448-.172.33-.118.604-.217.87-.256.112-.009.23.069.288.101l.217-.037a6.5 6.5 0 0 0 2.88 3.596l-.09.218c.033.084.069.199.044.282-.097.252-.263.517-.452.813-.091.136-.185.242-.268.399-.02.037-.045.095-.064.134-.128.275-.034.591.213.71.248.12.556-.007.69-.282v-.002c.02-.039.046-.09.062-.127.07-.162.094-.301.144-.458.132-.332.205-.68.387-.897.05-.06.13-.082.215-.105l.113-.205a6.453 6.453 0 0 0 4.609.012l.106.192c.086.028.18.042.256.155.136.232.229.507.342.84.05.156.074.295.145.457.016.037.043.09.062.129.133.276.442.402.69.282.247-.118.341-.435.213-.71-.02-.039-.045-.096-.065-.134-.083-.156-.177-.261-.268-.398-.19-.296-.346-.541-.443-.793-.04-.13.007-.21.038-.294-.018-.022-.059-.144-.083-.202a6.499 6.499 0 0 0 2.88-3.622c.064.01.176.03.213.038.075-.05.144-.114.28-.104.266.039.54.138.87.256.154.06.277.128.448.173.036.01.088.019.13.028l.009.003.007.001c.297.064.584-.098.645-.365.06-.266-.128-.537-.423-.608zM16.4 9.701l-1.95 1.746v.005a.44.44 0 0 0 .173.757l.003.01 2.526.728a5.199 5.199 0 0 0-.108-1.674A5.208 5.208 0 0 0 16.4 9.7zm-4.013 5.325a.437.437 0 0 0-.404-.232.44.44 0 0 0-.372.233h-.002l-1.268 2.292a5.164 5.164 0 0 0 3.326.003l-1.27-2.296h-.01zm1.888-1.293a.44.44 0 0 0-.27.036.44.44 0 0 0-.214.572l-.003.004 1.01 2.438a5.15 5.15 0 0 0 2.081-2.615l-2.6-.44-.004.005z"/>`
},
{
id: 'redis',
label: 'Redis',
category: 'tech',
tints: ['#FF4438'],
svg: `<path d="M22.71 13.145c-1.66 2.092-3.452 4.483-7.038 4.483-3.203 0-4.397-2.825-4.48-5.12.701 1.484 2.073 2.685 4.214 2.63 4.117-.133 6.94-3.852 6.94-7.239 0-4.05-3.022-6.972-8.268-6.972-3.752 0-8.4 1.428-11.455 3.685C2.59 6.937 3.885 9.958 4.35 9.626c2.648-1.904 4.748-3.13 6.784-3.744C8.12 9.244.886 17.05 0 18.425c.1 1.261 1.66 4.648 2.424 4.648.232 0 .431-.133.664-.365a100.49 100.49 0 0 0 5.54-6.765c.222 3.104 1.748 6.898 6.014 6.898 3.819 0 7.604-2.756 9.33-8.965.2-.764-.73-1.361-1.261-.73zm-4.349-5.013c0 1.959-1.926 2.922-3.685 2.922-.941 0-1.664-.247-2.235-.568 1.051-1.592 2.092-3.225 3.21-4.973 1.972.334 2.71 1.43 2.71 2.619z"/>`
},
// --- организации ---,
{
id: 'github',
label: 'GitHub',
category: 'org',
tints: ['#181717'],
svg: `<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/>`
},
{
id: 'gitlab',
label: 'GitLab',
category: 'org',
tints: ['#FC6D26'],
svg: `<path d="m23.6004 9.5927-.0337-.0862L20.3.9814a.851.851 0 0 0-.3362-.405.8748.8748 0 0 0-.9997.0539.8748.8748 0 0 0-.29.4399l-2.2055 6.748H7.5375l-2.2057-6.748a.8573.8573 0 0 0-.29-.4412.8748.8748 0 0 0-.9997-.0537.8585.8585 0 0 0-.3362.4049L.4332 9.5015l-.0325.0862a6.0657 6.0657 0 0 0 2.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 0 0 1.2197 0l1.4995-1.1321 2.4619-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 0 0 2.0094-7.003z"/>`
},
{
id: 'bitbucket',
label: 'Bitbucket',
category: 'org',
tints: ['#0052CC'],
svg: `<path d="M.778 1.213a.768.768 0 00-.768.892l3.263 19.81c.084.5.515.868 1.022.873H19.95a.772.772 0 00.77-.646l3.27-20.03a.768.768 0 00-.768-.891zM14.52 15.53H9.522L8.17 8.466h7.561z"/>`
},
{
id: 'git',
label: 'Git',
category: 'org',
tints: ['#F03C2E'],
svg: `<path d="M13.09 23.549a1.54 1.54 0 0 1-2.18 0L.451 13.089a1.54 1.54 0 0 1 0-2.179l7.191-7.19 2.733 2.733a1.85 1.85 0 0 0 .964 2.326v6.66a1.849 1.849 0 1 0 1.54 0V8.957l2.508 2.508a1.85 1.85 0 1 0 1.09-1.09l-2.634-2.634a1.85 1.85 0 0 0-2.378-2.377L8.73 2.63 10.91.451a1.54 1.54 0 0 1 2.179 0l10.459 10.46a1.54 1.54 0 0 1 0 2.179z"/>`
}
];;
const byId = new Map(LOGOS.map((l) => [l.id, l]));
export function logoById(id: string | null | undefined): LogoDef | undefined {
return id ? byId.get(id) : undefined;
}
// ─── авто-подложка под логотип ───────────────────────────────────────────────
// Если цвет логотипа близок к цвету ткани (кепки/футболки), под логотип
// рисуется контрастная плашка-«стикер». Порог подобран на глаз: ниже —
// подложка появляется чаще, выше — реже.
const CONTRAST_THRESHOLD = 120; // 0…441 (евклидово расстояние в RGB)
function hexToRgb(hex: string): [number, number, number] {
const h = hex.replace('#', '');
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
}
function luminance(rgb: [number, number, number]): number {
return (0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]) / 255;
}
function colorDistance(a: string, b: string): number {
const ra = hexToRgb(a);
const rb = hexToRgb(b);
return Math.hypot(ra[0] - rb[0], ra[1] - rb[1], ra[2] - rb[2]);
}
export interface LogoBackdrop {
x: number;
y: number;
w: number;
h: number;
rx: number;
fill: string;
stroke: string;
}
/**
* Плашка под логотип, если он сливается с поверхностью.
* surface — цвет ткани, logoColor — фактический цвет логотипа
* (для mono-логотипов это подставленный currentColor), cx/cy/size — центр
* и сторона квадрата логотипа на канвасе утки.
*/
export function logoBackdrop(
surface: string | null | undefined,
logoColors: string[],
cx: number,
cy: number,
size: number
): LogoBackdrop | null {
if (!surface || !surface.startsWith('#')) return null;
const colors = logoColors.filter((c) => c.startsWith('#'));
if (!colors.length) return null;
if (colors.every((c) => colorDistance(surface, c) >= CONTRAST_THRESHOLD)) return null;
// белая плашка на цветной/тёмной ткани, тёмная — на почти белой
const light = luminance(hexToRgb(surface)) > 0.68;
const pad = size * 0.14;
return {
x: cx - size / 2 - pad,
y: cy - size / 2 - pad,
w: size + pad * 2,
h: size + pad * 2,
rx: size * 0.22,
fill: light ? '#0f172a' : '#f8fafc',
stroke: light ? 'rgba(0,0,0,0.3)' : 'rgba(15,23,42,0.18)'
};
}
+103
View File
@@ -0,0 +1,103 @@
export const MOODS = ['happy', 'confused', 'excited', 'sleepy'] as const;
/** настроение утки — выводится из MOODS, отдельно не правится */
export type Mood = (typeof MOODS)[number];
const MOOD_LABELS: Record<Mood, string> = {
happy: 'довольная',
confused: 'в замешательстве',
excited: 'в восторге',
sleepy: 'сонная'
};
export function moodLabel(mood: Mood): string {
return MOOD_LABELS[mood];
}
export function moodFor(text: string): Mood {
const t = text.toLowerCase();
if (/(устал|спать|сон|ночь|надоело|sleep|tired|boring)/.test(t)) return 'sleepy';
if (/(ура|получилось|работает|наконец|супер|отлично|yay|works|fixed)/.test(t) || /!{2,}/.test(t))
return 'excited';
if (/\?|почему|как|не работает|сломал|баг|ошибк|bug|why|how|странно/.test(t)) return 'confused';
return 'happy';
}
/** те же пулы фраз, что у настоящего quack-инструмента (src/lib/mcp.ts) */
const QUACKS: Record<Mood | 'default', string[]> = {
happy: ['QUACK! 🎉', 'Quack quack! 😄', 'QUAAACK! 🦆✨'],
confused: ['Qu...ack?', 'Quack? 🤔', 'Quaaack...?'],
excited: ['QUACK QUACK QUACK!', 'QUAAACK!! 🔥', 'Quack! Quack! Quack!'],
sleepy: ['quack... 😴', 'quaack... 💤', 'quack. *yawn*'],
default: ['Quack!', 'Quack quack!', 'QUACK!', 'Quaaack!', 'Quack. 🦆']
};
function randomFrom<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)];
}
/** Подсказки утки-наставника — то, ради чего к ней идут. */
export const DUCK_TIPS = [
'Расскажи, что ты ожидаешь увидеть — и что видишь вместо этого.',
'Разбей проблему на шаги и проговори каждый вслух. Я слушаю.',
'А что приходит на входе? Проверь значения по одному.',
'Прочитай ошибку вслух, медленно. Что она говорит на самом деле?',
'Объясни код строка за строкой — где-то на середине станет ясно.',
'Что изменилось с тех пор, как всё работало?',
'Сделай минимальный пример, который воспроизводит проблему.',
'Проверь границы: пустые значения, нули, пустые строки.',
'Отдохни пять минут и вернись свежим взглядом.',
'Веришь своему логу — или своим догадкам? Проверь.'
];
export function randomTip(): string {
return randomFrom(DUCK_TIPS);
}
export interface SimulatedToolCall {
tool: 'quack';
arguments: { mood: Mood };
/** результат в формате MCP content-блоков — как вернул бы настоящий инструмент */
content: { type: 'text'; text: string }[];
}
/** Симуляция вызова MCP-инструмента quack: без сети, с «задержкой сети» и тем же ответом. */
export async function simulateMcpQuack(message: string): Promise<SimulatedToolCall> {
const mood = moodFor(message);
await new Promise((r) => setTimeout(r, 350 + Math.random() * 550));
return {
tool: 'quack',
arguments: { mood },
content: [{ type: 'text', text: randomFrom(QUACKS[mood]) }]
};
}
let audioCtx: AudioContext | undefined;
/** Синтезированное «кря» через WebAudio (без аудио-ассетов). */
export function playQuack() {
try {
audioCtx ??= new AudioContext();
const ctx = audioCtx;
const t = ctx.currentTime;
const osc = ctx.createOscillator();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(420, t);
osc.frequency.exponentialRampToValueAtTime(130, t + 0.18);
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = 1200;
filter.Q.value = 6;
const gain = ctx.createGain();
gain.gain.setValueAtTime(0.0001, t);
gain.gain.exponentialRampToValueAtTime(0.2, t + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, t + 0.22);
osc.connect(filter);
filter.connect(gain);
gain.connect(ctx.destination);
osc.start(t);
osc.stop(t + 0.25);
} catch {
// нет WebAudio — просто молча крякаем
}
}
+51
View File
@@ -0,0 +1,51 @@
import type { CapId, TeeId } from './wardrobe';
export interface Outfit {
cap: CapId;
tee: TeeId;
capLogo: string | null;
teeLogo: string | null;
bob: boolean;
sound: boolean;
}
const STORAGE_KEY = 'duck-outfit-v1';
function defaultOutfit(): Outfit {
return {
cap: 'baseball',
tee: 'white',
capLogo: null,
teeLogo: 'js',
bob: true,
sound: true
};
}
export const outfit = $state<Outfit>(defaultOutfit());
export const duckState = $state({ speaking: false });
if (typeof window !== 'undefined') {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const saved = JSON.parse(raw) as Partial<Outfit>;
Object.assign(outfit, saved);
}
} catch {
// битый localStorage — используем дефолтный образ
}
}
export function saveOutfit() {
if (typeof window === 'undefined') return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(outfit));
} catch {
// приватный режим / переполнение — молча игнорируем
}
}
export function resetOutfit() {
Object.assign(outfit, defaultOutfit());
}
+208
View File
@@ -0,0 +1,208 @@
export interface CapShape {
color: string;
dark: boolean;
/** позиция логотипа на кепке (координаты канваса утки 800x920) */
logo: { x: number; y: number; s: number };
/** разметка кепки, рисуется поверх головы утки */
svg: string;
}
export interface TeeDef {
id: TeeId;
label: string;
/** PNG-слой футболки (перекрашен из TShirt.jfif), null = без футболки */
img: string | null;
/** цвет ткани (примерный) — для авто-подложки под логотип */
color: string;
dark: boolean;
/** цвет для монохромных логотипов */
mono: string;
}
const shade = (opacity: number) => `rgba(0,0,0,${opacity})`;
// ─────────────────────────────────────────────────────────────────────────────
// КАПКИ — как подогнать под себя
//
// Утка лежит на канвасе 800×920: центр головы x=400, макушка y≈60,
// глаза занимают полосу y≈178…258 (белки начинаются на y=178).
// Кепка рисуется поверх головы, поэтому:
// • baseY (нижний край купола) держите в диапазоне 130…176 — ниже кепка
// начнёт закрывать глаза;
// • domeW больше ~420 будет шире головы;
// • позиция логотипа считается автоматически, но можно задать вручную
// параметром logo: { x, y, s } (s — сторона квадрата логотипа).
// Правьте числа в вызовах baseball(…) / beanie(…) / flatcap(…) / tophat(…)
// в списке CAPS ниже — утка в браузере обновится сразу (dev-режим).
// ─────────────────────────────────────────────────────────────────────────────
const CX = 400; // центр утки по горизонтали
/** купол: нижний край baseY, ширина domeW, высота domeH */
function dome(baseY: number, domeW: number, domeH: number): string {
const x0 = CX - domeW / 2;
const x1 = CX + domeW / 2;
const top = baseY - domeH;
return (
`M ${x0} ${baseY} C ${x0} ${top + 32} ${CX - domeW * 0.3} ${top} ${CX} ${top} ` +
`C ${CX + domeW * 0.3} ${top} ${x1} ${top + 32} ${x1} ${baseY} Z`
);
}
/** бейсболка: купол + козырёк-эллипс + швы + пуговка */
function baseball(opts: {
color: string;
baseY?: number; // нижний край купола
domeW?: number; // ширина купола
domeH?: number; // высота купола
brimRx?: number; // козырёк: полуширина
brimRy?: number; // козырёк: полувысота
logo?: { x: number; y: number; s: number };
}): CapShape {
const { color, baseY = 146, domeW = 376, domeH = 140, brimRx = 212, brimRy = 28 } = opts;
const top = baseY - domeH;
const seam =
(d: 1 | -1) =>
`M ${CX} ${top} C ${CX + d * domeW * 0.053} ${top + 44} ${CX + d * domeW * 0.062} ${baseY - 46} ${CX + d * domeW * 0.058} ${baseY - 4}`;
const svg =
`<path d="${dome(baseY, domeW, domeH)}" fill="${color}"/>` +
`<path d="${seam(-1)} ${seam(1)}" stroke="${shade(0.2)}" stroke-width="6" fill="none"/>` +
`<circle cx="${CX}" cy="${top + 4}" r="10" fill="${shade(0.3)}"/>` +
`<ellipse cx="${CX}" cy="${baseY + 2}" rx="${brimRx}" ry="${brimRy}" fill="${color}"/>` +
`<ellipse cx="${CX}" cy="${baseY + 2}" rx="${brimRx}" ry="${brimRy}" fill="${shade(0.24)}"/>`;
return {
color,
dark: true,
logo: opts.logo ?? { x: CX, y: Math.round(top + domeH / 2), s: Math.round(domeH * 0.48) },
svg
};
}
/** бини: купол + помпон + отворот-полоса (логотип живёт на отвороте) */
function beanie(opts: {
color: string;
baseY?: number;
domeW?: number;
domeH?: number;
bandH?: number; // высота отворота
pomR?: number; // радиус помпона
logo?: { x: number; y: number; s: number };
}): CapShape {
const { color, baseY = 148, domeW = 360, domeH = 140, bandH = 60, pomR = 24 } = opts;
const top = baseY - domeH;
const bw = domeW + 32;
const svg =
`<path d="${dome(baseY, domeW, domeH)}" fill="${color}"/>` +
`<circle cx="${CX}" cy="${top + 18}" r="${pomR}" fill="${color}"/>` +
`<circle cx="${CX}" cy="${top + 18}" r="${pomR}" fill="rgba(255,255,255,0.18)"/>` +
`<rect x="${CX - bw / 2}" y="${baseY - 36}" width="${bw}" height="${bandH}" rx="${bandH / 2}" fill="${color}"/>` +
`<rect x="${CX - bw / 2}" y="${baseY - 36}" width="${bw}" height="${bandH}" rx="${bandH / 2}" fill="${shade(0.16)}"/>`;
return {
color,
dark: true,
logo: opts.logo ?? { x: CX, y: Math.round(baseY - 36 + bandH / 2), s: Math.round(bandH * 0.8) },
svg
};
}
/** восьмиклинка: плоский купол + маленький козырёк + клинья-швы */
function flatcap(opts: {
color: string;
baseY?: number;
domeW?: number;
domeH?: number;
brimRx?: number;
brimRy?: number;
seamDx?: number; // отступ клиньев от центра
logo?: { x: number; y: number; s: number };
}): CapShape {
const { color, baseY = 150, domeW = 372, domeH = 132, brimRx = 158, brimRy = 26, seamDx = 60 } = opts;
const top = baseY - domeH;
const seam =
(d: 1 | -1) =>
`M ${CX + d * seamDx} ${top + 10} C ${CX + d * (seamDx - 12)} ${top + 54} ${CX + d * (seamDx - 16)} ${baseY - 42} ${CX + d * (seamDx - 12)} ${baseY - 2}`;
const svg =
`<path d="${dome(baseY, domeW, domeH)}" fill="${color}"/>` +
`<path d="${seam(-1)} ${seam(1)}" stroke="${shade(0.18)}" stroke-width="6" fill="none"/>` +
`<ellipse cx="${CX}" cy="${baseY}" rx="${brimRx}" ry="${brimRy}" fill="${color}"/>` +
`<ellipse cx="${CX}" cy="${baseY}" rx="${brimRx}" ry="${brimRy}" fill="${shade(0.2)}"/>`;
return {
color,
dark: false,
logo: opts.logo ?? { x: CX, y: Math.round(top + domeH * 0.52), s: Math.round(domeH * 0.44) },
svg
};
}
/** цилиндр: широкие поля + тулья + лента */
function tophat(opts: {
color: string;
baseY?: number;
crownW?: number; // ширина тульи
crownH?: number; // высота тульи
brimRx?: number; // поля: полуширина
brimRy?: number; // поля: полувысота
logo?: { x: number; y: number; s: number };
}): CapShape {
const { color, baseY = 146, crownW = 232, crownH = 132, brimRx = 242, brimRy = 30 } = opts;
const crownTop = baseY - crownH;
const svg =
`<ellipse cx="${CX}" cy="${baseY}" rx="${brimRx}" ry="${brimRy}" fill="${color}"/>` +
`<ellipse cx="${CX}" cy="${baseY}" rx="${brimRx}" ry="${brimRy}" fill="${shade(0.18)}"/>` +
`<rect x="${CX - crownW / 2}" y="${crownTop}" width="${crownW}" height="${crownH}" rx="8" fill="${color}"/>` +
`<ellipse cx="${CX}" cy="${crownTop + 4}" rx="${crownW / 2}" ry="14" fill="${color}"/>` +
`<ellipse cx="${CX}" cy="${crownTop + 4}" rx="${crownW / 2}" ry="14" fill="rgba(255,255,255,0.12)"/>` +
`<rect x="${CX - crownW / 2}" y="${baseY - 36}" width="${crownW}" height="30" fill="${shade(0.3)}"/>`;
return {
color,
dark: true,
logo: opts.logo ?? { x: CX, y: Math.round(crownTop + crownH * 0.36), s: Math.round(crownH * 0.44) },
svg
};
}
const CAP_LIST = [
{ id: 'none', label: 'Без кепки', color: '', dark: false, logo: { x: 0, y: 0, s: 0 }, svg: '' },
{ id: 'baseball', label: 'Бейсболка', ...baseball({ color: '#2563eb' }) },
{ id: 'beanie', label: 'Бини', ...beanie({ color: '#ef4444' }) },
{ id: 'flatcap', label: 'Восьмиклинка', ...flatcap({ color: '#16a34a' }) },
{ id: 'tophat', label: 'Цилиндр', ...tophat({ color: '#4b5b70' }) }
] as const;
/** id всех кепок — выводится из CAP_LIST, отдельно не правится */
export type CapId = (typeof CAP_LIST)[number]['id'];
export interface CapDef extends CapShape {
id: CapId;
label: string;
}
export const CAPS: readonly CapDef[] = CAP_LIST;
const TEE_LIST = [
{ id: 'none', label: 'Без футболки', img: null, color: '', dark: false, mono: '' },
{ id: 'white', label: 'Белая', img: '/duck/tee-white.png', color: '#e9edf1', dark: false, mono: '#0f172a' },
{ id: 'black', label: 'Чёрная', img: '/duck/tee-black.png', color: '#2b2f33', dark: true, mono: '#f8fafc' },
{ id: 'green', label: 'Терминальная', img: '/duck/tee-green.png', color: '#16a34a', dark: false, mono: '#0f172a' },
{ id: 'orange', label: 'Оранжевая', img: '/duck/tee-orange.png', color: '#f97316', dark: false, mono: '#0f172a' },
{ id: 'blue', label: 'Голубая', img: '/duck/tee-blue.png', color: '#2563eb', dark: false, mono: '#0f172a' }
] as const;
/** id всех футболок — выводится из TEE_LIST, отдельно не правится */
export type TeeId = (typeof TEE_LIST)[number]['id'];
export const TEES: readonly TeeDef[] = TEE_LIST;
/** позиция логотипа на груди футболки (канвас 800x920) */
export const TEE_LOGO = { x: 400, y: 645, s: 96 };
const capMap = new Map(CAPS.map((c) => [c.id, c]));
const teeMap = new Map(TEES.map((t) => [t.id, t]));
export function capById(id: string | null | undefined): CapDef {
return (id && capMap.get(id as CapId)) || CAPS[0];
}
export function teeById(id: string | null | undefined): TeeDef {
return (id && teeMap.get(id as TeeId)) || TEES[0];
}
+244 -2
View File
@@ -1,2 +1,244 @@
<h1>🦆 Rubber Duck Debugging as a Service</h1>
<p class="muted">Главная страница в разработке. А пока — смотрите <a href="/reports">отчёты тестирования</a> и <a href="/mcp">MCP-сервер</a>.</p>
<script lang="ts">
import DuckAvatar from '$lib/components/duck/DuckAvatar.svelte';
import ChatPanel from '$lib/components/duck/ChatPanel.svelte';
import WardrobePanel from '$lib/components/duck/WardrobePanel.svelte';
import SettingsPanel from '$lib/components/duck/SettingsPanel.svelte';
import { outfit, saveOutfit } from '$lib/duck/state.svelte';
const TABS = [
{ id: 'chat', label: 'Чат' },
{ id: 'wardrobe', label: 'Гардероб' },
{ id: 'settings', label: 'Настройки' }
] as const;
type Tab = (typeof TABS)[number]['id'];
let tab = $state<Tab>('chat');
// сохраняем образ при любом изменении
$effect(() => {
saveOutfit();
});
const outfitLine = $derived(
`{"cap":"${outfit.cap}","cap_logo":${JSON.stringify(outfit.capLogo)},` +
`"tee":"${outfit.tee}","tee_logo":${JSON.stringify(outfit.teeLogo)}}`
);
</script>
<svelte:head>
<title>Rubber Duck — отладка с уткой</title>
<meta name="description" content="Резиновая утка для отладки: переодевай, вешай логотипы, рассказывай о проблеме." />
</svelte:head>
<section class="hero">
<div>
<h1>Rubber Duck<span class="cursor mono">_</span></h1>
<p class="sub">
Проговори проблему вслух — утка выслушает. Пока говоришь, решение часто находится само.
</p>
</div>
<div class="chips mono">
<span class="chip">debugging-as-a-service</span>
<span class="chip">local-first</span>
<span class="chip">quack · симуляция</span>
</div>
</section>
<div class="grid">
<section class="stage" aria-label="Сцена с уткой">
<div class="stage-top mono">
<span>duck_id: RU-1337</span>
<span>status: listening</span>
<span>uptime: ∞</span>
</div>
<div class="duck-wrap">
<DuckAvatar />
</div>
<div class="term mono">
<div class="term-line"><span class="prompt">$</span> duck outfit --json</div>
<div class="term-out">{outfitLine}</div>
</div>
</section>
<aside class="side" aria-label="Чат и настройки">
<div class="tabs mono" role="tablist">
{#each TABS as t (t.id)}
<button
role="tab"
class:active={tab === t.id}
aria-selected={tab === t.id}
onclick={() => (tab = t.id)}
>
{t.label}
</button>
{/each}
</div>
<div class="tab-body chat-body" class:hidden={tab !== 'chat'}>
<ChatPanel />
</div>
<div class="tab-body" class:hidden={tab !== 'wardrobe'}>
<WardrobePanel />
</div>
<div class="tab-body" class:hidden={tab !== 'settings'}>
<SettingsPanel />
</div>
</aside>
</div>
<style>
.hero {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 20px;
margin-bottom: 22px;
flex-wrap: wrap;
}
h1 {
margin: 0;
font-size: 34px;
line-height: 1.15;
letter-spacing: -0.02em;
}
.cursor {
color: #f5883c;
animation: caret 1.1s steps(1) infinite;
font-weight: 400;
}
@keyframes caret {
50% {
opacity: 0;
}
}
.sub {
margin: 8px 0 0;
color: var(--muted);
max-width: 520px;
font-size: 15px;
}
.chips {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.chip {
font-size: 11.5px;
color: var(--muted);
border: 1px solid var(--border);
background: var(--panel);
border-radius: 999px;
padding: 4px 10px;
}
.grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 400px;
gap: 20px;
align-items: stretch;
}
.stage {
border: 1px solid var(--border);
border-radius: 16px;
background:
radial-gradient(420px 320px at 50% 46%, rgba(255, 217, 59, 0.09), transparent 70%),
linear-gradient(rgba(148, 163, 184, 0.06) 1px, transparent 1px),
linear-gradient(90deg, rgba(148, 163, 184, 0.06) 1px, transparent 1px),
var(--panel);
background-size: auto, 28px 28px, 28px 28px, auto;
display: flex;
flex-direction: column;
overflow: hidden;
}
.stage-top {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 10px 16px;
font-size: 11.5px;
color: var(--muted);
border-bottom: 1px solid var(--border);
}
.duck-wrap {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 22px 20px 10px;
min-height: 420px;
}
.term {
border-top: 1px solid var(--border);
background: rgba(2, 6, 23, 0.45);
padding: 10px 16px 12px;
font-size: 12px;
}
.term-line {
color: var(--muted);
}
.prompt {
color: var(--ok);
}
.term-out {
color: #fbbf24;
margin-top: 2px;
word-break: break-all;
}
.side {
border: 1px solid var(--border);
border-radius: 16px;
background: var(--panel);
display: flex;
flex-direction: column;
height: 640px;
overflow: hidden;
}
.tabs {
display: flex;
border-bottom: 1px solid var(--border);
}
.tabs button {
flex: 1;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--muted);
font: inherit;
font-size: 13px;
padding: 12px 8px;
cursor: pointer;
}
.tabs button:hover {
color: var(--text);
}
.tabs button.active {
color: var(--text);
border-bottom-color: #3b82f6;
}
.tab-body {
flex: 1;
overflow-y: auto;
padding: 14px;
min-height: 0;
}
.chat-body {
display: flex;
}
.tab-body.hidden {
display: none;
}
@media (max-width: 1020px) {
.grid {
grid-template-columns: 1fr;
}
.side {
height: auto;
}
.chat-body {
height: 520px;
}
.duck-wrap {
min-height: 320px;
}
}
</style>
Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB