WillHbx's picture
feat(app, frontend): split-phase endpoints, resume, player name, main-menu button
797862a
Raw
History Blame Contribute Delete
73.4 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<title>Ephemeral Hearts</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<!-- distinctive storybook display + a quiet humanist body (avoid generic system fonts) -->
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,500;0,600;1,500&family=Spectral:wght@300;400;500&display=swap" rel="stylesheet" />
<style>
:root{
--ink:#f3ece0; --ink-dim:#c9bfae;
--dusk-0:#0b0e14; --dusk-1:#151a26; --dusk-2:#1d2740;
--gold:#e8c172; --moss:#9fd29a; --rose:#e9a6a6;
--box: rgba(10,12,18,.78);
--display:"Cormorant Garamond", serif;
--body:"Spectral", Georgia, serif;
}
*{box-sizing:border-box} html,body{height:100%;margin:0}
body{
font-family:var(--body); color:var(--ink); background:var(--dusk-0);
overflow:hidden; -webkit-font-smoothing:antialiased;
}
/* ---- layered stage ---- */
#stage{position:fixed; inset:0; display:grid; place-items:stretch}
#backdrop{
position:absolute; inset:0; z-index:0; background-size:cover; background-position:center;
transition:opacity 1.1s ease, transform 8s ease-out; opacity:0; transform:scale(1.06);
filter:saturate(1.05);
}
#backdrop.on{opacity:1; transform:scale(1)}
/* atmosphere: vignette + grain so it never looks like a flat solid */
#stage::after{
content:""; position:absolute; inset:0; z-index:3; pointer-events:none;
background:radial-gradient(120% 90% at 50% 40%, transparent 55%, rgba(0,0,0,.55) 100%);
mix-blend-mode:multiply;
}
#sprites{position:absolute; inset:0; z-index:2; display:flex; align-items:flex-end;
justify-content:center; gap:4vw; padding:0 6vw 22vh}
.sprite-wrap{position:relative; display:inline-flex; align-items:flex-end}
.sprite{height:64vh; max-height:680px; filter:drop-shadow(0 18px 30px rgba(0,0,0,.5));
opacity:0; transform:translateY(24px); transition:opacity .7s ease, transform .7s ease,
filter .25s ease}
.sprite.on{opacity:1; transform:translateY(0)}
.sprite-wrap:hover .sprite.on{filter:drop-shadow(0 0 28px rgba(232,193,114,.7)) brightness(1.08)}
/* ---- hover card (overlaid ON the sprite, always in-bounds) ---- */
.char-card{
position:absolute; top:50%; left:50%;
width:220px; max-width:92%;
background:rgba(10,13,24,.93); border:1px solid rgba(232,193,114,.35);
border-radius:12px; padding:14px 16px; backdrop-filter:blur(10px);
box-shadow:0 8px 32px rgba(0,0,0,.7); pointer-events:none;
opacity:0; transform:translate(-50%,-50%) scale(.96);
transition:opacity .2s ease, transform .2s ease; z-index:10;
}
.sprite-wrap:hover .char-card{opacity:1; transform:translate(-50%,-50%) scale(1)}
.char-card-name{font-family:var(--display); font-size:1.15rem; color:var(--gold); margin-bottom:4px}
.char-card-bio{font-size:.82rem; color:var(--ink-dim); margin-bottom:10px; line-height:1.4}
.rel-row{display:flex; align-items:center; gap:8px; margin-bottom:10px}
.rel-bar{flex:1; height:5px; background:rgba(255,255,255,.10); border-radius:3px; overflow:hidden; position:relative}
.rel-fill-pos{position:absolute; left:0; top:0; bottom:0; width:0;
background:var(--moss); border-radius:3px; transition:width .5s ease}
.rel-fill-neg{position:absolute; right:0; top:0; bottom:0; width:0;
background:var(--rose); border-radius:3px; transition:width .5s ease}
.rel-val{font-size:.78rem; color:var(--ink-dim); min-width:28px; text-align:right}
.traits-section{margin-bottom:8px}
.traits-label{font-size:.72rem; text-transform:uppercase; letter-spacing:.8px;
color:var(--ink-dim); margin-bottom:5px}
.trait-item{font-size:.83rem; padding:2px 0; display:flex; align-items:center; gap:5px}
.trait-item.unlocked{color:var(--moss)}
.trait-item.locked{color:rgba(255,255,255,.3)}
.secret-goal{font-size:.83rem; padding:6px 8px; border-radius:6px; margin-top:6px}
.secret-goal.unlocked{background:rgba(232,193,114,.12); color:var(--gold)}
.secret-goal.locked{color:rgba(255,255,255,.25); font-style:italic}
/* ---- notification stack (sits just above the dialogue box) ---- */
#notif-stack{
position:fixed; bottom:calc(3.2vh + 130px); left:50%; transform:translateX(-50%);
z-index:20; display:flex; flex-direction:column; align-items:center; gap:8px;
pointer-events:none;
}
.notif{
background:rgba(13,16,28,.95); border:1px solid rgba(232,193,114,.6);
border-radius:10px; padding:12px 24px; font-size:1rem; color:var(--ink);
box-shadow:0 0 18px rgba(232,193,114,.25), 0 4px 20px rgba(0,0,0,.6);
white-space:nowrap; animation:notif-in .3s ease forwards;
}
.notif.out{animation:notif-out .4s ease forwards}
@keyframes notif-in{from{opacity:0;transform:translateY(10px) scale(.95)} to{opacity:1;transform:translateY(0) scale(1)}}
@keyframes notif-out{from{opacity:1;transform:translateY(0)} to{opacity:0;transform:translateY(-8px)}}
/* ---- dialogue ---- */
#dialogue{
position:absolute; left:50%; bottom:3.2vh; z-index:4; transform:translateX(-50%);
width:min(900px,92vw); background:var(--box); border:1px solid rgba(232,193,114,.25);
border-radius:14px; padding:18px 22px 16px; backdrop-filter:blur(6px);
box-shadow:0 10px 40px rgba(0,0,0,.45);
}
#speaker{font-family:var(--display); font-size:1.5rem; letter-spacing:.3px; color:var(--gold);
margin:0 0 4px}
#line{font-size:1.12rem; line-height:1.55; min-height:3.2em; color:var(--ink)}
#place{position:absolute; top:18px; left:22px; z-index:4; font-family:var(--display);
font-size:1.05rem; color:var(--ink-dim); letter-spacing:1px; text-transform:uppercase;
text-shadow:0 1px 8px #000}
/* ---- target row (who to address) ---- */
#target-row{
display:flex; align-items:center; gap:6px; margin-bottom:8px; flex-wrap:wrap;
}
.target-label{font-size:.75rem; color:var(--ink-dim); letter-spacing:.4px; flex-shrink:0}
.target-chip{
font-family:var(--body); cursor:pointer;
border:1px solid rgba(255,255,255,.15); border-radius:20px;
padding:4px 12px; font-size:.83rem;
background:rgba(255,255,255,.04); color:var(--ink-dim);
transition:background .15s, border-color .15s, color .15s;
}
.target-chip:hover{background:rgba(233,166,166,.1); border-color:rgba(233,166,166,.4); color:var(--ink)}
.target-chip.active{background:rgba(233,166,166,.18); border-color:var(--rose); color:var(--rose); font-weight:600}
.target-chip:disabled{opacity:.4; cursor:wait}
/* ---- action chips ---- */
#actions{display:flex; gap:7px; margin-bottom:10px; flex-wrap:wrap}
.action-chip{
font-family:var(--body); cursor:pointer; border:1px solid rgba(255,255,255,.18);
border-radius:20px; padding:6px 13px; font-size:.88rem; font-weight:500;
background:rgba(255,255,255,.05); color:var(--ink-dim);
transition:background .15s, border-color .15s, color .15s; white-space:nowrap;
}
.action-chip:hover{background:rgba(232,193,114,.1); border-color:rgba(232,193,114,.4); color:var(--ink)}
.action-chip.active{
background:rgba(232,193,114,.18); border-color:var(--gold);
color:var(--gold); font-weight:600;
}
.action-chip:disabled{opacity:.4; cursor:wait}
/* ---- input row ---- */
#inputrow{display:flex; gap:10px; margin-top:4px}
#say{flex:1; background:rgba(255,255,255,.06); border:1px solid rgba(255,255,255,.15);
color:var(--ink); border-radius:10px; padding:11px 14px; font-family:var(--body); font-size:1rem}
#say:focus{outline:none; border-color:var(--gold)}
button{font-family:var(--body); cursor:pointer; border:none; border-radius:10px;
padding:11px 16px; color:#10131a; background:var(--gold); font-weight:600; font-size:.98rem}
#send{background:var(--moss); color:#0d1a0e; font-size:1.2rem; padding:11px 18px; min-width:46px}
button.ghost{background:transparent; color:var(--ink); border:1px solid rgba(255,255,255,.22)}
button:disabled{opacity:.45; cursor:wait}
@keyframes pulse-rec{0%,100%{opacity:1}50%{opacity:.6}}
#mic.live{background:var(--rose); animation:pulse-rec 1.2s ease-in-out infinite}
#mic.waiting{background:rgba(255,255,255,.12); color:var(--ink-dim); cursor:wait}
/* ---- settings button ---- */
#settings-btn{
position:fixed; top:56px; left:16px; z-index:8;
display:flex; align-items:center; gap:7px;
background:rgba(10,13,24,.82); border:1px solid rgba(232,193,114,.30);
border-radius:10px; padding:9px 14px;
color:var(--ink-dim); font-family:var(--display); font-size:1rem;
backdrop-filter:blur(6px); cursor:pointer;
transition:background .15s, border-color .15s, color .15s, transform .15s;
}
#settings-btn:hover{
background:rgba(232,193,114,.10); border-color:rgba(232,193,114,.5);
color:var(--gold); transform:translateY(-1px);
}
/* ---- settings overlay ---- */
#settings-overlay{
display:none; position:fixed; inset:0; z-index:35;
background:rgba(0,0,0,.55); backdrop-filter:blur(4px);
align-items:center; justify-content:center;
}
#settings-overlay.open{display:flex}
#settings-panel{
width:min(380px,90vw); background:var(--dusk-1);
border:1px solid rgba(232,193,114,.25); border-radius:16px;
padding:28px 28px 24px; box-shadow:0 8px 40px rgba(0,0,0,.5);
}
#settings-header{
display:flex; align-items:center; justify-content:space-between; margin-bottom:24px;
}
#settings-header h3{
font-family:var(--display); font-size:1.2rem; color:var(--gold); font-weight:600;
}
#settings-close{
background:none; border:none; color:var(--ink-dim); font-size:1.1rem;
padding:4px 8px; cursor:pointer; border-radius:6px;
}
#settings-close:hover{color:var(--ink)}
.settings-row{
display:flex; align-items:center; gap:12px; margin-bottom:20px;
}
.settings-row label{
font-family:var(--display); font-size:.9rem; color:var(--ink); min-width:120px; flex-shrink:0;
}
.settings-row input[type=range]{
flex:1; -webkit-appearance:none; appearance:none;
height:4px; border-radius:2px; background:rgba(255,255,255,.15); outline:none; cursor:pointer;
}
.settings-row input[type=range]::-webkit-slider-thumb{
-webkit-appearance:none; appearance:none;
width:16px; height:16px; border-radius:50%;
background:var(--gold); cursor:pointer; box-shadow:0 0 4px rgba(232,193,114,.4);
}
.settings-row input[type=range]::-moz-range-thumb{
width:16px; height:16px; border-radius:50%; border:none;
background:var(--gold); cursor:pointer;
}
.settings-vol-val{
font-family:var(--body); font-size:.82rem; color:var(--ink-dim);
min-width:36px; text-align:right;
}
.settings-divider{
border:none; border-top:1px solid rgba(232,193,114,.1); margin:20px 0;
}
#settings-save-btn{
width:100%; padding:13px; font-size:1rem; font-family:var(--display);
background:rgba(232,193,114,.12); border:1px solid rgba(232,193,114,.4);
border-radius:10px; color:var(--gold); cursor:pointer; letter-spacing:.3px;
transition:background .15s;
}
#settings-save-btn:hover{background:rgba(232,193,114,.22)}
#settings-save-btn:disabled{opacity:.5; cursor:wait}
#settings-main-menu-btn{
width:100%; padding:13px; font-size:1rem; font-family:var(--display);
background:rgba(233,166,166,.08); border:1px solid rgba(233,166,166,.3);
border-radius:10px; color:var(--rose); cursor:pointer; letter-spacing:.3px;
transition:background .15s;
}
#settings-main-menu-btn:hover{background:rgba(233,166,166,.18)}
/* ---- ending overlays (victory / defeat) ---- */
#ending-overlay{
position:fixed; inset:0; z-index:50; display:none; flex-direction:column;
align-items:center; justify-content:center; padding:40px 24px;
text-align:center; overflow:hidden;
}
#ending-overlay.show{ display:flex }
#ending-overlay.victory{
background:radial-gradient(ellipse at 50% 0%, #2e1030 0%, #150920 50%, #08050f 100%);
}
#ending-overlay.defeat{
background:radial-gradient(ellipse at 50% 0%, #1a0808 0%, #0d0505 50%, #050305 100%);
}
/* Dark veil over the image so text stays readable */
#ending-overlay::before{
content:""; position:absolute; inset:0; pointer-events:none;
background:var(--ending-dim, rgba(10,6,18,.72));
z-index:0;
}
/* Particle shimmer layer */
#ending-overlay::after{
content:""; position:absolute; inset:0; pointer-events:none; z-index:0;
background-image:
radial-gradient(circle, rgba(255,255,255,.12) 1px, transparent 1px),
radial-gradient(circle, rgba(255,255,255,.06) 1px, transparent 1px);
background-size:120px 120px, 80px 80px;
background-position:0 0, 40px 40px;
animation:endingDrift 20s linear infinite;
opacity:.5;
}
#ending-overlay.defeat::after{ opacity:.18 }
/* All direct children must sit above the pseudo-elements */
#ending-overlay > *{ position:relative; z-index:1 }
@keyframes endingDrift{
from{ background-position:0 0, 40px 40px }
to { background-position:120px 120px, 160px 160px }
}
.ending-icon{ font-size:3.6rem; margin-bottom:16px; animation:endingPulse 2s ease-in-out infinite }
@keyframes endingPulse{ 0%,100%{transform:scale(1)} 50%{transform:scale(1.12)} }
.ending-title{
font-family:var(--display); font-size:2rem; letter-spacing:.15em;
margin-bottom:10px; position:relative;
}
.victory .ending-title{ color:#e8c172 }
.defeat .ending-title{ color:#c87e7e }
.ending-subtitle{
font-size:.78rem; letter-spacing:.3em; color:var(--ink-dim); margin-bottom:28px;
text-transform:uppercase;
}
.ending-divider{
font-size:.65rem; letter-spacing:.5em; margin-bottom:20px;
}
.victory .ending-divider{ color:#a07c3a }
.defeat .ending-divider{ color:#7c3a3a }
#ending-text{
font-family:var(--body); font-size:1.02rem; line-height:1.75;
max-width:520px; color:var(--ink); font-style:italic;
margin-bottom:36px; position:relative;
}
#ending-replay{
padding:12px 32px; border-radius:8px; cursor:pointer;
font-family:var(--display); font-size:.95rem; letter-spacing:.2px;
border:none; position:relative; transition:opacity .2s;
}
#ending-replay:hover{ opacity:.85 }
.victory #ending-replay{ background:rgba(232,193,114,.2); border:1px solid rgba(232,193,114,.5); color:var(--gold) }
.defeat #ending-replay{ background:rgba(200,126,126,.15); border:1px solid rgba(200,126,126,.4); color:#c87e7e }
/* ---- book intro overlay ---- */
#intro-book{
position:fixed; inset:0; z-index:30; display:flex; align-items:center; justify-content:center;
background:rgba(5,7,12,.70); backdrop-filter:blur(5px);
animation:fade-in .6s ease;
}
@keyframes fade-in{from{opacity:0} to{opacity:1}}
.book-wrap{
width:min(560px,90vw); position:relative;
background:linear-gradient(155deg,#f5edd6 0%,#e8d9b5 55%,#dccfa0 100%);
border-radius:3px 6px 6px 3px; padding:44px 48px 36px;
box-shadow:
-4px 0 8px rgba(0,0,0,.35),
0 20px 60px rgba(0,0,0,.65),
inset 3px 0 6px rgba(0,0,0,.12),
inset 0 0 40px rgba(139,99,43,.08);
color:#3a2a14; border:1px solid #c4a96a;
}
/* spine shadow */
.book-wrap::before{
content:""; position:absolute; left:0; top:0; bottom:0; width:18px;
background:linear-gradient(90deg,rgba(0,0,0,.25),transparent);
border-radius:3px 0 0 3px; pointer-events:none;
}
/* inner frame */
.book-wrap::after{
content:""; position:absolute; inset:10px 10px 10px 18px;
border:1px solid rgba(139,99,43,.25); border-radius:2px; pointer-events:none;
}
.book-ornament{text-align:center; color:#a07c3a; font-size:.85rem; letter-spacing:5px; margin:0 0 10px}
#book-title{
font-family:var(--display); font-size:1.6rem; font-weight:600; color:#2a1a08;
text-align:center; margin:0 0 16px; line-height:1.2;
}
.book-divider{text-align:center; color:#c4a96a; font-size:.7rem; letter-spacing:8px; margin:10px 0 18px}
#book-text{
font-family:var(--body); font-size:1.05rem; line-height:1.8; color:#3a2a14;
text-align:justify; font-style:italic; margin:0 0 24px; min-height:4em;
}
#book-close{
display:block; margin:0 auto; background:#7a5120; color:#f5edd6;
font-family:var(--display); font-size:1rem; font-weight:500; letter-spacing:.5px;
padding:11px 36px; border-radius:3px; border:1px solid #a07840; cursor:pointer;
box-shadow:0 3px 10px rgba(0,0,0,.3); transition:background .15s ease;
}
#book-close:hover{background:#9a6930}
/* ---- setup overlay ---- */
#setup{position:fixed; inset:0; z-index:9; display:grid; place-items:center;
background:radial-gradient(80% 80% at 50% 30%, var(--dusk-2), var(--dusk-0));}
.card{width:min(520px,92vw); text-align:center; padding:34px 30px}
.card h1{font-family:var(--display); font-weight:600; font-size:2.6rem; margin:.1em 0 .1em;
color:var(--ink)}
.card p{color:var(--ink-dim); margin:.2em 0 1.4em}
.field{display:flex; gap:10px; margin:10px 0; align-items:center; justify-content:space-between}
.field label{font-family:var(--display); font-size:1.2rem; color:var(--gold)}
select{flex:1; margin-left:14px; background:var(--dusk-2); color:var(--ink);
border:1px solid rgba(255,255,255,.18); border-radius:10px; padding:10px 12px; font-family:var(--body)}
select option{background:var(--dusk-2); color:var(--ink)}
#enter{width:100%; margin-top:16px; padding:14px; font-size:1.05rem}
#start-progress{margin-top:18px; display:none; text-align:left}
.sp-step{margin-bottom:12px}
.sp-step-header{display:flex; justify-content:space-between; align-items:baseline; margin-bottom:5px}
.sp-step-label{font-size:.78rem; font-family:var(--display); color:var(--gold); letter-spacing:.04em}
.sp-step-pct{font-size:.75rem; color:var(--ink-dim)}
.sp-track{height:5px; border-radius:3px; background:rgba(255,255,255,.08); overflow:hidden}
.sp-fill{height:100%; border-radius:3px; width:0%; transition:width .55s ease}
.sp-fill.phase-llm{background:linear-gradient(90deg,#b08a4a,var(--gold))}
.sp-fill.phase-img{background:linear-gradient(90deg,#6a9cc8,#a8d4f0)}
.sp-note{font-size:.78rem; color:var(--ink-dim); font-style:italic; margin-top:4px; min-height:1.1em; transition:opacity .35s}
.shimmer{color:var(--ink-dim); font-style:italic}
.hidden{display:none !important}
/* ---- journal button (top-right) ---- */
#journal-btn{
position:fixed; top:16px; right:16px; z-index:8;
display:flex; align-items:center; gap:7px;
background:rgba(10,13,24,.82); border:1px solid rgba(232,193,114,.30);
border-radius:10px; padding:9px 15px;
color:var(--gold); font-family:var(--display); font-size:1rem;
backdrop-filter:blur(6px); cursor:pointer;
transition:background .15s, border-color .15s, transform .15s;
position:fixed; /* re-state for specificity */
}
#journal-btn:hover{
background:rgba(232,193,114,.12); border-color:rgba(232,193,114,.55);
transform:translateY(-1px);
}
#journal-btn .jb-icon{font-size:1.15rem; line-height:1}
.je-badge{
display:none; position:absolute; top:-5px; right:-5px;
width:10px; height:10px; background:var(--rose); border-radius:50%;
border:2px solid var(--dusk-0);
}
#journal-btn.has-new .je-badge{display:block}
/* ---- journal side panel ---- */
#journal-overlay{
position:fixed; top:0; right:0; bottom:0;
width:min(400px,92vw); z-index:25;
display:flex; flex-direction:column;
background:rgba(8,10,18,.97);
border-left:1px solid rgba(232,193,114,.18);
backdrop-filter:blur(14px);
transform:translateX(105%);
transition:transform .35s cubic-bezier(.4,0,.2,1);
box-shadow:-14px 0 48px rgba(0,0,0,.55);
}
#journal-overlay.open{transform:translateX(0)}
#journal-header{
display:flex; align-items:center; justify-content:space-between;
padding:18px 20px 14px;
border-bottom:1px solid rgba(232,193,114,.13);
flex-shrink:0;
}
#journal-header h3{
font-family:var(--display); font-size:1.35rem;
color:var(--gold); margin:0; letter-spacing:.3px;
}
#journal-close{
background:transparent; border:none; color:var(--ink-dim);
font-size:1.2rem; cursor:pointer; padding:4px 9px;
border-radius:6px; transition:color .15s, background .15s;
}
#journal-close:hover{color:var(--ink); background:rgba(255,255,255,.07)}
#journal-list{
flex:1; overflow-y:auto; padding:16px 20px 28px;
scroll-behavior:smooth;
}
#journal-list::-webkit-scrollbar{width:4px}
#journal-list::-webkit-scrollbar-track{background:transparent}
#journal-list::-webkit-scrollbar-thumb{background:rgba(232,193,114,.22); border-radius:2px}
.journal-entry{margin-bottom:4px}
.je-meta{
font-size:.72rem; color:var(--ink-dim); letter-spacing:.6px;
text-transform:uppercase; margin-bottom:9px;
}
.je-meta em{font-style:normal; color:rgba(232,193,114,.55)}
.je-player{
font-size:.9rem; color:rgba(200,190,175,.6);
font-style:italic; margin-bottom:10px;
padding:6px 10px; border-left:2px solid rgba(255,255,255,.1);
border-radius:0 4px 4px 0;
}
.je-you{font-style:normal; color:rgba(210,200,185,.9); font-weight:500; margin-right:5px}
.je-speaker{
font-family:var(--display); font-size:1.1rem;
color:var(--gold); margin-bottom:3px;
}
.je-dialogue{font-size:.95rem; line-height:1.65; color:var(--ink)}
.je-sep{
text-align:center; color:rgba(232,193,114,.22);
font-size:.68rem; letter-spacing:7px; margin:18px 0;
}
.je-empty{
color:var(--ink-dim); font-style:italic; font-family:var(--display);
text-align:center; margin-top:50px; font-size:1.05rem; line-height:1.7;
}
/* ---- journal tabs ---- */
#journal-tabs{
display:flex; gap:0; flex-shrink:0;
border-bottom:1px solid rgba(232,193,114,.13);
}
.jtab{
flex:1; padding:10px 0; font-family:var(--body); font-size:.88rem;
color:var(--ink-dim); cursor:pointer; background:none; border:none;
border-bottom:2px solid transparent; transition:color .15s, border-color .15s;
letter-spacing:.3px;
}
.jtab:hover{color:var(--ink)}
.jtab.active{color:var(--gold); border-bottom-color:var(--gold)}
#journal-rel{
flex:1; overflow-y:auto; padding:16px 20px 28px;
scroll-behavior:smooth;
}
#journal-rel::-webkit-scrollbar{width:4px}
#journal-rel::-webkit-scrollbar-track{background:transparent}
#journal-rel::-webkit-scrollbar-thumb{background:rgba(232,193,114,.22); border-radius:2px}
.rel-entry{
display:flex; align-items:center; gap:12px;
padding:12px 0; border-bottom:1px solid rgba(255,255,255,.06);
}
.rel-entry:last-child{border-bottom:none}
.rel-avatar{
width:42px; height:42px; border-radius:50%; object-fit:cover; flex-shrink:0;
border:1px solid rgba(232,193,114,.2); background:rgba(255,255,255,.05);
display:flex; align-items:center; justify-content:center;
font-size:1.3rem; color:var(--ink-dim);
}
.rel-avatar img{width:42px; height:42px; border-radius:50%; object-fit:cover}
.rel-info{flex:1; min-width:0}
.rel-name{font-family:var(--display); font-size:1rem; color:var(--gold); margin-bottom:6px}
.rel-mini-bar{height:4px; border-radius:3px; background:rgba(255,255,255,.10); position:relative; overflow:hidden}
.rel-mini-pos{position:absolute; left:0; top:0; bottom:0; background:var(--moss); border-radius:3px; transition:width .5s ease}
.rel-mini-neg{position:absolute; right:0; top:0; bottom:0; background:var(--rose); border-radius:3px; transition:width .5s ease}
.rel-score{font-size:.78rem; min-width:32px; text-align:right; flex-shrink:0}
.rel-score.pos{color:var(--moss)}
.rel-score.neg{color:var(--rose)}
.rel-score.zero{color:var(--ink-dim)}
.rel-offstage{font-size:.7rem; color:rgba(200,190,175,.75); font-style:italic}
.rel-section-title{
font-family:var(--display); font-size:.72rem; color:var(--gold); opacity:.65;
letter-spacing:1.2px; text-transform:uppercase;
padding:14px 0 6px; border-bottom:1px solid rgba(232,193,114,.12); margin-bottom:8px;
}
.bond-graph-wrap{
padding:8px 0 8px; display:flex; justify-content:center;
}
.bond-graph-wrap svg{ width:100%; max-height:280px; display:block; overflow:visible }
.bond-list{ padding:0 4px 12px }
.bond-pair{ margin-bottom:7px; padding:6px 9px; background:rgba(255,255,255,.04); border-radius:7px }
.bond-row{ display:flex; align-items:center; gap:5px; padding:4px 0; font-size:.78rem }
.bond-row + .bond-row{ border-top:1px solid rgba(255,255,255,.06); margin-top:2px }
.bond-name{ font-family:var(--display); min-width:56px; color:var(--ink); white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
.bond-arrow{ color:var(--ink-dim); flex-shrink:0; font-size:.7rem }
.bond-note{ font-style:italic; flex:1; font-size:.73rem }
.bond-val{ font-family:var(--display); font-weight:600; min-width:30px; text-align:right; font-size:.78rem }
</style>
</head>
<body>
<div id="notif-stack"></div>
<div id="stage">
<div id="backdrop"></div>
<div id="sprites"></div>
<div id="place"></div>
<div id="dialogue">
<h2 id="speaker">…</h2>
<div id="line"><span class="shimmer">the story is awakening…</span></div>
<div id="target-row" class="hidden"></div>
<div id="actions">
<button class="action-chip active" data-action="talk">πŸ’¬ Talk</button>
<button class="action-chip" data-action="touch">🀝 Touch</button>
<button class="action-chip" data-action="silent">😢 Silence</button>
<button class="action-chip" data-action="give">🎁 Give</button>
<button class="action-chip" data-action="move">πŸšͺ Move</button>
<button class="action-chip" data-action="scout">πŸ‘οΈ Look around</button>
</div>
<div id="inputrow">
<input id="say" placeholder="say something…" autocomplete="off" />
<button id="send">βœ“</button>
<button id="mic" class="ghost" title="hold to speak">πŸŽ™οΈ</button>
</div>
</div>
</div>
<!-- Settings button β€” hidden until game starts -->
<button id="settings-btn" class="hidden" title="Settings">βš™οΈ</button>
<!-- Settings overlay -->
<div id="settings-overlay">
<div id="settings-panel">
<div id="settings-header">
<h3>βš™οΈ Settings</h3>
<button id="settings-close">βœ•</button>
</div>
<div class="settings-row">
<label>🎡 Music</label>
<input type="range" id="bgm-vol-slider" min="0" max="100" value="35" />
<span class="settings-vol-val" id="bgm-vol-val">35%</span>
</div>
<div class="settings-row">
<label>πŸ”Š Voice</label>
<input type="range" id="tts-vol-slider" min="0" max="100" value="100" />
<span class="settings-vol-val" id="tts-vol-val">100%</span>
</div>
<hr class="settings-divider" />
<button id="settings-save-btn">πŸ’Ύ Save current game</button>
<hr class="settings-divider" />
<button id="settings-main-menu-btn">🏠 Back to main menu</button>
</div>
</div>
<!-- Journal button β€” hidden until game starts -->
<button id="journal-btn" class="hidden" title="Story journal">
<span class="jb-icon">πŸ“–</span>
<span>Journal</span>
<span class="je-badge"></span>
</button>
<!-- Journal side panel -->
<div id="journal-overlay">
<div id="journal-header">
<h3>πŸ“– Journal</h3>
<button id="journal-close" title="Close">βœ•</button>
</div>
<div id="journal-tabs">
<button class="jtab active" data-tab="story">Story</button>
<button class="jtab" data-tab="relations">Relations</button>
</div>
<div id="journal-list" style="display:block">
<div class="je-empty">The story has not yet begun…</div>
</div>
<div id="journal-rel" style="display:none">
<div class="je-empty">No characters met yet…</div>
</div>
</div>
<!-- Book intro overlay β€” shown after /start if intro_text is non-empty -->
<div id="intro-book" class="hidden">
<div class="book-wrap">
<div class="book-ornament">β€” ✦ β€”</div>
<h2 id="book-title">Ephemeral Hearts</h2>
<div class="book-divider">Β· Β· Β·</div>
<p id="book-text"></p>
<div class="book-divider">Β· Β· Β·</div>
<button id="book-close">Begin the story β€Ί</button>
</div>
</div>
<!-- Ending overlay (victory / defeat) -->
<div id="ending-overlay">
<div class="ending-icon" id="ending-icon">✦</div>
<div class="ending-title" id="ending-title">The End</div>
<div class="ending-subtitle" id="ending-subtitle"></div>
<div class="ending-divider">Β· Β· Β· Β· Β·</div>
<p id="ending-text"></p>
<button id="ending-replay">Play again</button>
</div>
<!-- Choice screen: new game or load file -->
<div id="choice-screen" style="position:fixed;inset:0;z-index:10;display:grid;place-items:center;
background:radial-gradient(80% 80% at 50% 30%, var(--dusk-2), var(--dusk-0))">
<div class="card">
<h1>Ephemeral Hearts</h1>
<p>Every encounter changes everything.</p>
<button id="btn-new" style="width:100%;margin-bottom:12px;padding:14px;font-size:1.1rem;
background:rgba(232,193,114,.15);border:1px solid rgba(232,193,114,.4);border-radius:8px;
color:var(--gold);cursor:pointer;font-family:var(--display);letter-spacing:.3px;
transition:background .15s">✨ New Story</button>
<label id="btn-load-label" style="display:block;width:100%;padding:13px;font-size:1rem;
background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.15);border-radius:8px;
color:var(--ink-dim);cursor:pointer;font-family:var(--body);text-align:center;
transition:background .15s">πŸ“‚ Load a save file
<input type="file" id="load-file-input" accept=".json" style="display:none" />
</label>
</div>
</div>
<div id="setup" class="hidden">
<div class="card">
<h1>Ephemeral Hearts</h1>
<p>Every encounter changes everything. Choose where the story begins.</p>
<div class="field"><label>Your name</label>
<input id="player-name" placeholder="optional - what should they call you?" maxlength="24"
style="width:100%;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.15);
color:var(--ink);border-radius:10px;padding:11px 14px;font-family:var(--body);font-size:1rem;box-sizing:border-box" />
</div>
<div class="field"><label>Theme</label><select id="theme"></select></div>
<div class="field"><label>Tone</label><select id="tone"></select></div>
<button id="enter">Enter the story</button>
<div id="start-progress">
<div class="sp-step" id="sp-step-llm">
<div class="sp-step-header">
<span class="sp-step-label">✦ Writing the story</span>
<span class="sp-step-pct" id="sp-pct-llm">0%</span>
</div>
<div class="sp-track"><div class="sp-fill phase-llm" id="sp-fill-llm"></div></div>
<div class="sp-note" id="sp-note-llm">Waiting for the AI…</div>
</div>
<div class="sp-step" id="sp-step-img" style="opacity:.35">
<div class="sp-step-header">
<span class="sp-step-label">🎨 Painting the scene</span>
<span class="sp-step-pct" id="sp-pct-img">β€”</span>
</div>
<div class="sp-track"><div class="sp-fill phase-img" id="sp-fill-img"></div></div>
<div class="sp-note" id="sp-note-img">Starts once the story is written</div>
</div>
</div>
</div>
</div>
<script type="module">
import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
const $ = (id) => document.getElementById(id);
let client;
// --- boot: connect + populate the setup form from /themes ---
(async () => {
client = await Client.connect(window.location.origin);
try {
const { data } = await client.predict("/themes", {});
const { themes, tones } = data[0];
for (const [key, t] of Object.entries(themes)) {
$("theme").insertAdjacentHTML("beforeend", `<option value="${key}">${t.label}</option>`);
}
for (const t of tones) $("tone").insertAdjacentHTML("beforeend", `<option value="${t}">${t}</option>`);
} catch (e) { console.warn("themes endpoint not ready", e); }
// Offer to resume the session the server persisted on its side (one per server)
try {
const { data } = await client.predict("/session_info", {});
const info = data[0];
if (info?.exists && !info.ended) {
const btn = document.createElement("button");
btn.id = "btn-continue";
btn.style.cssText = $("btn-new").style.cssText;
btn.textContent = `β–Ά Continue last story (turn ${info.turn_index} - ${info.place})`;
$("btn-new").after(btn);
btn.addEventListener("click", async () => {
if (bgmEnabled) switchMusic("calm"); // user gesture is fresh β€” start audio now
$("choice-screen").classList.add("hidden");
busy(true);
try {
const { data: d } = await client.predict("/resume", {});
if (d[0]?.error) throw new Error(d[0].error);
const v = d[0];
// Rebuild the Story journal from the server-side recent turns:
// a "✦" recap entry for the condensed past, then numbered exchanges.
const hist = v.history || [];
const recapCount = hist.filter(t => t.speaker !== "The tale so far").length;
let n = (v.turn_index || recapCount) - recapCount;
history = hist.map(t => ({
turn: t.speaker === "The tale so far" ? "✦" : ++n,
place: v.place || "",
player: t.player === "(arrives)" ? "" : (t.player || ""),
action: "talk",
speaker: t.speaker,
dialogue: t.dialogue,
}));
journalTurn = v.turn_index || recapCount;
render(v);
$("journal-btn").classList.remove("hidden");
$("settings-btn").classList.remove("hidden");
} catch (err) {
console.error("resume failed", err);
$("choice-screen").classList.remove("hidden");
} finally {
busy(false);
}
});
}
} catch (e) { console.warn("session_info not available", e); }
})();
// --- journal ---
let history = [];
let journalTurn = 0;
let knownChars = []; // updated each render with known_characters from ViewState
let npcBonds = []; // updated each render with npc_bonds from ViewState
const spriteCache = {}; // id -> last known sprite_url (persists when char goes off-stage)
const ACTION_ICONS = { talk:"πŸ’¬", flirt:"🌸", touch:"🀝", ask:"πŸ€”", silent:"😢", give:"🎁", move:"πŸšͺ" };
function addToHistory(playerText, v, action = "talk") {
if (!v?.dialogue || !v?.speaker) return;
history.push({
turn: ++journalTurn,
place: v.place || "",
player: playerText || "",
action: action,
speaker: v.speaker,
dialogue: v.dialogue,
});
if ($("journal-overlay").classList.contains("open")) {
if (activeTab === "story") renderJournal();
else renderRelations();
} else {
$("journal-btn").classList.add("has-new");
}
}
function renderJournal() {
const list = $("journal-list");
if (history.length === 0) {
list.innerHTML = '<div class="je-empty">The story has not yet begun…</div>';
return;
}
list.innerHTML = history.map((e, i) => `
${i > 0 ? '<div class="je-sep">Β· Β· Β·</div>' : ""}
<div class="journal-entry">
<div class="je-meta">Turn ${e.turn}${e.place ? ` &nbsp;Β·&nbsp; <em>${e.place}</em>` : ""}</div>
${(e.player || e.action !== "talk") ? `<div class="je-player"><span class="je-you">${ACTION_ICONS[e.action] || "πŸ’¬"} You</span>${e.player ? ` "${e.player}"` : ""}</div>` : ""}
<div class="je-speaker">${e.speaker}</div>
<div class="je-dialogue">${e.dialogue}</div>
</div>
`).join("");
setTimeout(() => { list.scrollTop = list.scrollHeight; }, 30);
}
function renderRelations() {
const panel = $("journal-rel");
if (!knownChars.length) {
panel.innerHTML = '<div class="je-empty">No characters met yet…</div>';
return;
}
// --- section 1: player↔NPC bars ---
const sorted = [...knownChars].sort((a, b) => b.relationship - a.relationship);
const barsHtml = sorted.map(ch => {
const rel = ch.relationship ?? 0;
const posW = rel > 0 ? rel.toFixed(1) : "0";
const negW = rel < 0 ? Math.abs(rel).toFixed(1) : "0";
const scoreClass = rel > 0 ? "pos" : rel < 0 ? "neg" : "zero";
const label = rel > 0 ? `+${rel}` : `${rel}`;
const spriteUrl = ch.sprite_url || spriteCache[ch.id] || null;
const avatarHtml = spriteUrl
? `<img src="${spriteUrl}" alt="${ch.name}" />`
: `<span>πŸ‘€</span>`;
const isPresent = (window._presentIds || []).includes(ch.id);
return `<div class="rel-entry">
<div class="rel-avatar">${avatarHtml}</div>
<div class="rel-info">
<div class="rel-name">${ch.name} <span class="rel-offstage">${isPresent ? "on stage" : "away"}</span></div>
<div class="rel-mini-bar">
<div class="rel-mini-neg" style="width:${negW}%"></div>
<div class="rel-mini-pos" style="width:${posW}%"></div>
</div>
</div>
<span class="rel-score ${scoreClass}">${label}</span>
</div>`;
}).join("");
// --- section 2: NPC↔NPC bond graph + directed list ---
let graphHtml = "";
if (knownChars.length >= 2) {
// Group bonds by unordered pair; always show BOTH directions (fill missing with neutral 0)
const bondIndex = {}; // "srcId|tgtId" -> bond object
for (const b of npcBonds) bondIndex[`${b.source_id}|${b.target_id}`] = b;
// Collect all unique unordered pairs from all known characters
const seenPairs = new Set();
const pairList = [];
for (let i = 0; i < knownChars.length; i++) {
for (let j = i + 1; j < knownChars.length; j++) {
const a = knownChars[i], b = knownChars[j];
const key = [a.id, b.id].sort().join("|");
if (seenPairs.has(key)) continue;
seenPairs.add(key);
// Only show pairs where at least one direction has a recorded bond
const ab = bondIndex[`${a.id}|${b.id}`];
const ba = bondIndex[`${b.id}|${a.id}`];
if (!ab && !ba) continue;
pairList.push({ a, b, ab, ba });
}
}
const _bondRow = (srcName, tgtName, bond) => {
const val = bond ? bond.value : 0;
const note = bond ? bond.note : "";
const col = _bondColor(note, val);
const sign = val > 0 ? "+" : "";
const noteStr = note || (val === 0 ? "neutral" : "");
return `<div class="bond-row">
<span class="bond-name">${srcName}</span>
<span class="bond-arrow">β†’</span>
<span class="bond-name">${tgtName}</span>
<span class="bond-note" style="color:${col}">${noteStr}</span>
<span class="bond-val" style="color:${col}">${sign}${val}</span>
</div>`;
};
const bondListHtml = pairList.map(({ a, b, ab, ba }) =>
`<div class="bond-pair">
${_bondRow(a.name, b.name, ab)}
${_bondRow(b.name, a.name, ba)}
</div>`
).join("") || `<div class="je-empty" style="font-size:.78rem">No bonds recorded yet</div>`;
graphHtml = `<div class="rel-section-title">Between them</div>
<div class="bond-graph-wrap">${_buildBondGraph(knownChars, npcBonds)}</div>
<div class="bond-list">${bondListHtml}</div>`;
}
panel.innerHTML =
`<div class="rel-section-title">With you</div>${barsHtml}${graphHtml}`;
}
// Semantic coloring: note keywords override the numeric value
const _NEG_NOTE = /jealou|rival|resent|env[iy]|bitter|hostil|scorn|distrust|spite|hate|anger/i;
const _POS_NOTE = /fond|loyal|protect|admire|camaraderie|trust|warm|friend|love|cherish/i;
function _bondColor(note, value) {
if (note && _NEG_NOTE.test(note)) return "#c87e7e";
if (note && _POS_NOTE.test(note)) return "#7ec8a0";
return value >= 0 ? "#7ec8a0" : "#c87e7e";
}
function _buildBondGraph(chars, bonds) {
const W = 340, H = 280, cx = W / 2, cy = H / 2;
// Use a larger radius now that there's no center player node to avoid
const R = Math.min(cx, cy) - 44;
// Position NPCs in a circle
const nodes = chars.map((ch, i) => {
const angle = (2 * Math.PI * i / chars.length) - Math.PI / 2;
return {
id: ch.id, name: ch.name,
x: cx + R * Math.cos(angle),
y: cy + R * Math.sin(angle),
rel: ch.relationship ?? 0,
sprite: ch.sprite_url || spriteCache[ch.id] || null,
};
});
const nodeMap = Object.fromEntries(nodes.map(n => [n.id, n]));
// Curved NPC↔NPC bond arcs
let arcs = "";
// group bonds by pair to detect bidirectional
const pairOffset = {};
for (const b of bonds) {
const key = [b.source_id, b.target_id].sort().join("|");
pairOffset[key] = (pairOffset[key] || 0) + 1;
}
const pairUsed = {};
for (const b of bonds) {
const src = nodeMap[b.source_id], tgt = nodeMap[b.target_id];
if (!src || !tgt) continue;
const pairKey = [b.source_id, b.target_id].sort().join("|");
const side = (pairOffset[pairKey] > 1 && (pairUsed[pairKey] || 0) === 0) ? 1 : -1;
pairUsed[pairKey] = (pairUsed[pairKey] || 0) + 1;
// Perpendicular offset β€” use a CANONICAL direction (sorted by id) so that both
// A→B and B→A get the same perpendicular axis; side then reliably puts them
// on opposite sides instead of accidentally cancelling out.
const [canonSrc, canonTgt] = b.source_id < b.target_id ? [src, tgt] : [tgt, src];
const cdx = canonTgt.x - canonSrc.x, cdy = canonTgt.y - canonSrc.y;
const clen = Math.sqrt(cdx * cdx + cdy * cdy) || 1;
const perpX = -cdy / clen, perpY = cdx / clen; // canonical perpendicular unit vector
const curveStrength = pairOffset[pairKey] > 1 ? 48 : 18;
const cpx = (src.x + tgt.x) / 2 + perpX * curveStrength * side;
const cpy = (src.y + tgt.y) / 2 + perpY * curveStrength * side;
const col = _bondColor(b.note, b.value);
const op = (0.35 + Math.abs(b.value) / 100 * 0.65).toFixed(2);
const sw = (1 + Math.abs(b.value) / 100 * 3.5).toFixed(1);
// Arrowhead at 85% along bezier curve (near target)
const markerId = `arr-${b.source_id}-${b.target_id}`.replace(/[^a-z0-9-]/gi,"_");
arcs += `<defs><marker id="${markerId}" markerWidth="5" markerHeight="5"
refX="4" refY="2.5" orient="auto">
<polygon points="0,0 5,2.5 0,5" fill="${col}" fill-opacity="${op}"/>
</marker></defs>
<path d="M${src.x},${src.y} Q${cpx},${cpy} ${tgt.x},${tgt.y}"
fill="none" stroke="${col}" stroke-width="${sw}" stroke-opacity="${op}"
stroke-linecap="round" marker-end="url(#${markerId})"/>`;
if (b.note) {
// For bidirectional pairs, push each label further in its own canonical perpendicular
// direction so the two notes don't stack on top of each other.
const bidir = pairOffset[pairKey] > 1;
const baseLx = 0.25 * src.x + 0.5 * cpx + 0.25 * tgt.x;
const baseLy = 0.25 * src.y + 0.5 * cpy + 0.25 * tgt.y;
const labelOffsetX = bidir ? perpX * 10 * side : 0;
const labelOffsetY = bidir ? perpY * 10 * side : -4;
const lx = baseLx + labelOffsetX;
const ly = baseLy + labelOffsetY;
arcs += `<text x="${lx}" y="${ly}" text-anchor="middle"
font-size="9" fill="${col}"
paint-order="stroke" stroke="rgba(10,8,18,.85)" stroke-width="3" stroke-linejoin="round"
font-family="Georgia,serif" font-style="italic">${b.note}</text>`;
}
}
// NPC circle nodes (sprite or emoji) + name coloured by player relation
let nodesSvg = "";
for (const n of nodes) {
const clipId = `cl_${n.id}`.replace(/[^a-z0-9_]/gi,"_");
// Border colour by relation to player
const borderCol = n.rel > 20 ? "#7ec8a0" : n.rel < -20 ? "#c87e7e" : "rgba(232,193,114,.55)";
const nameCol = n.rel > 20 ? "#9ddcba" : n.rel < -20 ? "#dba0a0" : "rgba(232,193,114,.92)";
nodesSvg += `<circle cx="${n.x}" cy="${n.y}" r="22"
fill="rgba(255,255,255,.05)" stroke="${borderCol}" stroke-width="1.8"/>`;
if (n.sprite) {
nodesSvg += `<clipPath id="${clipId}"><circle cx="${n.x}" cy="${n.y}" r="20"/></clipPath>
<image href="${n.sprite}" x="${n.x-20}" y="${n.y-20}" width="40" height="40"
clip-path="url(#${clipId})" preserveAspectRatio="xMidYMid slice"/>`;
} else {
nodesSvg += `<text x="${n.x}" y="${n.y+5}" text-anchor="middle" font-size="16">πŸ‘€</text>`;
}
// Name label with dark stroke outline for readability
nodesSvg += `<text x="${n.x}" y="${n.y+38}" text-anchor="middle"
font-size="10.5" fill="${nameCol}" font-family="Georgia,serif"
paint-order="stroke" stroke="rgba(10,8,18,.9)" stroke-width="3.5" stroke-linejoin="round">${n.name}</text>`;
}
return `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg">
${arcs}${nodesSvg}
</svg>`;
}
// keep track of who's currently on stage for the relations panel
let _presentIds = [];
window._presentIds = _presentIds;
// --- journal tabs ---
let activeTab = "story";
document.querySelectorAll(".jtab").forEach(btn => {
btn.addEventListener("click", () => {
activeTab = btn.dataset.tab;
document.querySelectorAll(".jtab").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
if (activeTab === "story") {
$("journal-list").style.display = "block";
$("journal-rel").style.display = "none";
renderJournal();
} else {
$("journal-list").style.display = "none";
$("journal-rel").style.display = "block";
renderRelations();
}
});
});
$("journal-btn").addEventListener("click", () => {
const open = $("journal-overlay").classList.toggle("open");
if (open) {
$("journal-btn").classList.remove("has-new");
if (activeTab === "story") {
$("journal-list").style.display = "block";
$("journal-rel").style.display = "none";
renderJournal();
} else {
$("journal-list").style.display = "none";
$("journal-rel").style.display = "block";
renderRelations();
}
}
});
$("journal-close").addEventListener("click", () => {
$("journal-overlay").classList.remove("open");
});
// --- typewriter ---
function typeOut(el, text){
el.textContent = ""; let i = 0;
clearInterval(el._timer);
el._timer = setInterval(() => {
el.textContent = text.slice(0, ++i);
if (i >= text.length) clearInterval(el._timer);
}, 16);
}
// --- audio playback ---
let _currentAudio = null;
function playAudio(b64) {
if (_currentAudio) { _currentAudio.pause(); _currentAudio = null; }
if (!b64) return;
_currentAudio = new Audio(b64);
_currentAudio.play().catch(() => {}); // ignore autoplay policy errors silently
}
// --- render a text-only ViewState (phase 1 of a turn) ---
// Dialogue, notifications and music only; backdrop/sprites/target chips/ending overlay
// stay untouched until the slower /turn_images ViewState arrives.
function renderText(v){
if (v.known_characters?.length) knownChars = v.known_characters;
$("place").textContent = v.place || "";
$("speaker").textContent = v.speaker || "";
typeOut($("line"), v.dialogue || "");
if (v.current_music) switchMusic(v.current_music);
for (const msg of (v.notifications || [])) showNotif(msg);
}
// --- render a full ViewState ---
let lastBg = null;
function render(v, opts = {}){
if (v.known_characters?.length) {
knownChars = v.known_characters;
for (const ch of knownChars) {
if (ch.sprite_url) spriteCache[ch.id] = ch.sprite_url;
}
}
if (v.npc_bonds !== undefined) npcBonds = v.npc_bonds || [];
$("place").textContent = v.place || "";
$("speaker").textContent = v.speaker || "";
if (!opts.skipText) typeOut($("line"), v.dialogue || ""); // skipText: renderText already typed it
playTTS(v.audio_b64);
if (v.current_music) switchMusic(v.current_music);
if (v.backdrop_url && v.backdrop_url !== lastBg){
const bg = $("backdrop");
bg.classList.remove("on");
setTimeout(() => { bg.style.backgroundImage = `url(${v.backdrop_url})`; bg.classList.add("on"); }, 120);
lastBg = v.backdrop_url;
}
window._presentIds = (v.present || []).map(p => p.id);
const wrap = $("sprites"); wrap.innerHTML = "";
for (const p of (v.present || [])){
if (!p.sprite_url) continue;
const div = document.createElement("div");
div.className = "sprite-wrap";
const img = document.createElement("img");
img.className = "sprite"; img.src = p.sprite_url; img.alt = p.name;
// relation bar: positive → green left→right | negative → red right→left
const rel = p.relationship ?? 0;
const relPosW = rel > 0 ? rel.toFixed(1) : "0";
const relNegW = rel < 0 ? Math.abs(rel).toFixed(1) : "0";
const relLabel = rel > 0 ? `+${rel}` : `${rel}`;
// traits rows
const total = p.total_traits ?? 0;
let traitsHtml = "";
for (let i = 0; i < total; i++){
const unlocked = p.discovered_traits && p.discovered_traits[i];
traitsHtml += unlocked
? `<div class="trait-item unlocked">βœ“ ${p.discovered_traits[i]}</div>`
: `<div class="trait-item locked">πŸ”’ ???</div>`;
}
// secret goal
const goalHtml = p.secret_goal
? `<div class="secret-goal unlocked">πŸ’« ${p.secret_goal}</div>`
: `<div class="secret-goal locked">πŸ”’ Secret goal unknown</div>`;
const card = document.createElement("div");
card.className = "char-card";
card.innerHTML = `
<div class="char-card-name">${p.name}</div>
<div class="rel-row">
<div class="rel-bar">
<div class="rel-fill-neg" style="width:${relNegW}%"></div>
<div class="rel-fill-pos" style="width:${relPosW}%"></div>
</div>
<span class="rel-val">${relLabel}</span>
</div>
<div class="traits-section">
<div class="traits-label">Traits</div>
${traitsHtml || '<div class="trait-item locked">πŸ”’ ???</div>'}
</div>
${goalHtml}
`;
div.appendChild(img);
div.appendChild(card);
wrap.appendChild(div);
requestAnimationFrame(() => img.classList.add("on"));
}
// notifications
for (const msg of (v.notifications || [])) showNotif(msg);
// target chips β€” show only when multiple characters are on stage
updateTargetRow(v.present);
// ── Ending screen ──────────────────────────────────────────────────────────
if (v.ended && v.ending_text) {
setTimeout(() => showEnding(v.ending_kind, v.ending_text, v.backdrop_url), 1800);
}
}
function showEnding(kind, text, backdropUrl) {
const overlay = $("ending-overlay");
const isDefeat = kind === "defeat";
overlay.className = "show " + (isDefeat ? "defeat" : "victory");
// Use the generated ending illustration as the overlay background
if (backdropUrl) {
overlay.style.backgroundImage = `url(${backdropUrl})`;
overlay.style.backgroundSize = "cover";
overlay.style.backgroundPosition = "center";
// Darken overlay content area so text stays readable over the image
overlay.style.setProperty("--ending-dim", isDefeat
? "rgba(5,3,5,.78)" : "rgba(10,6,18,.65)");
}
$("ending-icon").textContent = isDefeat ? "πŸ’€" : "πŸ’•";
$("ending-title").textContent = isDefeat ? "Game Over" : "✦ Fin ✦";
$("ending-subtitle").textContent = isDefeat
? "The wanderer walks alone…"
: "A warm ending";
// Typewrite the epilogue text
const el = $("ending-text");
el.textContent = "";
let i = 0;
const iv = setInterval(() => {
if (i >= text.length) { clearInterval(iv); return; }
el.textContent += text[i++];
}, 28);
// Replay button label
$("ending-replay").textContent = isDefeat ? "Try again" : "Play again";
// Music: overlay already triggered set_music via render, but be safe
if (isDefeat && !bgmTrackName?.includes("sad")) switchMusic("sad");
if (!isDefeat && !bgmTrackName?.includes("romantic")) switchMusic("romantic");
}
function showNotif(msg){
const el = document.createElement("div");
el.className = "notif";
el.textContent = msg;
$("notif-stack").appendChild(el);
setTimeout(() => {
el.classList.add("out");
el.addEventListener("animationend", () => el.remove(), { once: true });
}, 5000);
}
// --- target selector ---
let currentTarget = ""; // "" = anyone
function updateTargetRow(present) {
const row = $("target-row");
if (!present || present.length <= 1) {
row.classList.add("hidden");
currentTarget = "";
return;
}
row.classList.remove("hidden");
row.innerHTML = `
<span class="target-label">To:</span>
<button class="target-chip active" data-name="">Everyone</button>
${present.map(p => `<button class="target-chip" data-name="${p.name}">${p.name}</button>`).join("")}
`;
row.querySelectorAll(".target-chip").forEach(btn => {
btn.addEventListener("click", () => {
if (btn.disabled) return;
row.querySelectorAll(".target-chip").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
currentTarget = btn.dataset.name;
});
});
}
function resetTarget() {
currentTarget = "";
const row = $("target-row");
row.querySelectorAll(".target-chip").forEach(b => b.classList.remove("active"));
const everyone = row.querySelector('.target-chip[data-name=""]');
if (everyone) everyone.classList.add("active");
}
// --- action chips ---
const ACTION_PLACEHOLDERS = {
talk: "say something…",
flirt: "add a flirty line… (or leave blank)",
touch: "add words… (optional)",
ask: "what do you want to know about them?",
silent: "",
give: "what do you offer?",
move: "where? (leave blank and the story decides)",
scout: "name someone specific… (or leave blank)",
};
let currentAction = "talk";
document.querySelectorAll(".action-chip").forEach(btn => {
btn.addEventListener("click", () => {
if (btn.disabled) return;
document.querySelectorAll(".action-chip").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
currentAction = btn.dataset.action;
if (currentAction === "silent") {
$("say").value = "";
$("say").disabled = true;
} else {
$("say").disabled = false;
$("say").placeholder = ACTION_PLACEHOLDERS[currentAction] || "say something…";
$("say").focus();
}
});
});
function resetAction() {
currentAction = "talk";
document.querySelectorAll(".action-chip").forEach(b => b.classList.remove("active"));
document.querySelector('.action-chip[data-action="talk"]').classList.add("active");
$("say").disabled = false;
$("say").placeholder = ACTION_PLACEHOLDERS.talk;
}
function busy(state){
$("send").disabled = state; $("say").disabled = state; $("mic").disabled = state;
document.querySelectorAll(".action-chip").forEach(b => b.disabled = state);
document.querySelectorAll(".target-chip").forEach(b => b.disabled = state);
}
// --- TTS toggle + volume ---
let ttsVol = 1.0; // 0–1, controlled by settings slider
let ttsEnabled = true;
// ttsBtn removed from DOM β€” state managed solely via settings sliders
const ttsBtn = { textContent: "", classList: { add:()=>{}, toggle:()=>{}, remove:()=>{} } };
function playTTS(audio_b64) {
if (!ttsEnabled || !audio_b64) return;
let audio = $("vn-audio");
if (!audio) {
audio = document.createElement("audio");
audio.id = "vn-audio";
document.body.appendChild(audio);
}
audio.volume = ttsVol;
audio.src = audio_b64;
audio.play().catch(() => {});
}
// --- BGM (background music) ---
const BGM_TRACKS = {
calm: "/music/calm.mp3",
romantic: "/music/romantic.mp3",
dramatic: "/music/dramatic.mp3",
mystery: "/music/mystery.mp3",
sad: "/music/sad.mp3",
joyful: "/music/joyful.mp3",
};
let bgmMasterVolume = 0.35; // master volume (0–1) β€” controlled by settings slider
const BGM_FADE_OUT_MS = 2800; // old track fades out over this long
const BGM_FADE_IN_MS = 3200; // new track fades in over this long
const BGM_OVERLAP_MS = 1200; // new track starts this many ms before old finishes
let bgmEnabled = true;
let bgmCurrent = null; // currently playing Audio object
let bgmTrackName = null; // track key of bgmCurrent
// bgmBtn removed from DOM β€” state managed solely via settings sliders
const bgmBtn = { textContent: "", classList: { add:()=>{}, toggle:()=>{}, remove:()=>{} } };
function switchMusic(name) {
if (!name || !BGM_TRACKS[name]) return;
if (name === bgmTrackName) return;
if (!bgmEnabled) { bgmTrackName = name; return; }
_bgmPlay(name);
}
function _bgmPlay(name) {
const old = bgmCurrent;
bgmTrackName = name;
const a = new Audio(BGM_TRACKS[name]);
a.loop = true;
a.volume = 0;
bgmCurrent = a;
// Helper: actually start the audio and fade it in
const startFadeIn = () => {
const p = a.play();
if (p !== undefined) {
p.catch(() => {
// Autoplay still blocked β€” retry on the very next user interaction
const retry = () => {
if (bgmCurrent === a && bgmEnabled) {
a.play().then(() => _bgmFade(a, 0, bgmMasterVolume, BGM_FADE_IN_MS)).catch(() => {});
}
};
document.addEventListener("click", retry, { once: true });
document.addEventListener("keydown", retry, { once: true });
});
}
_bgmFade(a, 0, bgmMasterVolume, BGM_FADE_IN_MS);
};
if (old) {
_bgmFade(old, old.volume, 0, BGM_FADE_OUT_MS, () => { old.pause(); old.src = ""; });
setTimeout(startFadeIn, BGM_FADE_OUT_MS - BGM_OVERLAP_MS);
} else {
startFadeIn();
}
}
// Smooth volume ramp using requestAnimationFrame + ease-in-out curve
function _bgmFade(audio, fromVol, toVol, ms, onDone) {
const start = performance.now();
function tick(now) {
const raw = Math.min(1, (now - start) / ms);
// ease-in-out: smoothstep
const t = raw * raw * (3 - 2 * raw);
audio.volume = fromVol + (toVol - fromVol) * t;
if (raw < 1) {
requestAnimationFrame(tick);
} else {
audio.volume = toVol;
if (onDone) onDone();
}
}
requestAnimationFrame(tick);
}
// --- book overlay dismiss ---
$("book-close").addEventListener("click", () => {
$("intro-book").classList.add("hidden");
$("say").focus();
});
// --- ending replay ---
$("ending-replay").addEventListener("click", () => {
// Stop music, reset ending overlay, go back to choice screen
if (bgmCurrent) { _bgmFade(bgmCurrent, bgmCurrent.volume, 0, 800, () => { bgmCurrent.pause(); bgmCurrent.src = ""; }); bgmCurrent = null; }
bgmTrackName = null;
$("ending-overlay").className = ""; // hide
// Reset game UI state
history = []; journalTurn = 0; knownChars = []; npcBonds = [];
$("place").textContent = ""; $("speaker").textContent = ""; $("line").textContent = "";
$("sprites").innerHTML = ""; lastBg = null;
$("backdrop").style.backgroundImage = "none"; $("backdrop").classList.remove("on");
$("journal-btn").classList.add("hidden");
$("settings-btn").classList.add("hidden");
$("enter").disabled = false;
$("enter").textContent = "Enter the story";
$("choice-screen").classList.remove("hidden");
});
// --- choice screen ---
$("btn-new").addEventListener("click", () => {
$("choice-screen").classList.add("hidden");
$("setup").classList.remove("hidden");
});
$("load-file-input").addEventListener("change", async (e) => {
const file = e.target.files[0];
if (!file) return;
const text = await file.text();
// Start a neutral track NOW (user gesture from the file picker is still active)
if (bgmEnabled) switchMusic("calm");
busy(true);
try {
// Extract client-side journal history before sending state to server
let parsed;
try { parsed = JSON.parse(text); } catch { parsed = {}; }
const savedHistory = Array.isArray(parsed.journal_history) ? parsed.journal_history : [];
const savedAudio = parsed.audio_settings || null;
const { data } = await client.predict("/load_file", { data: text });
$("choice-screen").classList.add("hidden");
$("setup").classList.add("hidden");
// Restore journal history (don't call addToHistory β€” that would duplicate the last line)
history = savedHistory;
journalTurn = savedHistory.length > 0 ? savedHistory[savedHistory.length - 1].turn : 0;
// Restore audio settings if present
if (savedAudio) {
const bgmPct = Math.max(0, Math.min(100, savedAudio.bgm_vol ?? 35));
const ttsPct = Math.max(0, Math.min(100, savedAudio.tts_vol ?? 100));
bgmMasterVolume = bgmPct / 100;
ttsVol = ttsPct / 100;
bgmEnabled = bgmPct > 0;
ttsEnabled = ttsPct > 0;
bgmVolSlider.value = bgmPct;
bgmVolVal.textContent = bgmPct + "%";
ttsVolSlider.value = ttsPct;
ttsVolVal.textContent = ttsPct + "%";
if (bgmCurrent) bgmCurrent.volume = bgmMasterVolume;
// If there's an explicit saved track and music is enabled, pre-seed the track name
// so render()'s switchMusic call (via current_music in ViewState) resumes it.
if (savedAudio.bgm_track && bgmEnabled) bgmTrackName = null; // force switchMusic to not skip
}
render(data[0]); // will call switchMusic with current_music from saved GameState flags
// Fallback: if ViewState had no current_music but we saved a track, start it now
if (savedAudio?.bgm_track && bgmEnabled && !bgmTrackName) {
switchMusic(savedAudio.bgm_track);
}
$("journal-btn").classList.remove("hidden");
$("settings-btn").classList.remove("hidden");
} catch(err) { alert("Could not load save: " + (err?.message || err)); }
busy(false);
});
// --- settings panel ---
$("settings-btn").addEventListener("click", () => $("settings-overlay").classList.add("open"));
$("settings-close").addEventListener("click", () => $("settings-overlay").classList.remove("open"));
$("settings-overlay").addEventListener("click", e => {
if (e.target === $("settings-overlay")) $("settings-overlay").classList.remove("open");
});
// --- BGM volume slider ---
// Moving slider: adjusts master volume live; going to 0 mutes, above 0 unmutes.
const bgmVolSlider = $("bgm-vol-slider");
const bgmVolVal = $("bgm-vol-val");
bgmVolSlider.addEventListener("input", () => {
const pct = parseInt(bgmVolSlider.value, 10);
bgmVolVal.textContent = pct + "%";
bgmMasterVolume = pct / 100;
if (pct === 0) {
if (bgmEnabled) { // mute: fade out & disable
bgmEnabled = false;
if (bgmCurrent) {
_bgmFade(bgmCurrent, bgmCurrent.volume, 0, 500, () => { bgmCurrent.pause(); bgmCurrent.src = ""; });
bgmCurrent = null;
}
}
} else {
if (!bgmEnabled) { // unmute: re-enable and restart remembered track
bgmEnabled = true;
if (bgmTrackName) _bgmPlay(bgmTrackName);
} else if (bgmCurrent) { // just change live volume
bgmCurrent.volume = bgmMasterVolume;
}
}
bgmBtn.classList.toggle("active", bgmEnabled);
bgmBtn.textContent = bgmEnabled ? "🎡" : "πŸ”‡";
});
// --- TTS volume slider ---
const ttsVolSlider = $("tts-vol-slider");
const ttsVolVal = $("tts-vol-val");
ttsVolSlider.addEventListener("input", () => {
const pct = parseInt(ttsVolSlider.value, 10);
ttsVolVal.textContent = pct + "%";
ttsVol = pct / 100;
ttsEnabled = pct > 0;
const a = $("vn-audio"); if (a) a.volume = ttsVol;
ttsBtn.classList.toggle("active", ttsEnabled);
ttsBtn.textContent = ttsEnabled ? "πŸ”Š" : "πŸ”‡";
});
// Save button
$("settings-save-btn").addEventListener("click", async () => {
const btn = $("settings-save-btn");
btn.disabled = true;
btn.textContent = "Saving…";
try {
const { data } = await client.predict("/save_data", {});
const saveObj = JSON.parse(data[0].json);
saveObj.journal_history = history;
saveObj.audio_settings = {
bgm_vol: Math.round(bgmMasterVolume * 100),
tts_vol: Math.round(ttsVol * 100),
bgm_track: bgmTrackName || null,
};
const blob = new Blob([JSON.stringify(saveObj)], { type: "application/json" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "ephemeral-hearts-save.json";
a.click();
URL.revokeObjectURL(a.href);
btn.textContent = "βœ“ Saved!";
setTimeout(() => { btn.textContent = "πŸ’Ύ Save current game"; btn.disabled = false; }, 2000);
} catch(err) {
console.error("save failed", err);
btn.textContent = "⚠️ Failed";
setTimeout(() => { btn.textContent = "πŸ’Ύ Save current game"; btn.disabled = false; }, 2000);
}
});
// --- main menu button in settings ---
$("settings-main-menu-btn").addEventListener("click", () => {
$("settings-overlay").classList.remove("open");
if (bgmCurrent) { _bgmFade(bgmCurrent, bgmCurrent.volume, 0, 800, () => { bgmCurrent.pause(); bgmCurrent.src = ""; }); bgmCurrent = null; }
bgmTrackName = null;
history = []; journalTurn = 0; knownChars = []; npcBonds = [];
$("place").textContent = ""; $("speaker").textContent = ""; $("line").textContent = "";
$("sprites").innerHTML = ""; lastBg = null;
$("backdrop").style.backgroundImage = "none"; $("backdrop").classList.remove("on");
$("journal-btn").classList.add("hidden");
$("settings-btn").classList.add("hidden");
$("journal-overlay").classList.remove("open");
$("ending-overlay").className = "";
$("enter").disabled = false;
$("enter").textContent = "Enter the story";
$("setup").classList.add("hidden");
$("choice-screen").classList.remove("hidden");
});
// tone -> initial BGM track (mirrors engine.py start() logic)
const TONE_MUSIC = { romantic:"romantic", flirty:"romantic", dramatic:"dramatic", bittersweet:"sad", comedic:"joyful" };
// --- start ---
$("enter").addEventListener("click", async () => {
const btn = $("enter");
btn.disabled = true;
const orig = btn.textContent;
btn.textContent = "✨ Creating your story…";
// Kick off music NOW while the user gesture is still fresh.
if (bgmEnabled) switchMusic(TONE_MUSIC[$("tone").value] || "calm");
// --- two-phase segmented progress ---
const spEl = $("start-progress");
spEl.style.display = "block";
// Per-phase helpers
let _spTick = null;
const _spNote = (id, msg) => {
const el = $(id);
el.style.opacity = "0";
setTimeout(() => { el.textContent = msg; el.style.opacity = "1"; }, 200);
};
// Animate fill toward `target` (0-100) at `stepMs` per tick; resolves when target hit
const _spCrawl = (fillId, pctId, target, stepMs) => new Promise(resolve => {
if (_spTick) { clearInterval(_spTick); _spTick = null; }
const fill = $(fillId), pctEl = $(pctId);
let cur = parseFloat(fill.style.width) || 0;
_spTick = setInterval(() => {
cur = Math.min(target, cur + 0.7);
fill.style.width = cur + "%";
pctEl.textContent = Math.round(cur) + "%";
if (cur >= target) { clearInterval(_spTick); _spTick = null; resolve(); }
}, stepMs);
});
const _spFinish = (fillId, pctId, label) => {
if (_spTick) { clearInterval(_spTick); _spTick = null; }
$(fillId).style.width = "100%";
$(pctId).textContent = label || "100%";
};
const spHide = () => {
spEl.style.display = "none";
["sp-fill-llm","sp-fill-img"].forEach(id => { $(id).style.width = "0%"; });
};
// LLM notes cycling (reassuring messages while waiting for the model)
const LLM_NOTES = [
"The AI is generating your opening scene…",
"First load can take 30–60 s while the model warms up…",
"Inventing characters, secrets, and a first line just for you…",
"Almost done writing β€” hang on…",
];
let _llmNoteIdx = 0;
const _llmNoteTick = setInterval(() => {
_llmNoteIdx = (_llmNoteIdx + 1) % LLM_NOTES.length;
_spNote("sp-note-llm", LLM_NOTES[_llmNoteIdx]);
}, 9000);
try {
// ── Phase 1 : LLM world init ──────────────────────────────────────────
_spNote("sp-note-llm", LLM_NOTES[0]);
// Crawl 0β†’88% slowly β€” will stop there until the real call returns
_spCrawl("sp-fill-llm", "sp-pct-llm", 88, 800);
const { data: d1 } = await client.predict("/start_text", {
theme: $("theme").value,
tone: $("tone").value,
player_name: $("player-name").value.trim(),
});
// LLM done β€” complete bar 1, unlock bar 2 (stay on setup screen)
clearInterval(_llmNoteTick);
_spFinish("sp-fill-llm", "sp-pct-llm", "100%");
_spNote("sp-note-llm", "Story written βœ“");
$("sp-step-img").style.opacity = "1";
_spNote("sp-note-img", "Generating backdrop and character illustration…");
$("sp-pct-img").textContent = "0%";
// ── Phase 2 : image generation ────────────────────────────────────────
// Crawl 0β†’85% while painter works
_spCrawl("sp-fill-img", "sp-pct-img", 85, 350);
const { data: d2 } = await client.predict("/start_images", {});
// Everything ready β€” complete bar 2, then reveal the game
_spFinish("sp-fill-img", "sp-pct-img", "100%");
_spNote("sp-note-img", "Scene ready βœ“");
render(d2[0]);
addToHistory("", d2[0]);
$("setup").classList.add("hidden");
$("journal-btn").classList.remove("hidden");
$("settings-btn").classList.remove("hidden");
setTimeout(() => {
spHide();
if (d2[0]?.intro_text) {
$("book-text").textContent = d2[0].intro_text;
$("intro-book").classList.remove("hidden");
}
}, 700);
} catch(err) {
console.error("start failed", err);
clearInterval(_llmNoteTick);
if (_spTick) { clearInterval(_spTick); _spTick = null; }
spHide();
btn.textContent = "⚠️ Failed β€” try again";
btn.disabled = false;
setTimeout(() => { btn.textContent = orig; }, 3000);
}
});
// --- a turn ---
async function sendTurn(){
const text = $("say").value.trim();
// allow empty text for non-talk actions (the action hint is enough)
if (!text && currentAction === "talk") return;
const action = currentAction;
$("say").value = ""; busy(true);
try {
// Phase 1 β€” dialogue appears immediately (input stays disabled until images land)
const { data: d1 } = await client.predict("/turn_text", { player_input: text, action, target: currentTarget });
renderText(d1[0]);
// Phase 2 β€” backdrop/sprites/TTS/ending (notifications already shown in phase 1)
const { data: d2 } = await client.predict("/turn_images", {});
render(d2[0], { skipText: true });
addToHistory(text, d2[0], action);
} finally {
busy(false);
resetAction();
resetTarget();
$("say").focus();
}
}
$("send").addEventListener("click", sendTurn);
$("say").addEventListener("keydown", (e) => { if (e.key === "Enter") sendTurn(); });
// --- voice (MediaRecorder -> /transcribe). Press to start, press again to stop. ---
let rec, chunks = [], recTimerId = null;
function micIdle() { const m=$("mic"); m.textContent="πŸŽ™οΈ"; m.title="click to record"; m.classList.remove("live","waiting"); }
function micTick(s) { const m=Math.floor(s/60), sec=String(s%60).padStart(2,"0"); $("mic").textContent=`● ${m}:${sec}`; }
function micWait() { const m=$("mic"); m.textContent="⏳"; m.title="transcribing…"; m.classList.remove("live"); m.classList.add("waiting"); }
$("mic").addEventListener("click", async () => {
const mic = $("mic");
if (rec && rec.state === "recording") { rec.stop(); return; }
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
rec = new MediaRecorder(stream); chunks = [];
rec.ondataavailable = (e) => { if (e.data.size > 0) chunks.push(e.data); };
// lock text input while recording; mic stays clickable to stop
$("say").disabled = true; $("send").disabled = true;
let secs = 0; micTick(0); mic.classList.add("live");
recTimerId = setInterval(() => micTick(++secs), 1000);
rec.onstop = async () => {
clearInterval(recTimerId);
stream.getTracks().forEach((t) => t.stop());
const blob = new Blob(chunks, { type: "audio/webm" });
console.debug("[stt] blob size", blob.size);
if (blob.size < 1000) { micIdle(); $("say").disabled = false; $("send").disabled = false; return; }
micWait(); mic.disabled = true;
try {
const file = new File([blob], "speech.webm", { type: "audio/webm" });
const { data } = await client.predict("/transcribe", { audio: handle_file(file) });
micIdle(); mic.disabled = false; $("say").disabled = false; $("send").disabled = false;
if (data[0]?.text) { $("say").value = data[0].text; $("say").focus(); return; }
} catch (e) { console.error("[stt] transcribe error", e); }
// fallback: restore input on error
mic.disabled = false; $("say").disabled = false; $("send").disabled = false;
};
rec.start(100);
} catch (e) {
console.warn("mic unavailable", e);
micIdle(); $("say").disabled = false; $("send").disabled = false;
}
});
</script>
</body>
</html>