Add face design: ASCII expressions + matrix rain simulator, wait cues, fresh latency numbers

- sim/face/index.html: browser simulator of the 800x800 round panel with
  six states (idle/listening/pensive/effort/speaking/error), state-driven
  rain density, blink/talk/thought animations, idle clock
- effort state gets hard-required wait cues: orbiting bezel arc, elapsed
  counter, max rain (Tatlock turns run 10-25s)
- rain driven by setInterval, not rAF: renders under the screenshot
  tool's --virtual-time-budget and mirrors LVGL lv_timer
- architecture.md: Face design contract (state table, protocol mapping,
  LVGL port notes) + latency table updated to GPU-era benchmarks
  (Steward ~6s warm, full flow 11-25s; old CPU-era figures retired)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 17:32:16 +02:00
co-authored by Claude Fable 5
parent 0886c84d5e
commit 42b988b7ef
4 changed files with 299 additions and 15 deletions
+231
View File
@@ -0,0 +1,231 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DeskLock Face — design simulator</title>
<!--
Design reference for the DeskLock face (docs/architecture.md "Face design").
This file is the source of truth for the look: the LVGL firmware port must match
the states, glyphs, and rain behaviour defined here.
URL params: ?state=idle|listening|pensive|effort|speaking|error
&demo=1 (auto-cycle) &nochrome=1 (hide controls, device view only)
-->
<style>
* { box-sizing: border-box; }
body {
margin: 0; padding: 24px;
background: #050807; color: #7fae91;
font-family: "Courier New", monospace;
display: flex; flex-direction: column; align-items: center; gap: 20px;
min-height: 100vh;
}
body.nochrome { padding: 0; }
body.nochrome #controls, body.nochrome #title { display: none; }
#title { font-size: 14px; letter-spacing: .3em; color: #2f7a4b; }
#stage { transform-origin: top center; display: flex; flex-direction: column; align-items: center; gap: 20px; }
/* The physical screen: 800x800 round panel */
#screen {
width: 800px; height: 800px; border-radius: 50%;
background: #000; overflow: hidden; position: relative;
box-shadow: 0 0 0 12px #0d0d0d, 0 0 70px rgba(0, 255, 120, .10);
}
#rain { position: absolute; inset: 0; }
#faceShade {
position: absolute; inset: 0;
background: radial-gradient(circle at 50% 47%,
rgba(0,0,0,.94) 0 25%, rgba(0,0,0,.6) 37%, rgba(0,0,0,0) 54%);
}
#faceWrap {
position: absolute; inset: 0;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 44px; color: #adffc8; text-shadow: 0 0 24px rgba(0,255,130,.4);
animation: breathe 4.5s ease-in-out infinite;
}
#eyes { font-size: 170px; line-height: 1; margin: 0; }
#mouth { font-size: 105px; line-height: 1; margin: 0; }
#thought {
position: absolute; top: 22%; right: 20%; width: 140px;
font-size: 62px; color: #63cf8b; text-align: left;
}
#clock {
font-size: 46px; color: #2f7a4b; text-shadow: none;
letter-spacing: .18em; display: none;
}
body[data-state="idle"] #clock { display: block; }
body[data-state="effort"] #faceWrap { animation: jitter .16s steps(2) infinite; }
body[data-state="error"] #faceWrap { opacity: .45; animation: none; }
/* wait cues while Tatlock works (10-25 s): orbiting arc + elapsed counter */
#orbit {
position: absolute; inset: 12px; border-radius: 50%;
border: 6px solid transparent; border-top-color: #37ff8b;
filter: drop-shadow(0 0 9px rgba(55,255,139,.55));
animation: spin 1.4s linear infinite; display: none;
}
#elapsed {
position: absolute; bottom: 13%; left: 50%; transform: translateX(-50%);
font-size: 42px; color: #2f7a4b; letter-spacing: .15em; display: none;
background: rgba(0,0,0,.8); padding: 6px 20px; border-radius: 12px;
}
body[data-state="effort"] #orbit,
body[data-state="effort"] #elapsed { display: block; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes breathe { 0%,100% { transform: translateY(0); } 50% { transform: translateY(9px); } }
@keyframes jitter { 0% { transform: translate(1px,-1px); } 100% { transform: translate(-1px,1px); } }
#controls { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
#controls button {
background: #0c1410; border: 1px solid #1d3527; color: #7fae91;
font-family: inherit; font-size: 14px; padding: 8px 16px; cursor: pointer;
letter-spacing: .1em;
}
#controls button.active { border-color: #37ff8b; color: #b9ffd6; }
</style>
</head>
<body data-state="idle">
<div id="title">DESKLOCK · FACE SIMULATOR · 800×800</div>
<div id="stage">
<div id="screen">
<canvas id="rain" width="800" height="800"></canvas>
<div id="faceShade"></div>
<div id="faceWrap">
<pre id="eyes"></pre>
<pre id="mouth"></pre>
<div id="clock"></div>
</div>
<div id="thought"></div>
<div id="orbit"></div>
<div id="elapsed"></div>
</div>
<div id="controls"></div>
</div>
<script>
"use strict";
/* ---- expression + rain definitions (the contract for the LVGL port) ---- */
const STATES = {
idle: { eyes: "- -", mouth: "\\_/", blink: true, rain: { streams: 2, speed: 4 } },
listening: { eyes: "O O", mouth: "o", blink: true, rain: { streams: 16, speed: 7 } },
pensive: { eyes: "· ·", mouth: "~", blink: false, thought: true,
rain: { streams: 7, speed: 5 } },
effort: { eyes: "> <", mouth: "~", blink: false, rain: { streams: 40, speed: 16 } },
speaking: { eyes: "^ ^", mouth: "o", blink: true, talk: true,
rain: { streams: 14, speed: 9 } },
error: { eyes: "x x", mouth: "-", blink: false, rain: { streams: 0, speed: 0 } },
};
const ORDER = ["idle", "listening", "pensive", "effort", "speaking", "error"];
const TALK = ["o", "O", "-", "O", "=", "o"];
const GLYPHS = "アイウエオカキクケコサシスセソタチツテトナニヌネノ0123456789ACEFHKZ$#%*+=<>";
const qs = new URLSearchParams(location.search);
let cur = ORDER.includes(qs.get("state")) ? qs.get("state") : "idle";
const st = () => STATES[cur];
const el = (id) => document.getElementById(id);
const eyesEl = el("eyes"), mouthEl = el("mouth"), thoughtEl = el("thought"),
clockEl = el("clock"), elapsedEl = el("elapsed");
let stateSince = performance.now();
/* ---- matrix rain ---- */
const R = 800, CELL = 22, COLS = Math.floor(R / CELL);
const ctx = el("rain").getContext("2d");
ctx.textAlign = "center";
let streams = [];
const spawn = () => ({
col: Math.floor(Math.random() * COLS),
y: -Math.random() * 14,
speed: st().rain.speed * (0.7 + Math.random() * 0.6),
});
let last = performance.now();
function frame(now) {
const dt = Math.min(0.05, (now - last) / 1000); last = now;
ctx.fillStyle = "rgba(0,0,0,0.08)";
ctx.fillRect(0, 0, R, R);
ctx.font = "bold " + CELL + "px monospace";
for (const s of streams) {
s.y += s.speed * dt;
const px = s.col * CELL + CELL / 2, py = s.y * CELL;
ctx.fillStyle = "rgba(195,255,215,0.95)";
ctx.fillText(GLYPHS[(Math.random() * GLYPHS.length) | 0], px, py);
ctx.fillStyle = "rgba(0,230,100,0.55)";
ctx.fillText(GLYPHS[(Math.random() * GLYPHS.length) | 0], px, py - CELL);
}
streams = streams.filter((s) => s.y * CELL <= R + 2 * CELL);
while (streams.length < st().rain.streams) streams.push(spawn());
}
/* ---- face behaviour ---- */
function applyFace() {
eyesEl.textContent = st().eyes;
mouthEl.textContent = st().mouth;
thoughtEl.textContent = "";
document.body.dataset.state = cur;
document.querySelectorAll("#controls button[data-state]").forEach(
(b) => b.classList.toggle("active", b.dataset.state === cur));
}
function setState(name) { cur = name; stateSince = performance.now(); applyFace(); }
setInterval(() => {
elapsedEl.textContent = "[ " + Math.floor((performance.now() - stateSince) / 1000) + "s ]";
}, 250);
(function blinkLoop() {
setTimeout(() => {
if (st().blink) {
eyesEl.textContent = st().eyes.replace(/[^ ]/g, "_");
setTimeout(() => { eyesEl.textContent = st().eyes; }, 130);
}
blinkLoop();
}, 2600 + Math.random() * 3600);
})();
let talkI = 0;
setInterval(() => { if (st().talk) mouthEl.textContent = TALK[talkI++ % TALK.length]; }, 150);
let dotI = 0;
setInterval(() => {
thoughtEl.textContent = st().thought ? ".".repeat(1 + (dotI++ % 3)) : "";
}, 480);
setInterval(() => {
const d = new Date();
clockEl.textContent =
String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0");
}, 1000);
/* ---- sim chrome ---- */
const controls = el("controls");
for (const name of ORDER) {
const b = document.createElement("button");
b.textContent = name; b.dataset.state = name;
b.onclick = () => { demo = false; demoBtn.textContent = "demo"; setState(name); };
controls.appendChild(b);
}
let demo = qs.get("demo") === "1", demoI = 0;
const demoBtn = document.createElement("button");
demoBtn.textContent = demo ? "demo: on" : "demo";
demoBtn.onclick = () => { demo = !demo; demoBtn.textContent = demo ? "demo: on" : "demo"; };
controls.appendChild(demoBtn);
setInterval(() => { if (demo) setState(ORDER[++demoI % ORDER.length]); }, 3000);
if (qs.get("nochrome") === "1") document.body.classList.add("nochrome");
function rescale() {
const pad = document.body.classList.contains("nochrome") ? 0 : 140;
const s = Math.min(1, (innerWidth - 20) / 824, (innerHeight - pad) / 824);
el("stage").style.transform = "scale(" + s + ")";
}
addEventListener("resize", rescale);
rescale();
applyFace();
/* fixed-step timer, not requestAnimationFrame: mirrors the LVGL port (lv_timer)
and renders under headless/virtual-time captures where rAF is starved */
setInterval(() => frame(performance.now()), 33);
</script>
</body>
</html>