deucebucket Claude Sonnet 4.6 commited on
Commit
dcb589c
·
1 Parent(s): d888911

feat: compact pet-home frontend (blob + chat + soul drawer)

Browse files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files changed (4) hide show
  1. static/app.js +61 -0
  2. static/index.html +32 -0
  3. static/styles.css +27 -0
  4. 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()