feat: add main page
@@ -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,106 @@
|
||||
<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 Logo from './Logo.svelte';
|
||||
|
||||
const cap = $derived(capById(outfit.cap));
|
||||
const tee = $derived(teeById(outfit.tee));
|
||||
|
||||
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}
|
||||
<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}
|
||||
<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={cap.dark ? '#f8fafc' : '#0f172a'}
|
||||
/>
|
||||
{/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>
|
||||
@@ -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) =>
|
||||
`<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',
|
||||
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:
|
||||
`<g fill="#2b2b2b">` +
|
||||
[0, 45, 90, 135, 180, 225, 270, 315]
|
||||
.map((a) => `<rect x="10.9" y="1.4" width="2.2" height="3.6" rx="0.7" transform="rotate(${a} 12 12)"/>`)
|
||||
.join('') +
|
||||
`<circle cx="12" cy="12" r="8.4"/></g>` +
|
||||
`<circle cx="12" cy="12" r="5.4" fill="#f74c00"/>` +
|
||||
`<text x="12" y="15.6" text-anchor="middle" font-family="system-ui, 'Segoe UI', sans-serif" font-weight="800" font-size="8.5" fill="#ffffff">R</text>`
|
||||
},
|
||||
{
|
||||
id: 'python',
|
||||
label: 'Python',
|
||||
category: 'lang',
|
||||
svg:
|
||||
`<path d="M11.9 2.2c-3.5 0-3.1 1.5-3.1 1.5l0 1.6h3.2v.5H6.6c0 0-2.1-.2-2.1 3.1 0 3.3 1.8 3.2 1.8 3.2h1.1v-1.7c0 0-.1-1.8 1.8-1.8h3.1c0 0 1.8 0 1.8-1.7V4c0-.1.3-1.8-2.2-1.8zM10.4 4a.7.7 0 1 1 0 1.4.7.7 0 0 1 0-1.4z" fill="#4584b6"/>` +
|
||||
`<path d="M11.9 2.2c-3.5 0-3.1 1.5-3.1 1.5l0 1.6h3.2v.5H6.6c0 0-2.1-.2-2.1 3.1 0 3.3 1.8 3.2 1.8 3.2h1.1v-1.7c0 0-.1-1.8 1.8-1.8h3.1c0 0 1.8 0 1.8-1.7V4c0-.1.3-1.8-2.2-1.8zM10.4 4a.7.7 0 1 1 0 1.4.7.7 0 0 1 0-1.4z" fill="#ffde57" transform="rotate(180 12 12)"/>`
|
||||
},
|
||||
// --- технологии ---
|
||||
{
|
||||
id: 'nginx',
|
||||
label: 'NGINX',
|
||||
category: 'tech',
|
||||
svg: badge('#009639', 'n', '#ffffff', 13)
|
||||
},
|
||||
{
|
||||
id: 'kafka',
|
||||
label: 'Kafka',
|
||||
category: 'tech',
|
||||
mono: true,
|
||||
svg:
|
||||
`<g fill="currentColor">` +
|
||||
`<circle cx="12" cy="12" r="3.1"/>` +
|
||||
`<circle cx="12" cy="3.6" r="2.1"/>` +
|
||||
`<circle cx="19.1" cy="16.1" r="2.1"/>` +
|
||||
`<circle cx="4.9" cy="16.1" r="2.1"/>` +
|
||||
`</g>` +
|
||||
`<path d="M12 6v2.9M14.5 13.5l2.7 1.5M9.5 13.5l-2.7 1.5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" fill="none"/>`
|
||||
},
|
||||
{
|
||||
id: 'docker',
|
||||
label: 'Docker',
|
||||
category: 'tech',
|
||||
svg:
|
||||
`<g fill="#2496ed">` +
|
||||
`<rect x="4.6" y="10" width="3.4" height="3.5" rx="0.5"/>` +
|
||||
`<rect x="8.6" y="10" width="3.4" height="3.5" rx="0.5"/>` +
|
||||
`<rect x="12.6" y="10" width="3.4" height="3.5" rx="0.5"/>` +
|
||||
`<rect x="8.6" y="6.2" width="3.4" height="3.5" rx="0.5"/>` +
|
||||
`<rect x="12.6" y="6.2" width="3.4" height="3.5" rx="0.5"/>` +
|
||||
`<path d="M2 13.5h20c0 4.4-3.4 7.6-9 7.9-5.4.3-10-2.6-11-7.9z"/>` +
|
||||
`</g>`
|
||||
},
|
||||
{
|
||||
id: 'kubernetes',
|
||||
label: 'Kubernetes',
|
||||
category: 'tech',
|
||||
svg:
|
||||
`<polygon points="12,2 19.8,5.8 21.7,14.2 16.3,21 7.7,21 2.3,14.2 4.2,5.8" fill="#326ce5"/>` +
|
||||
`<g stroke="#ffffff" stroke-width="1.3">` +
|
||||
`<line x1="12" y1="12" x2="12" y2="7.4"/><line x1="12" y1="12" x2="15.6" y2="9.1"/>` +
|
||||
`<line x1="12" y1="12" x2="16.5" y2="13"/><line x1="12" y1="12" x2="14" y2="16.1"/>` +
|
||||
`<line x1="12" y1="12" x2="10" y2="16.1"/><line x1="12" y1="12" x2="7.5" y2="13"/>` +
|
||||
`<line x1="12" y1="12" x2="8.4" y2="9.1"/>` +
|
||||
`</g>` +
|
||||
`<circle cx="12" cy="12" r="1.5" fill="#ffffff"/>` +
|
||||
`<circle cx="12" cy="12" r="4.6" fill="none" stroke="#ffffff" stroke-width="1.5"/>`
|
||||
},
|
||||
{
|
||||
id: 'redis',
|
||||
label: 'Redis',
|
||||
category: 'tech',
|
||||
svg:
|
||||
`<g><path d="M12 6 L21 10.2 12 14.4 3 10.2 Z" fill="#9e2a20" transform="translate(0 6.4)"/>` +
|
||||
`<path d="M12 6 L21 10.2 12 14.4 3 10.2 Z" fill="#c93325" transform="translate(0 3.2)"/>` +
|
||||
`<path d="M12 6 L21 10.2 12 14.4 3 10.2 Z" fill="#ee4a2e"/></g>`
|
||||
},
|
||||
// --- организации ---
|
||||
{
|
||||
id: 'github',
|
||||
label: 'GitHub',
|
||||
category: 'org',
|
||||
mono: true,
|
||||
svg:
|
||||
`<g transform="scale(1.5)" fill="currentColor">` +
|
||||
`<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/>` +
|
||||
`</g>`
|
||||
},
|
||||
{
|
||||
id: 'gitlab',
|
||||
label: 'GitLab',
|
||||
category: 'org',
|
||||
svg:
|
||||
`<path d="M12 21.3 L3.7 15.3 C3.4 15.1 3.3 14.7 3.4 14.4 L5.2 5.2 C5.3 4.8 5.8 4.7 6 5.1 L8.8 9.9 H15.2 L18 5.1 C18.2 4.7 18.7 4.8 18.8 5.2 L20.6 14.4 C20.7 14.7 20.6 15.1 20.3 15.3 Z" fill="#fc6d26"/>`
|
||||
},
|
||||
{
|
||||
id: 'bitbucket',
|
||||
label: 'Bitbucket',
|
||||
category: 'org',
|
||||
svg:
|
||||
`<path d="M3.4 3.8h17.2c.5 0 .9.5.8 1L18.9 19c-.1.5-.5.8-1 .8H6.1c-.5 0-.9-.3-1-.8L2.6 4.8c-.1-.5.3-1 .8-1z" fill="#2684ff"/>` +
|
||||
`<path d="M10.2 9.5h3.7l-.7 4.6h-2.3z" fill="#ffffff"/>`
|
||||
},
|
||||
{
|
||||
id: 'git',
|
||||
label: 'Git',
|
||||
category: 'org',
|
||||
svg:
|
||||
`<rect x="4.4" y="4.4" width="15.2" height="15.2" rx="2.4" transform="rotate(45 12 12)" fill="#f05032"/>` +
|
||||
`<g stroke="#ffffff" stroke-width="1.4" fill="none" stroke-linecap="round">` +
|
||||
`<path d="M9.2 10.8v.4c0 2.5 2.3 2.2 4.3 2.6"/>` +
|
||||
`</g>` +
|
||||
`<circle cx="9.2" cy="9.4" r="1.6" fill="#ffffff"/>` +
|
||||
`<circle cx="15" cy="14.6" r="1.6" fill="#ffffff"/>`
|
||||
}
|
||||
];
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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 — просто молча крякаем
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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 =
|
||||
`<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, 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];
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
After Width: | Height: | Size: 264 KiB |
|
After Width: | Height: | Size: 262 KiB |
|
After Width: | Height: | Size: 380 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 316 KiB |
|
After Width: | Height: | Size: 296 KiB |
|
After Width: | Height: | Size: 168 KiB |