diff --git a/apps/web/src/lib/assets/favicon.svg b/apps/web/src/lib/assets/favicon.svg
index cc5dc66..02b163d 100644
--- a/apps/web/src/lib/assets/favicon.svg
+++ b/apps/web/src/lib/assets/favicon.svg
@@ -1 +1 @@
-svelte-logo
\ No newline at end of file
+rubber-duck
diff --git a/apps/web/src/lib/components/duck/ChatPanel.svelte b/apps/web/src/lib/components/duck/ChatPanel.svelte
new file mode 100644
index 0000000..5639f41
--- /dev/null
+++ b/apps/web/src/lib/components/duck/ChatPanel.svelte
@@ -0,0 +1,231 @@
+
+
+
+
+ {#each messages as msg (msg.id)}
+
+
+ {#if msg.role === 'duck'}
🦆 {/if}
+
{msg.text}
+
+ {#if msg.role === 'duck' && msg.mood}
+
tools/call(quack, mood={msg.mood}) · {moodLabel(msg.mood)} · симуляция
+ {/if}
+
+ {/each}
+ {#if busy}
+
+ {/if}
+
+
+
+ {#each CHIPS as chip}
+ void send(chip)}>{chip}
+ {/each}
+
+
+
+
+ void send(draft)} disabled={busy || !draft.trim()}>
+ Крякнуть
+
+
+
+
+
diff --git a/apps/web/src/lib/components/duck/DuckAvatar.svelte b/apps/web/src/lib/components/duck/DuckAvatar.svelte
new file mode 100644
index 0000000..d58fbdf
--- /dev/null
+++ b/apps/web/src/lib/components/duck/DuckAvatar.svelte
@@ -0,0 +1,106 @@
+
+
+
+
+
+ {#if tee.img}
+
+ {/if}
+
+ {#if outfit.teeLogo && tee.img}
+
+ {/if}
+ {#if cap.id !== 'none'}
+ {@html cap.svg}
+ {#if outfit.capLogo}
+
+ {/if}
+ {/if}
+
+
+
+
diff --git a/apps/web/src/lib/components/duck/Logo.svelte b/apps/web/src/lib/components/duck/Logo.svelte
new file mode 100644
index 0000000..ca12b1f
--- /dev/null
+++ b/apps/web/src/lib/components/duck/Logo.svelte
@@ -0,0 +1,26 @@
+
+
+{#if def}
+ {@html def.svg}
+{/if}
diff --git a/apps/web/src/lib/components/duck/SettingsPanel.svelte b/apps/web/src/lib/components/duck/SettingsPanel.svelte
new file mode 100644
index 0000000..564ec72
--- /dev/null
+++ b/apps/web/src/lib/components/duck/SettingsPanel.svelte
@@ -0,0 +1,159 @@
+
+
+
+
+
+
+ Текущий образ
+ {outfitJson}
+
+ {copied ? 'Скопировано ✓' : 'Копировать JSON'}
+ Сбросить образ
+
+
+
+
+ О проекте
+
+ Утка-отладчик слушает и крякает. Вызов quack в чате —
+ локальная симуляция MCP-инструмента: без сети, с той же логикой ответа.
+ Настоящий MCP-эндпоинт живёт отдельно — /mcp .
+ Все логотипы принадлежат их правообладателям и используются
+ в иллюстративных целях в рамках добросовестного использования (fair use).
+
+
+
+
+
diff --git a/apps/web/src/lib/components/duck/WardrobePanel.svelte b/apps/web/src/lib/components/duck/WardrobePanel.svelte
new file mode 100644
index 0000000..65a756a
--- /dev/null
+++ b/apps/web/src/lib/components/duck/WardrobePanel.svelte
@@ -0,0 +1,285 @@
+
+
+
+
+ Кепка
+
+ {#each CAPS as cap (cap.id)}
+ {
+ outfit.cap = cap.id;
+ if (cap.id === 'none') outfit.capLogo = null;
+ }}
+ >
+
+ {#if cap.id === 'none'}
+ ∅
+ {:else}
+ {@html cap.svg}
+ {/if}
+
+ {cap.label}
+
+ {/each}
+
+
+
+
+ Футболка
+
+ {#each TEES as tee (tee.id)}
+
{
+ outfit.tee = tee.id;
+ if (tee.id === 'none') outfit.teeLogo = null;
+ }}
+ >
+
+ {#if !tee.img}
+ ∅
+ {:else}
+
+ {/if}
+
+ {tee.label}
+
+ {/each}
+
+
+
+
+ Логотип на кепке
+ {#if outfit.cap === 'none'}
+ Сначала наденьте кепку — печатать не на чем.
+ {:else}
+
+ {#each LOGO_CATEGORIES as cat (cat.id)}
+
+
{cat.label}
+
+ (outfit.capLogo = null)}
+ >
+ —
+
+ {#each LOGOS.filter((l) => l.category === cat.id) as logo (logo.id)}
+ (outfit.capLogo = logo.id)}
+ >
+
+
+ {/each}
+
+
+ {/each}
+
+ {/if}
+
+
+
+ Логотип на футболке
+ {#if outfit.tee === 'none'}
+ Сначала наденьте футболку — печатать не на чем.
+ {:else}
+
+ {#each LOGO_CATEGORIES as cat (cat.id)}
+
+
{cat.label}
+
+ (outfit.teeLogo = null)}
+ >
+ —
+
+ {#each LOGOS.filter((l) => l.category === cat.id) as logo (logo.id)}
+ (outfit.teeLogo = logo.id)}
+ >
+
+
+ {/each}
+
+
+ {/each}
+
+ {/if}
+
+
+
+ Все логотипы принадлежат их правообладателям. Используются на этой странице
+ в иллюстративных целях в рамках добросовестного использования (fair use).
+
+
+
+ {outfit.cap !== 'none' ? `кепка: ${capLabel()}` : 'без кепки'} ·
+ {outfit.tee !== 'none' ? `футболка: ${teeLabel()}` : 'без футболки'} ·
+ {capLogoLabel()}{teeLogoLabel()}
+
+
+
+
diff --git a/apps/web/src/lib/duck/logos.ts b/apps/web/src/lib/duck/logos.ts
new file mode 100644
index 0000000..a1d8ae1
--- /dev/null
+++ b/apps/web/src/lib/duck/logos.ts
@@ -0,0 +1,168 @@
+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;
+ /** разметка под viewBox 0 0 24 24 */
+ svg: string;
+}
+
+const badge = (fill: string, label: string, fg: string, fontSize = 10) =>
+ ` ` +
+ `${label} `;
+
+export const LOGOS: LogoDef[] = [
+ // --- языки ---
+ {
+ id: 'js',
+ label: 'JavaScript',
+ category: 'lang',
+ svg: badge('#f7df1e', 'JS', '#1a1a1a')
+ },
+ {
+ id: 'ts',
+ label: 'TypeScript',
+ category: 'lang',
+ svg: badge('#3178c6', 'TS', '#ffffff')
+ },
+ {
+ id: 'go',
+ label: 'Go',
+ category: 'lang',
+ svg: badge('#00add8', 'GO', '#ffffff', 9)
+ },
+ {
+ id: 'rust',
+ label: 'Rust',
+ category: 'lang',
+ svg:
+ `` +
+ [0, 45, 90, 135, 180, 225, 270, 315]
+ .map((a) => ` `)
+ .join('') +
+ ` ` +
+ ` ` +
+ `R `
+ },
+ {
+ id: 'python',
+ label: 'Python',
+ category: 'lang',
+ svg:
+ ` ` +
+ ` `
+ },
+ // --- технологии ---
+ {
+ id: 'nginx',
+ label: 'NGINX',
+ category: 'tech',
+ svg: badge('#009639', 'n', '#ffffff', 13)
+ },
+ {
+ id: 'kafka',
+ label: 'Kafka',
+ category: 'tech',
+ mono: true,
+ svg:
+ `` +
+ ` ` +
+ ` ` +
+ ` ` +
+ ` ` +
+ ` ` +
+ ` `
+ },
+ {
+ id: 'docker',
+ label: 'Docker',
+ category: 'tech',
+ svg:
+ `` +
+ ` ` +
+ ` ` +
+ ` ` +
+ ` ` +
+ ` ` +
+ ` ` +
+ ` `
+ },
+ {
+ id: 'kubernetes',
+ label: 'Kubernetes',
+ category: 'tech',
+ svg:
+ ` ` +
+ `` +
+ ` ` +
+ ` ` +
+ ` ` +
+ ` ` +
+ ` ` +
+ ` ` +
+ ` `
+ },
+ {
+ id: 'redis',
+ label: 'Redis',
+ category: 'tech',
+ svg:
+ ` ` +
+ ` ` +
+ ` `
+ },
+ // --- организации ---
+ {
+ id: 'github',
+ label: 'GitHub',
+ category: 'org',
+ mono: true,
+ svg:
+ `` +
+ ` ` +
+ ` `
+ },
+ {
+ id: 'gitlab',
+ label: 'GitLab',
+ category: 'org',
+ svg:
+ ` `
+ },
+ {
+ id: 'bitbucket',
+ label: 'Bitbucket',
+ category: 'org',
+ svg:
+ ` ` +
+ ` `
+ },
+ {
+ id: 'git',
+ label: 'Git',
+ category: 'org',
+ svg:
+ ` ` +
+ `` +
+ ` ` +
+ ` ` +
+ ` ` +
+ ` `
+ }
+];
+
+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;
+}
diff --git a/apps/web/src/lib/duck/quack.ts b/apps/web/src/lib/duck/quack.ts
new file mode 100644
index 0000000..fd6a8c3
--- /dev/null
+++ b/apps/web/src/lib/duck/quack.ts
@@ -0,0 +1,103 @@
+export const MOODS = ['happy', 'confused', 'excited', 'sleepy'] as const;
+
+/** настроение утки — выводится из MOODS, отдельно не правится */
+export type Mood = (typeof MOODS)[number];
+
+const MOOD_LABELS: Record = {
+ 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 = {
+ 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(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 {
+ 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 — просто молча крякаем
+ }
+}
diff --git a/apps/web/src/lib/duck/state.svelte.ts b/apps/web/src/lib/duck/state.svelte.ts
new file mode 100644
index 0000000..41167c0
--- /dev/null
+++ b/apps/web/src/lib/duck/state.svelte.ts
@@ -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(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;
+ 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());
+}
diff --git a/apps/web/src/lib/duck/wardrobe.ts b/apps/web/src/lib/duck/wardrobe.ts
new file mode 100644
index 0000000..f277d5f
--- /dev/null
+++ b/apps/web/src/lib/duck/wardrobe.ts
@@ -0,0 +1,206 @@
+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;
+ 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 =
+ ` ` +
+ ` ` +
+ ` ` +
+ ` ` +
+ ` `;
+ 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 =
+ ` ` +
+ ` ` +
+ ` ` +
+ ` ` +
+ ` `;
+ 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 =
+ ` ` +
+ ` ` +
+ ` ` +
+ ` `;
+ 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 =
+ ` ` +
+ ` ` +
+ ` ` +
+ ` ` +
+ ` ` +
+ ` `;
+ 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, dark: false, mono: '' },
+ { id: 'white', label: 'Белая', img: '/duck/tee-white.png', dark: false, mono: '#0f172a' },
+ { id: 'black', label: 'Чёрная', img: '/duck/tee-black.png', dark: true, mono: '#f8fafc' },
+ { id: 'green', label: 'Терминальная', img: '/duck/tee-green.png', dark: false, mono: '#0f172a' },
+ { id: 'orange', label: 'Оранжевая', img: '/duck/tee-orange.png', dark: false, mono: '#0f172a' },
+ { id: 'blue', label: 'Голубая', img: '/duck/tee-blue.png', 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];
+}
diff --git a/apps/web/src/routes/+page.svelte b/apps/web/src/routes/+page.svelte
index dc527d7..ac4fb85 100644
--- a/apps/web/src/routes/+page.svelte
+++ b/apps/web/src/routes/+page.svelte
@@ -1,2 +1,244 @@
-🦆 Rubber Duck Debugging as a Service
-Главная страница в разработке. А пока — смотрите отчёты тестирования и MCP-сервер .
+
+
+
+ Rubber Duck — отладка с уткой
+
+
+
+
+
+
Rubber Duck_
+
+ Проговори проблему вслух — утка выслушает. Пока говоришь, решение часто находится само.
+
+
+
+ debugging-as-a-service
+ local-first
+ quack · симуляция
+
+
+
+
+
+
+ duck_id: RU-1337
+ status: listening
+ uptime: ∞
+
+
+
+
+
+
$ duck outfit --json
+
{outfitLine}
+
+
+
+
+
+
+
diff --git a/apps/web/static/Duck.jfif b/apps/web/static/Duck.jfif
new file mode 100644
index 0000000..9369ce8
Binary files /dev/null and b/apps/web/static/Duck.jfif differ
diff --git a/apps/web/static/TShirt.jfif b/apps/web/static/TShirt.jfif
new file mode 100644
index 0000000..eecc876
Binary files /dev/null and b/apps/web/static/TShirt.jfif differ
diff --git a/apps/web/static/duck/duck.png b/apps/web/static/duck/duck.png
new file mode 100644
index 0000000..2434a25
Binary files /dev/null and b/apps/web/static/duck/duck.png differ
diff --git a/apps/web/static/duck/tee-black.png b/apps/web/static/duck/tee-black.png
new file mode 100644
index 0000000..fd0cee1
Binary files /dev/null and b/apps/web/static/duck/tee-black.png differ
diff --git a/apps/web/static/duck/tee-blue.png b/apps/web/static/duck/tee-blue.png
new file mode 100644
index 0000000..60cfc60
Binary files /dev/null and b/apps/web/static/duck/tee-blue.png differ
diff --git a/apps/web/static/duck/tee-green.png b/apps/web/static/duck/tee-green.png
new file mode 100644
index 0000000..a473936
Binary files /dev/null and b/apps/web/static/duck/tee-green.png differ
diff --git a/apps/web/static/duck/tee-orange.png b/apps/web/static/duck/tee-orange.png
new file mode 100644
index 0000000..1b1abd4
Binary files /dev/null and b/apps/web/static/duck/tee-orange.png differ
diff --git a/apps/web/static/duck/tee-white.png b/apps/web/static/duck/tee-white.png
new file mode 100644
index 0000000..b72e492
Binary files /dev/null and b/apps/web/static/duck/tee-white.png differ