Socrates_docker / static /index.html
AlessandroAmodioNGI's picture
Add auto per-turn reply-model routing (Default vs Light)
d692c87
Raw
History Blame Contribute Delete
196 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cielo</title>
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2/dist/umd/supabase.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root { --bg:#0d0d1a; --surface:#13132a; --border:#23234a; --text:#e8e8f0; --dim:#8888aa; --user-bg:#1e3a5f; --neon-blue:#3ea6ff; }
html, body { height:100%; background:var(--bg); color:var(--text);
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; font-size:15px; }
/* ── AUTH ──────────────────────────────────────────── */
#auth-screen {
display:flex; flex-direction:column; align-items:center;
justify-content:center; height:100%; gap:1.2rem; padding:2rem;
background: linear-gradient(rgba(0,0,0,0.3), rgba(0,0,0,0.3)),
url('/static/chalk_stars_bg.png') center/cover no-repeat;
}
#auth-screen .logo {
width:92px; height:92px; border-radius:50%; object-fit:cover;
border:3px solid #1bfdc5; box-shadow:0 0 24px rgba(27,253,197,.4);
}
#auth-screen h1 { font-size:2rem; letter-spacing:.06em; color:#1bfdc5; }
#auth-screen .sub { color:var(--dim); font-size:.9rem; }
#login-form { display:flex; flex-direction:column; gap:.75rem; width:100%; max-width:340px; }
#login-form input {
background:var(--surface); border:1px solid var(--border); color:var(--text);
padding:.75rem 1rem; border-radius:10px; font-size:.95rem; outline:none; transition:border-color .2s;
}
#login-form input:focus { border-color:#1bfdc5; }
#login-form button[type=submit] {
background:#1bfdc5; color:#0d0d1a; border:none; padding:.8rem; border-radius:10px;
font-size:1rem; font-weight:700; cursor:pointer; margin-top:.2rem; transition:opacity .2s;
}
#login-form button[type=submit]:hover { opacity:.85; }
#login-form button[type=submit]:disabled { opacity:.5; cursor:default; }
#auth-error { color:#ff6b6b; font-size:.85rem; min-height:1.2em; }
#auth-switch { font-size:.84rem; color:var(--dim); }
#auth-switch a { color:#1bfdc5; text-decoration:none; }
#auth-switch a:hover { text-decoration:underline; }
#auth-success { color:#1bfdc5; font-size:.85rem; min-height:1.2em; text-align:center; }
#auth-forgot { font-size:.84rem; }
#auth-forgot a { color:#1bfdc5; text-decoration:none; }
#auth-forgot a:hover { text-decoration:underline; }
/* ── INTRO / LANDING SCREEN ─────────────────────────── */
#intro-screen {
display:none; flex-direction:column; height:100%; overflow:hidden;
background: linear-gradient(rgba(0,0,0,0.4), rgba(0,0,0,0.55)),
url('/static/chalk_stars_bg.png') center/cover no-repeat;
}
#intro-header { flex-shrink:0; text-align:center; padding:1.5rem 1.3rem .3rem; }
#intro-header h1 {
font-size:1.9rem; letter-spacing:.03em; color:#1bfdc5; line-height:1.15;
text-shadow:0 0 24px rgba(27,253,197,.45);
}
#intro-header .intro-tagline {
margin:.6rem auto 0; color:var(--neon-blue); font-size:1.08rem; font-weight:600;
line-height:1.5; max-width:660px; text-shadow:0 0 16px rgba(62,166,255,.55);
}
#intro-bubble-canvas { position:relative; flex:1; min-height:0; min-width:0; overflow:hidden; }
#intro-footer {
flex-shrink:0; text-align:center; padding:.6rem 1.3rem 1.6rem;
background:linear-gradient(to top, rgba(0,0,0,.6), rgba(0,0,0,0));
}
#intro-footer .intro-blurb {
color:#e6e6f2; font-size:1.02rem; line-height:1.6; max-width:660px; margin:0 auto 1.1rem;
}
/* "Cielo" highlighted in neon blue so the guide stands out in the copy */
.cielo-hl { color:var(--neon-blue); font-weight:700; text-shadow:0 0 14px rgba(62,166,255,.6); }
#intro-begin-btn {
background:#1bfdc5; color:#0d0d1a; border:none; padding:.8rem 2.3rem; border-radius:12px;
font-size:1rem; font-weight:700; cursor:pointer; transition:opacity .2s, transform .18s;
box-shadow:0 0 22px rgba(27,253,197,.35);
}
#intro-begin-btn:hover { opacity:.9; transform:translateY(-1px); }
#intro-signin { margin-top:.8rem; font-size:.85rem; color:var(--dim); }
#intro-signin a { color:#1bfdc5; text-decoration:none; cursor:pointer; }
#intro-signin a:hover { text-decoration:underline; }
/* Intro character teaser (tap a bubble) */
#intro-teaser {
display:none; position:fixed; inset:0; z-index:200; padding:1.2rem;
background:rgba(0,0,0,.75); align-items:center; justify-content:center;
}
#intro-teaser.open { display:flex; }
#intro-teaser-card {
background:#0f0f1e; border-radius:22px; padding:1.9rem 1.7rem 1.6rem;
width:min(400px, 92vw); position:relative; text-align:center;
box-shadow:0 16px 60px rgba(0,0,0,.85);
border:2px solid var(--tz-color, #ff6b9d); animation:popIn .17s ease;
}
#intro-teaser-close {
position:absolute; top:.7rem; right:.95rem; background:none; border:none;
color:#666; font-size:1.3rem; cursor:pointer; line-height:1; transition:color .15s;
}
#intro-teaser-close:hover { color:#fff; }
#intro-teaser-av {
width:96px; height:96px; border-radius:50%; object-fit:cover; margin-bottom:.7rem;
border:3px solid var(--tz-color, #ff6b9d); box-shadow:0 0 20px rgba(0,0,0,.5);
}
#intro-teaser-name { font-size:1.25rem; font-weight:700; color:#fff; }
#intro-teaser-quote {
color:#cfcfe6; font-size:.96rem; line-height:1.5; font-style:italic; margin:.55rem 0 1.25rem;
}
#intro-teaser-btn {
background:var(--tz-color, #ff6b9d); color:#0d0d1a; border:none; width:100%;
padding:.72rem 1.4rem; border-radius:11px; font-size:.95rem; font-weight:700;
cursor:pointer; transition:opacity .18s;
}
#intro-teaser-btn:hover { opacity:.85; }
/* ── RESET / CONFIRM SCREEN ─────────────────────────── */
#reset-screen {
display:none; flex-direction:column; align-items:center;
justify-content:center; height:100%; gap:1.2rem; padding:2rem;
background: linear-gradient(rgba(0,0,0,0.3), rgba(0,0,0,0.3)),
url('/static/chalk_stars_bg.png') center/cover no-repeat;
}
#reset-screen .logo {
width:92px; height:92px; border-radius:50%; object-fit:cover;
border:3px solid #1bfdc5; box-shadow:0 0 24px rgba(27,253,197,.4);
}
#reset-screen h1 { font-size:2rem; letter-spacing:.06em; color:#1bfdc5; }
#reset-screen .sub { color:var(--dim); font-size:.9rem; text-align:center; max-width:340px; }
#reset-form { display:flex; flex-direction:column; gap:.75rem; width:100%; max-width:340px; margin-top:.4rem; }
#reset-form input {
background:var(--surface); border:1px solid var(--border); color:var(--text);
padding:.75rem 1rem; border-radius:10px; font-size:.95rem; outline:none; transition:border-color .2s;
}
#reset-form input:focus { border-color:#1bfdc5; }
#reset-form button, #reset-continue-btn {
background:#1bfdc5; color:#0d0d1a; border:none; padding:.8rem; border-radius:10px;
font-size:1rem; font-weight:700; cursor:pointer; margin-top:.2rem; width:100%; max-width:340px;
transition:opacity .2s;
}
#reset-form button:hover, #reset-continue-btn:hover { opacity:.85; }
#reset-form button:disabled { opacity:.5; cursor:default; }
#reset-error { color:#ff6b6b; font-size:.85rem; min-height:1.2em; text-align:center; }
#reset-stage-done { display:flex; flex-direction:column; align-items:center; gap:1rem; }
#reset-done-msg { color:#1bfdc5; font-size:1rem; text-align:center; max-width:340px; }
/* ── LANG SCREEN ────────────────────────────────────── */
#lang-screen {
display:none; flex-direction:column; align-items:center;
justify-content:center; height:100%; gap:1.5rem; padding:2rem;
background: linear-gradient(rgba(0,0,0,0.3), rgba(0,0,0,0.3)),
url('/static/chalk_stars_bg.png') center/cover no-repeat;
}
#lang-screen .logo {
width:80px; height:80px; border-radius:50%; object-fit:cover;
border:2px solid #1bfdc5; box-shadow:0 0 20px rgba(27,253,197,.35);
}
#lang-prompt { color:var(--dim); font-size:.95rem; text-align:center; }
#lang-grid {
display:grid; grid-template-columns:repeat(3,1fr); gap:.65rem;
width:100%; max-width:370px;
}
.lang-btn {
display:flex; flex-direction:column; align-items:center; gap:.3rem;
background:var(--surface); border:1px solid var(--border);
border-radius:14px; padding:.85rem .4rem; cursor:pointer;
transition:border-color .2s, background .2s; font-size:1rem; color:var(--text);
}
.lang-btn:hover { border-color:#1bfdc5; background:rgba(27,253,197,.08); }
.lang-flag { font-size:2rem; }
.lang-name { font-size:.78rem; color:var(--dim); }
/* ── CHAT SCREEN ────────────────────────────────────── */
#chat-screen { display:none; flex-direction:column; height:100%; }
/* Topbar */
#topbar {
display:flex; align-items:center; gap:.4rem; padding:.45rem .9rem;
background:var(--surface); border-bottom:1px solid var(--border);
overflow-x:auto; scrollbar-width:none; flex-shrink:0;
}
#topbar::-webkit-scrollbar { display:none; }
.char-btn {
display:flex; flex-direction:column; align-items:center; gap:3px;
background:none; border:2px solid transparent; border-radius:12px;
padding:4px 7px; cursor:pointer; flex-shrink:0;
transition:border-color .2s, background .2s;
}
.char-btn:hover { background:rgba(255,255,255,.05); }
.char-btn.active { border-color:var(--cc); background:rgba(255,255,255,.07); }
.char-btn img { width:40px; height:40px; border-radius:50%; border:2px solid transparent; object-fit:cover; transition:border-color .4s, box-shadow .4s; }
.char-btn.active img { border-color:var(--ec,var(--cc)); box-shadow:0 0 7px var(--ec,var(--cc))66; }
.char-btn span { font-size:.67rem; color:var(--dim); }
.char-btn.active span { color:var(--cc); }
.char-btn .emotion-lbl {
font-size:.6rem; font-weight:600; line-height:1; text-align:center;
color:var(--ec,var(--dim));
background:rgba(0,0,0,.55); border:1px solid var(--ec,transparent);
padding:2px 6px; border-radius:4px;
white-space:nowrap;
opacity:0; transition:opacity .35s ease;
}
.char-btn:hover .emotion-lbl, .char-btn .emotion-lbl.show { opacity:1; }
#topbar-gap { flex:1; min-width:.4rem; }
.topbar-icon-btn {
background:#2a2a2a; border:none; color:#1bfdc5;
width:36px; height:36px; border-radius:50%; font-size:1.05rem;
cursor:pointer; flex-shrink:0; display:flex; align-items:center;
justify-content:center; transition:background .2s, transform .15s;
}
.topbar-icon-btn:hover { background:#333; transform:scale(1.06); }
.topbar-icon-btn svg { width:19px; height:19px; }
#logout-btn {
background:none; border:1px solid var(--border); color:var(--dim);
padding:.28rem .6rem; border-radius:8px; font-size:.78rem; cursor:pointer;
flex-shrink:0; white-space:nowrap; transition:border-color .2s, color .2s;
}
#logout-btn:hover { border-color:#ff6b6b; color:#ff6b6b; }
/* Messages */
#msgs-area {
flex:1; overflow-y:auto; padding:1.1rem;
scrollbar-width:thin; scrollbar-color:var(--border) transparent;
background: url('/static/chat_bg.png') center/cover;
}
#msgs-list {
display:flex; flex-direction:column; gap:1.8rem;
}
.message { display:flex; align-items:flex-end; gap:9px; max-width:80%; }
.message.from-user { align-self:flex-end; flex-direction:row-reverse; }
.message.from-asst { align-self:flex-start; }
.msg-av { width:34px; height:34px; border-radius:50%; flex-shrink:0; object-fit:cover; border:2px solid transparent; transition:border-color .4s, box-shadow .4s; }
.bubble { padding:.7rem 1rem; border-radius:18px; line-height:1.6; word-break:break-word; }
.user-bub { background:var(--user-bg); border-bottom-right-radius:4px; }
.asst-bub {
background:
linear-gradient(rgba(255,255,255,.20), rgba(255,255,255,.20)),
rgba(var(--cc-rgb,27,253,197), 0.14);
border:1px solid rgba(var(--cc-rgb,27,253,197), 0.45);
border-bottom-left-radius:4px; border-left:3px solid var(--cc,#1bfdc5);
backdrop-filter:blur(6px);
color:#131313;
}
.asst-bub p { color:#131313; }
.char-lbl { display:block; font-size:.72rem; font-weight:700; margin-bottom:5px; }
/* Avatar hover tooltip */
.av-wrap { position:relative; flex-shrink:0; }
.av-wrap .av-tip {
display:none; position:absolute;
bottom:calc(100% + 7px); left:50%; transform:translateX(-50%);
background:rgba(13,13,26,.92); border:1px solid var(--border);
color:var(--text); font-size:.72rem; white-space:nowrap;
padding:4px 9px; border-radius:8px; pointer-events:none; z-index:20;
box-shadow:0 2px 8px rgba(0,0,0,.5);
}
.av-wrap:hover .av-tip { display:block; }
.bubble img.photo-thumb {
display:block; max-width:220px; max-height:170px;
border-radius:10px; margin-bottom:7px; object-fit:cover;
}
/* Loading dots in character color */
.dots { display:flex; gap:5px; padding:4px 2px; }
.dots span {
width:8px; height:8px; background:var(--cc,#1bfdc5);
border-radius:50%; animation:bop 1.2s infinite; opacity:.7;
}
.dots span:nth-child(2) { animation-delay:.22s; }
.dots span:nth-child(3) { animation-delay:.44s; }
@keyframes bop {
0%,60%,100% { transform:translateY(0); opacity:.5; }
30% { transform:translateY(-7px); opacity:1; }
}
.asst-bub.loading { animation:pulse-brd 1.4s ease-in-out infinite; }
@keyframes pulse-brd {
0%,100% { box-shadow:0 0 0 0 rgba(var(--cc-rgb,27,253,197),0); }
50% { box-shadow:0 0 0 3px rgba(var(--cc-rgb,27,253,197),.35); }
}
.date-sep {
align-self:center; color:var(--dim); font-size:.72rem;
padding:.35rem .85rem; background:var(--surface);
border:1px solid var(--border); border-radius:20px;
}
/* Input area */
#input-row {
display:flex; align-items:flex-end; gap:.5rem; padding:.65rem .9rem;
background:var(--surface); border-top:1px solid var(--border); flex-shrink:0;
}
#msg-input {
flex:1; background:var(--bg); border:1px solid var(--border); color:var(--text);
padding:.6rem .85rem; border-radius:12px; font-size:.95rem; resize:none;
outline:none; max-height:120px; overflow-y:auto; line-height:1.5;
transition:border-color .2s; font-family:inherit;
}
#msg-input:focus { border-color:#1bfdc5; }
.input-icon-btn {
background:#2a2a2a; border:none; color:#1bfdc5;
width:40px; height:40px; border-radius:50%; font-size:1.05rem;
cursor:pointer; flex-shrink:0; display:flex; align-items:center;
justify-content:center; transition:background .2s, transform .15s;
}
.input-icon-btn:hover { background:#333; transform:scale(1.06); }
.input-icon-btn svg { width:22px; height:22px; }
#send-btn {
background:#2a2a2a; color:#1bfdc5; border:none;
width:40px; height:40px; border-radius:50%; font-size:1.05rem;
cursor:pointer; flex-shrink:0; display:flex; align-items:center;
justify-content:center; transition:opacity .2s, background .2s, transform .15s;
}
#send-btn svg { width:22px; height:22px; }
#send-btn:hover { background:#333; transform:scale(1.06); }
#send-btn:disabled { opacity:.4; cursor:default; transform:none; }
/* ── PANELS ─────────────────────────────────────────── */
.panel-overlay {
position:fixed; inset:0; background:rgba(0,0,0,.5);
z-index:99; display:none;
}
.panel-overlay.open { display:block; }
.side-panel {
position:fixed; top:0; right:0; bottom:0;
width:min(400px,100vw);
background:var(--surface); border-left:1px solid var(--border);
display:flex; flex-direction:column;
transform:translateX(100%); transition:transform .28s ease; z-index:100;
}
.side-panel.open { transform:none; }
.panel-header {
display:flex; align-items:center; justify-content:space-between;
padding:.9rem 1.1rem; border-bottom:1px solid var(--border);
font-size:1rem; font-weight:600; flex-shrink:0;
}
.panel-close {
background:none; border:none; color:var(--dim); font-size:1.3rem;
cursor:pointer; line-height:1;
}
.panel-close:hover { color:var(--text); }
.panel-body { flex:1; overflow-y:auto; display:flex; flex-direction:column; }
/* ── LIBRARY PANEL ──────────────────────────────────── */
.lib-tabs { display:flex; border-bottom:1px solid var(--border); flex-shrink:0; }
.lib-tab {
flex:1; padding:.65rem; text-align:center; font-size:.85rem;
color:var(--dim); cursor:pointer; border-bottom:2px solid transparent;
background:none; border-top:none; border-left:none; border-right:none;
transition:color .2s, border-color .2s;
}
.lib-tab.active { color:var(--lib-color,#1bfdc5); border-bottom-color:var(--lib-color,#1bfdc5); }
.lib-list { padding:.5rem .9rem 1rem; display:flex; flex-direction:column; gap:.5rem; }
.lib-item {
background:rgba(255,255,255,.04); border:1px solid var(--border);
border-radius:10px; padding:.75rem .9rem; cursor:pointer;
transition:border-color .2s, background .2s;
}
.lib-item:hover { border-color:var(--lib-color,#1bfdc5); background:rgba(255,255,255,.07); }
.lib-item-title { font-size:.9rem; font-weight:600; margin-bottom:3px; }
.lib-item-sub { font-size:.78rem; color:var(--dim); line-height:1.4; }
.lib-item-badge {
display:inline-block; font-size:.65rem; font-weight:700;
padding:1px 6px; border-radius:10px; margin-left:6px;
background:rgba(255,255,255,.1); vertical-align:middle;
}
.lib-progress { display:flex; gap:4px; margin-top:7px; }
.lib-seg { width:22px; height:4px; border-radius:2px; background:rgba(255,255,255,.12); flex-shrink:0; }
.lib-seg.filled { background:var(--lib-color,#1bfdc5); opacity:.85; }
#lib-story-bar .lib-seg { width:14px; margin-top:0; }
/* ── LIFE EVENTS PANEL ──────────────────────────────── */
.ev-tabs { display:flex; border-bottom:1px solid var(--border); flex-shrink:0; }
.ev-tab {
flex:1; padding:.6rem .3rem; text-align:center; font-size:.8rem;
color:var(--dim); cursor:pointer; border-bottom:2px solid transparent;
background:none; border-top:none; border-left:none; border-right:none;
transition:color .2s, border-color .2s;
}
.ev-tab.active { color:#1bfdc5; border-bottom-color:#1bfdc5; }
.ev-section { display:none; padding:.85rem .9rem 1rem; flex-direction:column; gap:.75rem; }
.ev-section.active { display:flex; }
/* Share photo */
.photo-drop-area {
border:2px dashed var(--border); border-radius:12px;
padding:2rem 1rem; text-align:center; cursor:pointer;
color:var(--dim); font-size:.88rem; transition:border-color .2s;
display:flex; flex-direction:column; align-items:center; gap:.5rem;
}
.photo-drop-area:hover { border-color:#1bfdc5; color:#1bfdc5; }
.photo-drop-area .icon { font-size:2rem; }
.share-preview-wrap { position:relative; display:inline-block; }
.share-preview-img {
max-width:100%; max-height:180px; border-radius:10px; display:block;
object-fit:cover; border:1px solid var(--border);
}
.share-preview-clear {
position:absolute; top:5px; right:5px; background:rgba(0,0,0,.65);
border:none; color:#fff; width:24px; height:24px; border-radius:50%;
font-size:.8rem; cursor:pointer; display:flex; align-items:center; justify-content:center;
}
.ev-caption {
background:var(--bg); border:1px solid var(--border); color:var(--text);
padding:.55rem .8rem; border-radius:9px; font-size:.88rem; resize:none;
min-height:56px; outline:none; font-family:inherit; line-height:1.5; width:100%;
}
.ev-caption:focus { border-color:#1bfdc5; }
.ev-btn {
background:#1bfdc5; color:#0d0d1a; border:none; padding:.55rem;
border-radius:9px; font-size:.9rem; font-weight:600; cursor:pointer;
width:100%; transition:opacity .2s;
}
.ev-btn:disabled { opacity:.45; cursor:default; }
.ev-btn:hover:not(:disabled) { opacity:.85; }
/* Save memory */
.mem-form { display:flex; flex-direction:column; gap:.6rem; }
.mem-form textarea {
background:var(--bg); border:1px solid var(--border); color:var(--text);
padding:.55rem .8rem; border-radius:9px; font-size:.88rem; resize:vertical;
min-height:80px; outline:none; font-family:inherit; line-height:1.5;
}
.mem-form textarea:focus { border-color:#1bfdc5; }
.mem-photo-row { display:flex; align-items:center; gap:.6rem; }
.mem-photo-label {
background:none; border:1px solid var(--border); color:var(--dim);
padding:.4rem .7rem; border-radius:8px; font-size:.82rem; cursor:pointer;
display:flex; align-items:center; gap:.35rem; transition:border-color .2s;
}
.mem-photo-label:hover { border-color:var(--text); }
.mem-photo-thumb {
width:44px; height:44px; border-radius:7px; object-fit:cover;
border:1px solid var(--border); display:none;
}
/* Memories list */
.mem-card {
background:rgba(255,255,255,.04); border:1px solid var(--border);
border-radius:10px; padding:.75rem; display:flex; flex-direction:column; gap:.5rem;
}
.mem-card-row { display:flex; align-items:flex-start; gap:.6rem; }
.mem-card-photo { width:50px; height:50px; border-radius:8px; object-fit:cover; flex-shrink:0; }
.mem-card-right { flex:1; min-width:0; }
.mem-card-date { font-size:.72rem; color:var(--dim); margin-bottom:3px; }
.mem-card-text { font-size:.84rem; line-height:1.45;
overflow:hidden; display:-webkit-box; -webkit-line-clamp:3; -webkit-box-orient:vertical; }
.mem-edit-ta {
background:var(--bg); border:1px solid var(--border); color:var(--text);
padding:.45rem .7rem; border-radius:8px; font-size:.84rem; resize:vertical;
min-height:58px; outline:none; font-family:inherit; width:100%;
line-height:1.5; display:none;
}
.mem-edit-ta:focus { border-color:#1bfdc5; }
.mem-actions { display:flex; gap:.4rem; }
.ma-btn {
background:none; border:1px solid var(--border); color:var(--dim);
padding:.26rem .6rem; border-radius:7px; font-size:.78rem; cursor:pointer;
transition:border-color .2s, color .2s;
}
.ma-btn:hover { border-color:var(--text); color:var(--text); }
.ma-btn.del:hover { border-color:#ff6b6b; color:#ff6b6b; }
.ma-btn.pri { background:#1bfdc5; color:#0d0d1a; border-color:#1bfdc5; font-weight:600; }
.ma-btn.pri:hover { opacity:.85; }
.panel-empty { color:var(--dim); font-size:.88rem; text-align:center; padding:2rem 0; }
/* ── SETTINGS PANEL ─────────────────────────────────── */
.settings-body { padding:.9rem 1.1rem; display:flex; flex-direction:column; gap:1.1rem; }
.sg-label { font-size:.78rem; font-weight:600; color:var(--dim);
text-transform:uppercase; letter-spacing:.05em; margin-bottom:.4rem; }
.radio-row { display:flex; gap:.5rem; }
.radio-row label {
flex:1; display:flex; align-items:center; justify-content:center; gap:.4rem;
padding:.55rem .5rem; border-radius:9px; border:1px solid var(--border);
cursor:pointer; font-size:.88rem; transition:border-color .2s, background .2s;
}
.radio-row input[type=radio] { accent-color:#1bfdc5; }
.radio-row label:has(input:checked) { border-color:#1bfdc5; background:rgba(27,253,197,.1); color:#1bfdc5; }
.toggle-row { display:flex; align-items:center; justify-content:space-between; margin-bottom:.5rem; }
.toggle-sw {
width:44px; height:24px; background:var(--border);
border-radius:12px; position:relative; cursor:pointer; flex-shrink:0; transition:background .2s;
}
.toggle-sw.on { background:#1bfdc5; }
.toggle-sw::after {
content:''; position:absolute; top:3px; left:3px; width:18px; height:18px;
border-radius:50%; background:#fff; transition:transform .2s;
}
.toggle-sw.on::after { transform:translateX(20px); }
.voice-select {
background:var(--bg); border:1px solid var(--border); color:var(--text);
padding:.5rem .8rem; border-radius:9px; font-size:.88rem; width:100%; outline:none;
}
.voice-select:focus { border-color:#1bfdc5; }
.sg-info {
background:rgba(255,255,255,.04); border:1px solid var(--border);
border-radius:9px; padding:.6rem .85rem; font-size:.84rem; color:var(--dim); line-height:1.5;
}
.sg-info strong { color:var(--text); }
.char-model-row { display:flex; align-items:center; justify-content:space-between; gap:.6rem; }
.char-model-name { display:flex; align-items:center; gap:.45rem; font-size:.86rem; flex-shrink:0; }
.char-model-av { width:22px; height:22px; border-radius:50%; object-fit:cover; }
.char-model-tiers { display:flex; gap:.3rem; }
.char-model-btn {
background:var(--bg); border:1px solid var(--border); color:var(--dim);
padding:.3rem .55rem; border-radius:7px; font-size:.76rem; cursor:pointer;
transition:border-color .15s, background .15s, color .15s;
}
.char-model-btn.active { border-color:#1bfdc5; background:rgba(27,253,197,.12); color:#1bfdc5; }
/* ── PROFILE SCREEN ─────────────────────────────────────── */
#profile-screen {
display:none; flex-direction:column; height:100%;
background: linear-gradient(rgba(0,0,0,0.3), rgba(0,0,0,0.3)),
url('/static/chalk_stars_bg.png') center/cover no-repeat;
overflow:auto;
}
#profile-header {
display:flex; align-items:center; gap:.9rem; padding:.7rem 1.1rem;
background:rgba(0,0,0,.55); border-bottom:1px solid rgba(27,253,197,.18);
flex-shrink:0; position:sticky; top:0; z-index:5; flex-wrap:wrap;
}
#profile-back-btn {
background:#2a2a2a; border:1px solid rgba(255,255,255,.14); color:#e8e8f0;
padding:.65rem 1.15rem; border-radius:16px; font-size:.95rem; font-weight:700;
cursor:pointer; flex-shrink:0; white-space:nowrap; transition:background .2s, transform .15s;
}
#profile-back-btn:hover { background:#3a3a3a; transform:scale(1.05); }
#profile-cielo-av {
width:52px; height:52px; border-radius:50%; object-fit:cover; flex-shrink:0;
border:2px solid #1bfdc5; box-shadow:0 0 16px rgba(27,253,197,.45);
}
#profile-greeting { flex:1; font-size:.9rem; color:#bbb; font-style:italic; min-width:180px; }
#profile-chat-btn {
background:#1bfdc5; color:#0d0d1a; border:none; font-size:.95rem; font-weight:700;
padding:.65rem 1.15rem; border-radius:16px; cursor:pointer; white-space:nowrap;
flex-shrink:0; transition:transform .18s, opacity .18s;
}
#profile-chat-btn:hover { transform:scale(1.05); opacity:.92; }
#bubble-canvas {
position:relative; flex:1; min-height:0; min-width:0;
overflow:hidden;
}
/* Bubbles */
.pbub {
position:absolute; cursor:pointer;
display:flex; flex-direction:column; align-items:center;
justify-content:center; gap:4px; padding:10px 14px;
border:2px solid; text-align:center;
transition:transform .16s ease, box-shadow .16s ease, filter .16s ease;
will-change:transform;
user-select:none;
}
.pbub:hover {
transform:var(--bub-rot-hover) !important;
box-shadow:0 8px 32px rgba(0,0,0,.6);
filter:brightness(1.18) saturate(1.2);
z-index:4;
}
.pbub.filled { border-opacity:1; }
.pbub-preview {
font-size:calc(0.62rem * var(--bub-scale,1)); color:rgba(255,255,255,0.82);
line-height:1.3; text-align:center; overflow:hidden;
display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical;
max-width:90%; word-break:break-word;
transform:rotate(calc(-1 * var(--bub-rot, 0deg)));
}
.pbub-icon { font-size:calc(2rem * var(--bub-scale,1)); line-height:1; }
.pbub-avatar-thumb {
width:calc(2rem * var(--bub-scale,1)); height:calc(2rem * var(--bub-scale,1));
border-radius:50%; object-fit:cover; display:block;
}
.pbub-label {
font-size:calc(1rem * var(--bub-scale,1)); font-weight:700; color:#fff; letter-spacing:.03em;
display:inline-block;
transform:rotate(calc(-1 * var(--bub-rot, 0deg)));
}
/* Bubble shapes (5 patterns, cycled) */
.bshape-0 { border-radius:60% 40% 55% 45% / 45% 55% 40% 60%; }
.bshape-1 { border-radius:40% 60% 45% 55% / 55% 45% 60% 40%; }
.bshape-2 { border-radius:55% 45% 60% 40% / 40% 60% 45% 55%; }
.bshape-3 { border-radius:45% 55% 40% 60% / 60% 40% 55% 45%; }
.bshape-4 { border-radius:50% 50% 45% 55% / 55% 45% 50% 50%; }
/* Bubble sizes */
.bsz-nano { width:22px; height:18px; }
.bsz-xxs { width:40px; height:32px; }
.bsz-xs { width:80px; height:65px; }
.bsz-sm { width:155px; height:115px; }
.bsz-md { width:195px; height:145px; }
.bsz-lg { width:235px; height:170px; }
/* Decorative (no-label) fill bubbles */
.pbub-deco { pointer-events:none; opacity:.68; }
.pbub-deco:hover { filter:none !important; box-shadow:none !important; z-index:auto !important; }
/* Priority bubble — largest, pulsing */
.pbub.priority {
width:270px !important; height:200px !important;
z-index:5; border-width:3px !important;
animation:bubblePulse 2s ease-in-out infinite;
}
.pbub.priority .pbub-icon { font-size:calc(2.6rem * var(--bub-scale,1)); }
.pbub.priority .pbub-avatar-thumb { width:calc(2.6rem * var(--bub-scale,1)); height:calc(2.6rem * var(--bub-scale,1)); }
.pbub.priority .pbub-label { font-size:calc(1.25rem * var(--bub-scale,1)); }
@keyframes bubblePulse {
0%,100% { box-shadow:0 0 0 0 rgba(var(--bc-rgb),.55), 0 6px 22px rgba(0,0,0,.4); }
55% { box-shadow:0 0 0 16px rgba(var(--bc-rgb),0), 0 6px 22px rgba(0,0,0,.4); }
}
/* ── PHYSICS BUBBLES (circular, draggable) ──────────────── */
.pbub-node { position:absolute; will-change:transform; touch-action:none; }
.pbub-node > .pbub {
position:absolute; inset:0; width:auto !important; height:auto !important;
border-radius:50% !important; padding:6px; cursor:grab; overflow:hidden;
transition:box-shadow .16s ease, filter .16s ease, transform .14s ease;
}
.pbub.circ:hover { transform:scale(1.1) !important; }
.pbub.circ.priority { width:auto !important; height:auto !important; }
.pbub.circ.priority .pbub-icon { font-size:calc(2.4rem * var(--bub-scale,1)); }
.pbub.circ.priority .pbub-avatar-thumb { width:calc(2.4rem * var(--bub-scale,1)); height:calc(2.4rem * var(--bub-scale,1)); }
.pbub.circ.priority .pbub-label { font-size:calc(1.15rem * var(--bub-scale,1)); }
.pbub-node.dragging { z-index:9 !important; }
.pbub-node.dragging > .pbub {
transform:scale(1.08) !important; box-shadow:0 12px 40px rgba(0,0,0,.7);
filter:brightness(1.15) saturate(1.2); cursor:grabbing;
}
.snd-toggle {
background:none; border:none; font-size:1.65rem; cursor:pointer;
padding:.15rem; line-height:1; opacity:.85; flex-shrink:0;
transition:transform .18s, opacity .18s;
}
.snd-toggle:hover { transform:scale(1.15); opacity:1; }
.snd-toggle.muted { opacity:.4; }
/* ── LIBRARY BUBBLE SCREEN ──────────────────────────────── */
#library-screen {
display:none; flex-direction:column; height:100%;
background: linear-gradient(rgba(0,0,0,0.35), rgba(0,0,0,0.45)),
url('/static/chalk_stars_bg.png') center/cover no-repeat;
overflow:hidden;
}
#library-header {
display:flex; align-items:center; gap:.7rem; padding:.7rem 1.1rem;
background:rgba(0,0,0,.55); border-bottom:1px solid rgba(255,255,255,.12);
flex-shrink:0; z-index:5;
}
#lib-back-btn {
background:none; border:1px solid rgba(255,255,255,.22); color:#fff;
width:38px; height:38px; border-radius:50%; font-size:1.4rem; cursor:pointer;
flex-shrink:0; line-height:1; transition:border-color .18s, background .18s;
}
#lib-back-btn:hover { border-color:#fff; background:rgba(255,255,255,.08); }
#lib-crumb { flex:1; font-size:1rem; font-weight:600; color:#fff; display:flex; align-items:center; }
#lib-crumb .crumb-dim { color:#9a9ac0; font-weight:400; }
#lib-crumb .crumb-icon { display:inline-flex; margin-right:7px; color:#9a9ac0; flex-shrink:0; }
#lib-crumb .crumb-icon svg { width:17px; height:17px; }
#lib-bubble-canvas { position:relative; flex:1; min-height:0; min-width:0; overflow:hidden; }
.libbub-av { width:46%; height:46%; border-radius:50%; object-fit:cover;
margin-bottom:4px; box-shadow:0 0 10px rgba(0,0,0,.5); }
.libbub-icon { font-size:calc(2.2rem * var(--bub-scale,1)); line-height:1; }
.libbub-name { font-weight:700; color:#fff; text-align:center; line-height:1.15;
font-size:calc(.92rem * var(--bub-scale,1)); padding:0 6px; }
.libbub-sub { color:rgba(255,255,255,.72); text-align:center; line-height:1.25;
font-size:calc(.66rem * var(--bub-scale,1)); padding:0 10px; margin-top:3px;
display:-webkit-box; -webkit-line-clamp:3; -webkit-box-orient:vertical; overflow:hidden; }
.libbub-badge { margin-top:4px; font-size:calc(.55rem * var(--bub-scale,1)); font-weight:700;
letter-spacing:.07em; text-transform:uppercase; opacity:.85; }
.libbub-progress { display:flex; gap:3px; margin-top:5px; }
.libbub-seg { width:14px; height:3px; border-radius:2px; background:rgba(255,255,255,.18); }
.libbub-seg.filled { background:currentColor; }
.libbub-disabled .libbub-av { filter:grayscale(1) brightness(.75); opacity:.55; }
.libbub-disabled .libbub-name { color:#8a8aa0; }
/* Fixed (non-physics) section/topic cluster — mirrors the mobile app's
BubbleCluster: a faint parent ring with fixed child bubbles inside. */
.lib-cluster-ring {
position:absolute; border-radius:50%; border:1.5px solid; pointer-events:none;
}
.lib-cluster-bub {
position:absolute; border-radius:50%; border:2px solid; cursor:pointer;
display:flex; flex-direction:column; align-items:center; justify-content:center;
gap:4px; padding:10px; text-align:center; overflow:hidden;
transition:transform .16s ease, filter .16s ease;
}
.lib-cluster-bub:hover { transform:scale(1.06); filter:brightness(1.18) saturate(1.15); }
/* ── BUBBLE EXPANSION OVERLAY ───────────────────────────── */
#bub-overlay {
display:none; position:fixed; inset:0;
background:rgba(0,0,0,.75); z-index:200;
align-items:center; justify-content:center;
}
#bub-overlay.open { display:flex; }
#exp-card {
background:#0f0f1e; border-radius:22px; padding:1.6rem 1.7rem;
width:min(430px, 92vw); position:relative;
box-shadow:0 16px 60px rgba(0,0,0,.85);
border:2px solid var(--exp-color, #ff6b9d);
animation:popIn .17s ease;
}
@keyframes popIn {
from { transform:scale(.84); opacity:0; }
to { transform:scale(1); opacity:1; }
}
#exp-close {
position:absolute; top:.8rem; right:.9rem;
background:none; border:none; color:#666; font-size:1.25rem;
cursor:pointer; transition:color .15s; padding:0;
}
#exp-close:hover { color:#fff; }
#exp-head { display:flex; align-items:center; gap:.65rem; margin-bottom:.5rem; }
#exp-icon { font-size:1.8rem; line-height:1; }
#exp-lbl { font-size:1.1rem; font-weight:700; color:#fff; }
#exp-hint { font-size:.84rem; color:#999; margin-bottom:.85rem; line-height:1.5; }
#exp-textarea {
width:100%; background:#13132a; border:1px solid rgba(255,255,255,.15);
border-radius:12px; color:#e8e8f0; padding:.75rem 1rem;
font-size:.92rem; line-height:1.6; resize:vertical; min-height:88px;
font-family:inherit; outline:none; transition:border-color .2s;
}
#exp-textarea:focus { border-color:var(--exp-color, #ff6b9d); }
#exp-footer { display:flex; align-items:center; gap:.6rem; margin-top:.75rem; }
#exp-mic {
background:none; border:1px solid rgba(255,255,255,.22); color:#999;
width:38px; height:38px; border-radius:50%; font-size:1rem; cursor:pointer;
flex-shrink:0; transition:border-color .2s, color .2s, background .2s;
}
#exp-mic:hover { border-color:#fff; color:#fff; }
#exp-mic.recording {
background:#ff3b30; border-color:#ff3b30; color:#fff;
animation:micPulse 1.1s infinite;
}
@keyframes micPulse {
0%,100% { box-shadow:0 0 0 0 rgba(255,59,48,.45); }
50% { box-shadow:0 0 0 9px rgba(255,59,48,0); }
}
#exp-done {
flex:1; padding:.55rem; border-radius:10px; border:none;
font-size:.9rem; font-weight:700; cursor:pointer; color:#0d0d1a;
transition:opacity .18s;
}
#exp-done:hover { opacity:.82; }
/* Language picker inside expansion card */
#exp-lang-grid {
display:none; flex-wrap:wrap; gap:.5rem; margin-bottom:.6rem;
}
.lang-btn-sm {
background:#13132a; border:1.5px solid rgba(255,255,255,.18);
color:#ccc; border-radius:10px; padding:.45rem .75rem;
font-size:.82rem; cursor:pointer; transition:border-color .15s, color .15s;
}
.lang-btn-sm:hover { border-color:#fff; color:#fff; }
.lang-btn-sm.active { border-color:var(--exp-color,#ff6b9d); color:#fff; font-weight:700; }
/* Avatar upload inside expansion card */
#exp-avatar-actions {
display:none; flex-direction:column; align-items:center; gap:.9rem; margin-bottom:.6rem;
}
#exp-avatar-preview {
width:130px; height:130px; border-radius:50%; object-fit:cover;
border:2px solid var(--exp-color, #ff6b9d);
}
#exp-avatar-btn {
background:#13132a; border:1.5px solid rgba(255,255,255,.18);
color:#fff; border-radius:10px; padding:.6rem 1.15rem;
font-size:.88rem; font-weight:600; cursor:pointer;
transition:border-color .15s, opacity .15s;
}
#exp-avatar-btn:hover { border-color:var(--exp-color, #ff6b9d); opacity:.9; }
/* Save status toast */
#profile-toast {
position:fixed; bottom:1.3rem; left:50%; transform:translateX(-50%);
background:#1a1a32; border:1px solid rgba(255,255,255,.14);
border-radius:20px; padding:.4rem 1.2rem;
font-size:.82rem; color:#ccc; pointer-events:none;
opacity:0; transition:opacity .3s; z-index:300;
}
#profile-toast.show { opacity:1; }
</style>
</head>
<body>
<!-- AUTH -->
<div id="auth-screen">
<img src="/avatars/moon_stars.png" alt="Cielo" class="logo">
<h1>Cielo</h1>
<p class="sub">Sign in to continue</p>
<form id="login-form">
<input type="email" id="email" placeholder="Email" autocomplete="email" required>
<input type="password" id="password" placeholder="Password" autocomplete="current-password" required>
<button type="submit" id="login-btn">Sign In</button>
</form>
<p id="auth-error"></p>
<p id="auth-success"></p>
<p id="auth-forgot"><a href="#" id="forgot-link">Forgot your password?</a></p>
<p id="auth-switch">Don't have an account? <a href="#" id="signup-link">Sign up</a></p>
</div>
<!-- INTRO / LANDING (first screen for logged-out visitors) -->
<div id="intro-screen">
<div id="intro-header">
<h1>Cielo · The Story of Human Thought</h1>
<p class="intro-tagline">Explore your inner world and the great questions — guided by the finest minds in history.</p>
</div>
<div id="intro-bubble-canvas"></div>
<div id="intro-footer">
<p class="intro-blurb"><span class="cielo-hl">Cielo</span>, a young and charming guide, will welcome you into the world of philosophy and hand you the pass to speak with the great minds of the past — about everything, from the problem of existence to your everyday challenges. Join <span class="cielo-hl">Cielo</span> on her journey.</p>
<button id="intro-begin-btn">Begin your journey</button>
<p id="intro-signin">Already have an account? <a id="intro-signin-link">Sign in</a></p>
</div>
</div>
<!-- INTRO CHARACTER TEASER (tap a bubble) -->
<div id="intro-teaser">
<div id="intro-teaser-card">
<button id="intro-teaser-close"></button>
<img id="intro-teaser-av" src="" alt="">
<div id="intro-teaser-name"></div>
<p id="intro-teaser-quote"></p>
<button id="intro-teaser-btn">Sign in to talk →</button>
</div>
</div>
<!-- PASSWORD RESET / CONFIRMATION (landed from a verification or recovery email) -->
<div id="reset-screen">
<img src="/avatars/moon_stars.png" alt="Cielo" class="logo">
<h1>Cielo</h1>
<!-- Stage 1: choose a new password (shown after clicking the recovery link) -->
<div id="reset-stage-form">
<p class="sub">Choose a new password for your account.</p>
<form id="reset-form">
<input type="password" id="new-password" placeholder="New password" autocomplete="new-password" minlength="6" required>
<input type="password" id="new-password2" placeholder="Confirm new password" autocomplete="new-password" minlength="6" required>
<button type="submit" id="reset-btn">Change Password</button>
</form>
<p id="reset-error"></p>
</div>
<!-- Stage 2: confirmation (password changed, or email verified) -->
<div id="reset-stage-done" style="display:none;">
<p id="reset-done-msg">✓ Your password has been changed.</p>
<button id="reset-continue-btn">Continue</button>
</div>
</div>
<!-- LANG -->
<div id="lang-screen">
<img src="/avatars/moon_stars.png" alt="Cielo" class="logo">
<p id="lang-prompt">Language &middot; Lingua &middot; Langue &middot; Idioma &middot; Sprache</p>
<div id="lang-grid"></div>
</div>
<!-- CHAT -->
<div id="chat-screen">
<div id="topbar">
<div id="char-selector"></div>
<div id="topbar-gap"></div>
<button class="topbar-icon-btn" id="lib-btn" title="Character Library"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 6.5c-1.6-1.3-3.6-2-6.5-2-.6 0-1 .4-1 1v11c0 .6.4 1 1 1 2.6 0 4.6.6 6.5 2 1.9-1.4 3.9-2 6.5-2 .6 0 1-.4 1-1v-11c0-.6-.4-1-1-1-2.9 0-4.9.7-6.5 2z"/><path d="M12 6.5v13"/></svg></button>
<button class="topbar-icon-btn" id="profile-btn" title="My Profile"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9.2"/><circle cx="12" cy="10" r="3"/><path d="M5.5 18.2c1.4-2.6 3.8-4 6.5-4s5.1 1.4 6.5 4"/></svg></button>
<button class="topbar-icon-btn" id="settings-btn" title="Settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3.2"/><path d="M19.4 13.5a7.6 7.6 0 000-3l1.9-1.5-2-3.4-2.3.7a7.6 7.6 0 00-2.6-1.5L16 2h-4l-.4 2.3a7.6 7.6 0 00-2.6 1.5l-2.3-.7-2 3.4L6.6 10.5a7.6 7.6 0 000 3L4.7 15l2 3.4 2.3-.7c.8.7 1.7 1.2 2.6 1.5L12 22h4l.4-2.3c.9-.3 1.8-.8 2.6-1.5l2.3.7 2-3.4-1.9-1.5z"/></svg></button>
<button id="logout-btn">Sign out</button>
</div>
<div id="msgs-area"><div id="msgs-list"></div></div>
<div id="input-row">
<textarea id="msg-input" placeholder="Message Cielo…" rows="1"></textarea>
<button class="input-icon-btn" id="events-btn" title="Photos & Memories"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="6.5" width="14" height="12" rx="2.2"/><circle cx="7" cy="11" r="1.3"/><path d="M3.5 16l3.3-3.3c.6-.6 1.4-.6 2 0L12 16"/><path d="M8 5.7V5c0-1.1.9-2 2-2h9c1.1 0 2 .9 2 2v9c0 1.1-.9 2-2 2h-1"/></svg></button>
<button id="send-btn" title="Send"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V6"/><path d="M6 11l6-6 6 6"/></svg></button>
</div>
</div>
<!-- OVERLAY -->
<div class="panel-overlay" id="panel-overlay"></div>
<!-- ── LIBRARY PANEL ──────────────────────────────────── -->
<div class="side-panel" id="lib-panel">
<div class="panel-header">
<span id="lib-panel-title">📚 Library</span>
<button class="panel-close" id="lib-close"></button>
</div>
<div class="lib-tabs">
<button class="lib-tab active" data-tab="philosophy">🏛 Philosophy</button>
<button class="lib-tab" data-tab="story">📖 Story</button>
</div>
<div class="panel-body">
<div id="lib-story-progress" style="display:none; padding:.6rem .9rem .2rem;">
<div style="font-size:.7rem; color:var(--dim); margin-bottom:5px;">Story progress</div>
<div class="lib-progress" id="lib-story-bar"></div>
</div>
<div class="lib-list" id="lib-list"></div>
</div>
</div>
<!-- ── LIFE EVENTS PANEL ──────────────────────────────── -->
<div class="side-panel" id="events-panel">
<div class="panel-header">
<span>🖼 Photos &amp; Memories</span>
<button class="panel-close" id="events-close"></button>
</div>
<div class="ev-tabs">
<button class="ev-tab active" data-etab="share">📷 Share</button>
<button class="ev-tab" data-etab="save">💾 Save</button>
<button class="ev-tab" data-etab="list">📋 My Memories</button>
</div>
<div class="panel-body" style="overflow-y:auto">
<!-- Share Photo tab -->
<div class="ev-section active" id="etab-share">
<label class="photo-drop-area" for="share-file-input" id="share-drop">
<span class="icon">📷</span>
<span>Tap to choose a photo</span>
</label>
<input type="file" id="share-file-input" accept="image/*" style="display:none">
<div id="share-preview-wrap" class="share-preview-wrap" style="display:none">
<img id="share-preview-img" class="share-preview-img" src="" alt="preview">
<button class="share-preview-clear" id="share-clear-btn"></button>
</div>
<textarea class="ev-caption" id="share-caption" placeholder="Add a caption (optional)…" rows="2"></textarea>
<button class="ev-btn" id="share-send-btn" disabled>Send to chat</button>
</div>
<!-- Save Memory tab -->
<div class="ev-section" id="etab-save">
<div class="mem-form">
<textarea placeholder="Describe this memory…" id="mem-desc" rows="4"></textarea>
<div class="mem-photo-row">
<label class="mem-photo-label" for="mem-photo-file">📷 Add photo</label>
<input type="file" id="mem-photo-file" accept="image/*" style="display:none">
<img id="mem-photo-thumb" class="mem-photo-thumb" src="" alt="preview">
</div>
<button class="ev-btn" id="mem-save-btn">Save memory</button>
</div>
</div>
<!-- My Memories tab -->
<div class="ev-section" id="etab-list">
<div id="mem-list" style="display:flex;flex-direction:column;gap:.6rem;"></div>
</div>
</div>
</div>
<!-- ── SETTINGS PANEL ─────────────────────────────────── -->
<div class="side-panel" id="settings-panel">
<div class="panel-header">
<span>⚙ Settings</span>
<button class="panel-close" id="settings-close"></button>
</div>
<div class="panel-body settings-body">
<div>
<div class="sg-label">Response quality</div>
<div class="radio-row">
<label><input type="radio" name="quality" value="flat" checked> ⚡ Fast</label>
<label><input type="radio" name="quality" value="smart"> 🧠 Smart</label>
</div>
<div class="sg-info" style="margin-top:.5rem">
<strong>Fast</strong> — gpt-4.1-mini for all steps.<br>
<strong>Smart</strong> — Claude Sonnet for key reasoning.
</div>
</div>
<div>
<div class="sg-label">Reply routing (testing)</div>
<div class="sg-info" style="margin-bottom:.4rem">Applies whenever a character's tier below is "Fast" (the default). <strong>Default</strong> auto-picks Claude Sonnet 5 only for turns that need it (philosophy requests, storytelling, high emotional intensity, sarcastic/provocative tone) and gpt-4.1-mini otherwise. <strong>Light</strong> always uses gpt-4.1-mini.</div>
<div class="radio-row">
<label><input type="radio" name="reply-routing" value="default" checked> Default (auto)</label>
<label><input type="radio" name="reply-routing" value="light"> Light (always fast)</label>
</div>
</div>
<div>
<div class="sg-label">Model per character</div>
<div class="sg-info" style="margin-bottom:.4rem">Choose how much reasoning power each character's replies use. Overrides Reply routing above when set to Balanced or Best.</div>
<div id="char-model-prefs" style="display:flex;flex-direction:column;gap:.55rem"></div>
</div>
<div>
<div class="sg-label">Per-step model override (testing)</div>
<div class="sg-info" style="margin-bottom:.4rem">Overrides the toggle above for the chosen step only. Leave on "Default" to fall back to Fast/Smart.</div>
<div style="display:flex;flex-direction:column;gap:.5rem">
<label style="display:flex;justify-content:space-between;align-items:center;gap:.5rem">
<span style="font-size:.9rem">Core classifier</span>
<select class="voice-select" id="core-model-select" style="width:auto">
<option value="">Default (gpt-4.1-mini)</option>
<option value="anthropic/claude-haiku-4-5">Claude Haiku 4.5</option>
<option value="anthropic/claude-sonnet-5">Claude Sonnet 5</option>
</select>
</label>
<label style="display:flex;justify-content:space-between;align-items:center;gap:.5rem">
<span style="font-size:.9rem">Opinion trigger</span>
<select class="voice-select" id="opinion-model-select" style="width:auto">
<option value="">Default (Claude Haiku 4.5)</option>
<option value="gpt-4.1-mini">gpt-4.1-mini (cheaper, less reliable)</option>
<option value="anthropic/claude-sonnet-5">Claude Sonnet 5</option>
</select>
</label>
<label style="display:flex;justify-content:space-between;align-items:center;gap:.5rem">
<span style="font-size:.9rem">Secondary tasks (news/thread)</span>
<select class="voice-select" id="secondary-model-select" style="width:auto">
<option value="">Default (gpt-4.1-nano)</option>
<option value="gpt-4.1-mini">gpt-4.1-mini</option>
</select>
</label>
<label style="display:flex;justify-content:space-between;align-items:center;gap:.5rem">
<span style="font-size:.9rem">Perception</span>
<select class="voice-select" id="perception-model-select" style="width:auto">
<option value="">Default (gpt-4.1-mini)</option>
<option value="gpt-4.1-nano">gpt-4.1-nano (cheaper, less reliable)</option>
<option value="anthropic/claude-haiku-4-5">Claude Haiku 4.5</option>
<option value="anthropic/claude-sonnet-5">Claude Sonnet 5</option>
</select>
</label>
</div>
</div>
<div>
<div class="sg-label">Voice response</div>
<div class="toggle-row">
<span style="font-size:.9rem">Speak replies aloud</span>
<div class="toggle-sw" id="voice-toggle"></div>
</div>
<select class="voice-select" id="voice-select">
<option value="onyx">Onyx — deep, authoritative</option>
<option value="echo">Echo — calm, measured</option>
<option value="fable">Fable — expressive, warm</option>
<option value="nova">Nova — clear, energetic</option>
<option value="shimmer">Shimmer — soft, gentle</option>
<option value="alloy">Alloy — neutral, balanced</option>
</select>
</div>
<div>
<div class="sg-label">Interface language</div>
<select class="voice-select" id="lang-select"></select>
</div>
<div>
<div class="sg-label">Active character</div>
<div class="sg-info" id="char-info"></div>
</div>
</div>
</div>
<!-- ── PROFILE SCREEN ────────────────────────────────────── -->
<div id="profile-screen">
<div id="profile-header">
<button id="profile-back-btn" title="Exit without saving">✕ Exit</button>
<img src="/avatars/moon_stars.png" id="profile-cielo-av" alt="Cielo">
<div id="profile-greeting">Hi! I'm Cielo 😊 I made this space just to get to know you. The more bubbles you feel like touching and filling in, the more I'll understand you. But if you don't feel like playing right now, no worries — you can always come back another time.</div>
<button id="profile-snd" class="snd-toggle" title="Bubble sound on/off">🔊</button>
<button id="profile-chat-btn" title="Save and go to chat">💬 Save and go to Chat</button>
</div>
<div id="bubble-canvas"></div>
<input type="file" id="avatar-file-input" accept="image/*" style="display:none">
</div>
<!-- ── LIBRARY BUBBLE SCREEN ─────────────────────────────── -->
<div id="library-screen">
<div id="library-header">
<button id="lib-back-btn" title="Back"></button>
<div id="lib-crumb"><span class="crumb-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 6.5c-1.6-1.3-3.6-2-6.5-2-.6 0-1 .4-1 1v11c0 .6.4 1 1 1 2.6 0 4.6.6 6.5 2 1.9-1.4 3.9-2 6.5-2 .6 0 1-.4 1-1v-11c0-.6-.4-1-1-1-2.9 0-4.9.7-6.5 2z"/><path d="M12 6.5v13"/></svg></span>Library</div>
<button id="library-snd" class="snd-toggle" title="Bubble sound on/off">🔊</button>
</div>
<div id="lib-bubble-canvas"></div>
</div>
<!-- ── BUBBLE EXPANSION OVERLAY ──────────────────────────── -->
<div id="bub-overlay">
<div id="exp-card">
<button id="exp-close"></button>
<div id="exp-head">
<span id="exp-icon"></span>
<span id="exp-lbl"></span>
</div>
<p id="exp-hint"></p>
<div id="exp-lang-grid"></div>
<div id="exp-avatar-actions">
<img id="exp-avatar-preview" src="/avatars/user_avatar.png" alt="Your avatar">
<button id="exp-avatar-btn" type="button">🔄 Change your avatar</button>
</div>
<textarea id="exp-textarea" placeholder="Write anything…"></textarea>
<div id="exp-footer">
<button id="exp-mic" title="Speak">🎤</button>
<button id="exp-done">Done</button>
</div>
</div>
</div>
<div id="profile-toast"></div>
<script>
// ── Supabase ──────────────────────────────────────────────
const SUPABASE_URL = 'https://dsqsebvuzenmvktiwmwd.supabase.co';
const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRzcXNlYnZ1emVubXZrdGl3bXdkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTc5MTE5NzMsImV4cCI6MjA3MzQ4Nzk3M30.wxYTbOBwkXz9Rwnu1FSrAJ6bzIY67flWmNVfZP_KchA';
const { createClient } = supabase;
const sb = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
// ── Character data (Cielo first, then A-Z) ─────────────────
const CHARS = [
{ id:'cielo', name:'Cielo', short:'Cielo', color:'#ff6b9d', rgb:'255,107,157', av:'/avatars/cielo_avatar.png', style:'coordinator' },
{ id:'camus', name:'Albert Camus', short:'Camus', color:'#ff7043', rgb:'255,112,67', av:'/avatars/camus_avatar.png', style:'declarative' },
{ id:'diogenes', name:'Diogenes', short:'Diogenes', color:'#ffc107', rgb:'255,193,7', av:'/avatars/diogenes_avatar.png', style:'combative' },
{ id:'nietzsche', name:'Nietzsche', short:'Nietzsche', color:'#9c27b0', rgb:'156,39,176', av:'/avatars/nietzsche_avatar.png', style:'combative' },
{ id:'schopenhauer', name:'A. Schopenhauer', short:'Schopenhauer', color:'#546e7a', rgb:'84,110,122', av:'/avatars/schopenhauer_avatar.png', style:'declarative' },
{ id:'socrates', name:'Socrates', short:'Socrates', color:'#1bfdc5', rgb:'27,253,197', av:'/avatars/socrates_avatar3.png', style:'maieutic' },
];
// ── Emotion / mode config (mirrors mobile EMOTION_CONFIG) ──
const EMOTION_CONFIG = {
neutral: { color:'#1bfdc5', emoji:'', label:'' },
dialogic: { color:'#1bfdc5', emoji:'', label:'' },
excited: { color:'#FF6B00', emoji:'🎉', label:'sharing your joy' },
empathetic: { color:'#FF6B8A', emoji:'🤍', label:'moved' },
supportive: { color:'#FF6B8A', emoji:'🤍', label:'supportive' },
nostalgic: { color:'#C9A84C', emoji:'🕰️', label:'warmly curious' },
storyteller: { color:'#C9A84C', emoji:'📖', label:'telling a story' },
anxious: { color:'#FFD60A', emoji:'🙏', label:'reassuring' },
proud: { color:'#34C759', emoji:'⭐', label:'celebrating with you' },
vulnerable: { color:'#AF52DE', emoji:'🌿', label:'honoured' },
unkindness: { color:'#AF52DE', emoji:'🙁', label:'hurt but open' },
soccer_excited: { color:'#FF3B30', emoji:'⚽', label:'passionate' },
frustrated_with_assistant: { color:'#8E8E93', emoji:'😐', label:'listening carefully' },
firm: { color:'#8E8E93', emoji:'', label:'firm' },
teasing_accepted: { color:'#FF9500', emoji:'😏', label:'playful' },
teasing_unaccepted: { color:'#FF6B00', emoji:'🤚', label:'stepping back' },
playful: { color:'#FF9500', emoji:'😏', label:'playful' },
news: { color:'#FF9500', emoji:'📰', label:'informed' },
nervous_with_user: { color:'#FFD60A', emoji:'😬', label:'calm mediator' },
racist: { color:'#FF3B30', emoji:'✋', label:'firmly rejecting' },
violence: { color:'#FF3B30', emoji:'✋', label:'firmly rejecting' },
offending: { color:'#FF3B30', emoji:'✋', label:'de-escalating' },
philosophical: { color:'#5856D6', emoji:'🏛️', label:'deep in thought' },
pragmatic: { color:'#007AFF', emoji:'⚙️', label:'focused' },
factual: { color:'#007AFF', emoji:'', label:'precise' },
guided: { color:'#007AFF', emoji:'⚙️', label:'guiding' },
character_story: { color:'#C9A84C', emoji:'📖', label:'telling a story' },
philosophy_thread: { color:'#5856D6', emoji:'🏛️', label:'deep in thought' },
};
function emotionInfo(emotionCat, responseMode) {
const key = (emotionCat && emotionCat !== 'neutral') ? emotionCat : (responseMode || 'dialogic');
return EMOTION_CONFIG[key] || EMOTION_CONFIG.dialogic;
}
// ── Character Library ──────────────────────────────────────
const LIBRARY = {
socrates: { color:'#1bfdc5',
philosophy:[
{ id:'examined_life', title:'The Examined Life', subtitle:'Why the unexamined life is not worth living', prompt:'Tell me about the examined life — why did you say the unexamined life is not worth living?', level:1 },
{ id:'socratic_method', title:'The Socratic Method', subtitle:'Questioning as the path to truth', prompt:'Explain the Socratic method and how questioning leads to wisdom.', level:1 },
{ id:'theory_forms', title:'The Theory of Forms', subtitle:'The world of ideas behind appearances', prompt:'What is the Theory of Forms — what lies behind the world we perceive?', level:1 },
{ id:'justice_soul', title:'Justice and the Soul', subtitle:'What is justice, and why be just?', prompt:'What is justice, and why should we be just even when no one is watching?', level:2 },
{ id:'allegory_cave', title:'The Allegory of the Cave', subtitle:'Shadows, light, and the philosopher\'s duty', prompt:'Tell me about the Allegory of the Cave — what does it mean for how we live?', level:2 },
{ id:'death_soul', title:'Death and the Soul', subtitle:'Why Socrates welcomed death', prompt:'Why did you welcome death rather than fear it? What did you believe about the soul?', level:2 },
],
story:[
{ id:'birth_youth', title:'Birth and Youth', subtitle:'Son of a sculptor and a midwife in Athens', prompt:'Tell me about your early life in Athens — your family, your upbringing.' },
{ id:'oracle_riddle', title:'The Oracle\'s Riddle', subtitle:'Delphi called him wisest — he set out to disprove it', prompt:'What happened when the Oracle at Delphi called you the wisest of all men?' },
{ id:'soldier', title:'Soldier and Citizen', subtitle:'Courage at Potidaea and Delium', prompt:'Tell me about your time as a soldier — what happened at Potidaea and Delium?' },
{ id:'gadfly', title:'The Gadfly of Athens', subtitle:'Thirty years questioning the city', prompt:'What was it like to spend your life questioning people in the Agora? What drove you?' },
{ id:'trial', title:'The Trial', subtitle:'Impiety and corrupting youth', prompt:'Tell me about your trial — what were the charges, and how did you defend yourself?' },
{ id:'last_day', title:'The Last Day', subtitle:'Hemlock, friends, and a final conversation', prompt:'Tell me about your last day — what did you say to your friends before drinking the hemlock?' },
],
},
diogenes: { color:'#ffc107',
philosophy:[
{ id:'nature', title:'Living According to Nature', subtitle:'Strip away everything artificial', prompt:'What does it mean to live according to nature? Why did you reject everything artificial?', level:1 },
{ id:'convention', title:'Against Convention', subtitle:'Laws, customs, and the tyranny of opinion', prompt:'Why did you reject social conventions? What is wrong with how most people live?', level:1 },
{ id:'true_wealth', title:'True Wealth', subtitle:'Owning nothing, lacking nothing', prompt:'You owned almost nothing — yet you claimed to be the richest man alive. Explain.', level:1 },
{ id:'shamelessness', title:'Shamelessness as Virtue', subtitle:'Why Diogenes did in public what others hide', prompt:'Why did you do things in public that embarrassed people? What were you trying to show?', level:2 },
{ id:'self_suff', title:'Self-Sufficiency', subtitle:'The dog philosopher\'s radical independence', prompt:'What is self-sufficiency, and why is it the highest good for a Cynic?', level:2 },
{ id:'freedom', title:'Freedom and Slavery', subtitle:'Who is truly free?', prompt:'You were once a slave — yet you claimed to be freer than your master. How?', level:2 },
],
story:[
{ id:'sinope', title:'Sinope and Exile', subtitle:'Defacing the currency — literal and metaphorical', prompt:'Tell me about Sinope — the coins, the exile, and how it all began.' },
{ id:'barrel', title:'The Barrel', subtitle:'Home is wherever I lay my dog', prompt:'Tell me about life in the barrel — was it really home to you?' },
{ id:'alexander', title:'Meeting Alexander', subtitle:'You\'re blocking my sun', prompt:'What happened when Alexander the Great came to visit you? What did you tell him?' },
{ id:'athens', title:'Athens and the Market', subtitle:'Teaching philosophy by living it', prompt:'What was it like to live among the Athenians in the market? What did you teach them?' },
{ id:'pirates', title:'Captured by Pirates', subtitle:'Even enslaved, the philosopher commands', prompt:'You were captured by pirates and sold as a slave — what happened, and how did you respond?' },
{ id:'death', title:'Death and Legacy', subtitle:'Did he die laughing?', prompt:'How did you die, and what do you want to be remembered for?' },
],
},
nietzsche: { color:'#9c27b0',
philosophy:[
{ id:'death_god', title:'The Death of God', subtitle:'What it means that we killed God', prompt:'What do you mean by "God is dead"? What does that mean for how we live now?', level:1 },
{ id:'will_power', title:'Will to Power', subtitle:'The drive behind all living things', prompt:'Explain the will to power — is it just about domination, or something deeper?', level:1 },
{ id:'ubermensch', title:'The Übermensch', subtitle:'Not a Superman — a self-overcoming', prompt:'What is the Übermensch? What does it mean to overcome oneself?', level:1 },
{ id:'eternal_rec', title:'Eternal Recurrence', subtitle:'Would you live it all again, forever?', prompt:'Explain eternal recurrence — if you had to live your life again infinitely, would you?', level:2 },
{ id:'master_slave', title:'Master and Slave Morality', subtitle:'Resentment dressed as virtue', prompt:'What is the difference between master morality and slave morality?', level:2 },
{ id:'perspecti', title:'Perspectivism', subtitle:'There are no facts, only interpretations', prompt:'What is perspectivism? Does it mean truth doesn\'t exist?', level:2 },
],
story:[
{ id:'rocken', title:'Born in Röcken', subtitle:'The pastor\'s son who would bury God', prompt:'Tell me about your childhood in Röcken — how did growing up as a pastor\'s son shape you?' },
{ id:'basel', title:'The Basel Prodigy', subtitle:'Professor at 24, Wagner\'s young friend', prompt:'What was it like to become a professor so young, and how did you meet Wagner?' },
{ id:'wagner', title:'The Break with Wagner', subtitle:'When the music turned Christian', prompt:'What happened between you and Wagner? Why did you break with him?' },
{ id:'sils', title:'Sils-Maria', subtitle:'The thought of eternal recurrence strikes', prompt:'Tell me about Sils-Maria and the moment the idea of eternal recurrence came to you.' },
{ id:'turin', title:'Turin and Silence', subtitle:'A horse, a hug, and ten years of darkness', prompt:'What happened in Turin in January 1889? Tell me about the collapse.' },
{ id:'legacy', title:'The Distorted Legacy', subtitle:'How a sister and the Nazis hijacked his words', prompt:'How were your ideas twisted by your sister and later by the Nazis?' },
],
},
camus: { color:'#ff7043',
philosophy:[
{ id:'absurd', title:'The Absurd', subtitle:'Life has no meaning — and that\'s the starting point', prompt:'What is the Absurd? What happens when human longing meets a silent universe?', level:1 },
{ id:'sisyphus', title:'The Myth of Sisyphus', subtitle:'We must imagine Sisyphus happy', prompt:'Why must we imagine Sisyphus happy? What does his myth teach us about life?', level:1 },
{ id:'revolt', title:'Revolt', subtitle:'Against despair, against resignation', prompt:'What does revolt mean to you? Why is revolt the proper response to the absurd?', level:1 },
{ id:'mediterr', title:'Mediterranean Ethics', subtitle:'Warmth, limits, and living in the present', prompt:'What is your Mediterranean philosophy — warmth, sun, limits? What does it mean to live fully?', level:2 },
{ id:'nihilism', title:'Against Nihilism', subtitle:'Why absurdity doesn\'t mean nothing matters', prompt:'The absurd seems to lead to nihilism — why does it not, for you?', level:2 },
{ id:'justice', title:'Justice and Violence', subtitle:'Can we fight tyranny without becoming it?', prompt:'Can we fight injustice without using the same violence? What is the ethical limit of revolt?', level:2 },
],
story:[
{ id:'belcourt', title:'Belcourt, Algiers', subtitle:'Born poor, raised by a deaf-mute mother', prompt:'Tell me about your childhood in Belcourt — your mother, the poverty, the sun.' },
{ id:'tb', title:'Tuberculosis and Football', subtitle:'The illness that changed everything', prompt:'How did getting tuberculosis as a young man shape your philosophy and your life?' },
{ id:'resistance', title:'The Resistance', subtitle:'Editing Combat, hiding from the Gestapo', prompt:'What was it like to be part of the French Resistance — editing Combat under occupation?' },
{ id:'stranger', title:'The Stranger', subtitle:'A novel written in nine months', prompt:'Tell me about writing The Stranger — where did Meursault come from?' },
{ id:'nobel', title:'The Nobel Prize', subtitle:'I will defend my mother first', prompt:'What did you mean when you said you would defend your mother before justice?' },
{ id:'road', title:'The Road to Villeblevin', subtitle:'January 4, 1960 — an unfinished novel', prompt:'Tell me about the day you died — what were you working on, what was left unfinished?' },
],
},
schopenhauer: { color:'#546e7a',
philosophy:[
{ id:'world_will', title:'The World as Will', subtitle:'Behind every phenomenon: blind, ceaseless striving', prompt:'What is the Will? What is the world really made of, beneath appearances?', level:1 },
{ id:'suffering', title:'Suffering as Default', subtitle:'To want is to lack; to lack is to suffer', prompt:'Why is suffering the structural default of existence? Is there any escape?', level:1 },
{ id:'art', title:'Art as Salvation', subtitle:'Music as the direct voice of the Will', prompt:'How does art — especially music — free us from the Will? Why is it special?', level:1 },
{ id:'asceticism', title:'Asceticism', subtitle:'The Will that turns against itself', prompt:'What is asceticism, and why is denying the will the only true freedom?', level:2 },
{ id:'compassion', title:'Compassion', subtitle:'The only genuine moral foundation', prompt:'Why is compassion the only true basis of morality? What about reason or duty?', level:2 },
{ id:'eastern', title:'Eastern Wisdom', subtitle:'Buddha and the Upanishads in Western dress', prompt:'How did Buddhism and Hindu philosophy influence your thought?', level:2 },
],
story:[
{ id:'danzig', title:'Danzig Childhood', subtitle:'A merchant father, a novelist mother', prompt:'Tell me about your childhood in Danzig — your father, your mother Johanna, the tension between them.' },
{ id:'hamburg', title:'The Hamburg Gamble', subtitle:'Trade versus philosophy — Schopenhauer chooses', prompt:'How did you choose philosophy over your father\'s merchant business? What did it cost you?' },
{ id:'goethe', title:'Weimar and Goethe', subtitle:'The young pessimist meets the great optimist', prompt:'What was your relationship with Goethe like? What did he see in you?' },
{ id:'hegel', title:'The Rival of Hegel', subtitle:'Scheduling lectures to clash — and speaking to empty rooms', prompt:'Tell me about your rivalry with Hegel — why did you schedule your lectures against his?' },
{ id:'poodle', title:'The Poodle', subtitle:'Atma — companion to a man who trusted no human', prompt:'Tell me about Atma, your poodle. Why did you trust animals more than people?' },
{ id:'recognit', title:'Late Recognition', subtitle:'The World as Will finally read, at 72', prompt:'How did it feel to finally be recognised at the end of your life, after decades of obscurity?' },
],
},
};
// ── App state ──────────────────────────────────────────────
let token = null;
let userEmail = null;
let activeChar = 'socrates';
let busy = false;
let loadingDiv = null;
let voiceOn = false;
let selectedVoice = 'onyx';
let llmMode = 'flat';
let replyRoutingMode = 'default';
let coreModel = '';
let opinionModel = '';
let secondaryModel = '';
let perceptionModel = '';
// ── Per-character reply-model tier ───────────────────────────
const TIER_MODEL_MAP = {
fast: null, // no override — falls back to Fast/Smart toggle
balanced: 'anthropic/claude-sonnet-5',
best: 'anthropic/claude-opus-4-8',
};
let characterModelPrefs = {}; // { [charId]: 'fast' | 'balanced' | 'best' }
function resolveReplyModel(charId) {
return TIER_MODEL_MAP[characterModelPrefs[charId] || 'fast'] ?? null;
}
let sharePhotoFile = null;
let memPhotoFile = null;
let libActiveTab = 'philosophy';
let userLang = localStorage.getItem('userLang') || 'en';
let isFirstTime = false;
// Apply UI strings immediately on load using stored language
document.addEventListener('DOMContentLoaded', () => { if (typeof applyUiStrings === 'function') applyUiStrings(); });
// ── Supabase auth ──────────────────────────────────────────
let _recoveryMode = false;
// Capture the link type (signup / recovery) from the URL hash before
// supabase-js consumes it, so we can show the right confirmation page.
const _emailLinkType = (() => {
try { return new URLSearchParams(window.location.hash.replace(/^#/, '')).get('type'); }
catch { return null; }
})();
sb.auth.onAuthStateChange((ev, session) => {
// User clicked the "reset password" link in their email.
if (ev === 'PASSWORD_RECOVERY') {
_recoveryMode = true;
token = session ? session.access_token : null;
openReset();
return;
}
if (session) {
token = session.access_token;
userEmail = session.user.email;
// Just verified their email via the signup link → show a success page.
if (ev === 'SIGNED_IN' && _emailLinkType === 'signup') {
showConfirm('signup');
return;
}
if (_recoveryMode) return; // mid password-reset; don't bounce into chat
if (ev !== 'TOKEN_REFRESHED') openChatOrProfile();
} else {
token = null;
_recoveryMode = false;
// First visit (no stored session) → the intro/landing page.
// Explicit sign-out → straight to the login form (they know the product).
if (ev === 'SIGNED_OUT') openAuth(); else openIntro();
}
});
let _authMode = 'signin';
function _setAuthMode(mode) {
_authMode = mode;
const btn = document.getElementById('login-btn');
const sub = document.querySelector('#auth-screen .sub');
const sw = document.getElementById('auth-switch');
const pw = document.getElementById('password');
const fg = document.getElementById('auth-forgot');
document.getElementById('auth-error').textContent = '';
document.getElementById('auth-success').textContent = '';
// Password field is irrelevant when only requesting a reset email.
pw.style.display = (mode === 'forgot') ? 'none' : '';
pw.disabled = (mode === 'forgot'); // disabled => skipped by form validation
// The "forgot password?" link only makes sense from the sign-in screen.
fg.style.display = (mode === 'signin') ? '' : 'none';
if (mode === 'signup') {
btn.textContent = 'Create Account';
sub.textContent = 'Create your account';
sw.innerHTML = 'Already have an account? <a href="#" id="signup-link">Sign in</a>';
} else if (mode === 'forgot') {
btn.textContent = 'Send reset link';
sub.textContent = 'Enter your email to reset your password';
sw.innerHTML = 'Remembered it? <a href="#" id="signup-link">Sign in</a>';
} else {
btn.textContent = 'Sign In';
sub.textContent = 'Sign in to continue';
sw.innerHTML = "Don't have an account? <a href=\"#\" id=\"signup-link\">Sign up</a>";
}
btn.disabled = false;
document.getElementById('signup-link').addEventListener('click', _onAuthSwitchClick);
}
function _onAuthSwitchClick(e) {
e.preventDefault();
// From signup or forgot, the switch link always returns to sign-in.
_setAuthMode(_authMode === 'signin' ? 'signup' : 'signin');
}
document.getElementById('signup-link').addEventListener('click', _onAuthSwitchClick);
document.getElementById('forgot-link').addEventListener('click', (e) => {
e.preventDefault();
_setAuthMode('forgot');
});
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('login-btn');
const err = document.getElementById('auth-error');
const ok = document.getElementById('auth-success');
btn.disabled = true; err.textContent = ''; ok.textContent = '';
if (_authMode === 'forgot') {
btn.textContent = 'Sending…';
const { error } = await sb.auth.resetPasswordForEmail(
document.getElementById('email').value.trim(),
{ redirectTo: window.location.origin + window.location.pathname },
);
if (error) {
err.textContent = error.message;
btn.disabled = false; btn.textContent = 'Send reset link';
} else {
ok.textContent = 'Check your email for a link to reset your password.';
_setAuthMode('signin');
}
return;
}
if (_authMode === 'signup') {
btn.textContent = 'Creating account…';
const { error } = await sb.auth.signUp({
email: document.getElementById('email').value.trim(),
password: document.getElementById('password').value,
options: { emailRedirectTo: window.location.origin + window.location.pathname },
});
if (error) {
err.textContent = error.message;
btn.disabled = false; btn.textContent = 'Create Account';
} else {
ok.textContent = 'Check your email to confirm your account, then sign in.';
_setAuthMode('signin');
}
} else {
btn.textContent = 'Signing in…';
const { error } = await sb.auth.signInWithPassword({
email: document.getElementById('email').value.trim(),
password: document.getElementById('password').value,
});
if (error) { err.textContent = error.message; btn.disabled = false; btn.textContent = 'Sign In'; }
}
});
document.getElementById('logout-btn').addEventListener('click', () => sb.auth.signOut());
// ── Password reset / confirmation screen ───────────────────
// Stage 1: the "choose a new password" form (after the recovery link).
function openReset() {
_hideAll();
document.getElementById('reset-stage-form').style.display = '';
document.getElementById('reset-stage-done').style.display = 'none';
document.getElementById('reset-error').textContent = '';
document.getElementById('reset-form').reset();
const b = document.getElementById('reset-btn');
b.disabled = false; b.textContent = 'Change Password';
document.getElementById('reset-screen').style.display = 'flex';
}
// Stage 2: a confirmation page. kind = 'signup' | 'reset'.
function showConfirm(kind) {
_hideAll();
document.getElementById('reset-stage-form').style.display = 'none';
document.getElementById('reset-stage-done').style.display = 'flex';
document.getElementById('reset-done-msg').textContent = (kind === 'signup')
? '✓ Your email is verified — you are now signed in.'
: '✓ Your password has been changed. You are now signed in.';
document.getElementById('reset-screen').style.display = 'flex';
}
document.getElementById('reset-form').addEventListener('submit', async (e) => {
e.preventDefault();
const err = document.getElementById('reset-error');
const btn = document.getElementById('reset-btn');
const p1 = document.getElementById('new-password').value;
const p2 = document.getElementById('new-password2').value;
err.textContent = '';
if (p1 !== p2) { err.textContent = 'The two passwords do not match.'; return; }
btn.disabled = true; btn.textContent = 'Saving…';
const { error } = await sb.auth.updateUser({ password: p1 });
if (error) {
err.textContent = error.message;
btn.disabled = false; btn.textContent = 'Change Password';
return;
}
showConfirm('reset');
});
document.getElementById('reset-continue-btn').addEventListener('click', () => {
_recoveryMode = false;
if (token) openChatOrProfile(); else openAuth();
});
function _hideAll() {
if (typeof _physCtrl !== 'undefined' && _physCtrl) { _physCtrl.stop(); _physCtrl = null; }
if (typeof _libCtrl !== 'undefined' && _libCtrl) { _libCtrl.stop(); _libCtrl = null; }
if (typeof _introCtrl !== 'undefined' && _introCtrl) { _introCtrl.stop(); _introCtrl = null; }
document.getElementById('intro-screen').style.display = 'none';
document.getElementById('auth-screen').style.display = 'none';
document.getElementById('reset-screen').style.display = 'none';
document.getElementById('lang-screen').style.display = 'none';
document.getElementById('chat-screen').style.display = 'none';
document.getElementById('profile-screen').style.display = 'none';
document.getElementById('library-screen').style.display = 'none';
}
function openAuth() {
_hideAll();
document.getElementById('auth-screen').style.display = 'flex';
_setAuthMode('signin');
}
async function openChat() {
_hideAll();
document.getElementById('chat-screen').style.display = 'flex';
buildCharSelector();
await loadHistory();
injectCieloWelcome(); // guarded by localStorage — fires only once per account
}
async function openChatOrProfile() {
try {
const res = await fetch('/profile/load', { headers:{'Authorization':`Bearer ${token}`} });
if (res.ok) {
const { profile, avatar_url, character_model_prefs } = await res.json();
userAvatarUrl = avatar_url || null;
characterModelPrefs = character_model_prefs || {};
if (!profile.name) {
// Server confirms first-time user — reset stale localStorage flags so
// the language screen and Cielo welcome always appear for a new account.
isFirstTime = true;
localStorage.removeItem('cieloWelcomeDone');
openLangScreen(); return;
}
}
} catch {}
isFirstTime = false;
await openChat();
}
function openLangScreen() {
_hideAll();
document.getElementById('lang-screen').style.display = 'flex';
const grid = document.getElementById('lang-grid');
if (grid.children.length) return;
for (const l of LANG_OPTIONS) {
const btn = document.createElement('button');
btn.className = 'lang-btn';
btn.innerHTML = `<span class="lang-flag">${l.flag}</span><span>${l.native}</span>`;
btn.addEventListener('click', () => {
userLang = l.code;
localStorage.setItem('userLang', l.code);
saveLangToSupabase(l.code);
applyUiStrings();
openProfile();
});
grid.appendChild(btn);
}
}
function openProfile() {
_hideAll();
document.getElementById('profile-screen').style.display = 'flex';
profileDirty = false;
bubbleDraft = {};
applyUiStrings();
loadAndBuildProfile();
}
// ── Character selector ─────────────────────────────────────
function buildCharSelector() {
const el = document.getElementById('char-selector');
el.innerHTML = ''; el.style.cssText = 'display:flex;gap:.25rem;align-items:center;';
for (const ch of CHARS) {
const btn = document.createElement('button');
btn.className = 'char-btn' + (ch.id === activeChar ? ' active' : '');
btn.dataset.id = ch.id;
btn.style.cssText = `--cc:${ch.color}`;
btn.innerHTML = `<img src="${ch.av}" alt="${ch.name}"><span>${ch.short}</span><span class="emotion-lbl"></span>`;
btn.addEventListener('click', () => activateChar(ch.id));
el.appendChild(btn);
if (ch.id === 'cielo') {
const sep = document.createElement('div');
sep.style.cssText = 'width:1px;height:36px;background:rgba(255,255,255,.15);margin:0 .2rem;flex-shrink:0;';
el.appendChild(sep);
}
}
}
function activateChar(id) {
activeChar = id;
document.querySelectorAll('.char-btn').forEach(b => b.classList.toggle('active', b.dataset.id === id));
const ch = charById(id);
document.getElementById('msg-input').placeholder = `Message ${ch.name}…`;
document.getElementById('lib-btn').style.display = id === 'cielo' ? 'none' : '';
updateCharInfo();
}
function charById(id) { return CHARS.find(c => c.id === id) || CHARS[0]; }
// Darken a character's accent colour so it reads clearly as text on the (light,
// semi-transparent) chat bubble background — same hue, lower luminance.
function darkenColor(hex, amt) {
const h = hex.replace('#', '');
const r = Math.round(parseInt(h.slice(0,2),16) * (1 - amt));
const g = Math.round(parseInt(h.slice(2,4),16) * (1 - amt));
const b = Math.round(parseInt(h.slice(4,6),16) * (1 - amt));
return `rgb(${r},${g},${b})`;
}
// Mirror server _parse_mention: leading "@name" → character id (or null).
function parseMentionChar(message) {
const m = /^@(\w+)\s*/.exec(message || '');
if (!m) return null;
const mention = m[1].toLowerCase();
const c = CHARS.find(x =>
x.id.toLowerCase() === mention ||
x.name.toLowerCase() === mention ||
x.short.toLowerCase() === mention
);
return c ? c.id : null;
}
let _emotionTimer = null;
function setTopbarEmotion(charId, eInfo) {
const btn = document.querySelector(`.char-btn[data-id="${charId}"]`);
if (!btn || !eInfo) return;
// Update avatar ring to emotion color
btn.style.setProperty('--ec', eInfo.color);
// Update label text
const lbl = btn.querySelector('.emotion-lbl');
if (!lbl) return;
const labelText = eInfo.label ? (eInfo.emoji ? `${eInfo.emoji} ${eInfo.label}` : eInfo.label) : '';
lbl.textContent = labelText;
if (_emotionTimer) { clearTimeout(_emotionTimer); _emotionTimer = null; }
if (labelText) {
lbl.classList.add('show');
_emotionTimer = setTimeout(() => lbl.classList.remove('show'), 3000);
} else {
lbl.classList.remove('show');
}
}
function updateCharInfo() {
const ch = charById(activeChar);
const el = document.getElementById('char-info');
if (el) el.innerHTML = `<strong>${ch.name}</strong><br>Style: ${ch.style}`;
}
// ── Per-character reply-model tier UI ────────────────────────
function renderCharModelPrefs() {
const box = document.getElementById('char-model-prefs');
if (!box) return;
box.innerHTML = '';
for (const ch of CHARS) {
const current = characterModelPrefs[ch.id] || 'fast';
const row = document.createElement('div');
row.className = 'char-model-row';
row.innerHTML = `
<div class="char-model-name"><img src="${ch.av}" class="char-model-av" alt="${ch.name}">${ch.short}</div>
<div class="char-model-tiers">
<button type="button" class="char-model-btn${current === 'fast' ? ' active' : ''}" data-tier="fast">Fast</button>
<button type="button" class="char-model-btn${current === 'balanced'? ' active' : ''}" data-tier="balanced">Balanced</button>
<button type="button" class="char-model-btn${current === 'best' ? ' active' : ''}" data-tier="best">Best</button>
</div>`;
row.querySelectorAll('.char-model-btn').forEach(btn => {
btn.addEventListener('click', () => {
const tier = btn.dataset.tier;
characterModelPrefs[ch.id] = tier;
row.querySelectorAll('.char-model-btn').forEach(b => b.classList.toggle('active', b === btn));
saveCharModelPref(ch.id, tier);
});
});
box.appendChild(row);
}
}
async function saveCharModelPref(characterId, tier) {
try {
await fetch('/profile/model_pref', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ character_id: characterId, tier }),
});
} catch {}
}
// ── Chat history ───────────────────────────────────────────
async function loadHistory() {
try {
const res = await fetch('/chat/history', {
method:'POST',
headers:{ 'Authorization': `Bearer ${token}`, 'Content-Type':'application/json' },
body: JSON.stringify({}),
});
if (!res.ok) return;
const { messages = [] } = await res.json();
const list = document.getElementById('msgs-list');
list.innerHTML = '';
let lastDay = '';
let lastCharId = activeChar;
for (const m of messages) {
const day = (m.time || '').slice(0, 10);
if (day && day !== lastDay) { addDateSep(list, day); lastDay = day; }
const cid = m.character_id || 'socrates';
if (m.role === 'assistant') lastCharId = cid;
addBubble(list, m.role, m.content, cid);
}
activateChar(lastCharId);
scrollDown();
} catch {}
}
// ── Message rendering ──────────────────────────────────────
function addBubble(container, role, text, charId, photoDataUrl, eInfo) {
const ch = charById(charId);
const div = document.createElement('div');
div.className = `message ${role === 'user' ? 'from-user' : 'from-asst'}`;
const photoHtml = photoDataUrl ? `<img src="${photoDataUrl}" class="photo-thumb" alt="photo">` : '';
if (role === 'assistant') {
const ring = (eInfo && eInfo.color) ? eInfo.color : ch.color;
div.style.cssText = `--cc:${ch.color};--cc-rgb:${ch.rgb}`;
div.innerHTML = `
<div class="av-wrap">
<img src="${ch.av}" alt="${ch.name}" class="msg-av" style="border-color:${ring};box-shadow:0 0 7px ${ring}66">
</div>
<div class="bubble asst-bub">
<span class="char-lbl" style="color:${darkenColor(ch.color, 0.42)}">${ch.name}</span>
${photoHtml}<p>${esc(text).replace(/\n/g,'<br>')}</p>
</div>`;
} else {
div.innerHTML = `
<div class="bubble user-bub">${photoHtml}<p>${esc(text).replace(/\n/g,'<br>')}</p></div>
<img src="${userAvatarUrl || '/avatars/user_avatar.png'}" alt="You" class="msg-av">`;
}
container.appendChild(div);
scrollDown();
return div;
}
function addDateSep(container, isoDay) {
const d = document.createElement('div'); d.className = 'date-sep';
try { d.textContent = new Date(isoDay).toLocaleDateString(undefined, { weekday:'short', month:'short', day:'numeric' }); }
catch { d.textContent = isoDay; }
container.appendChild(d);
}
function addLoadingBubble(charId) {
const ch = charById(charId);
const div = document.createElement('div');
div.className = 'message from-asst';
div.style.cssText = `--cc:${ch.color};--cc-rgb:${ch.rgb}`;
div.innerHTML = `
<img src="${ch.av}" alt="${ch.name}" class="msg-av">
<div class="bubble asst-bub loading">
<div class="dots"><span></span><span></span><span></span></div>
</div>`;
document.getElementById('msgs-list').appendChild(div);
scrollDown();
return div;
}
function rmEl(el) { el?.parentNode?.removeChild(el); }
function scrollDown() { const a = document.getElementById('msgs-area'); requestAnimationFrame(() => { a.scrollTop = a.scrollHeight; }); }
function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
// ── Send message ───────────────────────────────────────────
document.getElementById('send-btn').addEventListener('click', send);
document.getElementById('msg-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
});
document.getElementById('msg-input').addEventListener('input', function () {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 120) + 'px';
});
async function send() {
if (busy) return;
const inp = document.getElementById('msg-input');
const text = inp.value.trim();
if (!text) return;
inp.value = ''; inp.style.height = 'auto';
const list = document.getElementById('msgs-list');
// Mirror the server's @mention switch on the client so the UI (loading bubble,
// colours) reflects the mentioned character immediately, not the previous one.
const mentioned = parseMentionChar(text);
if (mentioned && mentioned !== activeChar) activateChar(mentioned);
addBubble(list, 'user', text, activeChar);
await sendToChat(text, list);
}
async function sendToChat(message, list) {
setBusy(true);
loadingDiv = addLoadingBubble(activeChar);
try {
const res = await fetch('/chat/send', {
method:'POST',
headers:{ 'Authorization': `Bearer ${token}`, 'Content-Type':'application/json' },
body: JSON.stringify({
message,
character_id: activeChar,
username: (userEmail || 'user').split('@')[0],
ui_lang: userLang || (navigator.language || 'en').split('-')[0],
profile: {},
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
llm_mode: llmMode,
core_model: coreModel || null,
opinion_model: opinionModel || null,
secondary_model: secondaryModel || null,
perception_model: perceptionModel || null,
reply_model: resolveReplyModel(activeChar),
reply_routing_mode: replyRoutingMode,
}),
});
if (res.status === 401) { sb.auth.signOut(); return; }
const data = await res.json();
const replyObj = data.reply ?? data;
const replyText = typeof replyObj === 'string' ? replyObj
: typeof replyObj?.reply === 'string' ? replyObj.reply
: JSON.stringify(replyObj);
const charOut = data.character_id_used || data.character_id || activeChar;
const emotionCat = replyObj?.emotion?.category || data?.emotion?.category || 'neutral';
const responseMode = replyObj?.analysis?.response_mode || data?.analysis?.response_mode || 'dialogic';
const eInfo = emotionInfo(emotionCat, responseMode);
rmEl(loadingDiv); loadingDiv = null;
// Only proactively switch for non-coordinator responses (never switch back to Cielo)
if (charOut !== activeChar && charById(charOut).style !== 'coordinator') activateChar(charOut);
addBubble(list, 'assistant', replyText, charOut, null, eInfo);
setTopbarEmotion(charOut, eInfo);
if (voiceOn) speakText(replyText);
// recommended_character lives inside data.reply (replyObj), not at the outer data level
const recChar = replyObj?.recommended_character ?? data.recommended_character;
if (recChar && recChar !== activeChar) activateChar(recChar);
} catch {
rmEl(loadingDiv); loadingDiv = null;
addBubble(list, 'assistant', 'Something went wrong — please try again.', activeChar);
} finally {
setBusy(false);
}
}
function setBusy(v) { busy = v; document.getElementById('send-btn').disabled = v; }
// ── TTS ────────────────────────────────────────────────────
async function speakText(text) {
try {
const res = await fetch(`/tts?text=${encodeURIComponent(text.slice(0,1000))}&voice=${selectedVoice}`,
{ headers:{ 'Authorization': `Bearer ${token}` } });
if (!res.ok) return;
const url = URL.createObjectURL(await res.blob());
const audio = new Audio(url);
audio.play();
audio.onended = () => URL.revokeObjectURL(url);
} catch {}
}
// ── Library (bubble drill-down screen) ─────────────────────
document.getElementById('lib-btn').addEventListener('click', () => openLibraryScreen());
document.getElementById('lib-close').addEventListener('click', closePanel);
document.querySelectorAll('.lib-tab').forEach(tab => {
tab.addEventListener('click', () => {
libActiveTab = tab.dataset.tab;
document.querySelectorAll('.lib-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
renderLibrary(libActiveTab);
});
});
function _levelToSegments(level) {
if (level === 'light') return 1;
if (level === 'deep') return 2;
if (level === 'deepest' || level === 'complete') return 3;
return 0;
}
function _progressBar(segments) {
if (segments === 0) return '';
return '<div class="lib-progress">' +
[0,1,2].map(i => `<span class="lib-seg${i < segments ? ' filled' : ''}"></span>`).join('') +
'</div>';
}
const _libTransCache = {};
async function _translateLibItem(text, lang) {
const key = `${lang}::${text}`;
if (_libTransCache[key]) return _libTransCache[key];
try {
const r = await fetch('/wizard/translate', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify({ text, target_lang: lang }),
});
const j = await r.json();
_libTransCache[key] = j.translated_text || text;
return _libTransCache[key];
} catch { return text; }
}
async function renderLibrary(section) {
const lib = LIBRARY[activeChar] || LIBRARY.socrates;
const ch = charById(activeChar);
const items = lib[section] || [];
const color = lib.color || ch.color;
const libPanelTitle = document.getElementById('lib-panel-title');
libPanelTitle.textContent = `📚 ${ch.name} Library`;
libPanelTitle.dataset.customTitle = '1';
document.querySelector('.lib-tabs').style.setProperty('--lib-color', color);
document.querySelectorAll('.lib-tab').forEach(t => t.style.setProperty('--lib-color', color));
document.querySelector('.lib-tabs').style.setProperty('--lib-color', color);
// Fetch progress data for this character
const progressMap = {}; // philosophy: thread_index → level string
let storyChapterIndex = -1; // story: current chapter (-1 = never started)
try {
const { data: { user } } = await sb.auth.getUser();
if (user) {
if (section === 'philosophy') {
const { data } = await sb
.from('user_philosophy_progress')
.select('thread_index, current_level')
.eq('user_id', user.id)
.eq('character_id', activeChar);
(data || []).forEach(r => { progressMap[r.thread_index] = r.current_level; });
} else if (section === 'story') {
const { data } = await sb
.from('user_character_story_progress')
.select('chapter_index, beat_index')
.eq('user_id', user.id)
.eq('character_id', activeChar)
.maybeSingle();
if (data) storyChapterIndex = data.chapter_index;
}
}
} catch (_) {}
// Global story progress bar (8 chapters, shown above the list)
const storyProgressEl = document.getElementById('lib-story-progress');
const storyBarEl = document.getElementById('lib-story-bar');
if (section === 'story' && storyChapterIndex >= 0) {
storyBarEl.innerHTML = Array.from({length: 8}, (_, i) =>
`<span class="lib-seg${i <= storyChapterIndex ? ' filled' : ''}"></span>`
).join('');
storyProgressEl.style.display = '';
} else {
storyProgressEl.style.display = 'none';
}
const list = document.getElementById('lib-list');
list.innerHTML = '';
for (let i = 0; i < items.length; i++) {
const item = items[i];
const needsTrans = userLang !== 'en';
const [dispTitle, dispSub] = needsTrans
? await Promise.all([
_translateLibItem(item.title, userLang),
_translateLibItem(item.subtitle, userLang),
])
: [item.title, item.subtitle];
const div = document.createElement('div');
div.className = 'lib-item';
const badge = item.level ? `<span class="lib-item-badge" style="color:${color}">${item.level === 1 ? 'Intro' : 'Deep'}</span>` : '';
const bar = section === 'philosophy' ? _progressBar(_levelToSegments(progressMap[i])) : '';
div.innerHTML = `
<div class="lib-item-title" style="color:${color}">${esc(dispTitle)}${badge}</div>
<div class="lib-item-sub">${esc(dispSub)}</div>${bar}`;
div.addEventListener('click', () => {
closePanel();
const list = document.getElementById('msgs-list');
addBubble(list, 'user', item.prompt, activeChar);
sendToChat(item.prompt, list);
});
list.appendChild(div);
}
}
// ── Life Events panel ──────────────────────────────────────
document.getElementById('events-btn').addEventListener('click', () => openPanel('events-panel'));
document.getElementById('events-close').addEventListener('click', closePanel);
// Tab switching
document.querySelectorAll('.ev-tab').forEach(tab => {
tab.addEventListener('click', () => {
const target = tab.dataset.etab;
document.querySelectorAll('.ev-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.ev-section').forEach(s => s.classList.remove('active'));
tab.classList.add('active');
document.getElementById(`etab-${target}`).classList.add('active');
if (target === 'list') loadMemList();
});
});
// ── Share photo tab ────────────────────────────────────────
document.getElementById('share-file-input').addEventListener('change', (e) => {
const file = e.target.files?.[0]; if (!file) return;
sharePhotoFile = file;
const reader = new FileReader();
reader.onload = (ev) => {
document.getElementById('share-preview-img').src = ev.target.result;
document.getElementById('share-preview-wrap').style.display = 'inline-block';
document.getElementById('share-drop').style.display = 'none';
document.getElementById('share-send-btn').disabled = false;
};
reader.readAsDataURL(file);
e.target.value = '';
});
document.getElementById('share-clear-btn').addEventListener('click', () => {
sharePhotoFile = null;
document.getElementById('share-preview-wrap').style.display = 'none';
document.getElementById('share-drop').style.display = 'flex';
document.getElementById('share-send-btn').disabled = true;
});
document.getElementById('share-send-btn').addEventListener('click', async () => {
if (!sharePhotoFile) return;
const btn = document.getElementById('share-send-btn');
const caption = document.getElementById('share-caption').value.trim();
const dataUrl = document.getElementById('share-preview-img').src;
btn.disabled = true; btn.textContent = 'Analyzing…';
try {
const form = new FormData();
form.append('file', sharePhotoFile, sharePhotoFile.name);
const res = await fetch('/events/analyze', {
method:'POST', headers:{ 'Authorization': `Bearer ${token}` }, body: form,
});
if (res.status === 401) { sb.auth.signOut(); return; }
const data = await res.json();
const desc = data.photo_description || 'a photo';
const msg = caption ? `${caption} [Photo: ${desc}]` : `I'm sharing a photo: ${desc}`;
closePanel();
const list = document.getElementById('msgs-list');
addBubble(list, 'user', caption || '(sharing a photo)', activeChar, dataUrl);
await sendToChat(msg, list);
// reset
document.getElementById('share-clear-btn').click();
document.getElementById('share-caption').value = '';
} catch {
btn.disabled = false; btn.textContent = 'Send to chat';
}
});
// ── Save memory tab ────────────────────────────────────────
document.getElementById('mem-photo-file').addEventListener('change', (e) => {
const file = e.target.files?.[0]; if (!file) return;
memPhotoFile = file;
const reader = new FileReader();
reader.onload = (ev) => {
const img = document.getElementById('mem-photo-thumb');
img.src = ev.target.result; img.style.display = 'block';
};
reader.readAsDataURL(file);
});
document.getElementById('mem-save-btn').addEventListener('click', async () => {
const desc = document.getElementById('mem-desc').value.trim();
if (!desc) { document.getElementById('mem-desc').focus(); return; }
const btn = document.getElementById('mem-save-btn');
btn.disabled = true; btn.textContent = 'Saving…';
try {
let photoPath = null;
if (memPhotoFile) {
const form = new FormData();
form.append('file', memPhotoFile, memPhotoFile.name);
const r = await fetch('/events/photo', {
method:'POST', headers:{ 'Authorization': `Bearer ${token}` }, body: form,
});
if (r.ok) { const d = await r.json(); photoPath = d.photo_path || null; }
}
await fetch('/events/save', {
method:'POST',
headers:{ 'Authorization': `Bearer ${token}`, 'Content-Type':'application/json' },
body: JSON.stringify({ description: desc, photo_path: photoPath, username: (userEmail||'user').split('@')[0] }),
});
document.getElementById('mem-desc').value = '';
document.getElementById('mem-photo-thumb').style.display = 'none';
memPhotoFile = null;
// switch to list tab
document.querySelector('.ev-tab[data-etab=list]').click();
} catch {}
finally { btn.disabled = false; btn.textContent = 'Save memory'; }
});
// ── My Memories tab ────────────────────────────────────────
async function loadMemList() {
const list = document.getElementById('mem-list');
list.innerHTML = '<div class="panel-empty">Loading…</div>';
try {
const res = await fetch('/events/list?limit=30', { headers:{ 'Authorization': `Bearer ${token}` } });
if (!res.ok) { list.innerHTML = '<div class="panel-empty">Could not load memories.</div>'; return; }
const { events = [] } = await res.json();
list.innerHTML = '';
if (!events.length) { list.innerHTML = '<div class="panel-empty">No memories saved yet.</div>'; return; }
for (const ev of events) renderMemCard(list, ev);
} catch {
list.innerHTML = '<div class="panel-empty">Error loading memories.</div>';
}
}
function renderMemCard(container, ev) {
const card = document.createElement('div'); card.className = 'mem-card';
const date = ev.created_at
? new Date(ev.created_at).toLocaleDateString(undefined, { month:'short', day:'numeric', year:'numeric' }) : '';
const text = ev.raw_text || '';
const photoHtml = ev.photo_url ? `<img src="${ev.photo_url}" class="mem-card-photo" alt="photo">` : '';
card.innerHTML = `
<div class="mem-card-row">
${photoHtml}
<div class="mem-card-right">
<div class="mem-card-date">${esc(date)}</div>
<div class="mem-card-text">${esc(text)}</div>
<textarea class="mem-edit-ta">${esc(text)}</textarea>
</div>
</div>
<div class="mem-actions">
<button class="ma-btn edit-btn">✏ Edit</button>
<button class="ma-btn pri save-btn" style="display:none">💾 Save</button>
<button class="ma-btn cancel-btn" style="display:none">Cancel</button>
<button class="ma-btn del del-btn">🗑 Delete</button>
</div>`;
const textEl = card.querySelector('.mem-card-text');
const editTa = card.querySelector('.mem-edit-ta');
const editBtn = card.querySelector('.edit-btn');
const saveBtn = card.querySelector('.save-btn');
const canBtn = card.querySelector('.cancel-btn');
const delBtn = card.querySelector('.del-btn');
editBtn.addEventListener('click', () => {
textEl.style.display = 'none'; editTa.style.display = 'block'; editTa.value = text;
editBtn.style.display = 'none'; saveBtn.style.display = ''; canBtn.style.display = '';
});
canBtn.addEventListener('click', () => {
textEl.style.display = ''; editTa.style.display = 'none';
editBtn.style.display = ''; saveBtn.style.display = 'none'; canBtn.style.display = 'none';
});
saveBtn.addEventListener('click', async () => {
const newText = editTa.value.trim(); if (!newText) return;
saveBtn.disabled = true; saveBtn.textContent = '…';
try {
await fetch(`/events/${ev.event_id}`, {
method:'PUT',
headers:{ 'Authorization': `Bearer ${token}`, 'Content-Type':'application/json' },
body: JSON.stringify({ raw_text: newText }),
});
textEl.textContent = newText;
} catch {}
saveBtn.disabled = false; saveBtn.innerHTML = '💾 Save';
canBtn.click();
});
delBtn.addEventListener('click', async () => {
if (!confirm('Delete this memory?')) return;
try {
await fetch(`/events/${ev.event_id}`, {
method:'DELETE', headers:{ 'Authorization': `Bearer ${token}` },
});
card.remove();
} catch {}
});
container.appendChild(card);
}
// ── Settings panel ─────────────────────────────────────────
const LANG_OPTIONS = [
{code:'en', flag:'🇬🇧', native:'English'},
{code:'it', flag:'🇮🇹', native:'Italiano'},
{code:'es', flag:'🇪🇸', native:'Español'},
{code:'fr', flag:'🇫🇷', native:'Français'},
{code:'de', flag:'🇩🇪', native:'Deutsch'},
{code:'pt', flag:'🇵🇹', native:'Português'},
];
(function buildLangSelect() {
const sel = document.getElementById('lang-select');
for (const l of LANG_OPTIONS) {
const opt = document.createElement('option');
opt.value = l.code;
opt.textContent = `${l.flag} ${l.native}`;
sel.appendChild(opt);
}
sel.value = userLang;
sel.addEventListener('change', () => {
userLang = sel.value;
localStorage.setItem('userLang', userLang);
saveLangToSupabase(userLang);
applyUiStrings();
// Refresh profile page if currently visible
if (document.getElementById('profile-screen').style.display !== 'none') {
bubbleDraft = {};
loadAndBuildProfile();
}
});
})();
document.getElementById('settings-btn').addEventListener('click', () => {
document.getElementById('lang-select').value = userLang;
updateCharInfo();
renderCharModelPrefs();
openPanel('settings-panel');
});
document.getElementById('settings-close').addEventListener('click', closePanel);
document.querySelectorAll('input[name=quality]').forEach(r => r.addEventListener('change', () => { llmMode = r.value; }));
document.querySelectorAll('input[name="reply-routing"]').forEach(r => r.addEventListener('change', () => { replyRoutingMode = r.value; }));
document.getElementById('core-model-select').addEventListener('change', e => { coreModel = e.target.value; });
document.getElementById('opinion-model-select').addEventListener('change', e => { opinionModel = e.target.value; });
document.getElementById('secondary-model-select').addEventListener('change', e => { secondaryModel = e.target.value; });
document.getElementById('perception-model-select').addEventListener('change', e => { perceptionModel = e.target.value; });
document.getElementById('voice-toggle').addEventListener('click', () => {
voiceOn = !voiceOn;
document.getElementById('voice-toggle').classList.toggle('on', voiceOn);
});
document.getElementById('voice-select').addEventListener('change', e => { selectedVoice = e.target.value; });
// ── Panel helpers ──────────────────────────────────────────
let openPanelId = null;
function openPanel(id) {
if (openPanelId && openPanelId !== id) document.getElementById(openPanelId)?.classList.remove('open');
document.getElementById(id).classList.add('open');
document.getElementById('panel-overlay').classList.add('open');
openPanelId = id;
}
function closePanel() {
if (openPanelId) document.getElementById(openPanelId)?.classList.remove('open');
document.getElementById('panel-overlay').classList.remove('open');
openPanelId = null;
const libPT = document.getElementById('lib-panel-title');
if (libPT) delete libPT.dataset.customTitle;
}
document.getElementById('panel-overlay').addEventListener('click', closePanel);
// ── PROFILE SCREEN ─────────────────────────────────────────
const UI_STRINGS = {
en: {
greeting: "Hi! I'm Cielo 😊 I made this space just to get to know you. The more bubbles you feel like touching and filling in, the more I'll understand you. But if you don't feel like playing right now, no worries — you can always come back another time.",
back_to_chat: '✕ Exit',
save_and_chat: '💬 Save and go to Chat',
lib_title: '📚 Library',
lib_philosophy: '🏛 Philosophy',
lib_story: '📖 Story',
lib_story_prog: 'Story progress',
events_title: '🖼 Photos & Memories',
ev_share: '📷 Share',
ev_save: '💾 Save',
ev_memories: '📋 My Memories',
ev_share_tap: 'Tap to choose a photo',
ev_caption: 'Add a caption (optional)…',
ev_send: 'Send to chat',
ev_describe: 'Describe this memory…',
ev_save_btn: 'Save memory',
mem_loading: 'Loading…',
mem_empty: 'No memories saved yet.',
mem_error: 'Could not load memories.',
exp_placeholder: 'Write anything…',
exp_done: 'Done',
avatar_upload_btn:'🔄 Change your avatar',
saving: 'Saving…',
saved: 'Saved ✓',
save_error: 'Could not save',
},
it: {
greeting: "Ciao! Sono Cielo 😊 Ho creato questo spazio solo per conoscerti meglio. Più bolle tocchi e riempi, più ti capirò. Ma se ora non hai voglia di giocare, nessun problema — puoi sempre tornare un'altra volta.",
back_to_chat: '✕ Esci',
save_and_chat: '💬 Salva e vai alla chat',
lib_title: '📚 Libreria',
lib_philosophy: '🏛 Filosofia',
lib_story: '📖 Storia',
lib_story_prog: 'Progresso storia',
events_title: '🖼 Foto e ricordi',
ev_share: '📷 Condividi',
ev_save: '💾 Salva',
ev_memories: '📋 I miei ricordi',
ev_share_tap: 'Tocca per scegliere una foto',
ev_caption: 'Aggiungi una didascalia (opzionale)…',
ev_send: 'Invia in chat',
ev_describe: 'Descrivi questo ricordo…',
ev_save_btn: 'Salva ricordo',
mem_loading: 'Caricamento…',
mem_empty: 'Nessun ricordo salvato.',
mem_error: 'Impossibile caricare i ricordi.',
exp_placeholder: 'Scrivi qualcosa…',
exp_done: 'Fatto',
avatar_upload_btn:'🔄 Cambia il tuo avatar',
saving: 'Salvataggio…',
saved: 'Salvato ✓',
save_error: 'Impossibile salvare',
},
es: {
greeting: "¡Hola! Soy Cielo 😊 Creé este espacio solo para conocerte mejor. Cuantas más burbujas toques y completes, más te entenderé. Pero si ahora no tienes ganas de jugar, no te preocupes — siempre puedes volver otro día.",
back_to_chat: '✕ Salir',
save_and_chat: '💬 Guardar y volver al chat',
lib_title: '📚 Biblioteca',
lib_philosophy: '🏛 Filosofía',
lib_story: '📖 Historia',
lib_story_prog: 'Progreso de la historia',
events_title: '🖼 Fotos y recuerdos',
ev_share: '📷 Compartir',
ev_save: '💾 Guardar',
ev_memories: '📋 Mis recuerdos',
ev_share_tap: 'Toca para elegir una foto',
ev_caption: 'Añade un pie de foto (opcional)…',
ev_send: 'Enviar al chat',
ev_describe: 'Describe este recuerdo…',
ev_save_btn: 'Guardar recuerdo',
mem_loading: 'Cargando…',
mem_empty: 'No hay recuerdos guardados.',
mem_error: 'No se pudieron cargar los recuerdos.',
exp_placeholder: 'Escribe algo…',
exp_done: 'Hecho',
avatar_upload_btn:'🔄 Cambia tu avatar',
saving: 'Guardando…',
saved: 'Guardado ✓',
save_error: 'No se pudo guardar',
},
fr: {
greeting: "Salut ! Je suis Cielo 😊 J'ai créé cet espace juste pour apprendre à te connaître. Plus tu touches et remplis de bulles, mieux je te comprendrai. Mais si tu n'as pas envie de jouer maintenant, pas de souci — tu peux toujours revenir une autre fois.",
back_to_chat: '✕ Quitter',
save_and_chat: '💬 Enregistrer et retourner au chat',
lib_title: '📚 Bibliothèque',
lib_philosophy: '🏛 Philosophie',
lib_story: '📖 Histoire',
lib_story_prog: 'Progression de l\'histoire',
events_title: '🖼 Photos & Souvenirs',
ev_share: '📷 Partager',
ev_save: '💾 Enregistrer',
ev_memories: '📋 Mes souvenirs',
ev_share_tap: 'Appuie pour choisir une photo',
ev_caption: 'Ajoute une légende (optionnel)…',
ev_send: 'Envoyer au chat',
ev_describe: 'Décris ce souvenir…',
ev_save_btn: 'Enregistrer le souvenir',
mem_loading: 'Chargement…',
mem_empty: 'Aucun souvenir enregistré.',
mem_error: 'Impossible de charger les souvenirs.',
exp_placeholder: 'Écris quelque chose…',
exp_done: 'Terminé',
avatar_upload_btn:'🔄 Change ton avatar',
saving: 'Enregistrement…',
saved: 'Enregistré ✓',
save_error: 'Impossible d\'enregistrer',
},
de: {
greeting: "Hallo! Ich bin Cielo 😊 Ich habe diesen Raum geschaffen, nur um dich kennenzulernen. Je mehr Blasen du berühren und ausfüllen möchtest, desto besser verstehe ich dich. Aber wenn du gerade keine Lust zum Spielen hast, kein Problem — du kannst jederzeit ein andermal wiederkommen.",
back_to_chat: '✕ Verlassen',
save_and_chat: '💬 Speichern und zurück zum Chat',
lib_title: '📚 Bibliothek',
lib_philosophy: '🏛 Philosophie',
lib_story: '📖 Geschichte',
lib_story_prog: 'Geschichtsfortschritt',
events_title: '🖼 Fotos & Erinnerungen',
ev_share: '📷 Teilen',
ev_save: '💾 Speichern',
ev_memories: '📋 Meine Erinnerungen',
ev_share_tap: 'Tippe um ein Foto auszuwählen',
ev_caption: 'Beschriftung hinzufügen (optional)…',
ev_send: 'In Chat senden',
ev_describe: 'Beschreibe diese Erinnerung…',
ev_save_btn: 'Erinnerung speichern',
mem_loading: 'Lädt…',
mem_empty: 'Noch keine Erinnerungen gespeichert.',
mem_error: 'Erinnerungen konnten nicht geladen werden.',
exp_placeholder: 'Schreib etwas…',
exp_done: 'Fertig',
avatar_upload_btn:'🔄 Avatar ändern',
saving: 'Speichert…',
saved: 'Gespeichert ✓',
save_error: 'Konnte nicht gespeichert werden',
},
pt: {
greeting: "Oi! Eu sou o Cielo 😊 Criei este espaço só para te conhecer melhor. Quanto mais bolhas você tocar e preencher, mais eu vou te entender. Mas se agora você não estiver com vontade de brincar, sem problemas — você pode sempre voltar outro dia.",
back_to_chat: '✕ Sair',
save_and_chat: '💬 Salvar e voltar ao chat',
lib_title: '📚 Biblioteca',
lib_philosophy: '🏛 Filosofia',
lib_story: '📖 História',
lib_story_prog: 'Progresso da história',
events_title: '🖼 Fotos e memórias',
ev_share: '📷 Compartilhar',
ev_save: '💾 Salvar',
ev_memories: '📋 Minhas memórias',
ev_share_tap: 'Toque para escolher uma foto',
ev_caption: 'Adicione uma legenda (opcional)…',
ev_send: 'Enviar para o chat',
ev_describe: 'Descreva esta memória…',
ev_save_btn: 'Salvar memória',
mem_loading: 'Carregando…',
mem_empty: 'Nenhuma memória salva ainda.',
mem_error: 'Não foi possível carregar as memórias.',
exp_placeholder: 'Escreva algo…',
exp_done: 'Concluído',
avatar_upload_btn:'🔄 Mudar seu avatar',
saving: 'Salvando…',
saved: 'Salvo ✓',
save_error: 'Não foi possível salvar',
},
};
// Per-language bubble labels and hints (order matches PROFILE_BUBBLES)
const BUBBLE_TRANSLATIONS = {
en: null, // uses PROFILE_BUBBLES defaults
it: [
{label:'Il mio avatar', hint:'Carica una tua foto o immagine — sarà il tuo avatar in chat.'},
{label:'Nome', hint:'Come vuoi che ti chiami?'},
{label:'Compleanno', hint:'Quando sei nato/a? Giorno, mese, anno.'},
{label:'Casa', hint:'Dove vivi adesso? Città e paese.'},
{label:'Radici', hint:'Da dove vieni originariamente?'},
{label:'Partner', hint:'Come si chiama il tuo partner?'},
{label:'Figli', hint:'Hai figli? Nomi ed età.'},
{label:'Fratelli', hint:'Quanti fratelli e/o sorelle hai?'},
{label:'Migliore amico', hint:'Come si chiama il tuo migliore amico/a?'},
{label:'Lavoro', hint:'Che lavoro fai?'},
{label:'Studi', hint:'Cosa hai studiato?'},
{label:'Sport pratico', hint:'Che sport pratichi?'},
{label:'Sport seguo', hint:'Che sport segui?'},
{label:'La mia squadra', hint:'Qual è la tua squadra preferita?'},
{label:'Hobby', hint:'Cosa ami fare nel tempo libero?'},
{label:'Interessi', hint:'Argomenti che ti appassionano.'},
{label:'Cibo', hint:'Qual è il tuo cibo preferito?'},
{label:'Musica', hint:'Che genere musicale preferisci?'},
{label:'Band', hint:'Qual è la tua band preferita?'},
{label:'Cantante', hint:'Qual è il tuo cantante preferito?'},
{label:'Film', hint:'Qual è il tuo film preferito?'},
{label:'Attore', hint:'Chi è il tuo attore preferito?'},
{label:'Regista', hint:'Chi è il tuo regista preferito?'},
{label:'Libro', hint:'Qual è il tuo libro preferito?'},
{label:'Scrittore', hint:'Chi è il tuo scrittore preferito?'},
{label:'Colore', hint:'Qual è il tuo colore preferito?'},
{label:'Animale', hint:'Qual è il tuo animale preferito?'},
{label:'Stagione', hint:'Qual è la tua stagione preferita?'},
{label:'Animali dom.', hint:'Hai animali domestici?'},
{label:'Lingua', hint:'Che lingua preferisci?'},
],
es: [
{label:'Mi avatar', hint:'Sube tu foto o imagen — será tu avatar en el chat.'},
{label:'Nombre', hint:'¿Cómo quieres que te llame?'},
{label:'Cumpleaños', hint:'¿Cuándo naciste? Día, mes, año.'},
{label:'Hogar', hint:'¿Dónde vives ahora? Ciudad y país.'},
{label:'Raíces', hint:'¿De dónde eres originalmente?'},
{label:'Pareja', hint:'¿Cómo se llama tu pareja?'},
{label:'Hijos', hint:'¿Tienes hijos? Nombres y edades.'},
{label:'Hermanos', hint:'¿Cuántos hermanos tienes?'},
{label:'Mejor amigo', hint:'¿Cómo se llama tu mejor amigo/a?'},
{label:'Profesión', hint:'¿A qué te dedicas?'},
{label:'Estudios', hint:'¿Qué estudiaste?'},
{label:'Deporte (juego)',hint:'¿Qué deportes practicas?'},
{label:'Deporte (veo)', hint:'¿Qué deportes sigues?'},
{label:'Mi equipo', hint:'¿Cuál es tu equipo favorito?'},
{label:'Aficiones', hint:'¿Qué te gusta hacer en tu tiempo libre?'},
{label:'Intereses', hint:'Temas que te fascinan.'},
{label:'Comida', hint:'¿Cuál es tu comida favorita?'},
{label:'Música', hint:'¿Qué género musical prefieres?'},
{label:'Banda', hint:'¿Cuál es tu banda favorita?'},
{label:'Cantante', hint:'¿Cuál es tu cantante favorito?'},
{label:'Película', hint:'¿Cuál es tu película favorita?'},
{label:'Actor', hint:'¿Quién es tu actor favorito?'},
{label:'Director', hint:'¿Quién es tu director favorito?'},
{label:'Libro', hint:'¿Cuál es tu libro favorito?'},
{label:'Escritor', hint:'¿Quién es tu escritor favorito?'},
{label:'Color', hint:'¿Cuál es tu color favorito?'},
{label:'Animal', hint:'¿Cuál es tu animal favorito?'},
{label:'Estación', hint:'¿Cuál es tu estación favorita?'},
{label:'Mascotas', hint:'¿Tienes mascotas?'},
{label:'Idioma', hint:'¿Qué idioma prefieres?'},
],
fr: [
{label:'Mon avatar', hint:"Téléverse ta photo ou ton image — ce sera ton avatar dans le chat."},
{label:'Prénom', hint:"Comment dois-je t'appeler?"},
{label:'Anniversaire', hint:"Quand es-tu né/e? Jour, mois, année."},
{label:'Chez moi', hint:"Où vis-tu? Ville et pays."},
{label:'Origines', hint:"D'où viens-tu à l'origine?"},
{label:'Partenaire', hint:"Comment s'appelle ton partenaire?"},
{label:'Enfants', hint:"As-tu des enfants? Prénoms et âges."},
{label:'Frères/sœurs', hint:"Combien de frères et sœurs as-tu?"},
{label:'Meilleur ami', hint:"Comment s'appelle ton meilleur ami/e?"},
{label:'Métier', hint:"Que fais-tu dans la vie?"},
{label:'Études', hint:"Qu'as-tu étudié?"},
{label:'Sport (pratique)',hint:"Quels sports pratiques-tu?"},
{label:'Sport (suivi)', hint:"Quels sports suis-tu?"},
{label:'Mon équipe', hint:"Quelle est ton équipe favorite?"},
{label:'Loisirs', hint:"Qu'aimes-tu faire pendant ton temps libre?"},
{label:'Intérêts', hint:"Sujets qui te fascinent."},
{label:'Nourriture', hint:"Quel est ton plat préféré?"},
{label:'Musique', hint:"Quel genre musical préfères-tu?"},
{label:'Groupe', hint:"Quel est ton groupe préféré?"},
{label:'Chanteur', hint:"Qui est ton chanteur/chanteuse préféré/e?"},
{label:'Film', hint:"Quel est ton film préféré?"},
{label:'Acteur', hint:"Qui est ton acteur préféré?"},
{label:'Réalisateur', hint:"Qui est ton réalisateur préféré?"},
{label:'Livre', hint:"Quel est ton livre préféré?"},
{label:'Auteur', hint:"Qui est ton auteur préféré?"},
{label:'Couleur', hint:"Quelle est ta couleur préférée?"},
{label:'Animal', hint:"Quel est ton animal préféré?"},
{label:'Saison', hint:"Quelle est ta saison préférée?"},
{label:'Animaux', hint:"As-tu des animaux de compagnie?"},
{label:'Langue', hint:"Quelle langue préfères-tu?"},
],
de: [
{label:'Mein Avatar', hint:'Lade dein Foto oder Bild hoch — es wird dein Avatar im Chat.'},
{label:'Name', hint:'Wie soll ich dich nennen?'},
{label:'Geburtstag', hint:'Wann bist du geboren? Tag, Monat, Jahr.'},
{label:'Zuhause', hint:'Wo lebst du? Stadt und Land.'},
{label:'Herkunft', hint:'Woher kommst du ursprünglich?'},
{label:'Partner', hint:'Wie heißt dein Partner?'},
{label:'Kinder', hint:'Hast du Kinder? Namen und Alter.'},
{label:'Geschwister', hint:'Wie viele Geschwister hast du?'},
{label:'Bester Freund', hint:'Wie heißt dein bester Freund/deine beste Freundin?'},
{label:'Beruf', hint:'Was machst du beruflich?'},
{label:'Ausbildung', hint:'Was hast du studiert oder gelernt?'},
{label:'Sport (aktiv)', hint:'Welchen Sport treibst du?'},
{label:'Sport (Zuschauer)',hint:'Welchen Sport verfolgst du?'},
{label:'Mein Team', hint:'Was ist deine Lieblingsmannschaft?'},
{label:'Hobbys', hint:'Was machst du gerne in deiner Freizeit?'},
{label:'Interessen', hint:'Themen die dich faszinieren.'},
{label:'Essen', hint:'Was ist dein Lieblingsessen?'},
{label:'Musik', hint:'Welche Musikrichtung magst du?'},
{label:'Band', hint:'Was ist deine Lieblingsband?'},
{label:'Sänger', hint:'Wer ist dein Lieblingssänger?'},
{label:'Film', hint:'Was ist dein Lieblingsfilm?'},
{label:'Schauspieler', hint:'Wer ist dein Lieblingsschauspieler?'},
{label:'Regisseur', hint:'Wer ist dein Lieblingsregisseur?'},
{label:'Buch', hint:'Was ist dein Lieblingsbuch?'},
{label:'Autor', hint:'Wer ist dein Lieblingsautor?'},
{label:'Farbe', hint:'Was ist deine Lieblingsfarbe?'},
{label:'Tier', hint:'Was ist dein Lieblingstier?'},
{label:'Jahreszeit', hint:'Was ist deine Lieblingsjahreszeit?'},
{label:'Haustiere', hint:'Hast du Haustiere?'},
{label:'Sprache', hint:'Welche Sprache bevorzugst du?'},
],
pt: [
{label:'Meu avatar', hint:'Envie sua foto ou imagem — será seu avatar no chat.'},
{label:'Nome', hint:'Como devo te chamar?'},
{label:'Aniversário', hint:'Quando você nasceu? Dia, mês, ano.'},
{label:'Lar', hint:'Onde você mora? Cidade e país.'},
{label:'Raízes', hint:'De onde você é originalmente?'},
{label:'Parceiro', hint:'Como se chama seu parceiro/a?'},
{label:'Filhos', hint:'Você tem filhos? Nomes e idades.'},
{label:'Irmãos', hint:'Quantos irmãos você tem?'},
{label:'Melhor amigo', hint:'Como se chama seu melhor amigo/a?'},
{label:'Profissão', hint:'O que você faz?'},
{label:'Educação', hint:'O que você estudou?'},
{label:'Esporte (pratico)',hint:'Que esportes você pratica?'},
{label:'Esporte (assisto)',hint:'Que esportes você acompanha?'},
{label:'Meu time', hint:'Qual é o seu time favorito?'},
{label:'Hobbies', hint:'O que você ama fazer no tempo livre?'},
{label:'Interesses', hint:'Assuntos que te fascinam.'},
{label:'Comida', hint:'Qual é a sua comida favorita?'},
{label:'Música', hint:'Qual gênero musical você prefere?'},
{label:'Banda', hint:'Qual é a sua banda favorita?'},
{label:'Cantor', hint:'Qual é o seu cantor/cantora favorito?'},
{label:'Filme', hint:'Qual é o seu filme favorito?'},
{label:'Ator', hint:'Quem é o seu ator favorito?'},
{label:'Diretor', hint:'Quem é o seu diretor favorito?'},
{label:'Livro', hint:'Qual é o seu livro favorito?'},
{label:'Escritor', hint:'Quem é o seu escritor favorito?'},
{label:'Cor', hint:'Qual é a sua cor favorita?'},
{label:'Animal', hint:'Qual é o seu animal favorito?'},
{label:'Estação', hint:'Qual é a sua estação favorita?'},
{label:'Animais', hint:'Você tem animais de estimação?'},
{label:'Idioma', hint:'Qual idioma você prefere?'},
],
};
function getBubTrans(i) {
const t = BUBBLE_TRANSLATIONS[userLang];
if (!t) return { label: PROFILE_BUBBLES[i].label, hint: PROFILE_BUBBLES[i].hint };
return t[i] || { label: PROFILE_BUBBLES[i].label, hint: PROFILE_BUBBLES[i].hint };
}
let _pendingLang = null;
async function saveLangToSupabase(lang) {
try {
const { data: { user } } = await sb.auth.getUser();
if (!user) return;
await sb.from('user_ui_language').upsert(
{ user_id: user.id, initial_language: lang },
{ onConflict: 'user_id' }
);
} catch (_) {}
}
function applyUiStrings() {
const s = UI_STRINGS[userLang] || UI_STRINGS.en;
// Profile screen
const greetEl = document.getElementById('profile-greeting');
if (greetEl) greetEl.textContent = s.greeting;
const backBtn = document.getElementById('profile-back-btn');
if (backBtn) backBtn.textContent = s.back_to_chat;
const chatBtn = document.getElementById('profile-chat-btn');
if (chatBtn) chatBtn.textContent = s.save_and_chat;
// Library panel
const libTitle = document.getElementById('lib-panel-title');
if (libTitle && !libTitle.dataset.customTitle) libTitle.textContent = s.lib_title;
document.querySelectorAll('.lib-tab').forEach(t => {
if (t.dataset.tab === 'philosophy') t.textContent = s.lib_philosophy;
if (t.dataset.tab === 'story') t.textContent = s.lib_story;
});
const storyProgLabel = document.querySelector('#lib-story-progress div');
if (storyProgLabel) storyProgLabel.textContent = s.lib_story_prog;
// Events panel
const evTitle = document.querySelector('#events-panel .panel-header span');
if (evTitle) evTitle.textContent = s.events_title;
document.querySelectorAll('.ev-tab').forEach(t => {
if (t.dataset.etab === 'share') t.textContent = s.ev_share;
if (t.dataset.etab === 'save') t.textContent = s.ev_save;
if (t.dataset.etab === 'list') t.textContent = s.ev_memories;
});
const shareDrop = document.querySelector('#share-drop span:last-child');
if (shareDrop) shareDrop.textContent = s.ev_share_tap;
const captionEl = document.getElementById('share-caption');
if (captionEl) captionEl.placeholder = s.ev_caption;
const sendBtn = document.getElementById('share-send-btn');
if (sendBtn) sendBtn.textContent = s.ev_send;
const memDesc = document.getElementById('mem-desc');
if (memDesc) memDesc.placeholder = s.ev_describe;
const memSaveBtn = document.getElementById('mem-save-btn');
if (memSaveBtn) memSaveBtn.textContent = s.ev_save_btn;
// Expansion card
const ta = document.getElementById('exp-textarea');
if (ta) ta.placeholder = s.exp_placeholder;
const doneBtn = document.getElementById('exp-done');
if (doneBtn && activeBubId !== 'language') doneBtn.textContent = s.exp_done;
// Settings lang select sync
const sel = document.getElementById('lang-select');
if (sel) sel.value = userLang;
}
// Cielo first-chat welcome per language
const CIELO_WELCOME = {
en: "Hi! I'm Cielo. I'm here to help you explore big ideas and find the right voice for whatever's on your mind. Which philosopher would you like to talk to — or shall I help you choose?",
it: "Ciao! Sono Cielo. Sono qui per aiutarti a esplorare grandi idee e trovare la voce giusta per quello che hai in mente. Con quale filosofo vorresti parlare — o posso aiutarti a scegliere?",
es: "¡Hola! Soy Cielo. Estoy aquí para ayudarte a explorar grandes ideas y encontrar la voz adecuada. ¿Con qué filósofo te gustaría hablar?",
fr: "Bonjour ! Je suis Cielo. Je suis là pour t'aider à explorer de grandes idées. Avec quel philosophe aimerais-tu parler ?",
de: "Hallo! Ich bin Cielo. Ich bin hier, um dir zu helfen, große Ideen zu erkunden. Mit welchem Philosophen möchtest du sprechen?",
pt: "Olá! Eu sou Cielo. Estou aqui para ajudar você a explorar grandes ideias. Com qual filósofo você gostaria de conversar?",
};
// Decorative fill bubbles (no label, non-interactive)
const DECO_BUBBLES = [
{ color:'#f48fb1', icon:'✦', szKey:'xs' },
{ color:'#80cbc4', icon:'◆', szKey:'xs' },
{ color:'#fff176', icon:'✦', szKey:'xs' },
{ color:'#ce93d8', icon:'●', szKey:'xs' },
{ color:'#4fc3f7', icon:'◆', szKey:'xs' },
{ color:'#ffab91', icon:'✦', szKey:'xs' },
{ color:'#aed581', icon:'●', szKey:'xs' },
{ color:'#b39ddb', icon:'◆', szKey:'xs' },
];
const PROFILE_BUBBLES = [
{ id:'avatar', label:'My Avatar', icon:'📷', color:'#26c6da',
hint:'Upload your photo or image — it will be your avatar in chat.',
fields:[] },
{ id:'name', label:'Name', icon:'✍️', color:'#1bfdc5',
hint:'What should I call you?',
fields:['name'] },
{ id:'birthday', label:'Birthday', icon:'🎂', color:'#ffd54f',
hint:'When were you born? Day, month, year.',
fields:['date_of_birth'] },
{ id:'home', label:'Home', icon:'🏠', color:'#4fc3f7',
hint:'Where do you live now? City and country.',
fields:['living_country','living_place'] },
{ id:'roots', label:'Roots', icon:'🌱', color:'#aed581',
hint:'Where are you originally from?',
fields:['origin_country','origin_place'] },
{ id:'partner', label:'Partner', icon:'💑', color:'#f48fb1',
hint:"What's your partner's name?",
fields:['partner_name'] },
{ id:'kids', label:'Children', icon:'👶', color:'#ce93d8',
hint:'Do you have children? Names and ages.',
fields:['kids'] },
{ id:'siblings', label:'Siblings', icon:'👫', color:'#80cbc4',
hint:'How many brothers or sisters do you have?',
fields:['number_of_brothers_and_sisters'] },
{ id:'best_friend', label:'Best Friend', icon:'🤝', color:'#ffab91',
hint:"What's your best friend's name?",
fields:['best_friend_name'] },
{ id:'profession', label:'Profession', icon:'💼', color:'#ffb74d',
hint:'What do you do for work?',
fields:['profession'] },
{ id:'education', label:'Education', icon:'🎓', color:'#ff8a65',
hint:'What did you study?',
fields:['education'] },
{ id:'sport_play', label:'Sport I play',icon:'🏃', color:'#ef9a9a',
hint:'What sports do you play?',
fields:['sports_played'] },
{ id:'sport_watch', label:'Sport I watch',icon:'🏟️', color:'#90caf9',
hint:'What sports do you follow?',
fields:['sports_watched'] },
{ id:'team', label:'My Team', icon:'🏆', color:'#a5d6a7',
hint:"What's your favourite team?",
fields:['favorite_team'] },
{ id:'hobbies', label:'Hobbies', icon:'🎨', color:'#ce93d8',
hint:'What do you love doing in your free time?',
fields:['hobbies'] },
{ id:'interests', label:'Interests', icon:'💡', color:'#80cbc4',
hint:'Topics that fascinate you — ideas, fields, passions.',
fields:['interests'] },
{ id:'food', label:'Food', icon:'🍝', color:'#ffab91',
hint:"What's your favourite food?",
fields:['favorite_food'] },
{ id:'music', label:'Music', icon:'🎵', color:'#b39ddb',
hint:'Favourite music genre?',
fields:['favorite_music'] },
{ id:'band', label:'Band', icon:'🎸', color:'#9fa8da',
hint:'Favourite band?',
fields:['favorite_band'] },
{ id:'singer', label:'Singer', icon:'🎤', color:'#f06292',
hint:'Favourite singer?',
fields:['favorite_singer'] },
{ id:'movie', label:'Movie', icon:'🎬', color:'#ff8a65',
hint:'Favourite film?',
fields:['favorite_movie'] },
{ id:'actor', label:'Actor', icon:'⭐', color:'#ffd54f',
hint:'Favourite actor?',
fields:['favorite_actor'] },
{ id:'director', label:'Director', icon:'🎥', color:'#ef9a9a',
hint:'Favourite film director?',
fields:['favorite_movie_maker'] },
{ id:'book', label:'Book', icon:'📖', color:'#90caf9',
hint:'Favourite book?',
fields:['favorite_book'] },
{ id:'writer', label:'Writer', icon:'✒️', color:'#a5d6a7',
hint:'Favourite writer?',
fields:['favorite_writer'] },
{ id:'color', label:'Colour', icon:'🌈', color:'#e8eaf6',
hint:'Favourite colour?',
fields:['favorite_color'] },
{ id:'animal', label:'Animal', icon:'🦁', color:'#c5e1a5',
hint:'Favourite animal?',
fields:['favorite_animal'] },
{ id:'season', label:'Season', icon:'🍂', color:'#ffe0b2',
hint:'Favourite season?',
fields:['favorite_season'] },
{ id:'pets', label:'Pets', icon:'🐾', color:'#f8bbd0',
hint:'Do you have any pets?',
fields:['pets'] },
{ id:'language', label:'Language', icon:'🌐', color:'#5dade2',
hint:'Which language do you prefer?',
fields:[] },
];
// Size per bubble — intentional variety, not changed at runtime
const BUBBLE_SIZE_KEYS = [
'md', // avatar
'md','sm','md','sm', // name, birthday, home, roots
'sm','sm','sm','sm', // partner, kids, siblings, best_friend
'md','sm', // profession, education
'sm','sm','sm', // sport_play, sport_watch, team
'md','lg', // hobbies, interests
'sm', // food
'sm','sm','sm', // music, band, singer
'sm','sm','xs', // movie, actor, director
'sm','sm', // book, writer
'xs','xs','xs', // color, animal, season
'xs', // pets
'sm', // language
];
const BUBBLE_SIZES = { nano:{w:22,h:18}, xxs:{w:40,h:32}, xs:{w:80,h:65}, sm:{w:155,h:115}, md:{w:195,h:145}, lg:{w:235,h:170} };
const PRIORITY_SIZE = { w:270, h:200 };
const BUBBLE_GAP = 8; // tight gap — bubbles can be close, priority pulse overlaps a little
// Organic growth from canvas centre: first bubble at centre, each subsequent one
// attaches adjacent to a random already-placed bubble (dist = r_A + r_B + gap).
// Returns { results: positions[], placed: [{cx,cy,r}] }
function _generatePositions(canvasW, canvasH, bubSpecs, sizes = BUBBLE_SIZES) {
const placed = [];
const results = [];
const cx0 = canvasW / 2, cy0 = canvasH / 2;
for (let i = 0; i < bubSpecs.length; i++) {
const szKey = bubSpecs[i].szKey;
const { w, h } = sizes[szKey];
const r = Math.max(w, h) / 2;
let pos = null;
if (placed.length === 0) {
pos = { cx: cx0, cy: cy0 };
} else {
const shuffled = [...placed].sort(() => Math.random() - 0.5);
outer: for (const par of shuffled) {
const dist = par.r + r + BUBBLE_GAP;
for (let a = 0; a < 16; a++) {
const angle = Math.random() * 2 * Math.PI;
const cx = par.cx + Math.cos(angle) * dist;
const cy = par.cy + Math.sin(angle) * dist;
if (cx < r || cx > canvasW - r || cy < r || cy > canvasH - r) continue;
let ok = true;
for (const p of placed) {
if ((cx - p.cx) ** 2 + (cy - p.cy) ** 2 < (p.r + r + BUBBLE_GAP) ** 2) { ok = false; break; }
}
if (ok) { pos = { cx, cy }; break outer; }
}
}
}
// Random fallback if organic placement failed (canvas too full)
if (!pos) {
for (let t = 0; t < 800 && !pos; t++) {
const cx = r + Math.random() * (canvasW - 2 * r);
const cy = r + Math.random() * (canvasH - 2 * r);
let ok = true;
for (const p of placed) {
if ((cx - p.cx) ** 2 + (cy - p.cy) ** 2 < (p.r + r + BUBBLE_GAP) ** 2) { ok = false; break; }
}
if (ok) pos = { cx, cy };
}
}
if (!pos) pos = { cx: r + 2, cy: r + 2 + placed.reduce((m, p) => Math.max(m, p.cy + p.r), 0) + r + 4 };
placed.push({ cx: pos.cx, cy: pos.cy, r });
const rot = Math.round(Math.random() * 26 - 13);
results.push({ x: pos.cx - w / 2, y: pos.cy - h / 2, szKey, rot });
}
return { results, placed };
}
// Fill remaining canvas space with tiny deco bubbles (nano/xxs) via random scatter.
// Small radii let them squeeze into the interstices between labelled bubbles.
// Returns [{cx, cy, szKey}]
function _fillGaps(canvasW, canvasH, placed, scaledSizes) {
const DECO_SZ = ['nano', 'nano', 'xxs', 'nano', 'nano', 'xxs', 'nano'];
const all = placed.map(p => ({ ...p }));
const out = [];
for (let i = 0; i < 200; i++) {
const szKey = DECO_SZ[i % DECO_SZ.length];
const { w, h } = scaledSizes[szKey];
const r = Math.max(w, h) / 2;
let pos = null;
for (let t = 0; t < 80 && !pos; t++) {
const cx = r + Math.random() * (canvasW - 2 * r);
const cy = r + Math.random() * (canvasH - 2 * r);
let ok = true;
for (const p of all) {
if ((cx - p.cx) ** 2 + (cy - p.cy) ** 2 < (p.r + r + BUBBLE_GAP) ** 2) { ok = false; break; }
}
if (ok) pos = { cx, cy };
}
if (pos) {
all.push({ cx: pos.cx, cy: pos.cy, r });
out.push({ cx: pos.cx, cy: pos.cy, szKey });
}
}
return out;
}
// ── Shared circular-bubble physics engine ─────────────────
let _physCtrl = null; // personal-info field controller
let _libCtrl = null; // library field controller
let _introCtrl = null; // intro/landing field controller
let _introBuildId = 0;
// WebAudio bubble sounds (pop on collision + subtle ambient while moving).
// Synthesised so there is no asset dependency. Created lazily on first gesture.
const bubbleAudio = {
ctx:null, ambGain:null, ambOn:false, muted:false,
ensure() {
if (this.ctx) { if (this.ctx.state === 'suspended') this.ctx.resume(); return; }
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return;
this.ctx = new AC();
// Ambient: soft brown-ish noise through a low-pass filter, gain ramps with motion.
const len = this.ctx.sampleRate * 2;
const buf = this.ctx.createBuffer(1, len, this.ctx.sampleRate);
const d = buf.getChannelData(0);
let last = 0;
for (let i = 0; i < len; i++) { const w = Math.random() * 2 - 1; last = (last + 0.02 * w) / 1.02; d[i] = last * 3.2; }
const src = this.ctx.createBufferSource(); src.buffer = buf; src.loop = true;
const lp = this.ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 430;
const g = this.ctx.createGain(); g.gain.value = 0;
src.connect(lp); lp.connect(g); g.connect(this.ctx.destination);
src.start();
this.ambGain = g;
},
pop() {
if (this.muted) return;
this.ensure(); if (!this.ctx) return;
const t = this.ctx.currentTime;
const o = this.ctx.createOscillator(), g = this.ctx.createGain();
o.type = 'sine';
const f = 420 + Math.random() * 260;
o.frequency.setValueAtTime(f, t);
o.frequency.exponentialRampToValueAtTime(Math.max(80, f * 0.5), t + 0.12);
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(0.15, t + 0.008);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.16);
o.connect(g); g.connect(this.ctx.destination);
o.start(t); o.stop(t + 0.18);
},
ambient(on) {
if (!this.ctx || !this.ambGain) return;
on = on && !this.muted;
if (on === this.ambOn) return;
this.ambOn = on;
const t = this.ctx.currentTime;
this.ambGain.gain.cancelScheduledValues(t);
this.ambGain.gain.setTargetAtTime(on ? 0.055 : 0.0, t, 0.25);
},
toggleMute() { this.muted = !this.muted; if (this.muted) this.ambient(false); return this.muted; },
};
// Organic circle packing: first bubble at centre, each next attaches adjacent to a
// random already-placed bubble. Returns [{cx, cy}] (centres). gap = spacing px.
function packCircles(W, H, radii, gap) {
const placed = [], cx0 = W / 2, cy0 = H / 2;
const fits = (cx, cy, r) => {
if (cx < r || cx > W - r || cy < r || cy > H - r) return false;
for (const p of placed) if ((cx - p.cx) ** 2 + (cy - p.cy) ** 2 < (p.r + r + gap) ** 2) return false;
return true;
};
for (let i = 0; i < radii.length; i++) {
const r = radii[i];
let pos = null;
if (placed.length === 0) { pos = { cx: cx0, cy: cy0 }; }
else {
const shuffled = [...placed].sort(() => Math.random() - 0.5);
outer: for (const par of shuffled) {
const dist = par.r + r + gap;
for (let a = 0; a < 18; a++) {
const ang = Math.random() * 2 * Math.PI;
const cx = par.cx + Math.cos(ang) * dist, cy = par.cy + Math.sin(ang) * dist;
if (fits(cx, cy, r)) { pos = { cx, cy }; break outer; }
}
}
}
if (!pos) for (let t = 0; t < 800 && !pos; t++) {
const cx = r + Math.random() * (W - 2 * r), cy = r + Math.random() * (H - 2 * r);
if (fits(cx, cy, r)) pos = { cx, cy };
}
if (!pos) {
// Spiral fallback (golden angle) so two unplaceable circles never coincide;
// physics then resolves any residual overlap.
const a = i * 2.399963, rr = (Math.min(W, H) / 2) * Math.sqrt((i + 1) / radii.length);
pos = { cx: Math.min(Math.max(r, cx0 + Math.cos(a) * rr), W - r),
cy: Math.min(Math.max(r, cy0 + Math.sin(a) * rr), H - r) };
}
placed.push({ cx: pos.cx, cy: pos.cy, r });
}
return placed;
}
// Drives a set of circular bubble nodes: integration, walls, collisions, drag, tap.
// nodes: [{ el, restX, restY, r, interactive, ... }] — el is a .pbub-node, centred at rest.
// opts.onTap(node, idx) fires on a tap (press without significant drag) of an interactive node.
function runBubblePhysics(canvas, nodes, opts = {}) {
const onTap = opts.onTap || (() => {});
const N = nodes.length;
const posX = new Float64Array(N), posY = new Float64Array(N);
const velX = new Float64Array(N), velY = new Float64Array(N);
const restX = nodes.map(n => n.restX), restY = nodes.map(n => n.restY), rad = nodes.map(n => n.r);
const W = canvas.clientWidth || canvas.offsetWidth, H = canvas.clientHeight || canvas.offsetHeight;
const DAMP = 0.93, BOUNCE = 0.5, MAXOFF = 800;
const bound = opts.boundary || null; // {cx, cy, r} → soft circular wall (mobile-style)
const BOUND_K = 0.2; // elastic strength: push-back ∝ overshoot
const interactive = nodes.map(n => !!n.interactive);
let dragIdx = -1, downIdx = -1, moved = false, cooldown = 0, running = true, raf = 0;
let startPX = 0, startPY = 0, startBX = 0, startBY = 0, lastX = 0, lastY = 0;
nodes.forEach((n, i) => {
n.el.style.left = (restX[i] - rad[i]) + 'px';
n.el.style.top = (restY[i] - rad[i]) + 'px';
n.el.style.width = (2 * rad[i]) + 'px';
n.el.style.height = (2 * rad[i]) + 'px';
n.el.style.transform = 'translate(0px,0px)';
});
function frame() {
if (!running) return;
for (let j = 0; j < N; j++) {
if (j === dragIdx) continue;
velX[j] *= DAMP; velY[j] *= DAMP;
posX[j] = Math.max(-MAXOFF, Math.min(MAXOFF, posX[j] + velX[j]));
posY[j] = Math.max(-MAXOFF, Math.min(MAXOFF, posY[j] + velY[j]));
}
// Walls: a circular boundary when provided (keeps the whole cluster inside a
// circle, like the mobile app's CIRCLE_R); otherwise the rectangular canvas edges.
for (let j = 0; j < N; j++) {
if (j === dragIdx) continue;
const r = rad[j];
if (bound) {
const dx = (restX[j] + posX[j]) - bound.cx, dy = (restY[j] + posY[j]) - bound.cy;
const maxd = bound.r - r, d = Math.hypot(dx, dy);
if (d > maxd && d > 0.001) {
// Elastic wall: the further past the circle a bubble is, the harder it's
// sprung back in (push-back ∝ overshoot), like the mobile boundary.
const excess = d - maxd, nx = dx / d, ny = dy / d;
velX[j] -= nx * excess * BOUND_K;
velY[j] -= ny * excess * BOUND_K;
}
} else {
let cx = restX[j] + posX[j], cy = restY[j] + posY[j];
if (cx < r) { posX[j] += (r - cx); velX[j] = Math.abs(velX[j]) * BOUNCE; }
else if (cx > W-r) { posX[j] -= (cx - (W - r)); velX[j] = -Math.abs(velX[j]) * BOUNCE; }
if (cy < r) { posY[j] += (r - cy); velY[j] = Math.abs(velY[j]) * BOUNCE; }
else if (cy > H-r) { posY[j] -= (cy - (H - r)); velY[j] = -Math.abs(velY[j]) * BOUNCE; }
}
}
if (cooldown > 0) cooldown--;
for (let iter = 0; iter < 2; iter++) {
for (let a = 0; a < N; a++) {
const ra = rad[a];
for (let b = a + 1; b < N; b++) {
const rb = rad[b];
const ddx = (restX[b] + posX[b]) - (restX[a] + posX[a]);
const ddy = (restY[b] + posY[b]) - (restY[a] + posY[a]);
const minD = ra + rb, d2 = ddx * ddx + ddy * ddy;
if (d2 < minD * minD) {
const d = Math.sqrt(d2);
let nx, ny, ov;
if (d < 0.01) {
// Coincident centres — pick a deterministic direction so they separate.
const ang = a * 12.9898 + b * 78.233;
nx = Math.cos(ang); ny = Math.sin(ang); ov = minD;
} else {
nx = ddx / d; ny = ddy / d; ov = minD - d;
}
const aMov = a !== dragIdx, bMov = b !== dragIdx;
if (aMov && bMov) {
const half = ov * 0.5, imp = ov * 0.06;
posX[a] -= nx * half; posY[a] -= ny * half; posX[b] += nx * half; posY[b] += ny * half;
velX[a] -= nx * imp; velY[a] -= ny * imp; velX[b] += nx * imp; velY[b] += ny * imp;
} else if (aMov) {
posX[a] -= nx * ov; posY[a] -= ny * ov; velX[a] -= nx * ov * 0.12; velY[a] -= ny * ov * 0.12;
} else if (bMov) {
posX[b] += nx * ov; posY[b] += ny * ov; velX[b] += nx * ov * 0.12; velY[b] += ny * ov * 0.12;
}
// Pop only when the dragged bubble hits a labelled (interactive) bubble —
// decorative/unlabelled bubbles collide silently.
if ((a === dragIdx || b === dragIdx) && cooldown <= 0) {
const other = a === dragIdx ? b : a;
if (interactive[other]) { bubbleAudio.pop(); cooldown = 8; }
}
}
}
}
}
let moving = dragIdx >= 0;
for (let j = 0; j < N; j++) {
nodes[j].el.style.transform = `translate(${posX[j].toFixed(1)}px,${posY[j].toFixed(1)}px)`;
if (Math.abs(velX[j]) + Math.abs(velY[j]) > 0.45) moving = true;
}
bubbleAudio.ambient(moving);
raf = requestAnimationFrame(frame);
}
raf = requestAnimationFrame(frame);
function pt(e) { const r = canvas.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; }
function onMove(e) {
if (downIdx < 0) return;
const i = downIdx, p = pt(e);
if (Math.abs(p.x - startPX) + Math.abs(p.y - startPY) > 6) moved = true;
velX[i] = p.x - lastX; velY[i] = p.y - lastY; lastX = p.x; lastY = p.y;
posX[i] = Math.max(-MAXOFF, Math.min(MAXOFF, startBX + (p.x - startPX)));
posY[i] = Math.max(-MAXOFF, Math.min(MAXOFF, startBY + (p.y - startPY)));
// The dragged bubble follows the finger freely; once released, the elastic
// wall springs it (and any bubbles it shoved out) back inside the circle.
}
function onUp() {
if (downIdx < 0) return;
const i = downIdx; nodes[i].el.classList.remove('dragging');
if (!moved) onTap(nodes[i], i);
downIdx = -1; dragIdx = -1;
}
const downHandlers = [];
nodes.forEach((n, i) => {
if (!n.interactive) return;
const h = (e) => {
bubbleAudio.ensure();
downIdx = i; dragIdx = i; moved = false;
const p = pt(e); startPX = p.x; startPY = p.y; lastX = p.x; lastY = p.y;
startBX = posX[i]; startBY = posY[i];
n.el.classList.add('dragging');
try { n.el.setPointerCapture(e.pointerId); } catch (_) {}
e.preventDefault();
};
n.el.addEventListener('pointerdown', h);
downHandlers.push([n.el, h]);
});
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', onUp);
return {
stop() {
running = false;
cancelAnimationFrame(raf);
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onUp);
downHandlers.forEach(([el, h]) => el.removeEventListener('pointerdown', h));
bubbleAudio.ambient(false);
},
};
}
let bubbleDraft = {};
let profileDirty = false;
let activeBubId = null;
let expOriginalVal = '';
let micRecog = null;
let userAvatarUrl = null;
function profileFieldsToText(bub, profile) {
const parts = [];
for (const f of bub.fields) {
const v = profile[f];
if (v == null || v === '') continue;
if (Array.isArray(v)) {
if (f === 'kids') parts.push(...v.map(k => `${k.name} (age ${k.age})`));
else parts.push(...v.filter(Boolean));
} else {
parts.push(String(v));
}
}
return parts.join(', ');
}
let _physBuildId = 0;
function buildBubbleCanvas(profile = {}) {
const myBuild = ++_physBuildId;
const canvas = document.getElementById('bubble-canvas');
if (_physCtrl) { _physCtrl.stop(); _physCtrl = null; }
canvas.innerHTML = '';
// Pre-populate draft from saved profile
PROFILE_BUBBLES.forEach(bub => {
const existing = profileFieldsToText(bub, profile);
if (existing && !bubbleDraft[bub.id]) bubbleDraft[bub.id] = existing;
});
// Always init language bubble from current userLang
const _curLangOpt = LANG_OPTIONS.find(l => l.code === userLang);
bubbleDraft.language = _curLangOpt ? `${_curLangOpt.flag} ${_curLangOpt.native}` : userLang;
// Avatar bubble is "filled" whenever a photo has been uploaded
if (userAvatarUrl) bubbleDraft.avatar = userAvatarUrl;
// Wait one frame so the canvas has its real dimensions
requestAnimationFrame(() => {
if (myBuild !== _physBuildId) return; // superseded by a newer build
// Pin canvas to the remaining viewport height so bubbles always fill the screen
const headerH = (document.getElementById('profile-header') || {}).offsetHeight || 80;
const vpH = Math.max(480, window.innerHeight - headerH);
canvas.style.height = vpH + 'px';
canvas.style.minHeight = vpH + 'px';
const W = canvas.offsetWidth || window.innerWidth || 720;
const H = vpH;
// First empty bubble = priority (larger + pulsing)
const priorityBub = PROFILE_BUBBLES.find(b => !bubbleDraft[b.id]);
// Circular diameter per bubble from its size key
const baseDia = PROFILE_BUBBLES.map((b, i) => {
const sz = BUBBLE_SIZES[BUBBLE_SIZE_KEYS[i]];
let d = Math.max(sz.w, sz.h);
if (priorityBub && b.id === priorityBub.id) d = Math.max(d, PRIORITY_SIZE.w);
return d;
});
const DECO_COUNT = 24;
const decoDia = Array.from({ length: DECO_COUNT }, (_, i) => [54, 70, 46][i % 3]);
// Scale so labelled circles cover ~62% of the canvas
const labeledArea = baseDia.reduce((s, d) => s + Math.PI * (d / 2) ** 2, 0);
const scale = Math.max(0.32, Math.min(1.3, Math.sqrt(0.62 * W * H / labeledArea)));
canvas.style.setProperty('--bub-scale', scale);
const radii = baseDia.map(d => (d * scale) / 2);
const decoRadii = decoDia.map(d => (d * scale) / 2);
const centers = packCircles(W, H, radii.concat(decoRadii), 6 * scale);
const nodes = [];
PROFILE_BUBBLES.forEach((bub, i) => {
const c = centers[i], r = radii[i];
const filled = !!bubbleDraft[bub.id];
const trans = getBubTrans(i);
const node = document.createElement('div');
node.className = 'pbub-node';
const inner = document.createElement('div');
inner.className = `pbub circ${filled ? ' filled' : ''}`;
inner.dataset.bubId = bub.id;
inner.style.background = bub.color + (filled ? '55' : '1a');
inner.style.borderColor = bub.color + (filled ? 'bb' : '55');
inner.style.setProperty('--bc-rgb', hexToRgb(bub.color));
if (priorityBub && bub.id === priorityBub.id) inner.classList.add('priority');
if (bub.id === 'avatar') {
const thumbSrc = userAvatarUrl || '/avatars/user_avatar.png';
inner.innerHTML = `<img class="pbub-avatar-thumb" src="${thumbSrc}" alt="My avatar"><span class="pbub-label">${trans.label}</span>`;
} else {
const previewText = filled ? (bubbleDraft[bub.id] || '').split('\n')[0].trim() : '';
const previewHtml = previewText ? `<span class="pbub-preview">${esc(previewText)}</span>` : '';
inner.innerHTML = `<span class="pbub-icon">${bub.icon}</span><span class="pbub-label">${trans.label}</span>${previewHtml}`;
}
node.appendChild(inner);
canvas.appendChild(node);
nodes.push({ el: node, restX: c.cx, restY: c.cy, r, interactive: true, bubId: bub.id });
});
// Decorative (non-interactive) background bubbles that jostle with the rest —
// tinted with the same palette as the intro screen's deco field so they read
// clearly against the dark background instead of nearly-invisible flat white.
for (let k = 0; k < DECO_COUNT; k++) {
const c = centers[PROFILE_BUBBLES.length + k];
if (!c) continue;
const r = decoRadii[k];
const node = document.createElement('div');
node.className = 'pbub-node';
const inner = document.createElement('div');
inner.className = 'pbub circ pbub-deco';
const dc = INTRO_DECO_COLORS[k % INTRO_DECO_COLORS.length];
inner.style.background = dc + '2e';
inner.style.borderColor = dc + '70';
node.appendChild(inner);
canvas.appendChild(node);
nodes.push({ el: node, restX: c.cx, restY: c.cy, r, interactive: false });
}
_physCtrl = runBubblePhysics(canvas, nodes, {
onTap: (n) => { if (n.bubId) openExpansion(n.bubId); },
});
});
}
function hexToRgb(hex) {
const h = hex.replace('#', '');
return `${parseInt(h.slice(0,2),16)},${parseInt(h.slice(2,4),16)},${parseInt(h.slice(4,6),16)}`;
}
function updatePriorityBubble() {
// Pulse marker on the first still-empty bubble. The larger radius is applied at
// build time, so here we only toggle the pulsing class.
document.querySelectorAll('#bubble-canvas .pbub.priority').forEach(el => el.classList.remove('priority'));
const first = PROFILE_BUBBLES.find(b => !bubbleDraft[b.id]);
if (!first) return;
const el = document.querySelector(`#bubble-canvas [data-bub-id="${first.id}"]`);
if (el) el.classList.add('priority');
}
function openExpansion(bubId) {
const bub = PROFILE_BUBBLES.find(b => b.id === bubId);
if (!bub) return;
const bubIdx = PROFILE_BUBBLES.indexOf(bub);
const trans = getBubTrans(bubIdx);
activeBubId = bubId;
expOriginalVal = bubbleDraft[bubId] || '';
_pendingLang = null;
const card = document.getElementById('exp-card');
card.style.setProperty('--exp-color', bub.color);
document.getElementById('exp-icon').textContent = bub.icon;
document.getElementById('exp-lbl').textContent = trans.label;
document.getElementById('exp-hint').textContent = trans.hint;
const s = UI_STRINGS[userLang] || UI_STRINGS.en;
const ta = document.getElementById('exp-textarea');
const langGrid = document.getElementById('exp-lang-grid');
const avatarBox = document.getElementById('exp-avatar-actions');
const isLang = bubId === 'language';
const isAvatar = bubId === 'avatar';
ta.style.display = (isLang || isAvatar) ? 'none' : '';
document.getElementById('exp-mic').style.display = (isLang || isAvatar) ? 'none' : '';
langGrid.style.display = isLang ? 'flex' : 'none';
avatarBox.style.display = isAvatar ? 'flex' : 'none';
document.getElementById('exp-done').textContent = s.exp_done;
if (isLang) {
langGrid.innerHTML = '';
for (const l of LANG_OPTIONS) {
const btn = document.createElement('button');
btn.className = 'lang-btn-sm' + (l.code === userLang ? ' active' : '');
btn.textContent = `${l.flag} ${l.native}`;
btn.addEventListener('click', () => {
langGrid.querySelectorAll('.lang-btn-sm').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_pendingLang = l.code;
});
langGrid.appendChild(btn);
}
} else if (isAvatar) {
document.getElementById('exp-avatar-preview').src = userAvatarUrl || '/avatars/user_avatar.png';
document.getElementById('exp-avatar-btn').textContent = s.avatar_upload_btn;
} else {
ta.value = expOriginalVal;
setTimeout(() => ta.focus(), 60);
}
document.getElementById('exp-done').style.background = bub.color;
document.getElementById('bub-overlay').classList.add('open');
}
function closeExpansion() {
if (activeBubId) {
if (activeBubId === 'language') {
if (_pendingLang && _pendingLang !== userLang) {
userLang = _pendingLang;
localStorage.setItem('userLang', userLang);
saveLangToSupabase(userLang);
applyUiStrings();
const chosen = LANG_OPTIONS.find(l => l.code === userLang);
bubbleDraft.language = chosen ? `${chosen.flag} ${chosen.native}` : userLang;
profileDirty = true;
}
} else if (activeBubId === 'avatar') {
// Avatar is saved directly by the file-input upload handler — nothing to persist here.
} else {
const val = document.getElementById('exp-textarea').value.trim();
if (val !== expOriginalVal) profileDirty = true;
bubbleDraft[activeBubId] = val;
}
const bubEl = document.querySelector(`[data-bub-id="${activeBubId}"]`);
if (bubEl) {
const bub = PROFILE_BUBBLES.find(b => b.id === activeBubId);
const filled = activeBubId === 'language'
? !!bubbleDraft.language
: activeBubId === 'avatar'
? !!bubbleDraft.avatar
: !!(document.getElementById('exp-textarea').value.trim());
bubEl.classList.toggle('filled', filled);
if (bub) bubEl.style.borderColor = bub.color + (filled ? 'bb' : '55');
}
}
document.getElementById('bub-overlay').classList.remove('open');
stopMic();
activeBubId = null;
_pendingLang = null;
updatePriorityBubble();
buildBubbleCanvas();
}
document.getElementById('exp-close').addEventListener('click', closeExpansion);
document.getElementById('exp-done').addEventListener('click', closeExpansion);
document.getElementById('bub-overlay').addEventListener('click', e => {
if (e.target === document.getElementById('bub-overlay')) closeExpansion();
});
document.getElementById('exp-avatar-btn').addEventListener('click', () => {
document.getElementById('avatar-file-input').click();
});
// ── Speech recognition ───────────────────────────────────
function stopMic() {
if (micRecog) { try { micRecog.stop(); } catch {} micRecog = null; }
document.getElementById('exp-mic').classList.remove('recording');
}
document.getElementById('exp-mic').addEventListener('click', () => {
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SR) { alert('Speech recognition is not available in this browser.'); return; }
if (micRecog) { stopMic(); return; }
micRecog = new SR();
micRecog.continuous = false;
micRecog.interimResults = false;
micRecog.lang = navigator.language || 'en-US';
micRecog.onresult = e => {
const spoken = e.results[0][0].transcript;
const ta = document.getElementById('exp-textarea');
ta.value = (ta.value ? ta.value + ' ' : '') + spoken;
profileDirty = true;
};
micRecog.onend = () => { micRecog = null; document.getElementById('exp-mic').classList.remove('recording'); };
micRecog.onerror = () => stopMic();
micRecog.start();
document.getElementById('exp-mic').classList.add('recording');
});
// ── Avatar upload ──────────────────────────────────────────
document.getElementById('avatar-file-input').addEventListener('change', async (e) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
const preview = document.getElementById('exp-avatar-preview');
// Instant local preview while the upload is in flight.
const localUrl = URL.createObjectURL(file);
if (activeBubId === 'avatar') preview.src = localUrl;
try {
const form = new FormData();
form.append('file', file, file.name);
const res = await fetch('/profile/avatar', {
method:'POST', headers:{ 'Authorization': `Bearer ${token}` }, body: form,
});
if (res.status === 401) { sb.auth.signOut(); return; }
const data = await res.json();
if (res.ok && data.status === 'success' && data.avatar_url) {
userAvatarUrl = data.avatar_url;
bubbleDraft.avatar = userAvatarUrl;
if (activeBubId === 'avatar') preview.src = userAvatarUrl;
updatePriorityBubble();
buildBubbleCanvas();
showProfileToast('Photo updated ✓');
} else {
if (activeBubId === 'avatar') preview.src = userAvatarUrl || '/avatars/user_avatar.png';
showProfileToast('Could not upload photo');
}
} catch {
if (activeBubId === 'avatar') preview.src = userAvatarUrl || '/avatars/user_avatar.png';
showProfileToast('Could not upload photo');
} finally {
URL.revokeObjectURL(localUrl);
}
});
// ── Load / save profile ──────────────────────────────────
async function loadAndBuildProfile() {
try {
const res = await fetch('/profile/load', { headers:{'Authorization':`Bearer ${token}`} });
if (res.ok) {
const { profile, avatar_url, character_model_prefs } = await res.json();
userAvatarUrl = avatar_url || null;
if (character_model_prefs) characterModelPrefs = character_model_prefs;
buildBubbleCanvas(profile);
return;
}
} catch {}
buildBubbleCanvas({});
}
async function saveProfile() {
if (!profileDirty) return;
const nonEmpty = Object.fromEntries(Object.entries(bubbleDraft).filter(([,v]) => v.trim()));
if (!Object.keys(nonEmpty).length) return;
showProfileToast('Saving…');
try {
const res = await fetch('/profile/save', {
method:'POST',
headers:{'Authorization':`Bearer ${token}`, 'Content-Type':'application/json'},
body: JSON.stringify({ bubble_texts: nonEmpty, lang: userLang }),
});
profileDirty = false;
showProfileToast(res.ok ? 'Saved ✓' : 'Could not save');
} catch {
showProfileToast('Could not save');
}
}
function showProfileToast(msg) {
const el = document.getElementById('profile-toast');
el.textContent = msg;
el.classList.add('show');
clearTimeout(el._t);
el._t = setTimeout(() => el.classList.remove('show'), 2600);
}
function injectCieloWelcome() {
if (localStorage.getItem('cieloWelcomeDone')) return;
localStorage.setItem('cieloWelcomeDone', '1');
activateChar('cielo');
const msg = CIELO_WELCOME[userLang] || CIELO_WELCOME.en;
const list = document.getElementById('msgs-list');
addBubble(list, 'assistant', msg, 'cielo');
scrollDown();
}
// ── Library bubble screen (characters → section → items) ──
let libNav = { level: 0, charId: null, section: null };
function openLibraryScreen() {
// If the active character has a library, open straight into its sections;
// otherwise start at the all-characters level.
if (LIBRARY[activeChar]) libNav = { level: 1, charId: activeChar, section: null };
else libNav = { level: 0, charId: null, section: null };
_hideAll();
document.getElementById('library-screen').style.display = 'flex';
buildLibLevel();
}
function backToChat() {
_hideAll();
document.getElementById('chat-screen').style.display = 'flex';
}
function libBack() {
if (libNav.level === 2) { libNav.level = 1; libNav.section = null; buildLibLevel(); }
else if (libNav.level === 1) { libNav.level = 0; libNav.charId = null; buildLibLevel(); }
else backToChat();
}
async function _libProgress(charId, section) {
const map = {};
try {
const { data: { user } } = await sb.auth.getUser();
if (!user) return map;
if (section === 'philosophy') {
const { data } = await sb.from('user_philosophy_progress')
.select('thread_index,current_level').eq('user_id', user.id).eq('character_id', charId);
(data || []).forEach(r => { map[r.thread_index] = r.current_level; });
} else {
const { data } = await sb.from('user_character_story_progress')
.select('chapter_index').eq('user_id', user.id).eq('character_id', charId).maybeSingle();
map.story = data ? data.chapter_index : -1;
}
} catch (_) {}
return map;
}
let _libBuildId = 0;
// Fixed (non-draggable) two-circle / ring cluster layout, mirroring the mobile
// app's BubbleCluster: a faint parent ring, with either two fixed side-by-side
// circles (n===2, used for the Philosophy/Story picker) or the items spaced
// evenly around a ring (used for the topic list within a section).
function _clusterLayout(n, R) {
if (n === 2) {
const size = R * 0.9, dx = R * 0.5;
return [ { cx: -dx, cy: 0, size }, { cx: dx, cy: 0, size } ];
}
const size = R * (n <= 4 ? 0.62 : n <= 6 ? 0.52 : 0.42);
const ringR = R - size / 2 - 6;
return Array.from({ length: n }, (_, i) => {
const a = (i / n) * Math.PI * 2 - Math.PI / 2;
return { cx: Math.cos(a) * ringR, cy: Math.sin(a) * ringR, size };
});
}
// Same book glyph as the topbar's Library button, reused in the breadcrumb so
// the icon reads as one consistent symbol across the app.
const LIB_ICON_SVG = '<span class="crumb-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 6.5c-1.6-1.3-3.6-2-6.5-2-.6 0-1 .4-1 1v11c0 .6.4 1 1 1 2.6 0 4.6.6 6.5 2 1.9-1.4 3.9-2 6.5-2 .6 0 1-.4 1-1v-11c0-.6-.4-1-1-1-2.9 0-4.9.7-6.5 2z"/><path d="M12 6.5v13"/></svg></span>';
function renderLibCluster(canvas, parentColor, items) {
requestAnimationFrame(() => {
const headerH = (document.getElementById('library-header') || {}).offsetHeight || 64;
const vpH = Math.max(440, window.innerHeight - headerH);
canvas.style.height = vpH + 'px';
const W = canvas.offsetWidth || window.innerWidth || 720, H = vpH;
const cx = W / 2, cy = H / 2;
const R = Math.min(W, H) * 0.42;
const ring = document.createElement('div');
ring.className = 'lib-cluster-ring';
ring.style.cssText = `left:${cx - R}px; top:${cy - R}px; width:${R * 2}px; height:${R * 2}px; border-color:${parentColor}70; background:${parentColor}26;`;
canvas.appendChild(ring);
const positions = _clusterLayout(items.length, R);
items.forEach((it, i) => {
const p = positions[i];
const color = it.color || parentColor;
const node = document.createElement('div');
node.className = 'lib-cluster-bub';
node.style.cssText = `left:${cx + p.cx - p.size / 2}px; top:${cy + p.cy - p.size / 2}px; width:${p.size}px; height:${p.size}px; background:${color}4d; border-color:${color}cc; color:${color};`;
node.innerHTML = it.html;
if (it.onTap) node.addEventListener('click', it.onTap);
canvas.appendChild(node);
});
});
}
async function buildLibLevel() {
const myBuild = ++_libBuildId;
const canvas = document.getElementById('lib-bubble-canvas');
if (_libCtrl) { _libCtrl.stop(); _libCtrl = null; }
canvas.innerHTML = '';
const crumb = document.getElementById('lib-crumb');
// Levels 1 (Philosophy/Story picker) and 2 (topic list) use a fixed,
// non-physics cluster layout — two circles (or a ring) inside a bigger
// parent circle, matching the mobile app exactly.
if (libNav.level === 1) {
const ch = charById(libNav.charId), color = (LIBRARY[libNav.charId] || {}).color || ch.color;
crumb.innerHTML = `${LIB_ICON_SVG}${esc(ch.short)}`;
const s = UI_STRINGS[userLang] || UI_STRINGS.en;
const philLabel = (s.lib_philosophy || '🏛 Philosophy').replace(/^\S+\s*/, '');
const storyLabel = (s.lib_story || '📖 Story').replace(/^\S+\s*/, '');
renderLibCluster(canvas, color, [
{ color, html: `<span class="libbub-icon">🏛</span><span class="libbub-name">${esc(philLabel)}</span>`,
onTap: () => { libNav.level = 2; libNav.section = 'philosophy'; buildLibLevel(); } },
{ color, html: `<span class="libbub-icon">📖</span><span class="libbub-name">${esc(storyLabel)}</span>`,
onTap: () => { libNav.level = 2; libNav.section = 'story'; buildLibLevel(); } },
]);
return;
}
if (libNav.level === 2) {
const ch = charById(libNav.charId), color = (LIBRARY[libNav.charId] || {}).color || ch.color;
const section = libNav.section;
const items = (LIBRARY[libNav.charId] || {})[section] || [];
crumb.innerHTML = `${LIB_ICON_SVG}<span class="crumb-dim">${esc(ch.short)} · </span>${section === 'philosophy' ? 'Philosophy' : 'Story'}`;
const prog = await _libProgress(libNav.charId, section);
const clusterItems = await Promise.all(items.map(async (item, i) => {
const [title, sub] = userLang !== 'en'
? await Promise.all([_translateLibItem(item.title, userLang), _translateLibItem(item.subtitle, userLang)])
: [item.title, item.subtitle];
const segments = section === 'philosophy'
? _levelToSegments(prog[i])
: (prog.story >= 0 && i <= prog.story ? 3 : 0);
const badge = item.level ? `<span class="libbub-badge">${item.level === 1 ? 'Intro' : 'Deep'}</span>` : '';
const bar = segments ? `<span class="libbub-progress">${[0,1,2].map(k => `<span class="libbub-seg${k < segments ? ' filled' : ''}"></span>`).join('')}</span>` : '';
return {
color,
html: `<span class="libbub-name">${esc(title)}</span>${sub ? `<span class="libbub-sub">${esc(sub)}</span>` : ''}${badge}${bar}`,
onTap: () => {
activateChar(libNav.charId);
backToChat();
const list = document.getElementById('msgs-list');
addBubble(list, 'user', item.prompt, libNav.charId);
sendToChat(item.prompt, list);
},
};
}));
if (myBuild !== _libBuildId) return; // superseded during async translation/progress work
renderLibCluster(canvas, color, clusterItems);
return;
}
// Level 0 — the full character roster (built characters in full colour,
// not-yet-developed ones greyed out) stays on the floating physics field.
crumb.innerHTML = `${LIB_ICON_SVG}Library`;
const descs = INTRO_ROSTER.map(r => {
const built = !!r.built;
const ch = built ? charById(r.id) : null;
const color = built ? (LIBRARY[r.id] || {}).color || ch.color : (r.color || '#888888');
const av = built ? ch.av : `/avatars/${r.id}_avatar.png`;
const name = built ? ch.short : r.name;
return {
color, name, av, built,
onTap: built
? () => { libNav.level = 1; libNav.charId = r.id; buildLibLevel(); }
: () => showProfileToast(`${name} — coming soon!`),
};
});
if (myBuild !== _libBuildId) return; // superseded during async work
requestAnimationFrame(() => {
if (myBuild !== _libBuildId) return; // superseded by a newer build
const headerH = (document.getElementById('library-header') || {}).offsetHeight || 64;
const vpH = Math.max(440, window.innerHeight - headerH);
canvas.style.height = vpH + 'px';
const W = canvas.offsetWidth || window.innerWidth || 720, H = vpH;
const baseDia = descs.map(() => 150);
const labeledArea = baseDia.reduce((s, d) => s + Math.PI * (d / 2) ** 2, 0);
const scale = Math.max(0.34, Math.min(1.5, Math.sqrt(0.5 * W * H / labeledArea)));
canvas.style.setProperty('--bub-scale', scale);
const radii = baseDia.map(d => (d * scale) / 2);
const DECO_COUNT = 26;
const decoRadii = Array.from({ length: DECO_COUNT }, (_, i) => ([42, 56, 34][i % 3] * scale) / 2);
const centers = packCircles(W, H, radii.concat(decoRadii), 8 * scale);
const nodes = [];
descs.forEach((d, i) => {
const c = centers[i], r = radii[i];
const node = document.createElement('div');
node.className = 'pbub-node';
const inner = document.createElement('div');
inner.className = 'pbub circ' + (d.built ? '' : ' libbub-disabled');
inner.style.background = d.built ? d.color + '33' : 'rgba(255,255,255,.06)';
inner.style.borderColor = d.built ? d.color + 'cc' : 'rgba(255,255,255,.2)';
inner.style.color = d.built ? d.color : '#8a8aa0';
inner.style.setProperty('--bc-rgb', hexToRgb(d.color));
inner.innerHTML = `<img class="libbub-av" src="${d.av}" alt=""><span class="libbub-name">${esc(d.name)}</span>`;
node.appendChild(inner);
canvas.appendChild(node);
nodes.push({ el: node, restX: c.cx, restY: c.cy, r, interactive: true, onTap: d.onTap });
});
for (let k = 0; k < DECO_COUNT; k++) {
const c = centers[descs.length + k];
if (!c) continue;
const r = decoRadii[k];
const node = document.createElement('div');
node.className = 'pbub-node';
const inner = document.createElement('div');
inner.className = 'pbub circ pbub-deco';
inner.style.background = '#ffffff08';
inner.style.borderColor = '#ffffff18';
node.appendChild(inner);
canvas.appendChild(node);
nodes.push({ el: node, restX: c.cx, restY: c.cy, r, interactive: false });
}
_libCtrl = runBubblePhysics(canvas, nodes, { onTap: (nn) => { if (nn.onTap) nn.onTap(); } });
});
}
document.getElementById('lib-back-btn').addEventListener('click', libBack);
// ── Bubble sound toggle ───────────────────────────────────
function _wireSndToggle(id) {
const b = document.getElementById(id);
if (!b) return;
b.addEventListener('click', () => {
const muted = bubbleAudio.toggleMute();
document.querySelectorAll('.snd-toggle').forEach(x => {
x.textContent = muted ? '🔇' : '🔊';
x.classList.toggle('muted', muted);
});
});
}
_wireSndToggle('profile-snd');
_wireSndToggle('library-snd');
// ── Intro / landing screen ────────────────────────────────
const INTRO_QUOTES = {
socrates: 'The unexamined life is not worth living.',
camus: 'One must imagine Sisyphus happy.',
diogenes: 'I am looking for an honest human being.',
nietzsche: 'He who has a why to live can bear almost any how.',
schopenhauer: 'Talent hits a target no one else can hit; genius hits a target no one else can see.',
plato: 'The measure of a man is what he does with power.',
aristotle: 'Knowing yourself is the beginning of all wisdom.',
epicurus: 'Not what we have, but what we enjoy, constitutes our abundance.',
zeno: 'Man conquers the world by conquering himself.',
epictetus: "It's not what happens to you, but how you react that matters.",
marcus_aurelius: 'You have power over your mind — not outside events.',
plotinus: 'Withdraw into yourself and look.',
augustine: 'Patience is the companion of wisdom.',
descartes: 'I think, therefore I am.',
hume: 'A wise man proportions his belief to the evidence.',
kant: 'Live your life as though your every act were to become a universal law.',
kierkegaard: 'Life can only be understood backwards, but it must be lived forwards.',
marx: 'The philosophers have only interpreted the world; the point is to change it.',
mill: 'Ask yourself whether you are happy, and you cease to be so.',
husserl: 'Back to the things themselves.',
heidegger: 'Every man is born as many men and dies as a single one.',
wittgenstein: 'The limits of my language mean the limits of my world.',
russell: 'The whole problem with the world is that the wise are full of doubt.',
sartre: 'Man is condemned to be free.',
beauvoir: 'One is not born, but rather becomes, a woman.',
merleau_ponty: 'The world is not what I think, but what I live through.',
buddha: 'What we think, we become.',
nagarjuna: 'Everything is real, and is not real.',
laozi: 'The journey of a thousand miles begins with a single step.',
zhuangzi: 'Flow with whatever may happen, and let your mind be free.',
confucius: 'It does not matter how slowly you go, as long as you do not stop.',
shankara: 'The world is appearance; the Self alone is real.',
krishnamurti: 'It is no measure of health to be well adjusted to a sick society.',
nishida: 'We see by becoming one with the thing seen.',
dogen: 'To study the self is to forget the self.',
};
// Full roster (mirrors the mobile CHARACTER_ROSTER). Every character shows a
// full-colour neon icon on the intro; the "not built yet" limitation is only
// surfaced inside the app after sign-in. `built` characters take their colour +
// avatar from CHARS; the rest carry their own colour and a generated avatar at
// /avatars/<id>_avatar.png. Cielo (the coordinator) is intentionally excluded.
const INTRO_ROSTER = [
{ id:'socrates', name:'Socrates', built:true },
{ id:'diogenes', name:'Diogenes', built:true },
{ id:'nietzsche', name:'Nietzsche', built:true },
{ id:'camus', name:'Camus', built:true },
{ id:'schopenhauer', name:'Schopenhauer', built:true },
{ id:'plato', name:'Plato', built:false, color:'#ffb300' },
{ id:'aristotle', name:'Aristotle', built:false, color:'#22d3ee' },
{ id:'epicurus', name:'Epicurus', built:false, color:'#84cc16' },
{ id:'zeno', name:'Zeno of Citium', built:false, color:'#14b8a6' },
{ id:'epictetus', name:'Epictetus', built:false, color:'#38bdf8' },
{ id:'marcus_aurelius', name:'Marcus Aurelius', built:false, color:'#f5a623' },
{ id:'plotinus', name:'Plotinus', built:false, color:'#2dd4bf' },
{ id:'augustine', name:'Augustine', built:false, color:'#a855f7' },
{ id:'descartes', name:'Descartes', built:false, color:'#6366f1' },
{ id:'hume', name:'Hume', built:false, color:'#3b82f6' },
{ id:'kant', name:'Kant', built:false, color:'#818cf8' },
{ id:'kierkegaard', name:'Kierkegaard', built:false, color:'#ec4899' },
{ id:'marx', name:'Marx', built:false, color:'#ef4444' },
{ id:'mill', name:'Mill', built:false, color:'#f43f5e' },
{ id:'husserl', name:'Husserl', built:false, color:'#0ea5e9' },
{ id:'heidegger', name:'Heidegger', built:false, color:'#64b5f6' },
{ id:'wittgenstein', name:'Wittgenstein', built:false, color:'#3b82f6' },
{ id:'russell', name:'Russell', built:false, color:'#06b6d4' },
{ id:'sartre', name:'Sartre', built:false, color:'#fb923c' },
{ id:'beauvoir', name:'Beauvoir', built:false, color:'#f472b6' },
{ id:'merleau_ponty', name:'Merleau-Ponty', built:false, color:'#7c9cf5' },
{ id:'buddha', name:'Buddha', built:false, color:'#fbbf24' },
{ id:'nagarjuna', name:'Nagarjuna', built:false, color:'#fb923c' },
{ id:'laozi', name:'Laozi', built:false, color:'#34d399' },
{ id:'zhuangzi', name:'Zhuangzi', built:false, color:'#22c55e' },
{ id:'confucius', name:'Confucius', built:false, color:'#e11d48' },
{ id:'shankara', name:'Adi Shankara', built:false, color:'#f59e0b' },
{ id:'krishnamurti', name:'Krishnamurti', built:false, color:'#2dd4bf' },
{ id:'nishida', name:'Nishida Kitaro', built:false, color:'#8b5cf6' },
{ id:'dogen', name:'Dōgen', built:false, color:'#10b981' },
];
// Palette for the unlabelled decorative bubbles (mirrors the mobile DECO_COLORS).
const INTRO_DECO_COLORS = [
'#1bfdc5', '#ffd54f', '#f48fb1', '#b39ddb', '#80cbc4',
'#ffab91', '#90caf9', '#a5d6a7', '#ce93d8', '#ef9a9a',
'#4fc3f7', '#aed581', '#ffb74d', '#ff8a65', '#9fa8da',
];
function openIntro() {
_hideAll();
document.getElementById('intro-screen').style.display = 'flex';
buildIntroBubbles();
}
function buildIntroBubbles() {
const myBuild = ++_introBuildId;
const canvas = document.getElementById('intro-bubble-canvas');
if (_introCtrl) { _introCtrl.stop(); _introCtrl = null; }
canvas.innerHTML = '';
requestAnimationFrame(() => {
if (myBuild !== _introBuildId) return;
const W = canvas.offsetWidth || window.innerWidth || 720;
const H = canvas.offsetHeight;
if (H < 60) return; // laid out before flex sizing settled
// Every character shows a colour + avatar. Built ones pull from CHARS;
// the rest use their roster colour and their generated /avatars/ icon.
const descs = INTRO_ROSTER.map(r => {
if (r.built) { const ch = charById(r.id); return { ...r, color: ch.color, av: ch.av, name: ch.short }; }
return { ...r, av: `/avatars/${r.id}_avatar.png` };
});
// Uniform character bubbles + a field of small unlabelled "deco" bubbles that
// fill the cluster (like the mobile app). Deco area is folded into the scale so
// everything still fits.
const baseDia = descs.map(() => 104);
const DECO_COUNT = 34; // unlabelled filler bubbles
const decoBase = Array.from({ length: DECO_COUNT }, (_, i) => (i < 8 ? 54 : 34));
const mainArea = baseDia.reduce((s, d) => s + Math.PI * (d / 2) ** 2, 0);
const decoArea = decoBase.reduce((s, d) => s + Math.PI * (d / 2) ** 2, 0);
// Pack into a centred square smaller than the canvas so the circular cluster
// has clear margin (below the title, above the text); its motion is then bounded
// to a circle (see boundary below), matching the mobile app.
const side = Math.min(W, H) * 0.82;
const scale = Math.max(0.28, Math.min(1.05, Math.sqrt(0.72 * side * side / (mainArea + decoArea))));
canvas.style.setProperty('--bub-scale', scale);
const radii = baseDia.map(d => (d * scale) / 2);
const decoRadii = decoBase.map(d => (d * scale) / 2);
const centers = packCircles(side, side, radii.concat(decoRadii), 6 * scale);
const offX = (W - side) / 2, offY = (H - side) / 2; // recentre cluster in the canvas
centers.forEach(c => { c.cx += offX; c.cy += offY; });
const nodes = [];
descs.forEach((d, i) => {
const c = centers[i], r = radii[i];
const node = document.createElement('div');
node.className = 'pbub-node';
const inner = document.createElement('div');
inner.className = 'pbub circ';
inner.style.background = d.color + '33';
inner.style.borderColor = d.color + 'cc';
inner.style.color = d.color;
inner.style.setProperty('--bc-rgb', hexToRgb(d.color));
inner.innerHTML = `<img class="libbub-av" src="${d.av}" alt=""><span class="libbub-name">${esc(d.name)}</span>`;
node.appendChild(inner);
canvas.appendChild(node);
nodes.push({ el: node, restX: c.cx, restY: c.cy, r, interactive: true, onTap: () => showIntroTeaser(d) });
});
for (let k = 0; k < DECO_COUNT; k++) {
const c = centers[descs.length + k];
if (!c) continue;
const r = decoRadii[k];
const node = document.createElement('div');
node.className = 'pbub-node';
const inner = document.createElement('div');
inner.className = 'pbub circ pbub-deco';
const dc = INTRO_DECO_COLORS[k % INTRO_DECO_COLORS.length];
inner.style.background = dc + '22';
inner.style.borderColor = dc + '55';
node.appendChild(inner);
canvas.appendChild(node);
nodes.push({ el: node, restX: c.cx, restY: c.cy, r, interactive: false });
}
// Circle that snugly contains the packed cluster — passed to the physics engine
// as a soft wall so bubbles never leave the circle (mobile CIRCLE_R behaviour).
const bcx = W / 2, bcy = H / 2;
let bR = 0;
nodes.forEach(n => { bR = Math.max(bR, Math.hypot(n.restX - bcx, n.restY - bcy) + n.r); });
bR += 8 * scale;
_introCtrl = runBubblePhysics(canvas, nodes, {
boundary: { cx: bcx, cy: bcy, r: bR },
onTap: (nn) => { if (nn.onTap) nn.onTap(); },
});
});
}
function showIntroTeaser(d) {
const card = document.getElementById('intro-teaser-card');
const avEl = document.getElementById('intro-teaser-av');
const btn = document.getElementById('intro-teaser-btn');
card.style.setProperty('--tz-color', d.color);
document.getElementById('intro-teaser-name').textContent = d.name;
avEl.src = d.av; avEl.style.display = '';
document.getElementById('intro-teaser-quote').textContent = '“' + (INTRO_QUOTES[d.id] || '') + '”';
btn.textContent = `Sign in to talk with ${d.name} →`;
document.getElementById('intro-teaser').classList.add('open');
}
function hideIntroTeaser() {
document.getElementById('intro-teaser').classList.remove('open');
}
document.getElementById('intro-begin-btn').addEventListener('click', openAuth);
document.getElementById('intro-signin-link').addEventListener('click', openAuth);
document.getElementById('intro-teaser-close').addEventListener('click', hideIntroTeaser);
document.getElementById('intro-teaser-btn').addEventListener('click', () => { hideIntroTeaser(); openAuth(); });
document.getElementById('intro-teaser').addEventListener('click', (e) => {
if (e.target.id === 'intro-teaser') hideIntroTeaser();
});
// ── Rebuild bubble layout on resize ───────────────────────
let _bubResizeT = null;
window.addEventListener('resize', () => {
clearTimeout(_bubResizeT);
_bubResizeT = setTimeout(() => {
if (document.getElementById('intro-screen').style.display !== 'none') buildIntroBubbles();
else if (document.getElementById('profile-screen').style.display !== 'none') buildBubbleCanvas();
else if (document.getElementById('library-screen').style.display !== 'none') buildLibLevel();
}, 220);
});
// ── Profile ↔ chat navigation ────────────────────────────
document.getElementById('profile-chat-btn').addEventListener('click', async () => {
isFirstTime = false;
await saveProfile();
await openChat();
});
document.getElementById('profile-back-btn').addEventListener('click', async () => {
isFirstTime = false;
await openChat();
});
document.getElementById('profile-btn').addEventListener('click', () => openProfile());
</script>
</body>
</html>