Track sleeping_since (ISO timestamp) in ServerState; auto-cleared by _set() whenever state leaves sleeping. Frontend runs a 1s interval showing elapsed time as "Xs", "Xm Ys", or "Xh Ym" in a new ASLEEP stat row (visible only during state-sleeping). Timer survives page reload via server-side timestamp. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
478 lines
13 KiB
CSS
478 lines
13 KiB
CSS
/* ── Reset & base ─────────────────────────────────────────────────────────── */
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
:root {
|
|
--bg: #0a0a04;
|
|
--amber: #ffb000;
|
|
--amber-dim: #a07000;
|
|
--amber-dk: #6a4a00;
|
|
--green: #00ff88;
|
|
--red: #ff4444;
|
|
--font: 'Press Start 2P', monospace;
|
|
}
|
|
|
|
html, body {
|
|
height: 100%;
|
|
background: var(--bg);
|
|
color: var(--amber);
|
|
font-family: var(--font);
|
|
font-size: 10px;
|
|
}
|
|
|
|
/* ── Two-panel layout ────────────────────────────────────────────────────── */
|
|
#app {
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: flex-start;
|
|
justify-content: center;
|
|
min-height: 100vh;
|
|
gap: 2rem;
|
|
padding: 2rem;
|
|
}
|
|
|
|
/* Portrait / narrow: stack vertically */
|
|
@media (orientation: portrait) {
|
|
#app {
|
|
flex-direction: column;
|
|
align-items: center;
|
|
}
|
|
}
|
|
|
|
/* ── Screen panel ────────────────────────────────────────────────────────── */
|
|
#screen-panel {
|
|
flex: 0 0 auto;
|
|
}
|
|
|
|
#screen {
|
|
position: relative;
|
|
width: 280px;
|
|
min-height: 280px;
|
|
background: #0d0d06;
|
|
border: 3px solid var(--amber-dim);
|
|
border-radius: 12px;
|
|
padding: 1.2rem 1rem 0.8rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
/* CRT scanline overlay */
|
|
background-image: repeating-linear-gradient(
|
|
0deg,
|
|
transparent,
|
|
transparent 2px,
|
|
rgba(0,0,0,0.15) 2px,
|
|
rgba(0,0,0,0.15) 4px
|
|
);
|
|
box-shadow: 0 0 24px rgba(255,176,0,0.15), inset 0 0 40px rgba(0,0,0,0.4);
|
|
}
|
|
|
|
/* ── WebSocket status dot ────────────────────────────────────────────────── */
|
|
#ws-dot {
|
|
position: absolute;
|
|
top: 10px;
|
|
right: 10px;
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
background: var(--red);
|
|
transition: background 0.3s;
|
|
}
|
|
#ws-dot.connected { background: var(--green); }
|
|
#ws-dot.polling { background: var(--amber-dim); }
|
|
#ws-dot.pulse { animation: pulse-dot 0.3s ease-out; }
|
|
|
|
@keyframes pulse-dot {
|
|
0% { transform: scale(1.8); opacity: 0.6; }
|
|
100% { transform: scale(1); opacity: 1; }
|
|
}
|
|
|
|
/* ── Llama container ─────────────────────────────────────────────────────── */
|
|
#llama-wrap {
|
|
position: relative;
|
|
width: 150px;
|
|
height: 175px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
#llama {
|
|
width: 135px;
|
|
height: 160px;
|
|
}
|
|
|
|
/* SVG part colors */
|
|
.part { fill: var(--amber); stroke: none; }
|
|
.tail { stroke: var(--amber); fill: none; }
|
|
.hoof { fill: var(--amber-dk); }
|
|
.nostril { fill: var(--bg); }
|
|
.shine { fill: var(--bg); }
|
|
.mouth-closed { stroke: var(--bg); fill: none; }
|
|
.mouth-open { fill: var(--bg); }
|
|
.book { fill: var(--amber-dim); stroke: var(--amber-dk); stroke-width: 0.5; }
|
|
.book line { stroke: var(--amber); }
|
|
.sleep-eye { stroke: var(--bg); fill: none; }
|
|
|
|
/* ── State-based element visibility ─────────────────────────────────────── */
|
|
|
|
/* Default: open eyes, no accessories */
|
|
.sleep-eye { display: none; }
|
|
.mouth-open { display: none; }
|
|
.book { display: none; }
|
|
#zzz-wrap { display: none; }
|
|
#speech-bubble { display: none; }
|
|
#progress-wrap { display: none; }
|
|
|
|
/* sleeping */
|
|
body.state-sleeping .sleep-eye { display: block; }
|
|
body.state-sleeping .eye { display: none; }
|
|
body.state-sleeping .shine { display: none; }
|
|
body.state-sleeping #zzz-wrap { display: flex; }
|
|
|
|
/* processing_prompt */
|
|
body.state-processing_prompt .book { display: block; }
|
|
|
|
/* generating */
|
|
body.state-generating .mouth-open { display: block; }
|
|
body.state-generating .mouth-closed { display: none; }
|
|
body.state-generating #speech-bubble { display: flex; }
|
|
|
|
/* loading */
|
|
body.state-loading #progress-wrap { display: flex; }
|
|
|
|
/* processing_prompt */
|
|
body.state-processing_prompt #progress-wrap { display: flex; }
|
|
|
|
/* offline */
|
|
body.state-offline #llama { opacity: 0.15; }
|
|
|
|
/* error: llama flashes red */
|
|
body.state-error { --amber: var(--red); --amber-dim: #cc3333; --amber-dk: #882222; }
|
|
body.state-error #llama { animation: error-flash 1s ease-in-out infinite; }
|
|
@keyframes error-flash {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.4; }
|
|
}
|
|
|
|
/* ── ZZZ ─────────────────────────────────────────────────────────────────── */
|
|
#zzz-wrap {
|
|
position: absolute;
|
|
top: 8px;
|
|
right: 4px;
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
gap: 2px;
|
|
pointer-events: none;
|
|
}
|
|
.z {
|
|
font-family: var(--font);
|
|
color: var(--amber);
|
|
animation: float-z 2.4s ease-in-out infinite;
|
|
opacity: 0;
|
|
}
|
|
.z1 { font-size: 9px; animation-delay: 0s; }
|
|
.z2 { font-size: 11px; animation-delay: 0.6s; }
|
|
.z3 { font-size: 13px; animation-delay: 1.2s; }
|
|
|
|
@keyframes float-z {
|
|
0% { opacity: 0; transform: translateY(0); }
|
|
20% { opacity: 1; }
|
|
80% { opacity: 0.8; }
|
|
100% { opacity: 0; transform: translateY(-28px); }
|
|
}
|
|
|
|
/* ── Speech bubble ───────────────────────────────────────────────────────── */
|
|
#speech-bubble {
|
|
position: absolute;
|
|
top: 10px;
|
|
right: 0;
|
|
background: var(--amber);
|
|
color: var(--bg);
|
|
border-radius: 8px;
|
|
padding: 4px 7px;
|
|
font-size: 14px;
|
|
gap: 1px;
|
|
pointer-events: none;
|
|
}
|
|
#speech-bubble::after {
|
|
content: '';
|
|
position: absolute;
|
|
bottom: -7px;
|
|
left: 10px;
|
|
border: 7px solid transparent;
|
|
border-top-color: var(--amber);
|
|
border-bottom: 0;
|
|
}
|
|
.dot {
|
|
animation: blink-dot 1.2s ease-in-out infinite;
|
|
opacity: 0;
|
|
}
|
|
.d1 { animation-delay: 0s; }
|
|
.d2 { animation-delay: 0.3s; }
|
|
.d3 { animation-delay: 0.6s; }
|
|
|
|
@keyframes blink-dot {
|
|
0%, 100% { opacity: 0; }
|
|
50% { opacity: 1; }
|
|
}
|
|
|
|
/* ── State label ─────────────────────────────────────────────────────────── */
|
|
#state-label {
|
|
font-size: 8px;
|
|
color: var(--amber-dim);
|
|
letter-spacing: 2px;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
/* ── Progress bar ────────────────────────────────────────────────────────── */
|
|
#progress-wrap {
|
|
width: 100%;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
#progress-bar {
|
|
width: 100%;
|
|
height: 8px;
|
|
border: 1px solid var(--amber-dim);
|
|
border-radius: 2px;
|
|
overflow: hidden;
|
|
}
|
|
#progress-fill {
|
|
height: 100%;
|
|
background: var(--amber);
|
|
width: 0%;
|
|
transition: width 0.4s ease;
|
|
}
|
|
#progress-label {
|
|
font-size: 8px;
|
|
color: var(--amber);
|
|
}
|
|
|
|
/* ── Resize handle ───────────────────────────────────────────────────────── */
|
|
#resize-handle {
|
|
position: absolute;
|
|
right: -6px;
|
|
top: 0;
|
|
bottom: 0;
|
|
width: 12px;
|
|
cursor: ew-resize;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
opacity: 0.3;
|
|
transition: opacity 0.15s;
|
|
user-select: none;
|
|
z-index: 10;
|
|
}
|
|
#resize-handle:hover,
|
|
#resize-handle.dragging { opacity: 1; }
|
|
#resize-handle::after {
|
|
content: '';
|
|
width: 2px;
|
|
height: 48px;
|
|
background: var(--amber-dim);
|
|
border-radius: 1px;
|
|
box-shadow: -4px 0 0 var(--amber-dim), 4px 0 0 var(--amber-dim);
|
|
}
|
|
@media (orientation: portrait) {
|
|
#resize-handle { display: none; }
|
|
}
|
|
|
|
/* ── Stats panel ─────────────────────────────────────────────────────────── */
|
|
#stats-panel {
|
|
position: relative;
|
|
flex: 0 0 300px;
|
|
background: #0d0d06;
|
|
border: 3px solid var(--amber-dim);
|
|
border-radius: 12px;
|
|
padding: 1.2rem 1rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.9rem;
|
|
box-shadow: 0 0 24px rgba(255,176,0,0.10);
|
|
}
|
|
|
|
@media (orientation: portrait) {
|
|
#stats-panel { width: 280px; flex: 0 0 auto; }
|
|
}
|
|
|
|
.stat-row {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 3px;
|
|
}
|
|
.sk {
|
|
font-size: 7px;
|
|
color: var(--amber-dk);
|
|
letter-spacing: 1px;
|
|
}
|
|
.sv {
|
|
font-size: 8px;
|
|
color: var(--amber);
|
|
word-break: break-all;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
/* hide irrelevant rows per state */
|
|
body.state-offline #row-model,
|
|
body.state-offline #row-prompt,
|
|
body.state-offline #row-speed,
|
|
body.state-offline #row-tokens,
|
|
body.state-offline #row-load-stage { display: none; }
|
|
|
|
body.state-starting #row-prompt,
|
|
body.state-starting #row-speed,
|
|
body.state-starting #row-tokens,
|
|
body.state-starting #row-load-stage { display: none; }
|
|
|
|
body.state-loading #row-prompt,
|
|
body.state-loading #row-speed,
|
|
body.state-loading #row-tokens { display: none; }
|
|
|
|
body.state-idle #row-prompt,
|
|
body.state-idle #row-speed,
|
|
body.state-idle #row-tokens,
|
|
body.state-idle #row-load-stage { display: none; }
|
|
|
|
body.state-sleeping #row-prompt,
|
|
body.state-sleeping #row-speed,
|
|
body.state-sleeping #row-tokens,
|
|
body.state-sleeping #row-load-stage { display: none; }
|
|
|
|
body:not(.state-sleeping) #row-sleep-timer { display: none; }
|
|
|
|
body.state-waiting #row-prompt,
|
|
body.state-waiting #row-speed,
|
|
body.state-waiting #row-tokens,
|
|
body.state-waiting #row-load-stage { display: none; }
|
|
|
|
body.state-unloading #row-prompt,
|
|
body.state-unloading #row-speed,
|
|
body.state-unloading #row-tokens,
|
|
body.state-unloading #row-load-stage { display: none; }
|
|
|
|
body.state-error #row-prompt,
|
|
body.state-error #row-speed,
|
|
body.state-error #row-tokens,
|
|
body.state-error #row-load-stage { display: none; }
|
|
|
|
body.state-processing_prompt #row-speed,
|
|
body.state-processing_prompt #row-load-stage { display: none; }
|
|
|
|
body.state-generating #row-load-stage { display: none; }
|
|
|
|
/* ── Per-state SVG animations ────────────────────────────────────────────── */
|
|
|
|
/* sleeping: gentle breathe on body */
|
|
body.state-sleeping .body,
|
|
body.state-sleeping .neck,
|
|
body.state-sleeping .head,
|
|
body.state-sleeping .ear {
|
|
animation: breathe 3.5s ease-in-out infinite;
|
|
transform-origin: 50px 90px;
|
|
}
|
|
|
|
@keyframes breathe {
|
|
0%, 100% { transform: scaleY(1); }
|
|
50% { transform: scaleY(1.03); }
|
|
}
|
|
|
|
/* idle: occasional ear twitch */
|
|
body.state-idle .right-ear {
|
|
animation: ear-twitch 5s ease-in-out infinite;
|
|
transform-origin: 62px 22px;
|
|
}
|
|
|
|
@keyframes ear-twitch {
|
|
0%, 85%, 100% { transform: rotate(15deg); }
|
|
90% { transform: rotate(5deg); }
|
|
95% { transform: rotate(22deg); }
|
|
}
|
|
|
|
/* waiting: both ears perk forward */
|
|
body.state-waiting .left-ear {
|
|
transform: rotate(-5deg);
|
|
transform-origin: 38px 22px;
|
|
transition: transform 0.3s;
|
|
}
|
|
body.state-waiting .right-ear {
|
|
transform: rotate(5deg);
|
|
transform-origin: 62px 22px;
|
|
transition: transform 0.3s;
|
|
}
|
|
|
|
/* loading: walk cycle on legs */
|
|
body.state-loading .leg-fl,
|
|
body.state-loading .leg-bl {
|
|
animation: walk-a 0.7s ease-in-out infinite;
|
|
transform-origin: 34px 100px;
|
|
}
|
|
body.state-loading .leg-fr,
|
|
body.state-loading .leg-br {
|
|
animation: walk-b 0.7s ease-in-out infinite;
|
|
transform-origin: 45px 100px;
|
|
}
|
|
|
|
@keyframes walk-a {
|
|
0%, 100% { transform: translateY(0); }
|
|
50% { transform: translateY(-6px); }
|
|
}
|
|
@keyframes walk-b {
|
|
0%, 100% { transform: translateY(-6px); }
|
|
50% { transform: translateY(0); }
|
|
}
|
|
|
|
/* processing_prompt: eyes move side to side (reading) */
|
|
body.state-processing_prompt .left-eye,
|
|
body.state-processing_prompt .right-eye,
|
|
body.state-processing_prompt .shine {
|
|
animation: read-eyes 2s ease-in-out infinite;
|
|
}
|
|
|
|
@keyframes read-eyes {
|
|
0%, 100% { transform: translateX(0); }
|
|
30% { transform: translateX(-1.5px); }
|
|
70% { transform: translateX(1.5px); }
|
|
}
|
|
|
|
/* generating: tail wag + mouth animation */
|
|
body.state-generating .tail {
|
|
animation: wag 0.45s ease-in-out infinite alternate;
|
|
transform-origin: 72px 82px;
|
|
}
|
|
body.state-generating .mouth-open {
|
|
animation: talk 0.3s ease-in-out infinite alternate;
|
|
transform-origin: 50px 39px;
|
|
}
|
|
|
|
@keyframes wag {
|
|
0% { transform: rotate(-18deg); }
|
|
100% { transform: rotate(18deg); }
|
|
}
|
|
@keyframes talk {
|
|
0% { transform: scaleY(0.4); }
|
|
100% { transform: scaleY(1); }
|
|
}
|
|
|
|
/* warming_up: yawn — jaw drops */
|
|
body.state-warming_up .mouth-open { display: block; }
|
|
body.state-warming_up .mouth-closed { display: none; }
|
|
body.state-warming_up .mouth-open {
|
|
animation: yawn 2.5s ease-in-out infinite;
|
|
transform-origin: 50px 39px;
|
|
}
|
|
@keyframes yawn {
|
|
0%, 20%, 100% { transform: scaleY(0.3); }
|
|
50%, 70% { transform: scaleY(1.4); }
|
|
}
|
|
|
|
/* unloading: whole llama fades and drifts right */
|
|
body.state-unloading #llama {
|
|
animation: unload 1.5s ease-in-out infinite alternate;
|
|
}
|
|
@keyframes unload {
|
|
0% { transform: translateX(0); opacity: 1; }
|
|
100% { transform: translateX(12px); opacity: 0.4; }
|
|
}
|