Spaces:
Running
Running
Commit ·
dcb589c
1
Parent(s): d888911
feat: compact pet-home frontend (blob + chat + soul drawer)
Browse filesCo-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- static/app.js +61 -0
- static/index.html +32 -0
- static/styles.css +27 -0
- tests/test_api.py +8 -0
static/app.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const $ = (s) => document.querySelector(s);
|
| 2 |
+
const DIMS = ["V","A","D","U","G","W","I"];
|
| 3 |
+
|
| 4 |
+
const FACES = {
|
| 5 |
+
content: '<circle cx="65" cy="114" r="8" fill="#222"/><circle cx="95" cy="114" r="8" fill="#222"/><path d="M67 134 Q80 146 93 134" stroke="#222" stroke-width="4" fill="none" stroke-linecap="round"/>',
|
| 6 |
+
excited: '<circle cx="65" cy="112" r="10" fill="#222"/><circle cx="95" cy="112" r="10" fill="#222"/><path d="M64 132 Q80 150 96 132 Q80 142 64 132 Z" fill="#222"/>',
|
| 7 |
+
sad: '<path d="M58 116 q8 -6 16 0" stroke="#222" stroke-width="4" fill="none" stroke-linecap="round"/><path d="M86 116 q8 -6 16 0" stroke="#222" stroke-width="4" fill="none" stroke-linecap="round"/><path d="M70 146 Q80 138 90 146" stroke="#222" stroke-width="4" fill="none" stroke-linecap="round"/>',
|
| 8 |
+
angry: '<path d="M56 110 L78 117" stroke="#222" stroke-width="4" stroke-linecap="round"/><path d="M104 110 L82 117" stroke="#222" stroke-width="4" stroke-linecap="round"/><path d="M70 146 Q80 138 90 146" stroke="#222" stroke-width="4" fill="none" stroke-linecap="round"/>',
|
| 9 |
+
neutral: '<circle cx="65" cy="116" r="7" fill="#222"/><circle cx="95" cy="116" r="7" fill="#222"/><path d="M70 138 L90 138" stroke="#222" stroke-width="4" stroke-linecap="round"/>',
|
| 10 |
+
};
|
| 11 |
+
|
| 12 |
+
function render(state) {
|
| 13 |
+
const ap = state.appearance;
|
| 14 |
+
const fill = `hsl(${ap.hue} ${ap.saturation}% ${ap.lightness}%)`;
|
| 15 |
+
const foot = `hsl(${ap.hue} ${ap.saturation}% ${ap.lightness - 12}%)`;
|
| 16 |
+
$("#body").setAttribute("fill", fill);
|
| 17 |
+
document.querySelectorAll(".foot").forEach(f => f.setAttribute("fill", foot));
|
| 18 |
+
$("#face").innerHTML = FACES[ap.face] || FACES.neutral;
|
| 19 |
+
const aura = $("#aura");
|
| 20 |
+
aura.style.background = `radial-gradient(circle, hsl(${ap.hue} ${ap.saturation}% ${ap.lightness}% / ${ap.aura}), transparent 65%)`;
|
| 21 |
+
const lean = (ap.lean || 0) * 10;
|
| 22 |
+
const dy = (ap.droop || 0) * 14;
|
| 23 |
+
$("#blob").style.transform = `translateX(-50%) translateY(${dy}px) scale(${ap.scale}) rotate(${lean}deg)`;
|
| 24 |
+
$("#moodword").textContent = state.mood_word;
|
| 25 |
+
if (ap.mood) {
|
| 26 |
+
$("#moodReadout").textContent = DIMS.map((d, i) => `${d} ${String(ap.mood[i]).padStart(3)}`).join("\n");
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
function bubble(text) {
|
| 31 |
+
const b = $("#bubble");
|
| 32 |
+
b.textContent = text; b.classList.remove("hidden");
|
| 33 |
+
clearTimeout(b._t); b._t = setTimeout(() => b.classList.add("hidden"), 4500);
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
function speak(text, voice) {
|
| 37 |
+
if (!text || !window.speechSynthesis) return;
|
| 38 |
+
const u = new SpeechSynthesisUtterance(text);
|
| 39 |
+
u.pitch = (voice && voice.pitch) || 1.4; // pitched up = cute Tomodachi voice
|
| 40 |
+
u.rate = (voice && voice.rate) || 1.0;
|
| 41 |
+
window.speechSynthesis.cancel();
|
| 42 |
+
window.speechSynthesis.speak(u);
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
async function say() {
|
| 46 |
+
const inp = $("#msg"); const text = inp.value.trim(); if (!text) return;
|
| 47 |
+
inp.value = "";
|
| 48 |
+
const r = await fetch("/say", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({text})});
|
| 49 |
+
if (!r.ok) return;
|
| 50 |
+
const state = await r.json();
|
| 51 |
+
render(state);
|
| 52 |
+
bubble(state.emoticon); // the "words" are an emoticon
|
| 53 |
+
speak(state.simlish, state.voice); // the sound is simlish babble, client-side
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
$("#send").onclick = say;
|
| 57 |
+
$("#msg").addEventListener("keydown", (e) => { if (e.key === "Enter") say(); });
|
| 58 |
+
$("#soulHandle").onclick = () => $("#soulDrawer").classList.remove("hidden");
|
| 59 |
+
$("#closeSoul").onclick = () => $("#soulDrawer").classList.add("hidden");
|
| 60 |
+
|
| 61 |
+
fetch("/snapshot").then(r => r.json()).then(render);
|
static/index.html
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
| 5 |
+
<title>clanker</title><link rel="stylesheet" href="/static/styles.css">
|
| 6 |
+
</head>
|
| 7 |
+
<body>
|
| 8 |
+
<div id="habitat">
|
| 9 |
+
<div id="window"></div><div id="plant">🪴</div><div id="rug"></div>
|
| 10 |
+
<div id="aura"></div>
|
| 11 |
+
<svg id="blob" viewBox="0 0 160 200" width="150" height="185">
|
| 12 |
+
<ellipse cx="80" cy="186" rx="46" ry="8" fill="#0002"/>
|
| 13 |
+
<ellipse class="foot" cx="62" cy="176" rx="12" ry="8"/><ellipse class="foot" cx="98" cy="176" rx="12" ry="8"/>
|
| 14 |
+
<path id="body" d="M80 64 C128 64 138 120 132 150 C126 178 96 182 80 182 C64 182 34 178 28 150 C22 120 32 64 80 64 Z"/>
|
| 15 |
+
<g id="face"></g>
|
| 16 |
+
</svg>
|
| 17 |
+
<div class="chip" id="who">🙂 guest</div>
|
| 18 |
+
<div class="chip" id="moodword">calm</div>
|
| 19 |
+
<div id="dock">
|
| 20 |
+
<input id="msg" placeholder="say something to it…" autocomplete="off">
|
| 21 |
+
<button id="send">send</button>
|
| 22 |
+
</div>
|
| 23 |
+
<div class="handle" id="soulHandle">◐ soul</div>
|
| 24 |
+
<div id="soulDrawer" class="drawer hidden">
|
| 25 |
+
<h3>◐ soul</h3>
|
| 26 |
+
<div id="moodReadout" class="readout"></div>
|
| 27 |
+
<button id="closeSoul">close</button>
|
| 28 |
+
</div>
|
| 29 |
+
<div id="bubble" class="hidden"></div>
|
| 30 |
+
</div>
|
| 31 |
+
<script src="/static/app.js"></script>
|
| 32 |
+
</body></html>
|
static/styles.css
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*{box-sizing:border-box}html,body{margin:0;height:100%;overflow:hidden;font-family:system-ui,sans-serif}
|
| 2 |
+
#habitat{position:relative;width:100vw;height:100vh;
|
| 3 |
+
background:linear-gradient(#cfe8f7 0%,#e9f3df 58%,#dfe7c8 58%,#e7eccb 100%)}
|
| 4 |
+
#window{position:absolute;top:6vh;left:8vw;width:140px;height:104px;border:7px solid #fff;border-radius:8px;
|
| 5 |
+
background:linear-gradient(#bfe3ff,#e9f7ff)}
|
| 6 |
+
#plant{position:absolute;bottom:12vh;right:8vw;font-size:52px}
|
| 7 |
+
#rug{position:absolute;bottom:9vh;left:50%;transform:translateX(-50%);width:340px;height:64px;border-radius:50%;background:#e7c98f88}
|
| 8 |
+
#aura{position:absolute;left:50%;bottom:16vh;width:340px;height:340px;transform:translate(-50%,40%);border-radius:50%;
|
| 9 |
+
pointer-events:none;transition:background .6s,opacity .6s}
|
| 10 |
+
#blob{position:absolute;left:50%;bottom:16vh;transform:translateX(-50%);transition:transform .5s}
|
| 11 |
+
#body{transition:fill .6s}.foot{transition:fill .6s}
|
| 12 |
+
.chip{position:absolute;top:16px;background:rgba(255,255,255,.85);border-radius:20px;padding:6px 13px;font-size:13px;font-weight:600;box-shadow:0 2px 6px #0002}
|
| 13 |
+
#who{left:16px}#moodword{right:16px}
|
| 14 |
+
#dock{position:absolute;left:50%;bottom:18px;transform:translateX(-50%);display:flex;gap:7px;width:min(62vw,520px)}
|
| 15 |
+
#dock input{flex:1;border:none;border-radius:22px;padding:11px 16px;font-size:14px;box-shadow:0 2px 6px #0002}
|
| 16 |
+
#dock button{border:none;border-radius:22px;padding:0 18px;font-weight:700;background:#ffcf4d;box-shadow:0 2px 6px #0002;cursor:pointer}
|
| 17 |
+
.handle{position:absolute;left:0;top:40%;background:rgba(255,255,255,.9);font-weight:700;font-size:12px;
|
| 18 |
+
padding:10px 7px;border-radius:0 10px 10px 0;writing-mode:vertical-rl;cursor:pointer;box-shadow:0 2px 8px #0002}
|
| 19 |
+
.drawer{position:absolute;top:0;left:0;height:100%;width:min(46vw,420px);background:rgba(28,30,40,.94);color:#e7e7ee;
|
| 20 |
+
padding:18px;box-shadow:6px 0 24px #0004;border-radius:0 14px 14px 0;transition:transform .3s}
|
| 21 |
+
.drawer.hidden{transform:translateX(-105%)}
|
| 22 |
+
.drawer h3{margin:2px 0 14px;font-size:14px;text-transform:uppercase;color:#9fb}
|
| 23 |
+
.readout{font-family:ui-monospace,monospace;font-size:13px;line-height:1.9;white-space:pre}
|
| 24 |
+
.drawer button{margin-top:16px;background:#ffffff22;color:#fff;border:none;border-radius:8px;padding:8px 14px;cursor:pointer}
|
| 25 |
+
#bubble{position:absolute;left:50%;bottom:46vh;transform:translateX(-50%);background:#fff;border-radius:16px;
|
| 26 |
+
padding:8px 18px;font-size:30px;line-height:1;max-width:60vw;box-shadow:0 4px 14px #0003;transition:opacity .4s}
|
| 27 |
+
.hidden{opacity:0;pointer-events:none}
|
tests/test_api.py
CHANGED
|
@@ -31,3 +31,11 @@ def test_say_returns_voice_and_appearance():
|
|
| 31 |
def test_say_rejects_empty():
|
| 32 |
r = client.post("/say", json={"text": " "})
|
| 33 |
assert r.status_code == 422
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
def test_say_rejects_empty():
|
| 32 |
r = client.post("/say", json={"text": " "})
|
| 33 |
assert r.status_code == 422
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_index_serves_html():
|
| 37 |
+
r = client.get("/")
|
| 38 |
+
assert r.status_code == 200
|
| 39 |
+
html = r.text
|
| 40 |
+
assert 'id="blob"' in html
|
| 41 |
+
assert "say something" in html.lower()
|