Chat / templates /index.html
wop's picture
Update templates/index.html
cf1d8e7 verified
<!DOCTYPE html>
<html lang="en" data-density="comfortable">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#08101a" />
<link rel="icon" type="image/png" href="/templates/logo.png" />
<title>{{ app_title }}</title>
<style>
/* ── Design Tokens ── */
:root {
--bg: #08101a;
--panel: rgba(15, 22, 34, .94);
--panel2: rgba(20, 29, 44, .96);
--border: rgba(148, 163, 184, .14);
--border2: rgba(148, 163, 184, .2);
--text: #eef3f9;
--muted: #a4b3c7;
--accent: #7ca6ff;
--accent2: #4fd1c5;
--good: #2dd4bf;
--bad: #f87171;
--warn: #fbbf24;
--shadow: 0 18px 44px rgba(0,0,0,.34);
--shadow-soft: 0 8px 26px rgba(0,0,0,.2);
--radius-xs: 4px;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 22px;
--radius-pill: 999px;
--space-1: 4px; --space-2: 8px; --space-3: 12px;
--space-4: 16px; --space-6: 24px; --space-8: 32px;
--ease-out: cubic-bezier(.22,.61,.36,1);
--ease-in-out: cubic-bezier(.4,0,.2,1);
--ease-spring: cubic-bezier(.34,1.56,.64,1);
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--font: "Segoe UI Variable Text", "Segoe UI", Aptos, system-ui, -apple-system, sans-serif;
--bubble-padding: 10px 14px;
--turn-gap: 6px;
--font-size-base: 14px;
}
/* ── Light Mode ── */
@media (prefers-color-scheme: light) {
:root {
--bg: #f8f9fc; --panel: rgba(255,255,255,.96);
--panel2: rgba(245,247,252,.98);
--border: rgba(30,40,80,.1); --border2: rgba(30,40,80,.16);
--text: #1a1d26; --muted: #5a6374;
--accent: #3b6fd4; --accent2: #0d9488;
--good: #0d9488; --bad: #dc2626; --warn: #d97706;
--shadow: 0 18px 44px rgba(0,0,0,.1);
--shadow-soft: 0 8px 26px rgba(0,0,0,.07);
}
html, body { background: radial-gradient(ellipse at top center, #e8edf8 0%, var(--bg) 50%); }
body::before { opacity: .06; }
#topbar { background: rgba(248,249,252,.88); }
.compose { background: rgba(248,249,252,.92); }
}
/* ── Density Variants ── */
[data-density="compact"] { --bubble-padding: 6px 10px; --turn-gap: 3px; --font-size-base: 13px; }
[data-density="spacious"] { --bubble-padding: 14px 18px; --turn-gap: 10px; --font-size-base: 15px; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
width: 100%; height: var(--app-height, 100%);
background: radial-gradient(ellipse at top center, #161c2b 0%, var(--bg) 50%);
color: var(--text); font-family: var(--font);
overflow: hidden; -webkit-font-smoothing: antialiased; overscroll-behavior: none;
}
body::before {
content: ""; position: fixed; inset: 0;
background-image:
linear-gradient(rgba(124,166,255,.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(124,166,255,.025) 1px, transparent 1px);
background-size: 56px 56px; pointer-events: none; opacity: .25; will-change: transform;
}
#app {
position: relative; z-index: 1; height: var(--app-height, 100%);
display: flex; flex-direction: column; min-height: 0;
}
.skip-link {
position: absolute; top: -100px; left: var(--space-4);
background: var(--panel); color: var(--text);
padding: var(--space-2) var(--space-4); border-radius: var(--radius-md);
z-index: 300; font-size: 13px; font-weight: 600;
border: 1px solid var(--border2); text-decoration: none;
transition: top 150ms var(--ease-out);
}
.skip-link:focus { top: var(--space-2); }
/* ── Topbar ── */
#topbar {
height: 56px; padding: 0 var(--space-4);
display: flex; align-items: center; justify-content: space-between;
border-bottom: 1px solid var(--border);
backdrop-filter: blur(20px) saturate(180%);
background: rgba(11,14,20,.78); flex-shrink: 0; position: relative; z-index: 10;
}
@media (max-height: 500px) { #topbar { height: 42px; } }
.brand { display: flex; align-items: center; gap: 10px; min-width: 0; }
.logo {
width: 30px; height: 30px; border-radius: 10px; display: block; object-fit: cover;
box-shadow: 0 6px 18px rgba(108,131,255,.25); border: 1px solid rgba(255,255,255,.08); flex: 0 0 auto;
}
.brand-title { font-weight: 700; letter-spacing: -.03em; font-size: 15px; }
.brand-sub { color: var(--muted); font-size: 11px; font-family: var(--mono); margin-left: 2px; }
.top-actions { display: flex; align-items: center; gap: var(--space-1); position: relative; }
.top-btn {
border: 1px solid var(--border2); background: rgba(255,255,255,.03); color: var(--muted);
border-radius: var(--radius-md); padding: 6px 12px; font: inherit; font-size: 12px;
cursor: pointer; transition: border-color 180ms var(--ease-out), color 180ms var(--ease-out), background 180ms var(--ease-out), transform 180ms var(--ease-out);
display: flex; align-items: center; gap: 5px; position: relative;
}
.top-btn:hover { border-color: rgba(108,131,255,.35); color: var(--text); background: rgba(108,131,255,.06); }
.top-btn svg { width: 14px; height: 14px; }
.top-btn[aria-label]::after {
content: attr(aria-label); position: absolute; top: calc(100% + 6px); right: 0;
background: var(--panel2); border: 1px solid var(--border2); border-radius: var(--radius-sm);
padding: 4px 8px; font-size: 11px; color: var(--text); white-space: nowrap;
opacity: 0; pointer-events: none; transition: opacity 150ms var(--ease-out); z-index: 50;
}
.top-btn[aria-label]:hover::after { opacity: 1; }
/* ── Status bar ── */
#statusbar {
height: 0; overflow: hidden; transition: height 220ms var(--ease-out), opacity 220ms var(--ease-out);
opacity: 0; border-bottom: 1px solid transparent; background: rgba(11,14,20,.6);
display: flex; align-items: center; justify-content: center;
font-size: 12px; font-family: var(--mono); color: var(--muted); flex-shrink: 0;
}
#statusbar.visible { height: 32px; opacity: 1; border-bottom-color: var(--border); }
#statusbar .status-dot {
width: 6px; height: 6px; border-radius: 50%; margin-right: var(--space-2);
display: inline-block; background: var(--accent); animation: pulse-dot 1.2s ease infinite;
}
@keyframes pulse-dot { 0%, 100% { opacity: .4; transform: scale(.85); } 50% { opacity: 1; transform: scale(1.1); } }
#scrollProgress {
position: fixed !important; top: 0; left: 0; height: 3px; width: 0%;
background: linear-gradient(90deg, var(--accent), var(--accent2));
transition: width 100ms ease; z-index: 9999; pointer-events: none; transform-origin: left center;
}
/* ── Chat area ── */
#chat {
flex: 1; min-height: 0; overflow-y: auto; padding: 20px 14px 24px;
scroll-behavior: auto; overscroll-behavior-y: contain;
-webkit-overflow-scrolling: touch; overflow-anchor: auto;
scrollbar-width: thin; scrollbar-color: rgba(255,255,255,.1) transparent;
}
#chat::-webkit-scrollbar { width: 5px; }
#chat::-webkit-scrollbar-track { background: transparent; }
#chat::-webkit-scrollbar-thumb { background: rgba(255,255,255,.1); border-radius: var(--radius-pill); }
.wrap { max-width: 760px; margin: 0 auto; }
/* ── Welcome ── */
.welcome {
margin: 6vh auto 0; max-width: 480px; text-align: center;
padding: var(--space-6) 20px; border: 1px solid var(--border);
border-radius: var(--radius-xl); background: rgba(255,255,255,.025);
box-shadow: var(--shadow); animation: fadeUp 400ms var(--ease-out) both;
}
@media (max-height: 500px) { .welcome { margin-top: 1vh; padding: var(--space-3); } }
.welcome h1 { font-size: 22px; font-weight: 700; letter-spacing: -.03em; line-height: 1.3; }
.welcome p { color: var(--muted); line-height: 1.6; margin-top: var(--space-2); font-size: 13px; }
.welcome-suggestions { display: flex; flex-wrap: wrap; gap: var(--space-2); justify-content: center; margin-top: var(--space-4); }
.suggestion-chip {
border: 1px solid var(--border2); background: rgba(255,255,255,.03);
border-radius: var(--radius-pill); padding: 6px 14px; font: inherit; font-size: 12px;
color: var(--muted); cursor: pointer;
transition: border-color 160ms var(--ease-out), color 160ms var(--ease-out), background 160ms var(--ease-out);
text-align: left;
}
.suggestion-chip:hover { border-color: rgba(108,131,255,.35); color: var(--text); background: rgba(108,131,255,.05); }
@keyframes fadeUp { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
/* ── Skeleton ── */
.skeleton-wrap { padding: 20px 0; }
.skeleton {
background: linear-gradient(90deg, rgba(255,255,255,.03) 0%, rgba(255,255,255,.07) 50%, rgba(255,255,255,.03) 100%);
background-size: 200% 100%; animation: skeleton-shimmer 1.5s ease infinite; border-radius: var(--radius-md);
}
@keyframes skeleton-shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
.skeleton-line { height: 14px; margin-bottom: 8px; }
.skeleton-line.short { width: 40%; } .skeleton-line.medium { width: 70%; } .skeleton-line.long { width: 95%; }
.skeleton-bubble { height: 80px; border-radius: var(--radius-lg); margin-bottom: 12px; }
/* ── Turns ── */
.turn { display: flex; gap: 10px; margin-bottom: var(--turn-gap); align-items: flex-start; }
.turn.new-turn { animation: fadeUp 280ms var(--ease-out) both; }
.turn.user { justify-content: flex-end; }
.avatar {
width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center;
font-size: 12px; flex: 0 0 auto; transition: transform 200ms var(--ease-spring);
}
.avatar:hover { transform: scale(1.1); }
.avatar.user { background: linear-gradient(135deg, #1f2b63, #2d1d58); border: 1px solid rgba(108,131,255,.2); }
.avatar.assistant { background: linear-gradient(135deg, #163d34, #183c54); border: 1px solid rgba(45,212,191,.2); }
.bubble {
max-width: min(620px, calc(100vw - 100px)); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: var(--bubble-padding);
line-height: 1.6; font-size: var(--font-size-base);
white-space: pre-wrap; word-break: break-word; background: rgba(255,255,255,.03);
}
.turn.assistant .bubble { border-radius: var(--radius-xs) var(--radius-lg) var(--radius-lg) var(--radius-lg); }
.turn.user .bubble {
background: linear-gradient(135deg, rgba(108,131,255,.15), rgba(161,110,255,.12));
border-color: rgba(108,131,255,.2);
border-radius: var(--radius-lg) var(--radius-lg) var(--radius-xs) var(--radius-lg);
}
.turn-meta {
margin-top: var(--space-1); font-size: 10px; color: var(--muted);
font-family: var(--mono); display: flex; gap: 6px; align-items: center; flex-wrap: wrap;
}
.chip {
border: 1px solid var(--border); border-radius: var(--radius-pill);
padding: 2px 7px; font-size: 11px; letter-spacing: .03em;
display: inline-flex; align-items: center; gap: 3px;
}
.chip.good { color: var(--good); border-color: rgba(45,212,191,.25); }
.chip.muted { color: var(--muted); }
.chip.warn { color: var(--warn); border-color: rgba(251,191,36,.25); }
.chip.matched { color: var(--accent); border-color: rgba(108,131,255,.25); }
/* ── Best answer ── */
.best-answer-bubble {
border: 1px solid rgba(45,212,191,.15);
border-radius: var(--radius-xs) var(--radius-lg) var(--radius-lg) var(--radius-lg);
padding: var(--bubble-padding); background: rgba(45,212,191,.04);
line-height: 1.6; font-size: var(--font-size-base);
white-space: pre-wrap; word-break: break-word; outline: none;
}
.best-answer-meta { margin-top: var(--space-1); display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
/* ── Shared Markdown Elements ── */
.bubble p, .best-answer-bubble p, .other-answer-text p { margin: 3px 0; white-space: normal; }
.bubble h1, .bubble h2, .bubble h3, .bubble h4, .bubble h5, .bubble h6,
.best-answer-bubble h1, .best-answer-bubble h2, .best-answer-bubble h3,
.best-answer-bubble h4, .best-answer-bubble h5, .best-answer-bubble h6,
.other-answer-text h1, .other-answer-text h2, .other-answer-text h3 {
line-height: 1.3; margin: 8px 0 3px; white-space: normal;
}
.bubble h1, .best-answer-bubble h1 { font-size: 1.35em; font-weight: 800; }
.bubble h2, .best-answer-bubble h2 { font-size: 1.2em; font-weight: 700; }
.bubble h3, .best-answer-bubble h3 { font-size: 1.05em; font-weight: 700; color: var(--accent); }
.bubble h4, .best-answer-bubble h4 { font-size: .95em; font-weight: 700; }
.bubble h5, .best-answer-bubble h5, .bubble h6, .best-answer-bubble h6 { font-size: .88em; font-weight: 700; }
.bubble ul, .bubble ol, .best-answer-bubble ul, .best-answer-bubble ol,
.other-answer-text ul, .other-answer-text ol { margin: 3px 0 3px 18px; padding: 0; white-space: normal; }
.bubble li, .best-answer-bubble li, .other-answer-text li { margin: 1px 0; white-space: normal; }
.task-item { list-style: none; margin-left: -18px; }
.task-item input[type="checkbox"] { accent-color: var(--accent2); margin-right: 6px; pointer-events: none; cursor: default; }
.bubble code, .best-answer-bubble code, .other-answer-text code {
font-family: var(--mono); font-size: .87em;
background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.1);
border-radius: var(--radius-xs); padding: 1px 5px;
}
.code-block-wrapper { position: relative; margin: 6px 0; }
.bubble pre, .best-answer-bubble pre, .other-answer-text pre {
background: rgba(0,0,0,.38); border: 1px solid var(--border);
border-radius: var(--radius-sm); padding: 10px 12px;
overflow-x: auto; white-space: pre; font-family: var(--mono);
font-size: .84em; line-height: 1.5; margin: 0;
}
.bubble pre code, .best-answer-bubble pre code, .other-answer-text pre code {
background: none; border: none; padding: 0; font-size: inherit;
}
.code-lang-label {
position: absolute; top: 5px; left: 6px;
background: rgba(255,255,255,.06);
border-radius: 0 0 var(--radius-xs) var(--radius-xs);
padding: 2px 8px; font-size: 10px; color: var(--muted);
font-family: var(--mono); text-transform: uppercase; letter-spacing: .05em; pointer-events: none;
}
.copy-code-btn {
position: absolute; top: 6px; right: 6px;
border: 1px solid var(--border2); background: rgba(0,0,0,.3); color: var(--muted);
border-radius: var(--radius-xs); padding: 2px 7px;
font: inherit; font-size: 10px; font-family: var(--mono); cursor: pointer;
opacity: 0; transition: opacity 150ms var(--ease-out), color 150ms var(--ease-out), border-color 150ms var(--ease-out);
}
.code-block-wrapper:hover .copy-code-btn { opacity: 1; }
.copy-code-btn:hover { color: var(--text); border-color: rgba(108,131,255,.4); }
.copy-code-btn.copied { color: var(--good); border-color: rgba(45,212,191,.4); opacity: 1; }
.bubble blockquote, .best-answer-bubble blockquote, .other-answer-text blockquote {
border-left: 3px solid var(--accent); background: rgba(124,166,255,.04);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
margin: 6px 0; padding: 6px 12px; color: var(--muted); white-space: normal; font-style: italic;
}
.bubble hr, .best-answer-bubble hr { border: none; border-top: 1px solid var(--border2); margin: 8px 0; }
.bubble a, .best-answer-bubble a, .other-answer-text a { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; }
.bubble a[href^="http"]::after, .best-answer-bubble a[href^="http"]::after { content: " β†—"; font-size: .75em; opacity: .5; }
.bubble table, .best-answer-bubble table, .other-answer-text table { border-collapse: collapse; width: 100%; margin: 8px 0; font-size: 13px; white-space: normal; }
.bubble th, .bubble td, .best-answer-bubble th, .best-answer-bubble td,
.other-answer-text th, .other-answer-text td { border: 1px solid var(--border2); padding: 6px 10px; text-align: left; }
.bubble th, .best-answer-bubble th { background: rgba(255,255,255,.05); font-weight: 600; }
sup { font-size: .75em; vertical-align: super; line-height: 0; }
sub { font-size: .75em; vertical-align: sub; line-height: 0; }
.md-img {
display: block; max-width: 100%; min-width: 60px; max-height: 480px;
width: auto; height: auto; border-radius: var(--radius-md); border: 1px solid var(--border);
margin: 6px 0; object-fit: contain; cursor: zoom-in; transition: opacity 150ms ease;
}
.md-img:hover { opacity: .9; }
p.md-gap { min-height: 0.35em; margin: 0 !important; padding: 0; }
.quality-dots { display: inline-flex; gap: 2px; align-items: center; margin-left: 4px; }
.quality-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--border2); }
.quality-dot.filled { background: var(--accent2); }
/* ── Thinking Block ── */
.thinking-dropdown { margin: 6px 0; border-radius: var(--radius-md); overflow: hidden; }
.thinking-summary {
display: flex; align-items: center; gap: 8px; padding: 8px 12px;
background: rgba(255,255,255,.04); border: 1px solid var(--border);
border-radius: var(--radius-md); cursor: pointer; user-select: none;
font-size: 12px; font-family: var(--mono); color: var(--muted);
transition: background 150ms var(--ease-out), border-color 150ms var(--ease-out);
list-style: none;
}
.thinking-summary:hover { background: rgba(255,255,255,.06); border-color: var(--border2); }
.thinking-summary::-webkit-details-marker { display: none; }
.thinking-summary .thinking-arrow {
display: inline-block; transition: transform 200ms var(--ease-out); font-size: 10px;
}
details.thinking-dropdown[open] .thinking-arrow { transform: rotate(90deg); }
.thinking-summary .thinking-spinner {
width: 12px; height: 12px; border: 2px solid var(--border2);
border-top-color: var(--accent); border-radius: 50%;
animation: spin-thinking .8s linear infinite;
}
@keyframes spin-thinking { to { transform: rotate(360deg); } }
.thinking-content {
padding: 10px 14px; font-size: 13px; line-height: 1.6;
color: var(--muted); border: 1px solid var(--border); border-top: none;
border-radius: 0 0 var(--radius-md) var(--radius-md);
background: rgba(255,255,255,.02); white-space: pre-wrap; word-break: break-word;
max-height: 400px; overflow-y: auto;
}
.thinking-content p { color: var(--muted); }
/* ── Vote ── */
.vote-row { display: flex; gap: var(--space-1); align-items: center; margin-top: 6px; flex-wrap: wrap; }
.vote-btn {
border: 1px solid var(--border2); background: rgba(255,255,255,.02); color: var(--muted);
border-radius: var(--radius-sm); padding: 4px 9px; font: inherit; font-size: 11px;
cursor: pointer; display: inline-flex; align-items: center; gap: 3px;
position: relative; overflow: hidden;
transition: border-color 160ms var(--ease-out), color 160ms var(--ease-out), background 160ms var(--ease-out), transform 160ms var(--ease-spring);
}
.vote-btn:hover { border-color: rgba(108,131,255,.35); color: var(--text); background: rgba(108,131,255,.07); }
.vote-btn.voted-up { border-color: rgba(45,212,191,.5); color: var(--good); background: rgba(45,212,191,.08); }
.vote-btn.voted-down { border-color: rgba(248,113,113,.5); color: var(--bad); background: rgba(248,113,113,.08); }
.vote-btn:active { transform: scale(.92); }
.vote-count { display: inline-block; overflow: hidden; height: 1.2em; position: relative; }
.vote-count-inner { display: block; transition: transform 300ms var(--ease-spring); }
.action-btn {
border: 1px solid var(--border2); background: rgba(255,255,255,.02); color: var(--muted);
border-radius: var(--radius-sm); padding: 4px 9px; font: inherit; font-size: 11px;
cursor: pointer; display: inline-flex; align-items: center; gap: 4px;
transition: border-color 160ms var(--ease-out), color 160ms var(--ease-out), background 160ms var(--ease-out);
}
.action-btn:hover { border-color: rgba(108,131,255,.35); color: var(--text); background: rgba(108,131,255,.05); }
/* ── Write answer ── */
.write-answer-btn {
border: 1px solid rgba(45,212,191,.3); background: rgba(45,212,191,.08); color: var(--good);
border-radius: var(--radius-md); padding: var(--space-2) 14px;
font: inherit; font-size: 12px; font-weight: 600; cursor: pointer;
display: inline-flex; align-items: center; gap: 6px; margin-top: var(--space-2);
transition: border-color 180ms var(--ease-out), color 180ms var(--ease-out), background 180ms var(--ease-out), transform 180ms var(--ease-spring);
}
.write-answer-btn:hover { background: rgba(45,212,191,.14); border-color: rgba(45,212,191,.5); }
.write-answer-btn svg { width: 14px; height: 14px; }
.write-panel {
max-height: 0; overflow: hidden; opacity: 0; margin-top: 0;
transition: max-height 300ms var(--ease-out), opacity 250ms var(--ease-out), margin 200ms var(--ease-out);
}
.write-panel.open { max-height: 400px; opacity: 1; margin-top: 10px; }
.write-tabs { display: flex; gap: 2px; margin-bottom: 6px; }
.write-tab {
border: 1px solid var(--border); border-bottom: none; background: transparent; color: var(--muted);
border-radius: var(--radius-sm) var(--radius-sm) 0 0; padding: 4px 12px;
font: inherit; font-size: 11px; cursor: pointer; transition: color 150ms, background 150ms, border-color 150ms;
}
.write-tab.active { border-color: var(--accent); color: var(--accent); background: rgba(108,131,255,.08); }
.write-textarea {
width: 100%; min-height: 80px; max-height: 160px; resize: vertical;
border: 1px solid var(--border2); border-radius: 0 var(--radius-md) var(--radius-md) var(--radius-md);
background: var(--panel); color: var(--text); font: inherit; font-size: 13px; line-height: 1.55;
padding: 10px 12px; outline: none;
transition: border-color 200ms var(--ease-out), height 100ms var(--ease-out);
}
.write-textarea:focus { border-color: rgba(45,212,191,.4); box-shadow: 0 0 0 3px rgba(45,212,191,.07); }
.write-textarea::placeholder { color: #5a6178; }
.write-preview {
min-height: 80px; max-height: 160px; overflow-y: auto;
border: 1px solid var(--border2); border-radius: 0 var(--radius-md) var(--radius-md) var(--radius-md);
background: var(--panel); padding: 10px 12px; font-size: 13px; line-height: 1.6; display: none;
}
.write-preview.active { display: block; }
.char-count { font-size: 10px; font-family: var(--mono); color: var(--muted); text-align: right; margin-top: 2px; }
.char-count.near-limit { color: var(--warn); }
.char-count.over-limit { color: var(--bad); }
.write-actions { display: flex; gap: 6px; margin-top: 6px; align-items: center; }
.write-submit {
border: 1px solid rgba(45,212,191,.4); background: rgba(45,212,191,.12); color: var(--good);
border-radius: var(--radius-sm); padding: 6px 14px; font: inherit; font-size: 12px; font-weight: 600;
cursor: pointer; transition: border-color 160ms, color 160ms, background 160ms;
}
.write-submit:hover { background: rgba(45,212,191,.2); border-color: rgba(45,212,191,.6); }
.write-submit:disabled { opacity: .4; cursor: not-allowed; }
.write-cancel {
border: 1px solid var(--border); background: transparent; color: var(--muted);
border-radius: var(--radius-sm); padding: 6px 12px; font: inherit; font-size: 12px;
cursor: pointer; transition: border-color 160ms, color 160ms;
}
.write-cancel:hover { border-color: var(--border2); color: var(--text); }
/* ── Other answers ── */
.other-answers-toggle {
margin-top: var(--space-2); border: 1px solid var(--border); background: rgba(255,255,255,.02);
color: var(--muted); border-radius: var(--radius-md); padding: 6px 12px;
font: inherit; font-size: 11px; cursor: pointer;
display: inline-flex; align-items: center; gap: 5px;
transition: border-color 180ms var(--ease-out), color 180ms var(--ease-out), background 180ms var(--ease-out);
}
.other-answers-toggle:hover { border-color: rgba(108,131,255,.3); color: var(--text); }
.other-answers-toggle .arrow, .related-toggle .arrow {
display: inline-block; transition: transform 200ms var(--ease-out); font-size: 10px;
}
.other-answers-toggle.open .arrow, .related-toggle.open .arrow { transform: rotate(90deg); }
.other-answers-panel, .related-panel, .versions-panel {
max-height: 0; overflow: hidden; opacity: 0;
transition: max-height 300ms var(--ease-out), opacity 200ms var(--ease-out);
}
.other-answers-panel { margin-top: var(--space-1); }
.other-answers-panel.open { max-height: 3000px; opacity: 1; }
.related-panel.open { max-height: 3000px; opacity: 1; margin-top: 6px; }
.versions-panel {
border-left: 2px solid var(--border2); padding-left: 10px; margin-top: var(--space-1);
transition: max-height 280ms var(--ease-out), opacity 180ms var(--ease-out);
}
.versions-panel.open { max-height: 1500px; opacity: 1; }
.other-answer-card {
border: 1px solid var(--border); border-radius: var(--radius-md);
padding: 10px 12px; margin-top: 6px; background: rgba(255,255,255,.02);
animation: fadeUp 200ms var(--ease-out) both; position: relative;
}
.other-answer-card.related {
background: linear-gradient(180deg, rgba(108,131,255,.05), rgba(255,255,255,.02));
border-color: rgba(108,131,255,.16);
}
.other-answer-head {
display: flex; gap: 6px; flex-wrap: wrap; align-items: center;
color: var(--muted); font-family: var(--mono); font-size: 10px; margin-bottom: 6px;
}
.other-answer-text { font-size: 13px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; }
/* ── Preview lines ── */
.preview-block { display: flex; gap: 8px; align-items: flex-start; margin-top: 6px; }
.preview-label {
flex: 0 0 auto; font-family: var(--mono); font-size: 9px; color: var(--muted);
background: rgba(255,255,255,.04); border: 1px solid var(--border);
border-radius: var(--radius-xs); padding: 2px 6px; line-height: 1.3; letter-spacing: .06em;
}
.preview-text {
flex: 1; min-width: 0; font-size: 12.5px; line-height: 1.5; color: var(--text);
display: -webkit-box; -webkit-line-clamp: 3; line-clamp: 3;
-webkit-box-orient: vertical; overflow: hidden; white-space: normal; word-break: break-word;
}
.preview-text.muted-preview { color: var(--muted); }
.preview-actions { display: flex; align-items: center; gap: 6px; margin-top: 8px; flex-wrap: wrap; }
.preview-actions .vote-row { margin-top: 0; }
.ask-btn {
border: 1px solid rgba(108,131,255,.35); background: rgba(108,131,255,.1); color: var(--accent);
border-radius: var(--radius-sm); padding: 4px 11px; font: inherit; font-size: 11px; font-weight: 600;
cursor: pointer; display: inline-flex; align-items: center; gap: 4px;
transition: border-color 160ms var(--ease-out), color 160ms var(--ease-out), background 160ms var(--ease-out), transform 160ms var(--ease-spring);
}
.ask-btn:hover { background: rgba(108,131,255,.2); border-color: rgba(108,131,255,.55); }
.ask-btn:active { transform: scale(.95); }
.ask-btn svg { width: 11px; height: 11px; }
/* ── Versions ── */
.versions-toggle {
margin-top: var(--space-1); color: var(--muted); font-size: 10px;
cursor: pointer; font-family: var(--mono);
display: inline-flex; align-items: center; gap: 4px;
border: none; background: none; padding: 0; transition: color 150ms var(--ease-out);
}
.versions-toggle:hover { color: var(--text); }
.version-card {
border: 1px solid var(--border); background: rgba(255,255,255,.02);
border-radius: var(--radius-md); padding: 8px 10px; margin-top: var(--space-1);
animation: fadeUp 180ms var(--ease-out) both;
}
.version-head {
font-size: 10px; color: var(--muted); font-family: var(--mono);
display: flex; gap: 5px; flex-wrap: wrap; align-items: center; margin-bottom: var(--space-1);
}
/* ── Propose version ── */
.propose-panel {
max-height: 0; overflow: hidden; opacity: 0; margin-top: 6px;
transition: max-height 280ms var(--ease-out), opacity 200ms var(--ease-out);
}
.propose-panel.open { max-height: 400px; opacity: 1; }
.propose-textarea {
width: 100%; min-height: 60px; max-height: 140px; resize: vertical;
border: 1px solid var(--border2); border-radius: var(--radius-md);
background: var(--panel); color: var(--text); font: inherit; font-size: 13px; line-height: 1.55;
padding: 8px 10px; outline: none;
transition: border-color 200ms var(--ease-out), height 100ms var(--ease-out);
}
.propose-textarea:focus { border-color: rgba(108,131,255,.4); box-shadow: 0 0 0 3px rgba(108,131,255,.07); }
.propose-textarea::placeholder { color: #5a6178; }
.propose-actions { display: flex; gap: 6px; margin-top: 6px; }
.propose-submit {
border: 1px solid rgba(108,131,255,.3); background: rgba(108,131,255,.1); color: var(--accent);
border-radius: var(--radius-sm); padding: 5px 12px; font: inherit; font-size: 11px;
cursor: pointer; transition: border-color 160ms, color 160ms, background 160ms;
}
.propose-submit:hover { background: rgba(108,131,255,.18); border-color: rgba(108,131,255,.5); }
.propose-submit:disabled { opacity: .5; cursor: not-allowed; }
.propose-cancel {
border: 1px solid var(--border); background: transparent; color: var(--muted);
border-radius: var(--radius-sm); padding: 5px 10px; font: inherit; font-size: 11px; cursor: pointer;
}
/* ── Typing indicator ── */
.typing-indicator {
display: flex; gap: 10px; margin-bottom: 6px; align-items: flex-start;
animation: fadeUp 250ms var(--ease-out) both;
}
.typing-dots {
display: flex; gap: 4px; align-items: center; padding: 12px 16px;
border: 1px solid var(--border);
border-radius: var(--radius-xs) var(--radius-lg) var(--radius-lg) var(--radius-lg);
background: rgba(255,255,255,.03);
}
.typing-dots span {
width: 6px; height: 6px; border-radius: 50%; background: var(--muted);
animation: typingBounce 1.1s ease infinite;
}
.typing-dots span:nth-child(2) { animation-delay: .15s; }
.typing-dots span:nth-child(3) { animation-delay: .3s; }
@keyframes typingBounce { 0%, 60%, 100% { transform: translateY(0); opacity: .35; } 30% { transform: translateY(-5px); opacity: 1; } }
.answer-reveal {
opacity: 0; transform: translateY(4px); filter: blur(1px);
transition: opacity 180ms var(--ease-out), transform 180ms var(--ease-out), filter 180ms var(--ease-out);
}
.answer-reveal.revealed { opacity: 1; transform: translateY(0); filter: blur(0); }
@keyframes glow-in { 0% { box-shadow: 0 0 0 rgba(45,212,191,0); } 40% { box-shadow: 0 0 18px rgba(45,212,191,.3); } 100% { box-shadow: none; } }
.answer-new-glow { animation: glow-in 700ms var(--ease-out) both; }
/* ── Related ── */
.related-stack { margin-top: 14px; padding-top: 12px; border-top: 1px solid rgba(255,255,255,.06); }
.related-stack .chip { margin-bottom: 6px; }
.related-toggle {
margin-top: 2px; border: 1px solid var(--border); background: rgba(255,255,255,.02);
color: var(--muted); border-radius: var(--radius-md); padding: 6px 12px;
font: inherit; font-size: 11px; cursor: pointer;
display: inline-flex; align-items: center; gap: 5px;
transition: border-color 180ms var(--ease-out), color 180ms var(--ease-out), background 180ms var(--ease-out);
}
.related-toggle:hover { border-color: rgba(108,131,255,.3); color: var(--text); }
.related-panel { margin-top: 0; transition: max-height 280ms var(--ease-out), opacity 180ms var(--ease-out), margin-top 180ms var(--ease-out); }
.related-score { color: var(--accent); border-color: rgba(108,131,255,.22); }
.related-note { color: var(--muted); font-size: 11px; line-height: 1.5; margin-top: 6px; }
/* ── Composer ── */
.compose {
border-top: 1px solid var(--border); background: rgba(11,14,20,.85);
backdrop-filter: blur(16px) saturate(160%);
padding: 10px 14px calc(14px + env(safe-area-inset-bottom)); flex-shrink: 0;
}
@media (max-height: 500px) { .compose { padding: 6px 10px; } }
.compose-inner {
max-width: 760px; margin: 0 auto;
border: 1px solid var(--border2); border-radius: var(--radius-lg);
padding: 8px 10px 6px; background: var(--panel); box-shadow: var(--shadow-soft);
transition: border-color 200ms var(--ease-out), box-shadow 200ms var(--ease-out); position: relative;
}
.compose-inner:focus-within {
border-color: rgba(108,131,255,.4);
box-shadow: 0 0 0 3px rgba(108,131,255,.12), var(--shadow-soft);
}
.autocomplete-dropdown {
position: absolute; bottom: calc(100% + 6px); left: 0; right: 0;
background: var(--panel2); border: 1px solid var(--border2);
border-radius: var(--radius-md); z-index: 20; overflow: hidden;
box-shadow: var(--shadow); display: none;
}
.autocomplete-dropdown.open { display: block; }
.autocomplete-item {
padding: 8px 12px; cursor: pointer;
display: flex; justify-content: space-between; align-items: center; gap: 8px;
transition: background 120ms; border-bottom: 1px solid var(--border);
}
.autocomplete-item:last-child { border-bottom: none; }
.autocomplete-item:hover { background: rgba(108,131,255,.07); }
.autocomplete-match { font-size: 13px; color: var(--text); }
.autocomplete-meta { font-size: 10px; color: var(--muted); font-family: var(--mono); white-space: nowrap; }
#prompt {
width: 100%; min-height: 40px; max-height: 180px; resize: none; border: none; outline: none;
background: transparent; color: var(--text); font: inherit; font-size: var(--font-size-base);
line-height: 1.55; padding: 2px 2px 4px; transition: height 100ms var(--ease-out);
}
#prompt::placeholder { color: #5a6178; }
.compose-row {
display: flex; align-items: center; justify-content: space-between;
gap: var(--space-2); border-top: 1px solid var(--border); padding-top: 6px;
}
.hint { color: var(--muted); font-size: 10px; font-family: var(--mono); }
.send-btn {
border: none; border-radius: 10px; padding: 7px 16px; cursor: pointer;
font: inherit; font-size: 13px; font-weight: 600; color: white;
background: linear-gradient(135deg, var(--accent), var(--accent2));
box-shadow: 0 4px 14px rgba(108,131,255,.2);
transition: transform 140ms var(--ease-spring), box-shadow 140ms var(--ease-out), filter 140ms var(--ease-out);
}
.send-btn:hover { transform: translateY(-1px); box-shadow: 0 6px 20px rgba(108,131,255,.3); }
.send-btn:active { transform: scale(.96); }
.send-btn:disabled { opacity: .4; cursor: not-allowed; transform: none; box-shadow: none; }
/* ── Settings panel ── */
.settings-backdrop {
position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 99;
opacity: 0; pointer-events: none; transition: opacity 250ms var(--ease-in-out);
}
.settings-backdrop.visible { opacity: 1; pointer-events: auto; }
#settingsPanel {
position: fixed; top: 56px; right: 0; width: 280px;
background: var(--panel); border: 1px solid var(--border2);
border-radius: 0 0 0 var(--radius-lg); box-shadow: var(--shadow); z-index: 100;
transform: translateX(100%); transition: transform 250ms var(--ease-in-out);
padding: 14px 16px; backdrop-filter: blur(24px) saturate(200%);
}
#settingsPanel.open { transform: translateX(0); }
.settings-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.settings-title { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
.settings-close {
border: none; background: none; color: var(--muted); cursor: pointer;
font-size: 16px; line-height: 1; padding: 2px 4px; border-radius: var(--radius-xs); transition: color 150ms;
}
.settings-close:hover { color: var(--text); }
.setting-row {
display: flex; align-items: center; justify-content: space-between;
padding: 6px 0; border-bottom: 1px solid var(--border);
}
.setting-row:last-child { border-bottom: none; }
.setting-label { font-size: 12px; color: var(--text); }
.setting-desc { font-size: 10px; color: var(--muted); margin-top: 1px; }
.anim-segment { display: flex; flex-direction: column; gap: 4px; margin-top: 6px; }
.anim-option {
display: flex; align-items: center; justify-content: space-between;
padding: 6px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border);
cursor: pointer; font-size: 12px; color: var(--muted);
transition: border-color 150ms, background 150ms;
}
.anim-option.active { border-color: rgba(108,131,255,.4); background: rgba(108,131,255,.08); color: var(--accent); }
.anim-preview { width: 24px; height: 8px; border-radius: 4px; background: var(--border2); overflow: hidden; position: relative; }
.anim-option.active .anim-preview::after {
content: ""; position: absolute; inset: 0;
background: linear-gradient(90deg, var(--accent), var(--accent2));
animation: anim-preview-slide 1s ease infinite alternate;
}
@keyframes anim-preview-slide { from { transform: translateX(-100%); } to { transform: translateX(0); } }
.density-segment { display: flex; gap: 4px; margin-top: 6px; width: 100%; }
.density-option {
flex: 1; padding: 6px; text-align: center; border: 1px solid var(--border);
border-radius: var(--radius-sm); cursor: pointer; font-size: 11px; color: var(--muted);
transition: border-color 150ms, background 150ms, color 150ms;
}
.density-option.active { border-color: rgba(108,131,255,.4); background: rgba(108,131,255,.08); color: var(--accent); }
/* ── Toast ── */
#toast {
position: fixed; left: 50%; bottom: 80px;
transform: translateX(-50%) translateY(12px); opacity: 0; pointer-events: none;
transition: opacity 200ms var(--ease-in-out), transform 200ms var(--ease-in-out); z-index: 50;
background: rgba(17,21,29,.95); border: 1px solid var(--border2); border-radius: var(--radius-md);
padding: 8px 14px; color: var(--text); font-family: var(--mono); font-size: 11px;
box-shadow: var(--shadow); white-space: nowrap; backdrop-filter: blur(10px);
display: flex; align-items: center; gap: 8px;
}
#toast.show { opacity: 1; transform: translateX(-50%) translateY(0); pointer-events: auto; }
#toast.good { border-color: rgba(45,212,191,.4); color: var(--good); }
#toast.bad { border-color: rgba(248,113,113,.4); color: var(--bad); }
.toast-retry {
border: 1px solid currentColor; border-radius: var(--radius-xs);
background: transparent; color: inherit; padding: 2px 8px;
font: inherit; font-size: 10px; cursor: pointer; white-space: nowrap;
}
.no-answer-bubble { border-style: dashed !important; color: var(--muted); }
/* ── Jump to latest ── */
#jumpLatest {
position: fixed; left: 50%; bottom: 132px;
transform: translateX(-50%) translateY(8px); z-index: 55;
border: 1px solid rgba(124,166,255,.3); background: rgba(14,20,31,.94);
color: var(--text); border-radius: var(--radius-pill);
padding: 8px 14px; font: inherit; font-size: 12px;
box-shadow: var(--shadow-soft); backdrop-filter: blur(10px);
opacity: 0; pointer-events: none; cursor: pointer;
transition: opacity 160ms var(--ease-out), transform 160ms var(--ease-out);
}
#jumpLatest.show { opacity: 1; pointer-events: auto; transform: translateX(-50%) translateY(0); }
/* ── Lightbox ── */
#lightbox {
position: fixed; inset: 0; background: rgba(0,0,0,.9); z-index: 400;
display: none; place-items: center; cursor: zoom-out; animation: fadeIn 150ms ease;
}
#lightbox.open { display: grid; }
#lightbox img { max-width: 95vw; max-height: 95vh; border-radius: var(--radius-sm); box-shadow: 0 20px 60px rgba(0,0,0,.5); cursor: default; }
#lightboxClose {
position: absolute; top: 16px; right: 16px;
border: 1px solid rgba(255,255,255,.2); background: rgba(0,0,0,.5); color: white;
border-radius: var(--radius-pill); width: 36px; height: 36px;
display: grid; place-items: center; cursor: pointer; font-size: 18px; line-height: 1;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
/* ── Command palette ── */
#cmdBackdrop {
position: fixed; inset: 0; background: rgba(0,0,0,.55); z-index: 500;
display: none; place-items: flex-start center; padding-top: 18vh;
}
#cmdBackdrop.open { display: grid; }
#cmdPalette {
width: min(480px, 90vw); background: var(--panel2); border: 1px solid var(--border2);
border-radius: var(--radius-xl); box-shadow: var(--shadow); overflow: hidden;
animation: fadeUp 150ms var(--ease-out) both;
}
#cmdInput {
width: 100%; border: none; border-bottom: 1px solid var(--border);
background: transparent; color: var(--text); font: inherit; font-size: 14px;
padding: 14px 16px; outline: none;
}
#cmdInput::placeholder { color: var(--muted); }
.cmd-list { max-height: 280px; overflow-y: auto; padding: 4px 0; }
.cmd-item {
padding: 10px 16px; cursor: pointer; display: flex; align-items: center; gap: 10px;
font-size: 13px; color: var(--text); transition: background 100ms;
}
.cmd-item:hover, .cmd-item.focused { background: rgba(108,131,255,.1); }
.cmd-item-icon { color: var(--muted); font-size: 16px; width: 20px; text-align: center; flex-shrink: 0; }
.cmd-item-label { flex: 1; }
.cmd-item-shortcut { font-family: var(--mono); font-size: 10px; color: var(--muted); }
.cmd-empty { padding: 16px; text-align: center; color: var(--muted); font-size: 13px; }
/* ── Confirm modal ── */
#confirmBackdrop {
position: fixed; inset: 0; background: rgba(0,0,0,.55); z-index: 600;
display: none; place-items: center;
}
#confirmBackdrop.open { display: grid; }
#confirmModal {
width: min(360px, 90vw); background: var(--panel2); border: 1px solid var(--border2);
border-radius: var(--radius-xl); padding: 24px; box-shadow: var(--shadow);
animation: fadeUp 150ms var(--ease-out) both;
}
.confirm-title { font-size: 15px; font-weight: 700; margin-bottom: 8px; }
.confirm-msg { font-size: 13px; color: var(--muted); line-height: 1.5; margin-bottom: 20px; }
.confirm-actions { display: flex; gap: 8px; justify-content: flex-end; }
.confirm-ok {
border: none; border-radius: var(--radius-sm); padding: 7px 18px;
font: inherit; font-size: 13px; font-weight: 600;
background: var(--bad); color: white; cursor: pointer; transition: filter 150ms;
}
.confirm-ok:hover { filter: brightness(1.1); }
.confirm-cancel {
border: 1px solid var(--border2); background: transparent; color: var(--muted);
border-radius: var(--radius-sm); padding: 7px 14px; font: inherit; font-size: 13px; cursor: pointer;
}
.question-note {
font-size: 11px; color: var(--muted); font-family: var(--mono);
margin-top: 4px; display: flex; align-items: center; gap: 4px;
}
/* ── Focus styles ── */
#jumpLatest:focus-visible, .top-btn:focus-visible, .vote-btn:focus-visible,
.action-btn:focus-visible, .ask-btn:focus-visible, .write-answer-btn:focus-visible,
.other-answers-toggle:focus-visible, .related-toggle:focus-visible, .send-btn:focus-visible,
.write-submit:focus-visible, .write-cancel:focus-visible, .propose-submit:focus-visible,
.propose-cancel:focus-visible, .anim-option:focus-visible, .density-option:focus-visible,
.suggestion-chip:focus-visible, .cmd-item:focus-visible, .confirm-ok:focus-visible,
.confirm-cancel:focus-visible, .copy-code-btn:focus-visible {
outline: 2px solid rgba(124,166,255,.7); outline-offset: 2px;
}
/* ── Responsive ── */
@media (max-width: 600px) {
#topbar { padding: 0 10px; }
.brand-sub { display: none; }
.bubble { max-width: calc(100vw - 80px); }
.welcome { margin-top: 3vh; padding: 18px 14px; }
.welcome h1 { font-size: 18px; }
#settingsPanel { width: 100%; border-radius: 0 0 var(--radius-lg) var(--radius-lg); }
#jumpLatest { bottom: 120px; max-width: calc(100vw - 24px); white-space: nowrap; }
}
@media (pointer: coarse) {
.vote-btn, .action-btn, .ask-btn, .versions-toggle, .other-answers-toggle,
.related-toggle, .write-answer-btn { min-height: 44px; padding: 8px 14px; }
.vote-btn { min-height: 44px; }
.top-btn { min-height: 40px; }
}
</style>
</head>
<body>
<a href="#prompt" class="skip-link">Skip to chat input</a>
<div id="app">
<header id="topbar" role="banner">
<div class="brand">
<img class="logo" src="/templates/logo.png" alt="Human Intelligence logo" />
<div>
<div class="brand-title">Human Intelligence</div>
<div class="brand-sub">community answers</div>
</div>
</div>
<nav class="top-actions" aria-label="Chat actions">
<button class="top-btn" id="newChatBtn" aria-label="Start a new chat">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
New chat
</button>
<button class="top-btn" id="settingsBtn" aria-label="Open appearance settings" aria-expanded="false" aria-controls="settingsPanel">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</button>
</nav>
</header>
<div id="statusbar" role="status" aria-live="assertive">
<span class="status-dot" aria-hidden="true"></span>
<span id="statusText">Thinking…</span>
</div>
<button id="jumpLatest" type="button" aria-label="Jump to latest content">New content below Β· Jump ↓</button>
<div class="settings-backdrop" id="settingsBackdrop" aria-hidden="true"></div>
<div id="settingsPanel" role="dialog" aria-label="Appearance settings" aria-modal="false">
<div class="settings-header">
<div class="settings-title">Appearance</div>
<button class="settings-close" id="settingsClose" aria-label="Close settings">Γ—</button>
</div>
<div class="setting-row">
<div>
<div class="setting-label">Response animation</div>
<div class="setting-desc">How answers are revealed</div>
</div>
</div>
<div class="anim-segment" id="animSegment" role="radiogroup" aria-label="Animation style">
<div class="anim-option" data-anim="none" role="radio" aria-checked="true" tabindex="0"><span>Instant</span><div class="anim-preview"></div></div>
<div class="anim-option" data-anim="ai" role="radio" aria-checked="false" tabindex="0"><span>Quick fade</span><div class="anim-preview"></div></div>
<div class="anim-option" data-anim="human" role="radio" aria-checked="false" tabindex="0"><span>Gentle fade</span><div class="anim-preview"></div></div>
<div class="anim-option" data-anim="diffusion" role="radio" aria-checked="false" tabindex="0"><span>Soft reveal</span><div class="anim-preview"></div></div>
<div class="anim-option" data-anim="diffusion-v2" role="radio" aria-checked="false" tabindex="0"><span>Slow reveal</span><div class="anim-preview"></div></div>
</div>
<div class="setting-row" style="margin-top:12px;">
<div>
<div class="setting-label">Content density</div>
<div class="setting-desc">Amount of spacing in chat</div>
</div>
</div>
<div class="density-segment" id="densitySegment" role="radiogroup" aria-label="Content density">
<div class="density-option" data-density="compact" role="radio" aria-checked="false" tabindex="0">Compact</div>
<div class="density-option active" data-density="comfortable" role="radio" aria-checked="true" tabindex="0">Default</div>
<div class="density-option" data-density="spacious" role="radio" aria-checked="false" tabindex="0">Spacious</div>
</div>
</div>
<main id="chat" role="main">
<div id="scrollProgress" aria-hidden="true"></div>
<div class="wrap">
<div class="welcome" id="welcome">
<h1 id="welcomeTitle">Ask a question. Get answers from real people.</h1>
<p>Type a question below. If a matching answer exists, it appears instantly. Otherwise, anyone can write the first answer.</p>
<p>Please do not share personal or sensitive information.</p>
<div class="welcome-suggestions" id="welcomeSuggestions" aria-label="Example questions">
<button class="suggestion-chip" data-q="How does the internet work?">πŸ’‘ How does the internet work?</button>
<button class="suggestion-chip" data-q="What is the best way to learn programming?">πŸ–₯ Best way to learn programming?</button>
<button class="suggestion-chip" data-q="How do I improve my sleep?">πŸŒ™ How do I improve my sleep?</button>
</div>
</div>
<div id="transcript" aria-live="polite" aria-atomic="false"></div>
</div>
</main>
<div class="compose">
<form id="composeForm" class="compose-inner" onsubmit="return false;" autocomplete="off">
<div class="autocomplete-dropdown" id="autocompleteDropdown" role="listbox" aria-label="Similar questions"></div>
<textarea id="prompt" rows="1" placeholder="Ask a question…" aria-label="Your question" autocomplete="off" spellcheck="true"></textarea>
<div class="compose-row">
<div class="hint" id="hint" aria-live="polite">Enter to ask Β· Shift+Enter newline Β· Ctrl+K commands</div>
<button class="send-btn" id="sendBtn" type="submit">Ask</button>
</div>
</form>
</div>
</div>
<div id="toast" role="alert" aria-live="assertive"></div>
<div id="lightbox" aria-modal="true" aria-label="Image viewer" role="dialog">
<button id="lightboxClose" aria-label="Close image viewer">Γ—</button>
<img id="lightboxImg" src="" alt="" />
</div>
<div id="cmdBackdrop" role="dialog" aria-modal="true" aria-label="Command palette">
<div id="cmdPalette">
<input id="cmdInput" type="text" placeholder="Type a command or search…" aria-label="Command search" autocomplete="off" />
<div class="cmd-list" id="cmdList" role="listbox"></div>
</div>
</div>
<div id="confirmBackdrop" role="dialog" aria-modal="true" aria-labelledby="confirmTitle">
<div id="confirmModal">
<div class="confirm-title" id="confirmTitle">Are you sure?</div>
<div class="confirm-msg" id="confirmMsg"></div>
<div class="confirm-actions">
<button class="confirm-cancel" id="confirmCancel">Cancel</button>
<button class="confirm-ok" id="confirmOk">Confirm</button>
</div>
</div>
</div>
<script>window.__HI_INIT__ = {{ init_json | safe }};</script>
<script>
(() => {
'use strict';
/* ═══════════════════════════════════════════
STATE
═══════════════════════════════════════════ */
const S = {
clientId: null, conversation: null, currentQuestion: '',
relatedAnswers: [], loading: false, atBottom: true,
animMode: localStorage.getItem('hi_anim') || 'none',
density: localStorage.getItem('hi_density') || 'comfortable',
lastAction: null, originalTitle: document.title,
};
/* ═══════════════════════════════════════════
UTILITIES
═══════════════════════════════════════════ */
const $ = id => document.getElementById(id);
const qs = (sel, ctx = document) => ctx.querySelector(sel);
const qsa = (sel, ctx = document) => Array.from(ctx.querySelectorAll(sel));
const sleep = ms => new Promise(r => setTimeout(r, ms));
function updateAppHeight() {
const h = window.visualViewport?.height || window.innerHeight || document.documentElement.clientHeight;
document.documentElement.style.setProperty('--app-height', `${Math.round(h)}px`);
}
function getClientId() {
let id = localStorage.getItem('hi_client_id');
if (!id) {
id = (crypto.randomUUID?.() || Date.now().toString(36) + Math.random().toString(36).slice(2))
.replace(/-/g, '').slice(0, 16);
localStorage.setItem('hi_client_id', id);
}
return id;
}
function debounce(fn, ms) {
let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); };
}
function debounceClick(fn, ms = 350) {
let blocked = false;
return async (...a) => {
if (blocked) return;
blocked = true;
try { await fn(...a); } finally { setTimeout(() => { blocked = false; }, ms); }
};
}
/* ═══════════════════════════════════════════
TOAST / STATUS
═══════════════════════════════════════════ */
function toast(msg, kind = '', retryFn = null) {
const t = $('toast');
t.innerHTML = '';
const span = document.createElement('span');
span.textContent = msg;
t.appendChild(span);
if (retryFn) {
const btn = document.createElement('button');
btn.className = 'toast-retry'; btn.textContent = 'Retry';
btn.onclick = () => { hideToast(); retryFn(); };
t.appendChild(btn);
}
t.className = 'show' + (kind ? ' ' + kind : '');
clearTimeout(t._t);
t._t = setTimeout(hideToast, retryFn ? 6000 : 2500);
}
function hideToast() { $('toast').className = ''; }
let statusTimers = [];
function showStatus(text) { $('statusText').textContent = text; $('statusbar').classList.add('visible'); }
function hideStatus() { statusTimers.forEach(clearTimeout); statusTimers = []; $('statusbar').classList.remove('visible'); }
function showStatusWithEscalation() {
showStatus('Searching for answers…');
statusTimers.push(setTimeout(() => showStatus('Still searching…'), 8000));
statusTimers.push(setTimeout(() => showStatus('Taking longer than usual…'), 20000));
}
/* ═══════════════════════════════════════════
ESCAPE / TEXT
═══════════════════════════════════════════ */
function esc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function nl2br(s) { return esc(s).replace(/\n/g, '<br>'); }
function previewText(s) {
return String(s||'').replace(/```[\s\S]*?```/g,' [code] ')
.replace(/!\[[^\]]*\]\([^)]*\)/g,' [image] ').replace(/\[([^\]]+)\]\([^)]*\)/g,'$1')
.replace(/^[#>\-*+]\s+/gm,'').replace(/[*_~`]+/g,'').replace(/\s+/g,' ').trim();
}
/* ═══════════════════════════════════════════
INLINE MARKDOWN
═══════════════════════════════════════════ */
function renderInlineMarkdown(s) {
const tokens = [];
let raw = String(s||'').replace(
/!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)|\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
(m, imgAlt, imgSrc, linkText, linkHref) => {
const idx = tokens.length;
tokens.push(imgSrc !== undefined
? `<img class="md-img" src="${imgSrc}" alt="${esc(imgAlt)}" loading="lazy">`
: `<a href="${linkHref}" target="_blank" rel="noopener noreferrer">${esc(linkText)}</a>`);
return `\x00${idx}\x00`;
}
);
raw = raw.replace(/`([^`]+)`/g, (_, c) => {
const idx = tokens.length; tokens.push(`<code>${esc(c)}</code>`); return `\x00${idx}\x00`;
});
let out = esc(raw);
out = out.replace(/\*\*\*([^*]+)\*\*\*/g,'<strong><em>$1</em></strong>');
out = out.replace(/___([^_]+)___/g,'<strong><em>$1</em></strong>');
out = out.replace(/\*\*([^*]+)\*\*/g,'<strong>$1</strong>');
out = out.replace(/__([^_]+)__/g,'<strong>$1</strong>');
out = out.replace(/\*([^*\s][^*]*[^*\s]|\S)\*/g,'<em>$1</em>');
out = out.replace(/_([^_\s][^_]*[^_\s]|\S)_/g,'<em>$1</em>');
out = out.replace(/~~([^~]+)~~/g,'<s>$1</s>');
out = out.replace(/\^([^^]+)\^/g,'<sup>$1</sup>');
out = out.replace(/~([^~]+)~/g,'<sub>$1</sub>');
out = out.replace(/\x00(\d+)\x00/g, (_, i) => tokens[Number(i)]);
return out;
}
/* ═══════════════════════════════════════════
BLOCK MARKDOWN RENDERER
(with thinking block extraction)
═══════════════════════════════════════════ */
/**
* Extract <|thinking|>…</|thinking|> blocks from raw text.
* Returns { segments: [ {type:'text'|'thinking', content:string} ] }
*/
function extractThinkingBlocks(md) {
const segments = [];
const openTag = '<|thinking|>';
const closeTag = '</|thinking|>';
let cursor = 0;
while (cursor < md.length) {
const openIdx = md.indexOf(openTag, cursor);
if (openIdx === -1) {
segments.push({ type: 'text', content: md.slice(cursor) });
break;
}
if (openIdx > cursor) {
segments.push({ type: 'text', content: md.slice(cursor, openIdx) });
}
const closeIdx = md.indexOf(closeTag, openIdx + openTag.length);
if (closeIdx === -1) {
// Unclosed thinking block β€” treat rest as thinking
segments.push({ type: 'thinking', content: md.slice(openIdx + openTag.length) });
cursor = md.length;
break;
}
segments.push({ type: 'thinking', content: md.slice(openIdx + openTag.length, closeIdx) });
cursor = closeIdx + closeTag.length;
}
return segments;
}
function renderMarkdown(md) {
const lines = String(md||'').replace(/\r\n/g,'\n').split('\n');
const out = [];
let inCode = false, codeLang = '', codeBuf = [];
let lastWasBlank = false;
const listStack = [];
function closeListsTo(target) {
while (listStack.length && listStack[listStack.length-1].indent > target)
out.push(`</${listStack.pop().type}>`);
}
function closeLists() { while (listStack.length) out.push(`</${listStack.pop().type}>`); }
let inQuote = false;
function closeQuote() { if (inQuote) { out.push('</blockquote>'); inQuote = false; } }
let inTable = false, tableHeaders = [], tableAligns = [];
function closeTable() {
if (inTable) { out.push('</tbody></table>'); inTable = false; tableHeaders = []; tableAligns = []; }
}
for (let li = 0; li < lines.length; li++) {
const raw = lines[li].trimEnd();
if (inCode) {
if (/^```/.test(raw)) {
const highlighted = esc(codeBuf.join('\n'));
const langLabel = codeLang ? `<span class="code-lang-label">${esc(codeLang)}</span>` : '';
out.push(`<div class="code-block-wrapper">${langLabel}<button class="copy-code-btn" data-copy-code="${encodeURIComponent(codeBuf.join('\n'))}">Copy</button><pre><code>${highlighted}</code></pre></div>`);
codeBuf = []; codeLang = ''; inCode = false;
} else codeBuf.push(raw);
continue;
}
if (/^```/.test(raw)) {
closeLists(); closeQuote(); closeTable();
codeLang = raw.replace(/^```/,'').trim().toLowerCase(); inCode = true; continue;
}
if (!raw.trim()) {
closeLists(); closeQuote(); closeTable();
if (!lastWasBlank) out.push('<p class="md-gap"></p>');
lastWasBlank = true; continue;
}
lastWasBlank = false;
if (/^#{1,6}\s/.test(raw)) {
closeLists(); closeQuote(); closeTable();
const lvl = Math.min(6, raw.match(/^#+/)[0].length);
out.push(`<h${lvl}>${renderInlineMarkdown(raw.replace(/^#+\s+/,''))}</h${lvl}>`); continue;
}
if (/^(-{3,}|\*{3,}|_{3,})$/.test(raw.trim())) {
closeLists(); closeQuote(); closeTable(); out.push('<hr>'); continue;
}
if (/^> ?/.test(raw)) {
closeLists(); closeTable();
if (!inQuote) { out.push('<blockquote>'); inQuote = true; }
out.push(`<p>${renderInlineMarkdown(raw.replace(/^> ?/,''))}</p>`); continue;
}
closeQuote();
if (/^\|.+\|$/.test(raw)) {
const nextLine = lines[li+1] ? lines[li+1].trimEnd() : '';
if (/^\|[\s|:\-]+\|$/.test(nextLine)) {
closeLists();
tableHeaders = raw.split('|').slice(1,-1).map(c=>c.trim());
const sepCells = nextLine.split('|').slice(1,-1).map(c=>c.trim());
tableAligns = sepCells.map(c => /^:-+:$/.test(c)?'center': /^-+:$/.test(c)?'right':'left');
out.push('<table><thead><tr>');
tableHeaders.forEach((h,i)=>out.push(`<th style="text-align:${tableAligns[i]}">${renderInlineMarkdown(h)}</th>`));
out.push('</tr></thead><tbody>'); inTable = true; li++; continue;
} else if (inTable) {
const cells = raw.split('|').slice(1,-1).map(c=>c.trim());
out.push('<tr>');
cells.forEach((c,i)=>out.push(`<td style="text-align:${tableAligns[i]||'left'}">${renderInlineMarkdown(c)}</td>`));
out.push('</tr>'); continue;
}
}
if (inTable && !/^\|/.test(raw)) closeTable();
const ulMatch = raw.match(/^(\s*)[-*]\s+(.*)/);
const olMatch = raw.match(/^(\s*)\d+\.\s+(.*)/);
if (ulMatch || olMatch) {
closeQuote(); closeTable();
const indent = (ulMatch||olMatch)[1].length;
const type = ulMatch ? 'ul' : 'ol';
const text = ulMatch ? ulMatch[2] : olMatch[2];
if (!listStack.length || indent > listStack[listStack.length-1].indent) {
out.push(`<${type}>`); listStack.push({type,indent});
} else if (indent < listStack[listStack.length-1].indent) {
closeListsTo(indent);
if (!listStack.length || listStack[listStack.length-1].indent !== indent) {
out.push(`<${type}>`); listStack.push({type,indent});
}
}
const taskMatch = text.match(/^\[([ xX])\]\s+(.*)/);
if (taskMatch) {
const checked = taskMatch[1] !== ' ';
out.push(`<li class="task-item"><input type="checkbox" ${checked?'checked':''} disabled aria-checked="${checked}">${renderInlineMarkdown(taskMatch[2])}</li>`);
} else out.push(`<li>${renderInlineMarkdown(text)}</li>`);
continue;
}
closeLists(); closeTable();
out.push(`<p>${renderInlineMarkdown(raw)}</p>`);
}
closeLists(); closeQuote(); closeTable();
if (inCode) out.push(`<div class="code-block-wrapper"><pre><code>${codeBuf.join('\n')}</code></pre></div>`);
return out.join('');
}
/**
* Render markdown that may contain thinking blocks.
* Returns HTML with <details> dropdowns for each thinking block.
* `thinkingDuration` is the number of seconds to display (null = still thinking).
*/
function renderMarkdownWithThinking(md, thinkingDuration = null) {
const segments = extractThinkingBlocks(md);
return segments.map(seg => {
if (seg.type === 'thinking') {
const durLabel = thinkingDuration != null
? `thinking for ${thinkingDuration}s`
: `thinking…`;
const spinnerOrArrow = thinkingDuration != null
? `<span class="thinking-arrow" aria-hidden="true">β–Ά</span>`
: `<span class="thinking-spinner" aria-hidden="true"></span>`;
const thinkingHtml = renderMarkdown(seg.content.trim());
return `<details class="thinking-dropdown">
<summary class="thinking-summary">${spinnerOrArrow} <span>${esc(durLabel)}</span></summary>
<div class="thinking-content">${thinkingHtml}</div>
</details>`;
}
return renderMarkdown(seg.content);
}).join('');
}
/* ═══════════════════════════════════════════
TIME HELPERS
═══════════════════════════════════════════ */
function relativeTime(iso) {
if (!iso) return '';
try {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff/60000);
if (mins<1) return 'just now';
if (mins<60) return `${mins}m ago`;
const hrs = Math.floor(mins/60);
if (hrs<24) return `${hrs}h ago`;
const days = Math.floor(hrs/24);
if (days<7) return `${days}d ago`;
return new Date(iso).toLocaleString([],{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'});
} catch { return iso; }
}
/* ═══════════════════════════════════════════
QUALITY SCORE
═══════════════════════════════════════════ */
function answerQuality(text) {
let s = 0;
if (text.length>200) s++; if (/```/.test(text)) s++;
if (/https?:\/\//.test(text)) s++; if (/\n[-*] /.test(text)) s++;
return Math.min(s,4);
}
function renderQualityDots(text) {
const q = answerQuality(text);
return `<span class="quality-dots" aria-label="Answer quality">${
Array.from({length:4},(_,i)=>`<span class="quality-dot ${i<q?'filled':''}" aria-hidden="true"></span>`).join('')
}</span>`;
}
/* ═══════════════════════════════════════════
SCROLL
═══════════════════════════════════════════ */
function isNearBottom() { const c=$('chat'); return c.scrollHeight-c.scrollTop-c.clientHeight<72; }
function setJumpLatest(v) { $('jumpLatest')?.classList.toggle('show',!!v); }
let scrollRAF = null;
function scrollBottom(force=false) {
if (scrollRAF) return;
scrollRAF = requestAnimationFrame(() => {
scrollRAF = null; const c=$('chat'); if (!c) return;
if (force||S.atBottom||isNearBottom()) {
c.scrollTop = c.scrollHeight; setJumpLatest(false); S.atBottom = true;
} else setJumpLatest(true);
});
}
function updateScrollProgress() {
const c=$('chat'), bar=$('scrollProgress'); if (!c||!bar) return;
const pct = c.scrollHeight<=c.clientHeight ? 0 : (c.scrollTop/(c.scrollHeight-c.clientHeight))*100;
bar.style.width = pct.toFixed(1)+'%';
}
/* ═══════════════════════════════════════════
DOM HELPERS
═══════════════════════════════════════════ */
function appendHTML(target, html) {
if (!html) return;
const tpl = document.createElement('template');
tpl.innerHTML = html.trim();
target.appendChild(tpl.content);
}
/* ═══════════════════════════════════════════
ANIMATE TEXT (chunked autoregressive)
═══════════════════════════════════════════ */
async function animateText(el, text) {
if (!el) return;
const mode = S.animMode;
const delays = { none:0, ai:18, human:30, diffusion:50, 'diffusion-v2':70 };
const delay = delays[mode] ?? 0;
const segments = extractThinkingBlocks(text);
if (mode === 'none') {
el.innerHTML = renderMarkdownWithThinking(text, null);
finalizeThinkingBlocks(el);
bindCodeCopyButtons(el);
return;
}
el.innerHTML = '';
for (const seg of segments) {
if (seg.type === 'thinking') {
await animateThinkingBlock(el, seg.content, delay);
} else {
const textContainer = document.createElement('div');
el.appendChild(textContainer);
await animateMarkdownChunked(textContainer, seg.content, delay);
}
}
finalizeThinkingBlocks(el);
bindCodeCopyButtons(el);
}
/**
* Animate a thinking block: show a "thinking…" dropdown that updates
* in real-time, then when complete, display final duration.
*/
async function animateThinkingBlock(parentEl, thinkingText, delay) {
const details = document.createElement('details');
details.className = 'thinking-dropdown';
const summary = document.createElement('summary');
summary.className = 'thinking-summary';
summary.innerHTML = `<span class="thinking-spinner" aria-hidden="true"></span> <span class="thinking-label">thinking…</span>`;
const contentDiv = document.createElement('div');
contentDiv.className = 'thinking-content';
details.appendChild(summary);
details.appendChild(contentDiv);
parentEl.appendChild(details);
scrollBottom();
const startTime = performance.now();
// Stream the thinking content in chunks
await animateMarkdownChunked(contentDiv, thinkingText.trim(), delay);
const elapsed = ((performance.now() - startTime) / 1000).toFixed(1);
// Replace spinner with arrow + final time
summary.innerHTML = `<span class="thinking-arrow" aria-hidden="true">β–Ά</span> <span>thinking for ${elapsed}s</span>`;
}
/**
* Animate markdown by splitting it into small line-based chunks
* and rendering progressively, so it feels autoregressive.
*/
async function animateMarkdownChunked(el, mdText, delay) {
if (!mdText.trim()) return;
const lines = mdText.replace(/\r\n/g,'\n').split('\n');
let buffer = '';
// We'll accumulate lines and re-render periodically
// Chunk size: 1-3 lines at a time for natural feel
const chunkSize = delay > 40 ? 1 : delay > 15 ? 2 : 3;
for (let i = 0; i < lines.length; i += chunkSize) {
const chunk = lines.slice(i, i + chunkSize).join('\n');
buffer += (buffer ? '\n' : '') + chunk;
// Render current buffer
const html = renderMarkdown(buffer);
el.innerHTML = html;
bindCodeCopyButtons(el);
scrollBottom();
if (delay > 0 && i + chunkSize < lines.length) {
await sleep(delay);
}
}
}
/**
* After all animation is done, finalize any thinking blocks
* that might still show spinners (for non-animated renders).
*/
function finalizeThinkingBlocks(el) {
// For statically rendered thinking blocks, we just need to make sure
// the duration is set. This is handled in renderMarkdownWithThinking
// for instant mode. For animated mode, animateThinkingBlock handles it.
}
/* ═══════════════════════════════════════════
TYPING INDICATOR
═══════════════════════════════════════════ */
function showTyping() {
removeTyping();
$('transcript').insertAdjacentHTML('beforeend', `
<div class="typing-indicator" id="typingInd" aria-label="Loading response" role="status">
<div class="avatar assistant" aria-hidden="true">✦</div>
<div class="typing-dots" aria-hidden="true"><span></span><span></span><span></span></div>
</div>`);
scrollBottom();
}
function removeTyping() { $('typingInd')?.remove(); }
/* ═══════════════════════════════════════════
LIGHTBOX
═══════════════════════════════════════════ */
function openLightbox(src, alt) {
$('lightboxImg').src = src; $('lightboxImg').alt = alt||'';
$('lightbox').classList.add('open'); $('lightboxClose').focus();
document.addEventListener('keydown', closeLightboxOnKey);
}
function closeLightbox() {
$('lightbox').classList.remove('open'); $('lightboxImg').src = '';
document.removeEventListener('keydown', closeLightboxOnKey);
}
function closeLightboxOnKey(e) { if (e.key==='Escape') closeLightbox(); }
/* ═══════════════════════════════════════════
CODE COPY BUTTONS
═══════════════════════════════════════════ */
function bindCodeCopyButtons(ctx = document) {
qsa('[data-copy-code]', ctx).forEach(btn => {
if (btn._bound) return; btn._bound = true;
btn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(decodeURIComponent(btn.getAttribute('data-copy-code')));
btn.textContent='Copied!'; btn.classList.add('copied');
setTimeout(()=>{btn.textContent='Copy';btn.classList.remove('copied');},2000);
} catch { toast('Could not copy','bad'); }
});
});
}
/* ═══════════════════════════════════════════
ANSWER HELPERS
═══════════════════════════════════════════ */
function activeVersion(answer) {
const v = answer?.versions||[];
if (!v.length) return null;
return v.find(x=>x.id===answer.active_version)
|| [...v].sort((a,b)=>(Number(b.votes||0)-Number(a.votes||0)) || String(b.created_at||'').localeCompare(String(a.created_at||'')))[0];
}
function answerScore(a) { const v=activeVersion(a); return v?Number(v.votes||0):0; }
function sortedAnswers(conv) {
return [...(conv?.answers||[])].sort((a,b)=>{
const d=answerScore(b)-answerScore(a);
return d!==0?d:String(b.created_at||'').localeCompare(String(a.created_at||''));
});
}
/* ═══════════════════════════════════════════
RENDER HELPERS
═══════════════════════════════════════════ */
const COPY_SVG = `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`;
const ASK_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>`;
const PENCIL_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>`;
function renderVoteRow(answerId, ver) {
const myVote = ver.votes_by_client?.[S.clientId];
const vu = myVote===1, vd = myVote===-1;
const cnt = Number(ver.votes||0);
return `<div class="vote-row">
<button class="vote-btn ${vu?'voted-up':''}" data-vote="${answerId}|${ver.id}|1"
aria-label="Upvote. ${cnt} vote${cnt!==1?'s':''}. ${vu?'You voted.':'Not voted.'}" aria-pressed="${vu}">
β–² <span class="vote-count"><span class="vote-count-inner">${cnt}</span></span>
</button>
<button class="vote-btn ${vd?'voted-down':''}" data-vote="${answerId}|${ver.id}|-1"
aria-label="Downvote.${vd?' You voted.':''}" aria-pressed="${vd}">β–Ό</button>
<button class="action-btn" data-copy-answer="${answerId}" data-answer-id="${ver.id}">${COPY_SVG} Copy</button>
</div>`;
}
function renderVersions(answer) {
const act = activeVersion(answer);
const others = (answer.versions||[]).filter(v=>v.id!==act?.id);
if (!others.length) return '';
return `
<button class="versions-toggle" type="button" data-toggle-versions="${answer.id}" aria-controls="vp-${answer.id}" aria-expanded="false">
<span class="arrow" aria-hidden="true">β–Ά</span> ${others.length} version${others.length>1?'s':''}
</button>
<div class="versions-panel" id="vp-${answer.id}" role="region" aria-label="Other versions">
${others.map(v=>`
<div class="version-card">
<div class="version-head">
<span>${esc(v.author||'Anonymous')}</span><span aria-hidden="true">Β·</span>
<span>${relativeTime(v.created_at)}</span><span aria-hidden="true">Β·</span>
<span>${Number(v.votes||0)} vote${Number(v.votes||0)!==1?'s':''}</span>
</div>
<div class="preview-block">
<span class="preview-label">A</span>
<div class="preview-text">${esc(previewText(v.text||''))}</div>
</div>
<div class="preview-actions">
${renderVoteRow(answer.id, v)}
<button class="ask-btn" type="button" data-ask-current="1" aria-label="Ask this question again">${ASK_SVG} Ask</button>
</div>
</div>`).join('')}
</div>`;
}
function renderPropose(answerId) {
return `
<button class="action-btn" type="button" data-propose="${answerId}" aria-controls="pp-${answerId}" aria-expanded="false" title="Suggest an improved version">✏ Propose version</button>
<div class="propose-panel" id="pp-${answerId}" role="region" aria-label="Propose version">
<textarea class="propose-textarea" placeholder="Write a better version…" rows="3" aria-label="Proposed version text"></textarea>
<div class="char-count"><span class="cc-cur">0</span> / 5000</div>
<div class="propose-actions">
<button class="propose-submit" data-submit-proposal="${answerId}">Submit</button>
<button class="propose-cancel" data-cancel-propose="${answerId}">Cancel</button>
</div>
</div>`;
}
function renderWriteAnswer(convId) {
return `
<button class="write-answer-btn" type="button" id="writeAnswerBtn" aria-controls="writePanel" aria-expanded="false">${PENCIL_SVG} Write an answer</button>
<div class="write-panel" id="writePanel" role="region" aria-label="Write your answer">
<div class="write-tabs" role="tablist">
<button class="write-tab active" role="tab" id="writeTabEdit" aria-selected="true" aria-controls="writeEditorPane">Write</button>
<button class="write-tab" role="tab" id="writeTabPreview" aria-selected="false" aria-controls="writePreviewPane">Preview</button>
</div>
<div id="writeEditorPane" role="tabpanel" aria-labelledby="writeTabEdit">
<textarea class="write-textarea" id="writeTextarea" placeholder="Write your answer here… Markdown is supported." rows="4" aria-label="Your answer" maxlength="5000"></textarea>
</div>
<div id="writePreviewPane" role="tabpanel" aria-labelledby="writeTabPreview" class="write-preview"></div>
<div class="char-count" id="writeCharCount"><span id="writeCharCur">0</span> / 5000</div>
<div class="write-actions">
<button class="write-submit" id="writeSubmit">Submit answer</button>
<button class="write-cancel" id="writeCancel">Cancel</button>
</div>
</div>`;
}
function renderAnswerBlock(answer, idx, isBest) {
const v = activeVersion(answer); if (!v) return '';
const rawText = v.text||'';
const label = isBest ? `<span class="chip good">βœ“ best answer</span>` : `<span class="chip muted">answer ${idx+1}</span>`;
const bubbleId = isBest ? 'id="bestAnswerText"' : '';
const bubbleClass = isBest ? 'best-answer-bubble' : 'bubble';
const glowClass = isBest ? 'answer-new-glow' : '';
return `
<div ${bubbleId} class="${bubbleClass} ${glowClass}" tabindex="-1">${isBest ? '' : renderMarkdownWithThinking(rawText)}</div>
<div class="turn-meta" style="margin-top:var(--space-1);">
${label} ${renderQualityDots(rawText)}
<span>${esc(v.author||'Anonymous')}</span><span aria-hidden="true">Β·</span>
<span>${relativeTime(v.created_at)}</span>
</div>
${renderVoteRow(answer.id, v)}
<div style="display:flex;gap:var(--space-1);flex-wrap:wrap;margin-top:var(--space-1);">
${renderVersions(answer)} ${renderPropose(answer.id)}
</div>`;
}
function renderOtherAnswers(answers) {
if (answers.length<=1) return '';
const others = answers.slice(1);
return `
<button class="other-answers-toggle" type="button" id="otherAnswersToggle" aria-controls="otherAnswersPanel" aria-expanded="false">
<span class="arrow" aria-hidden="true">β–Ά</span> ${others.length} other answer${others.length>1?'s':''}
</button>
<div class="other-answers-panel" id="otherAnswersPanel" role="region" aria-label="Other answers">
${others.map((a,i)=>{
const v=activeVersion(a); if(!v) return '';
return `<div class="other-answer-card">
<div class="other-answer-head">
<span class="chip muted">answer ${i+2}</span>
<span>${esc(v.author||'Anonymous')}</span><span aria-hidden="true">Β·</span>
<span>${relativeTime(v.created_at)}</span> ${renderQualityDots(v.text||'')}
</div>
<div class="other-answer-text">${renderMarkdownWithThinking(v.text||'')}</div>
${renderVoteRow(a.id, v)}
<div style="display:flex;gap:var(--space-1);flex-wrap:wrap;margin-top:var(--space-1);">
${renderVersions(a)} ${renderPropose(a.id)}
</div>
</div>`;
}).join('')}
</div>`;
}
function renderRelated(rel) {
if (!rel?.length) return '';
return `
<div class="related-stack">
<div class="chip muted">from similar questions</div>
<button class="related-toggle" type="button" id="relatedToggle" aria-controls="relatedPanel" aria-expanded="false">
<span class="arrow" aria-hidden="true">β–Ά</span> ${rel.length} related answer${rel.length>1?'s':''}
</button>
<div class="related-panel" id="relatedPanel" role="region" aria-label="Related answers">
${rel.map(r=>`
<div class="other-answer-card related">
<div class="other-answer-head">
<span class="chip matched">related</span>
<span class="chip related-score">score ${Number(r.score||0).toFixed(2)}</span>
</div>
<div class="preview-block"><span class="preview-label">Q</span>
<div class="preview-text">${esc(previewText(r.question||''))}</div></div>
<div class="preview-block"><span class="preview-label">A</span>
<div class="preview-text muted-preview">${esc(previewText(r.answer||''))}</div></div>
<div class="preview-actions">
<button class="ask-btn" type="button" data-ask-question="${esc(r.question||'')}" aria-label="Ask this question">${ASK_SVG} Ask</button>
</div>
</div>`).join('')}
<p class="related-note">Previews of answers from semantically similar questions. Click Ask to start a fresh conversation.</p>
</div>
</div>`;
}
/* ═══════════════════════════════════════════
MAIN RENDER
═══════════════════════════════════════════ */
async function renderConversation(questionText, doAnimate) {
const tr=$('transcript'), wl=$('welcome');
const frag = document.createDocumentFragment();
if (!S.conversation) {
wl.style.display=''; tr.replaceChildren();
setJumpLatest(false); document.title=S.originalTitle;
updateWelcomeState(); return;
}
wl.style.display='none';
const q = questionText||S.conversation.question||'';
document.title = q.slice(0,60)+' β€” '+S.originalTitle;
if (S.conversation.id) history.replaceState({cid:S.conversation.id},'',`/q/${S.conversation.id}`);
const isNew = !S.conversation.created_at || (Date.now()-new Date(S.conversation.created_at).getTime())<10000;
const questionNote = isNew
? `<div class="question-note">πŸ†• You're the first to ask this!</div>`
: `<div class="question-note">Asked ${relativeTime(S.conversation.created_at)}</div>`;
appendHTML(frag, `
<div class="turn user new-turn"><div>
<div class="bubble">${nl2br(q)}</div>
<div class="turn-meta"><span class="chip muted">question</span><span>${relativeTime(S.conversation.created_at)}</span></div>
${questionNote}
</div><div class="avatar user" aria-hidden="true">U</div></div>`);
const answers = sortedAnswers(S.conversation);
if (!answers.length) {
appendHTML(frag, `
<div class="turn assistant new-turn"><div class="avatar assistant" aria-hidden="true">✦</div><div>
<div class="bubble no-answer-bubble" role="status">⏳ No answer yet. Be the first to write one.</div>
<div class="turn-meta"><span class="chip warn">⏳ awaiting answer</span></div>
${renderWriteAnswer(S.conversation.id)}
<div id="relatedMount"></div>
</div></div>`);
} else {
appendHTML(frag, `
<div class="turn assistant new-turn"><div class="avatar assistant" aria-hidden="true">✦</div>
<div style="min-width:0;flex:1;">
${renderAnswerBlock(answers[0], 0, true)}
${renderWriteAnswer(S.conversation.id)}
${renderOtherAnswers(answers)}
<div id="relatedMount"></div>
</div>
</div>`);
}
tr.replaceChildren(frag);
if (answers.length) {
const bestV = activeVersion(answers[0]);
if (bestV) {
const el = $('bestAnswerText');
if (doAnimate) {
await animateText(el, bestV.text||'');
} else if (el) {
el.innerHTML = renderMarkdownWithThinking(bestV.text||'');
bindCodeCopyButtons(el);
}
}
}
const rm = $('relatedMount');
if (rm && S.relatedAnswers.length) rm.innerHTML = renderRelated(S.relatedAnswers);
bindHandlers(); scrollBottom(); restoreDraft();
}
/* ═══════════════════════════════════════════
DRAFTS
═══════════════════════════════════════════ */
function draftKey() { return S.conversation ? `hi_draft_${S.conversation.id}` : null; }
function saveDraft(text) { const k=draftKey(); if(!k) return; text ? localStorage.setItem(k,text) : localStorage.removeItem(k); }
function clearDraft() { const k=draftKey(); if(k) localStorage.removeItem(k); }
function restoreDraft() {
const k=draftKey(); if(!k) return;
const saved = localStorage.getItem(k);
const ta = $('writeTextarea');
if (saved && ta) {
ta.value = saved; updateCharCount(ta,'writeCharCur',5000);
const panel=$('writePanel'), btn=$('writeAnswerBtn');
if (panel&&btn) { panel.classList.add('open'); btn.setAttribute('aria-expanded','true'); }
}
}
function updateCharCount(ta, spanId, max) {
const span=$(spanId); if(!span) return;
const len=ta.value.length; span.textContent=len;
const row=span.closest('.char-count');
if(row){ row.classList.toggle('near-limit',len>max*.85&&len<=max); row.classList.toggle('over-limit',len>max); }
}
function updateWelcomeState() {
const title=$('welcomeTitle'); if(!title) return;
title.textContent = localStorage.getItem('hi_last_cid')
? 'Welcome back. Ask something new.'
: 'Ask a question. Get answers from real people.';
}
/* ═══════════════════════════════════════════
EVENT DELEGATION
═══════════════════════════════════════════ */
function bindHandlers() {
const tr=$('transcript');
if (tr._delegated) return;
tr._delegated = true;
tr.addEventListener('click', async e => {
const voteBtn = e.target.closest('[data-vote]');
if (voteBtn) { await handleVote(voteBtn); return; }
const askQ = e.target.closest('[data-ask-question]');
if (askQ) { handleAskFromCard(askQ.getAttribute('data-ask-question')); return; }
if (e.target.closest('[data-ask-current]')) { handleAskFromCard(S.currentQuestion); return; }
const copyAns = e.target.closest('[data-copy-answer]');
if (copyAns) { await handleCopyAnswer(copyAns); return; }
const toggleVer = e.target.closest('[data-toggle-versions]');
if (toggleVer) { handleToggleVersions(toggleVer); return; }
if (e.target.closest('#otherAnswersToggle')) { handleTogglePanel('otherAnswersToggle','otherAnswersPanel'); return; }
if (e.target.closest('#relatedToggle')) { handleTogglePanel('relatedToggle','relatedPanel'); return; }
const proposeBtn = e.target.closest('[data-propose]');
if (proposeBtn) { handleProposeToggle(proposeBtn); return; }
const cancelProp = e.target.closest('[data-cancel-propose]');
if (cancelProp) { handleProposeCancel(cancelProp); return; }
const submitProp = e.target.closest('[data-submit-proposal]');
if (submitProp) { await handleSubmitProposal(submitProp); return; }
if (e.target.closest('#writeAnswerBtn')) { handleWriteToggle(); return; }
if (e.target.closest('#writeCancel')) { handleWriteCancel(); return; }
if (e.target.closest('#writeSubmit')) { await handleWriteSubmit(); return; }
if (e.target.closest('#writeTabEdit')) { handleWriteTab('edit'); return; }
if (e.target.closest('#writeTabPreview')) { handleWriteTab('preview'); return; }
const img = e.target.closest('.md-img');
if (img) { openLightbox(img.src, img.alt); return; }
});
tr.addEventListener('input', e => {
const ta = e.target;
if (ta.tagName!=='TEXTAREA') return;
autoGrow(ta);
if (ta.id==='writeTextarea') {
updateCharCount(ta,'writeCharCur',5000);
saveDraft(ta.value);
const preview=$('writePreviewPane');
if (preview?.classList.contains('write-preview') && !preview.style.display?.includes('none'))
preview.innerHTML = renderMarkdown(ta.value);
} else {
const panel = ta.closest('.propose-panel');
if (panel) {
const ccSpan=qs('.cc-cur',panel);
if(ccSpan){ ccSpan.textContent=ta.value.length;
const ccRow=qs('.char-count',panel);
if(ccRow){ ccRow.classList.toggle('near-limit',ta.value.length>4250); ccRow.classList.toggle('over-limit',ta.value.length>5000); }
}
}
}
});
}
/* ── Individual Handlers ── */
const handleVote = debounceClick(async btn => {
if (!S.conversation) return;
const [aid,vid,d] = btn.getAttribute('data-vote').split('|');
const delta = Number(d);
const answer = (S.conversation.answers||[]).find(a=>a.id===aid);
const ver = answer?.versions?.find(v=>v.id===vid);
const myVote = ver?.votes_by_client?.[S.clientId];
const effectiveDelta = myVote===delta ? 0 : delta;
const countEl = qs('.vote-count-inner',btn.parentElement);
const prevCount = countEl ? Number(countEl.textContent) : 0;
if (countEl) {
const newCount = prevCount + (effectiveDelta===0 ? -delta : delta);
countEl.style.transform = `translateY(${delta>0?'-100%':'100%'})`;
requestAnimationFrame(()=>{ countEl.textContent=newCount; countEl.style.transform=''; });
}
btn.classList.toggle('voted-up', effectiveDelta===1);
btn.classList.toggle('voted-down', effectiveDelta===-1);
if ('vibrate' in navigator) navigator.vibrate(10);
S.lastAction = ()=>handleVote(btn);
const res = await callAPI('vote',{
conversation_id:S.conversation.id, answer_id:aid, version_id:vid, delta:effectiveDelta
});
if (res.ok) {
S.conversation=res.conversation; save();
updateVoteBtn(btn,aid,vid,res.conversation);
} else {
if(countEl) countEl.textContent=prevCount;
btn.classList.toggle('voted-up', myVote===1);
btn.classList.toggle('voted-down', myVote===-1);
toast(res.error||'Vote failed','bad',S.lastAction);
}
});
function updateVoteBtn(btn,aid,vid,conv) {
const answer=(conv.answers||[]).find(a=>a.id===aid);
const ver=answer?.versions?.find(v=>v.id===vid);
if(!ver) return;
const myVote=ver.votes_by_client?.[S.clientId];
const cnt=Number(ver.votes||0);
const countEl=qs('.vote-count-inner',btn.parentElement);
if(countEl) countEl.textContent=cnt;
const vu=myVote===1, vd=myVote===-1;
btn.classList.toggle('voted-up',vu); btn.classList.toggle('voted-down',vd);
if(btn.getAttribute('data-vote').endsWith('|1')){
btn.setAttribute('aria-label',`Upvote. ${cnt} vote${cnt!==1?'s':''}. ${vu?'You voted.':'Not voted.'}`);
btn.setAttribute('aria-pressed',String(vu));
} else btn.setAttribute('aria-pressed',String(vd));
}
async function handleCopyAnswer(btn) {
const answer=(S.conversation?.answers||[]).find(a=>a.id===btn.getAttribute('data-copy-answer'));
const ver=activeVersion(answer); if(!ver) return;
try { await navigator.clipboard.writeText(ver.text||''); toast('Answer copied','good'); }
catch { toast('Could not copy','bad'); }
}
function handleAskFromCard(q) {
const text=String(q||'').trim();
if(!text||S.loading) return;
const p=$('prompt'); if(p){p.value=text;autoGrow(p);} submitPrompt();
}
function handleToggleVersions(btn) {
const id=btn.getAttribute('data-toggle-versions'), p=$('vp-'+id); if(!p) return;
const open=p.classList.toggle('open');
const arrow=qs('.arrow',btn); if(arrow) arrow.style.transform=open?'rotate(90deg)':'';
btn.setAttribute('aria-expanded',String(open));
}
function handleTogglePanel(toggleId, panelId) {
const toggle=$(toggleId), panel=$(panelId); if(!toggle||!panel) return;
const open=panel.classList.toggle('open');
toggle.classList.toggle('open',open); toggle.setAttribute('aria-expanded',String(open));
}
function handleProposeToggle(btn) {
const id=btn.getAttribute('data-propose'), p=$('pp-'+id); if(!p) return;
const open=p.classList.toggle('open'); btn.setAttribute('aria-expanded',String(open));
if(open){const ta=qs('textarea',p); if(ta) setTimeout(()=>ta.focus(),80);}
}
function handleProposeCancel(btn) {
const id=btn.getAttribute('data-cancel-propose'), p=$('pp-'+id);
if(p) p.classList.remove('open');
const trigger=qs(`[data-propose="${id}"]`);
if(trigger){trigger.setAttribute('aria-expanded','false');trigger.focus();}
}
const handleSubmitProposal = debounceClick(async btn => {
const aid=btn.getAttribute('data-submit-proposal');
const box=$('pp-'+aid), ta=box?qs('textarea',box):null;
const text=ta?ta.value.trim():'';
if(!text){toast('Empty proposal','bad');return;} if(!S.conversation) return;
btn.disabled=true; const orig=btn.textContent; btn.textContent='Saving…';
showStatus('Saving proposal…');
S.lastAction=()=>handleSubmitProposal(btn);
const res=await callAPI('propose',{conversation_id:S.conversation.id,answer_id:aid,text});
hideStatus(); btn.disabled=false; btn.textContent=orig;
if(res.ok){S.conversation=res.conversation;save();renderConversation(S.currentQuestion,false);toast('Version proposed','good');}
else toast(res.error||'Error','bad',S.lastAction);
});
function handleWriteToggle() {
const p=$('writePanel'),btn=$('writeAnswerBtn'); if(!p||!btn) return;
const open=p.classList.toggle('open'); btn.setAttribute('aria-expanded',String(open));
if(open){const ta=$('writeTextarea');if(ta) setTimeout(()=>ta.focus(),100);}
}
function handleWriteCancel() {
const p=$('writePanel'); if(p) p.classList.remove('open');
const btn=$('writeAnswerBtn'); if(btn){btn.setAttribute('aria-expanded','false');btn.focus();}
}
function handleWriteTab(mode) {
const editTab=$('writeTabEdit'),previewTab=$('writeTabPreview');
const editorPane=$('writeEditorPane'),previewPane=$('writePreviewPane');
if(!editTab||!previewTab||!editorPane||!previewPane) return;
if(mode==='edit'){
editTab.classList.add('active'); editTab.setAttribute('aria-selected','true');
previewTab.classList.remove('active'); previewTab.setAttribute('aria-selected','false');
editorPane.style.display=''; previewPane.style.display='none';
previewPane.classList.remove('active'); $('writeTextarea')?.focus();
} else {
previewTab.classList.add('active'); previewTab.setAttribute('aria-selected','true');
editTab.classList.remove('active'); editTab.setAttribute('aria-selected','false');
editorPane.style.display='none'; previewPane.style.display='';
previewPane.classList.add('active');
const ta=$('writeTextarea');
previewPane.innerHTML = ta ? renderMarkdown(ta.value) : '<p style="color:var(--muted)">Nothing to preview.</p>';
bindCodeCopyButtons(previewPane);
}
}
const handleWriteSubmit = debounceClick(async () => {
const ta=$('writeTextarea'), text=ta?ta.value.trim():'';
if(!text){toast('Empty answer','bad');return;} if(!S.conversation) return;
const ws=$('writeSubmit');
if(ws){ws.disabled=true;ws.textContent='Saving…';} showStatus('Saving answer…');
S.lastAction=handleWriteSubmit;
const res=await callAPI('answer',{conversation_id:S.conversation.id,text,question:S.currentQuestion});
hideStatus(); if(ws){ws.disabled=false;ws.textContent='Submit answer';}
if(res.ok){
S.conversation=res.conversation; clearDraft(); save();
await renderConversation(S.currentQuestion,false); toast('Answer saved','good');
setTimeout(()=>{const el=$('bestAnswerText');if(el){el.setAttribute('tabindex','-1');el.focus();}},200);
} else toast(res.error||'Error','bad',S.lastAction);
});
/* ═══════════════════════════════════════════
AUTOCOMPLETE
═══════════════════════════════════════════ */
const debouncedAutocomplete = debounce(async q => {
if(!q||q.length<4){closeAutocomplete();return;}
const res = await callAPI('search',{query:q,limit:5});
if(!res.ok||!res.results?.length){closeAutocomplete();return;}
showAutocomplete(res.results);
}, 300);
function showAutocomplete(results) {
const dd=$('autocompleteDropdown'); if(!dd) return;
dd.innerHTML = results.map((r,i)=>
`<div class="autocomplete-item" role="option" tabindex="-1" data-ac-q="${esc(r.question)}" id="ac-item-${i}">
<span class="autocomplete-match">${esc(r.question)}</span>
<span class="autocomplete-meta">${Number(r.answer_count||0)} answer${Number(r.answer_count||0)!==1?'s':''}</span>
</div>`).join('');
dd.classList.add('open');
qsa('.autocomplete-item',dd).forEach(item=>{
item.addEventListener('click',()=>{
$('prompt').value=item.getAttribute('data-ac-q');
closeAutocomplete(); submitPrompt();
});
});
}
function closeAutocomplete() { $('autocompleteDropdown')?.classList.remove('open'); }
/* ═══════════════════════════════════════════
API
═══════════════════════════════════════════ */
function save() { if(S.conversation) localStorage.setItem('hi_last_cid',S.conversation.id); }
const inflightRequests = new Set();
async function callAPI(action, payload={}) {
const key = action+':'+JSON.stringify(payload);
if ((action==='search'||action==='get_conversation') && inflightRequests.has(key))
return {ok:false,error:'Request in progress'};
inflightRequests.add(key);
try {
const resp = await fetch('/api',{
method:'POST',
headers:{'Content-Type':'application/json','X-Client-Id':S.clientId},
body:JSON.stringify({action,client_id:S.clientId,...payload}),
});
const data = await resp.json().catch(()=>null);
if(!resp.ok) return data&&typeof data==='object' ? data : {ok:false,error:`Request failed (${resp.status})`};
return data||{ok:false,error:'Empty response from server'};
} catch(err) { return {ok:false,error:err?.message||'Network error'}; }
finally { inflightRequests.delete(key); }
}
/* ═══════════════════════════════════════════
ASK / SUBMIT
═══════════════════════════════════════════ */
async function askQuestion(q) {
showStatusWithEscalation(); showTyping();
S.loading=true; $('sendBtn').disabled=true; closeAutocomplete();
S.lastAction=()=>askQuestion(q);
const res = await callAPI('ask',{question:q});
removeTyping(); hideStatus(); S.loading=false; $('sendBtn').disabled=false;
if(!res.ok){toast(res.error||'Error','bad',S.lastAction);return;}
S.conversation=res.conversation; S.currentQuestion=q;
S.relatedAnswers=Array.isArray(res.related)?res.related:[];
save(); toast(res.matched?'βœ“ Existing answer found':'βœ“ New question created','good');
await renderConversation(q,true);
}
async function submitPrompt() {
const p=$('prompt'), text=p.value.trim();
if(!text||S.loading) return;
p.value=''; autoGrow(p); await askQuestion(text);
}
function autoGrow(el) {
el.style.height='auto';
let h=Math.min(el.scrollHeight,180); if(h<40) h=40;
requestAnimationFrame(()=>{el.style.height=h+'px';});
}
/* ═══════════════════════════════════════════
LOAD SAVED
═══════════════════════════════════════════ */
async function loadSaved() {
const id=localStorage.getItem('hi_last_cid'); if(!id) return;
const tr=$('transcript'), wl=$('welcome');
wl.style.display='none';
tr.innerHTML=`<div class="skeleton-wrap">
<div class="skeleton skeleton-bubble"></div>
<div class="skeleton skeleton-line long"></div>
<div class="skeleton skeleton-line medium"></div>
<div class="skeleton skeleton-line short"></div></div>`;
showStatus('Loading conversation…');
const res=await callAPI('get_conversation',{conversation_id:id});
hideStatus();
if(res.ok&&res.conversation){
S.conversation=res.conversation; S.currentQuestion=res.conversation.question||'';
S.relatedAnswers=[]; renderConversation(S.currentQuestion,false);
} else { tr.innerHTML=''; wl.style.display=''; updateWelcomeState(); localStorage.removeItem('hi_last_cid'); }
}
/* ═══════════════════════════════════════════
NEW CHAT
═══════════════════════════════════════════ */
async function newChat() {
if(qsa('textarea').some(t=>t.value.trim())){
if(!await confirmModal('Start a new chat?','You have unsaved content. It will be lost.')) return;
}
S.conversation=null; S.currentQuestion=''; S.relatedAnswers=[]; S.atBottom=true;
localStorage.removeItem('hi_last_cid');
$('transcript').innerHTML=''; $('welcome').style.display='';
updateWelcomeState(); setJumpLatest(false); $('prompt').value='';
autoGrow($('prompt')); history.replaceState({},'','/');
document.title=S.originalTitle; $('prompt').focus();
}
function confirmModal(title, msg) {
return new Promise(resolve => {
$('confirmTitle').textContent=title; $('confirmMsg').textContent=msg;
$('confirmBackdrop').classList.add('open'); $('confirmOk').focus();
function cleanup(r){$('confirmBackdrop').classList.remove('open');$('confirmOk').onclick=null;$('confirmCancel').onclick=null;resolve(r);}
$('confirmOk').onclick=()=>cleanup(true); $('confirmCancel').onclick=()=>cleanup(false);
});
}
/* ═══════════════════════════════════════════
SETTINGS
═══════════════════════════════════════════ */
function initSettings() {
const panel=$('settingsPanel'), btn=$('settingsBtn'), backdrop=$('settingsBackdrop');
function setOpen(open, focusEl=null) {
panel.classList.toggle('open',open); backdrop.classList.toggle('visible',open);
btn.setAttribute('aria-expanded',String(open)); panel.inert=!open;
if(open){qs('.anim-option',panel)?.focus();} else if(focusEl) focusEl.focus();
}
btn.onclick=()=>setOpen(!panel.classList.contains('open'));
$('settingsClose').onclick=()=>setOpen(false,btn);
backdrop.onclick=()=>setOpen(false,btn);
document.addEventListener('keydown',e=>{if(e.key==='Escape'&&panel.classList.contains('open'))setOpen(false,btn);});
// Animation options
const animOpts=qsa('.anim-option',$('animSegment'));
function syncAnim(){
animOpts.forEach(o=>{const a=S.animMode===o.getAttribute('data-anim');o.classList.toggle('active',a);o.setAttribute('aria-checked',String(a));});
}
animOpts.forEach(o=>{
o.addEventListener('click',()=>{S.animMode=o.getAttribute('data-anim');localStorage.setItem('hi_anim',S.animMode);syncAnim();});
o.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();o.click();}});
});
// Density options
const densityOpts=qsa('.density-option',$('densitySegment'));
function syncDensity(){
densityOpts.forEach(o=>{const a=S.density===o.getAttribute('data-density');o.classList.toggle('active',a);o.setAttribute('aria-checked',String(a));});
document.documentElement.setAttribute('data-density',S.density);
}
densityOpts.forEach(o=>{
o.addEventListener('click',()=>{S.density=o.getAttribute('data-density');localStorage.setItem('hi_density',S.density);syncDensity();});
o.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();o.click();}});
});
$('jumpLatest').onclick=()=>scrollBottom(true);
$('lightbox').onclick=e=>{if(e.target===$('lightbox'))closeLightbox();};
$('lightboxClose').onclick=closeLightbox;
panel.inert=true; syncAnim(); syncDensity();
}
/* ═══════════════════════════════════════════
COMMAND PALETTE
═══════════════════════════════════════════ */
const COMMANDS = [
{icon:'✦',label:'New chat',shortcut:'Ctrl+N',action:newChat},
{icon:'πŸ“‹',label:'Copy best answer',action:async()=>{
const el=$('bestAnswerText');if(!el){toast('No answer to copy','bad');return;}
try{await navigator.clipboard.writeText(el.innerText);toast('Copied','good');}catch{toast('Could not copy','bad');}
}},
{icon:'✏',label:'Write an answer',action:()=>$('writeAnswerBtn')?.click()},
{icon:'πŸ”—',label:'Copy page URL',action:async()=>{
try{await navigator.clipboard.writeText(location.href);toast('URL copied','good');}catch{toast('Could not copy','bad');}
}},
{icon:'βš™',label:'Open settings',action:()=>$('settingsBtn').click()},
{icon:'↓',label:'Jump to latest',action:()=>scrollBottom(true)},
];
let cmdFocusIdx=-1;
function openCommandPalette(){
$('cmdBackdrop').classList.add('open'); $('cmdInput').value='';
$('cmdInput').focus(); cmdFocusIdx=-1; renderCmdList('');
}
function closeCommandPalette(){$('cmdBackdrop').classList.remove('open');$('prompt').focus();}
function renderCmdList(query){
const list=$('cmdList'), q=query.toLowerCase();
const filtered=q?COMMANDS.filter(c=>c.label.toLowerCase().includes(q)):COMMANDS;
if(!filtered.length){list.innerHTML='<div class="cmd-empty">No commands found.</div>';return;}
list.innerHTML=filtered.map((c,i)=>
`<div class="cmd-item" role="option" data-cmd-idx="${i}" tabindex="-1">
<span class="cmd-item-icon" aria-hidden="true">${c.icon}</span>
<span class="cmd-item-label">${esc(c.label)}</span>
${c.shortcut?`<span class="cmd-item-shortcut">${esc(c.shortcut)}</span>`:''}
</div>`).join('');
list._filtered=filtered;
qsa('.cmd-item',list).forEach((item,i)=>{
item.addEventListener('click',()=>{closeCommandPalette();filtered[i].action();});
});
}
function initCommandPalette(){
const backdrop=$('cmdBackdrop'), input=$('cmdInput'), list=$('cmdList');
backdrop.addEventListener('click',e=>{if(e.target===backdrop)closeCommandPalette();});
input.addEventListener('input',()=>{cmdFocusIdx=-1;renderCmdList(input.value);});
input.addEventListener('keydown',e=>{
const items=qsa('.cmd-item',list);
if(e.key==='ArrowDown'){e.preventDefault();cmdFocusIdx=Math.min(cmdFocusIdx+1,items.length-1);items[cmdFocusIdx]?.focus();}
else if(e.key==='ArrowUp'){e.preventDefault();cmdFocusIdx=Math.max(cmdFocusIdx-1,-1);cmdFocusIdx<0?input.focus():items[cmdFocusIdx]?.focus();}
else if(e.key==='Escape') closeCommandPalette();
else if(e.key==='Enter'&&items.length) items[0]?.click();
});
}
/* ═══════════════════════════════════════════
GLOBAL KEYBOARD
═══════════════════════════════════════════ */
function initKeyboard(){
document.addEventListener('keydown',e=>{
const ctrl=e.ctrlKey||e.metaKey;
if(ctrl&&e.key==='k'){e.preventDefault();openCommandPalette();return;}
if(ctrl&&e.key==='n'){e.preventDefault();newChat();return;}
if(ctrl&&e.key==='Enter'&&e.target.tagName==='TEXTAREA'){
e.preventDefault();
if(e.target.id==='writeTextarea') handleWriteSubmit();
else{const p=e.target.closest('.propose-panel');if(p)qs('.propose-submit',p)?.click();}
return;
}
});
}
/* ═══════════════════════════════════════════
SUGGESTION CHIPS + PULL TO REFRESH
═══════════════════════════════════════════ */
function initSuggestionChips(){
qsa('.suggestion-chip').forEach(chip=>{
chip.addEventListener('click',()=>{
const q=chip.getAttribute('data-q'); if(!q) return;
$('prompt').value=q; autoGrow($('prompt')); submitPrompt();
});
});
}
function initPullToRefresh(){
const chat=$('chat'); let pullStart=0;
chat.addEventListener('touchstart',e=>{pullStart=chat.scrollTop===0?e.touches[0].clientY:0;},{passive:true});
chat.addEventListener('touchend',e=>{
if(!pullStart) return;
if(e.changedTouches[0].clientY-pullStart>80&&S.conversation){
pullStart=0; showStatus('Refreshing…');
callAPI('get_conversation',{conversation_id:S.conversation.id}).then(res=>{
hideStatus();
if(res.ok&&res.conversation){S.conversation=res.conversation;renderConversation(S.currentQuestion,false);toast('Refreshed','good');}
});
}
pullStart=0;
},{passive:true});
}
/* ═══════════════════════════════════════════
INIT
═══════════════════════════════════════════ */
async function init(){
updateAppHeight();
window.addEventListener('resize',updateAppHeight);
window.addEventListener('orientationchange',updateAppHeight);
if(window.visualViewport){
window.visualViewport.addEventListener('resize',updateAppHeight);
window.visualViewport.addEventListener('scroll',updateAppHeight);
}
if(window.matchMedia('(prefers-reduced-motion: reduce)').matches) S.animMode='none';
S.clientId=getClientId();
S.density=localStorage.getItem('hi_density')||'comfortable';
document.documentElement.setAttribute('data-density',S.density);
const chat=$('chat');
chat.addEventListener('scroll',()=>{
S.atBottom=isNearBottom(); if(S.atBottom) setJumpLatest(false); updateScrollProgress();
},{passive:true});
$('composeForm').addEventListener('submit',e=>{e.preventDefault();submitPrompt();});
$('sendBtn').addEventListener('click',e=>{e.preventDefault();submitPrompt();});
$('newChatBtn').addEventListener('click',newChat);
const prompt=$('prompt');
prompt.addEventListener('input',e=>{autoGrow(e.target);debouncedAutocomplete(e.target.value.trim());});
prompt.addEventListener('keydown',e=>{
if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();submitPrompt();}
if(e.key==='Escape') closeAutocomplete();
if(e.key==='ArrowDown'){const f=qs('.autocomplete-item',$('autocompleteDropdown'));if(f){e.preventDefault();f.focus();}}
});
document.addEventListener('click',e=>{if(!e.target.closest('.compose-inner'))closeAutocomplete();});
initSettings(); initCommandPalette(); initKeyboard();
initSuggestionChips(); initPullToRefresh();
const d=window.__HI_INIT__||{};
if(d.client_id) S.clientId=d.client_id;
updateWelcomeState(); await loadSaved(); prompt.focus();
}
init();
})();
</script>
</body>
</html>