Chat / templates /index.html
wop's picture
Update templates/index.html
dfd08ac 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;
}
/* ── Reduced Motion ── */
@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 ── */
.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; }
/* Tooltip */
.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); }
}
/* ── Scroll Progress ── */
/*
Use fixed positioning so the progress bar is always pinned
to the viewport top. `position: sticky` can behave relative
to a scroll container or transformed ancestor, causing the
bar to appear in the wrong place.
*/
#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;
}
/* ── 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 lists */
.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 */
.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 */
.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); }
/* ── 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;
transition: border-color 160ms var(--ease-out), color 160ms var(--ease-out), background 160ms var(--ease-out), transform 160ms var(--ease-spring);
display: inline-flex; align-items: center; gap: 3px;
position: relative; overflow: hidden;
}
.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;
transition: border-color 160ms var(--ease-out), color 160ms var(--ease-out), background 160ms var(--ease-out);
display: inline-flex; align-items: center; gap: 4px;
}
.action-btn:hover { border-color: rgba(108,131,255,.35); color: var(--text); background: rgba(108,131,255,.05); }
/* ── Write answer inline panel ── */
.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;
transition: border-color 180ms var(--ease-out), color 180ms var(--ease-out), background 180ms var(--ease-out), transform 180ms var(--ease-spring);
display: inline-flex; align-items: center; gap: 6px;
margin-top: var(--space-2);
}
.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;
transition: max-height 300ms var(--ease-out), opacity 250ms var(--ease-out), margin 200ms var(--ease-out);
opacity: 0; margin-top: 0;
}
.write-panel.open { max-height: 400px; opacity: 1; margin-top: 10px; }
/* Write tabs */
.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;
transition: border-color 180ms var(--ease-out), color 180ms var(--ease-out), background 180ms var(--ease-out);
display: inline-flex; align-items: center; gap: 5px;
}
.other-answers-toggle:hover { border-color: rgba(108,131,255,.3); color: var(--text); }
.other-answers-toggle .arrow { display: inline-block; transition: transform 200ms var(--ease-out); font-size: 10px; }
.other-answers-toggle.open .arrow { transform: rotate(90deg); }
.other-answers-panel {
max-height: 0; overflow: hidden;
transition: max-height 300ms var(--ease-out), opacity 200ms var(--ease-out);
opacity: 0; margin-top: var(--space-1);
}
.other-answers-panel.open { max-height: 3000px; 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 (similar questions + versions) ── */
.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 button */
.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;
transition: border-color 160ms var(--ease-out), color 160ms var(--ease-out), background 160ms var(--ease-out), transform 160ms var(--ease-spring);
display: inline-flex; align-items: center; gap: 4px;
}
.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); }
.versions-panel {
max-height: 0; overflow: hidden;
transition: max-height 280ms var(--ease-out), opacity 180ms var(--ease-out);
opacity: 0; border-left: 2px solid var(--border2);
padding-left: 10px; margin-top: var(--space-1);
}
.versions-panel.open { max-height: 1500px; opacity: 1; }
.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;
transition: max-height 280ms var(--ease-out), opacity 200ms var(--ease-out);
opacity: 0; margin-top: 6px;
}
.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 ── */
.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); }
/* ── Answer new glow ── */
@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;
transition: border-color 180ms var(--ease-out), color 180ms var(--ease-out), background 180ms var(--ease-out);
display: inline-flex; align-items: center; gap: 5px;
}
.related-toggle:hover { border-color: rgba(108,131,255,.3); color: var(--text); }
.related-toggle .arrow { display: inline-block; transition: transform 200ms var(--ease-out); font-size: 10px; }
.related-toggle.open .arrow { transform: rotate(90deg); }
.related-panel {
max-height: 0; overflow: hidden;
transition: max-height 280ms var(--ease-out), opacity 180ms var(--ease-out), margin-top 180ms var(--ease-out);
opacity: 0; margin-top: 0;
}
.related-panel.open { max-height: 3000px; opacity: 1; margin-top: 6px; }
.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 */
.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 segmented control */
.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;
transition: border-color 150ms, background 150ms;
font-size: 12px; color: var(--muted);
}
.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 control */
.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 ── */
.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>
<!-- Settings backdrop -->
<div class="settings-backdrop" id="settingsBackdrop" aria-hidden="true"></div>
<!-- Settings panel -->
<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>
<!-- Toast -->
<div id="toast" role="alert" aria-live="assertive"></div>
<!-- Lightbox -->
<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>
<!-- Command palette -->
<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>
<!-- Confirm modal -->
<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, // for retry
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));
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 ? 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 sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
function debounce(fn, ms) {
let t;
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
}
function debounceClick(fn, ms = 350) {
let blocked = false;
return async (...args) => {
if (blocked) return;
blocked = true;
try { await fn(...args); }
finally { setTimeout(() => { blocked = false; }, ms); }
};
}
/* ═══════════════════════════════════════════════
TOAST
═══════════════════════════════════════════════ */
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() {
const t = $('toast');
t.className = '';
}
/* ═══════════════════════════════════════════════
STATUS BAR
═══════════════════════════════════════════════ */
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
═══════════════════════════════════════════════ */
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>'); }
/* Strip markdown markers for clean inline previews */
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();
}
/* ═══════════════════════════════════════════════
SYNTAX HIGHLIGHTER (lightweight)
═══════════════════════════════════════════════ */
function syntaxHighlight(code, _lang) {
return esc(code);
}
/* ═══════════════════════════════════════════════
INLINE MARKDOWN
═══════════════════════════════════════════════ */
function renderInlineMarkdown(s) {
const tokens = [];
// Extract images, links first
let raw = String(s || '').replace(
/!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)|\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
(match, imgAlt, imgSrc, linkText, linkHref) => {
const idx = tokens.length;
if (imgSrc !== undefined) {
tokens.push(`<img class="md-img" src="${imgSrc}" alt="${esc(imgAlt)}" loading="lazy">`);
} else {
tokens.push(`<a href="${linkHref}" target="_blank" rel="noopener noreferrer">${esc(linkText)}</a>`);
}
return `\x00${idx}\x00`;
}
);
// Extract inline code
raw = raw.replace(/`([^`]+)`/g, (_, c) => {
const idx = tokens.length;
tokens.push(`<code>${esc(c)}</code>`);
return `\x00${idx}\x00`;
});
let out = esc(raw);
// Bold+italic
out = out.replace(/\*\*\*([^*]+)\*\*\*/g, '<strong><em>$1</em></strong>');
out = out.replace(/___([^_]+)___/g, '<strong><em>$1</em></strong>');
// Bold
out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
out = out.replace(/__([^_]+)__/g, '<strong>$1</strong>');
// Italic
out = out.replace(/\*([^*\s][^*]*[^*\s]|\S)\*/g, '<em>$1</em>');
out = out.replace(/_([^_\s][^_]*[^_\s]|\S)_/g, '<em>$1</em>');
// Strikethrough
out = out.replace(/~~([^~]+)~~/g, '<s>$1</s>');
// Superscript / subscript
out = out.replace(/\^([^^]+)\^/g, '<sup>$1</sup>');
out = out.replace(/~([^~]+)~/g, '<sub>$1</sub>');
// Restore tokens
out = out.replace(/\x00(\d+)\x00/g, (_, i) => tokens[Number(i)]);
return out;
}
/* ═══════════════════════════════════════════════
BLOCK MARKDOWN RENDERER
═══════════════════════════════════════════════ */
function renderMarkdown(md) {
const lines = String(md || '').replace(/\r\n/g, '\n').split('\n');
const out = [];
let inCode = false, codeLang = '', codeBuf = [];
let lastWasBlank = false;
// List stack: [{type:'ul'|'ol', indent:number}]
const listStack = [];
function closeListsTo(targetIndent) {
while (listStack.length && listStack[listStack.length - 1].indent > targetIndent) {
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; } }
// Table state
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();
// Code block
if (inCode) {
if (/^```/.test(raw)) {
const highlighted = syntaxHighlight(codeBuf.join('\n'), codeLang);
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;
}
// Blank line
if (!raw.trim()) {
closeLists(); closeQuote(); closeTable();
if (!lastWasBlank) out.push('<p class="md-gap"></p>');
lastWasBlank = true; continue;
}
lastWasBlank = false;
// Heading
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;
}
// HR
if (/^(-{3,}|\*{3,}|_{3,})$/.test(raw.trim())) {
closeLists(); closeQuote(); closeTable();
out.push('<hr>'); continue;
}
// Blockquote
if (/^> ?/.test(raw)) {
closeLists(); closeTable();
if (!inQuote) { out.push('<blockquote>'); inQuote = true; }
out.push(`<p>${renderInlineMarkdown(raw.replace(/^> ?/, ''))}</p>`);
continue;
}
closeQuote();
// Table detection
if (/^\|.+\|$/.test(raw)) {
closeTable(); // might open a new one
const nextLine = lines[li + 1] ? lines[li + 1].trimEnd() : '';
if (/^\|[\s|:\-]+\|$/.test(nextLine)) {
// Header row
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 => {
if (/^:-+:$/.test(c)) return 'center';
if (/^-+:$/.test(c)) return 'right';
return '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++; // skip separator
continue;
} else if (inTable) {
// Data row
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();
// Lists (with nesting)
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 === 0 || 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 });
}
}
// Task list
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('');
}
/* ═══════════════════════════════════════════════
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 fmtTime(iso);
} catch { return iso; }
}
function fmtTime(iso) {
if (!iso) return '';
try { return new Date(iso).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); }
catch { return iso; }
}
/* ═══════════════════════════════════════════════
QUALITY SCORE
═══════════════════════════════════════════════ */
function answerQuality(text) {
let score = 0;
if (text.length > 200) score++;
if (/```/.test(text)) score++;
if (/https?:\/\//.test(text)) score++;
if (/\n[-*] /.test(text)) score++;
return Math.min(score, 4);
}
function renderQualityDots(text) {
const q = answerQuality(text);
let html = '<span class="quality-dots" aria-label="Answer quality">';
for (let i = 0; i < 4; i++) {
html += `<span class="quality-dot ${i < q ? 'filled' : ''}" aria-hidden="true"></span>`;
}
html += '</span>';
return html;
}
/* ═══════════════════════════════════════════════
SCROLL
═══════════════════════════════════════════════ */
function isNearBottom() {
const c = $('chat');
return c.scrollHeight - c.scrollTop - c.clientHeight < 72;
}
function setJumpLatest(visible) {
const btn = $('jumpLatest');
if (btn) btn.classList.toggle('show', !!visible);
}
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');
const 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
═══════════════════════════════════════════════ */
async function animateText(el, text) {
if (!el) return;
const mode = S.animMode;
const delays = { none: 0, ai: 60, human: 90, diffusion: 130, 'diffusion-v2': 170 };
const delay = delays[mode] ?? 0;
const html = renderMarkdown(text);
if (mode === 'none') {
el.innerHTML = html;
bindCodeCopyButtons(el);
return;
}
const tmp = document.createElement('div');
tmp.innerHTML = html;
el.innerHTML = '';
for (const node of Array.from(tmp.childNodes)) {
const n = node.cloneNode(true);
if (n.nodeType === 1) {
n.classList.add('answer-reveal');
el.appendChild(n);
requestAnimationFrame(() => n.classList.add('revealed'));
} else {
el.appendChild(n);
}
scrollBottom();
await sleep(delay);
}
bindCodeCopyButtons(el);
}
/* ═══════════════════════════════════════════════
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() { const el = $('typingInd'); if (el) el.remove(); }
/* ═══════════════════════════════════════════════
LIGHTBOX
═══════════════════════════════════════════════ */
function openLightbox(src, alt) {
const lb = $('lightbox');
const img = $('lightboxImg');
img.src = src;
img.alt = alt || '';
lb.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 () => {
const code = decodeURIComponent(btn.getAttribute('data-copy-code'));
try {
await navigator.clipboard.writeText(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;
let f = v.find(x => x.id === answer.active_version);
if (f) return f;
return [...v].sort((a, b) => {
const d = Number(b.votes || 0) - Number(a.votes || 0);
return d !== 0 ? d : 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
═══════════════════════════════════════════════ */
function renderVoteRow(answerId, ver) {
const myVote = ver.votes_by_client?.[S.clientId];
const vu = myVote === 1;
const vd = myVote === -1;
const cnt = Number(ver.votes || 0);
const upLabel = `Upvote. Currently ${cnt} vote${cnt !== 1 ? 's' : ''}. ${vu ? 'You voted.' : 'Not voted.'}`;
const dnLabel = `Downvote.${vd ? ' You voted.' : ''}`;
return `<div class="vote-row">
<button class="vote-btn ${vu ? 'voted-up' : ''}" data-vote="${answerId}|${ver.id}|1" aria-label="${esc(upLabel)}" 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="${esc(dnLabel)}" aria-pressed="${vd}">β–Ό</button>
<button class="action-btn" data-copy-answer="${answerId}" data-answer-id="${ver.id}">
<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>
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 in a fresh conversation">
<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>
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 of this answer">
✏ 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">
<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>
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 ? '' : renderMarkdown(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">${renderMarkdown(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 || !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 => {
const q = String(r.question || '');
return `
<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(q))}</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(q)}" aria-label="Ask this question">
<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>
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');
const 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 || '';
// Update document title
document.title = q.slice(0, 60) + ' β€” ' + S.originalTitle;
// Update URL
if (S.conversation.id) {
history.replaceState({ cid: S.conversation.id }, '', `/q/${S.conversation.id}`);
}
// Question note
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>`;
// Question bubble
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 {
const best = answers[0];
appendHTML(frag, `
<div class="turn assistant new-turn">
<div class="avatar assistant" aria-hidden="true">✦</div>
<div style="min-width:0;flex:1;">
${renderAnswerBlock(best, 0, true)}
${renderWriteAnswer(S.conversation.id)}
${renderOtherAnswers(answers)}
<div id="relatedMount"></div>
</div>
</div>`);
}
tr.replaceChildren(frag);
// Animate best answer
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 = renderMarkdown(bestV.text || '');
bindCodeCopyButtons(el);
}
}
}
const relatedMount = $('relatedMount');
if (relatedMount && S.relatedAnswers.length) {
relatedMount.innerHTML = renderRelated(S.relatedAnswers);
}
bindHandlers();
scrollBottom();
// Restore draft if any
restoreDraft();
}
/* ═══════════════════════════════════════════════
DRAFTS
═══════════════════════════════════════════════ */
function draftKey() {
return S.conversation ? `hi_draft_${S.conversation.id}` : null;
}
function saveDraft(text) {
const k = draftKey();
if (!k) return;
if (text) localStorage.setItem(k, text);
else 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);
// Auto-open the panel if draft exists
const panel = $('writePanel');
const btn = $('writeAnswerBtn');
if (panel && btn) {
panel.classList.add('open');
btn.setAttribute('aria-expanded', 'true');
}
}
}
function clearDraft() { const k = draftKey(); if (k) localStorage.removeItem(k); }
/* ═══════════════════════════════════════════════
CHAR COUNT HELPER
═══════════════════════════════════════════════ */
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 * 0.85 && len <= max);
row.classList.toggle('over-limit', len > max);
}
}
/* ═══════════════════════════════════════════════
WELCOME STATE
═══════════════════════════════════════════════ */
function updateWelcomeState() {
const title = $('welcomeTitle');
if (!title) return;
const isReturning = !!localStorage.getItem('hi_last_cid');
title.textContent = isReturning
? 'Welcome back. Ask something new.'
: 'Ask a question. Get answers from real people.';
}
/* ═══════════════════════════════════════════════
BIND HANDLERS (event delegation)
═══════════════════════════════════════════════ */
function bindHandlers() {
// -- Event delegation on transcript --
// (Remove old listener then re-add so we don't stack)
const tr = $('transcript');
if (tr._delegated) return;
tr._delegated = true;
tr.addEventListener('click', async e => {
// Vote
const voteBtn = e.target.closest('[data-vote]');
if (voteBtn) { await handleVote(voteBtn); return; }
// Ask a related/similar question
const askQ = e.target.closest('[data-ask-question]');
if (askQ) { handleAskFromCard(askQ.getAttribute('data-ask-question')); return; }
// Re-ask the current question
if (e.target.closest('[data-ask-current]')) { handleAskFromCard(S.currentQuestion); return; }
// Copy answer text
const copyAns = e.target.closest('[data-copy-answer]');
if (copyAns) { await handleCopyAnswer(copyAns); return; }
// Toggle versions
const toggleVer = e.target.closest('[data-toggle-versions]');
if (toggleVer) { handleToggleVersions(toggleVer); return; }
// Other answers toggle
if (e.target.closest('#otherAnswersToggle')) { handleTogglePanel('otherAnswersToggle', 'otherAnswersPanel'); return; }
// Related toggle
if (e.target.closest('#relatedToggle')) { handleTogglePanel('relatedToggle', 'relatedPanel'); return; }
// Propose toggle
const proposeBtn = e.target.closest('[data-propose]');
if (proposeBtn) { handleProposeToggle(proposeBtn); return; }
// Cancel propose
const cancelProp = e.target.closest('[data-cancel-propose]');
if (cancelProp) { handleProposeCancel(cancelProp); return; }
// Submit proposal
const submitProp = e.target.closest('[data-submit-proposal]');
if (submitProp) { await handleSubmitProposal(submitProp); return; }
// Write answer toggle
if (e.target.closest('#writeAnswerBtn')) { handleWriteToggle(); return; }
// Cancel write
if (e.target.closest('#writeCancel')) { handleWriteCancel(); return; }
// Submit write
if (e.target.closest('#writeSubmit')) { await handleWriteSubmit(); return; }
// Write tabs
if (e.target.closest('#writeTabEdit')) { handleWriteTab('edit'); return; }
if (e.target.closest('#writeTabPreview')) { handleWriteTab('preview'); return; }
// Image lightbox
const img = e.target.closest('.md-img');
if (img) { openLightbox(img.src, img.alt); return; }
});
// Textarea events (can't fully delegate these)
tr.addEventListener('input', e => {
const ta = e.target;
if (ta.tagName !== 'TEXTAREA') return;
if (ta.id === 'writeTextarea') {
autoGrow(ta);
updateCharCount(ta, 'writeCharCur', 5000);
saveDraft(ta.value);
// Live preview update
const preview = $('writePreviewPane');
if (preview && preview.classList.contains('write-preview') && !preview.style.display.includes('none')) {
preview.innerHTML = renderMarkdown(ta.value);
}
} else {
// Propose textareas
autoGrow(ta);
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);
// Find current vote state for this version
const answers = S.conversation.answers || [];
const answer = answers.find(a => a.id === aid);
const ver = answer?.versions?.find(v => v.id === vid);
const myVote = ver?.votes_by_client?.[S.clientId];
// Toggle off if same direction
const effectiveDelta = myVote === delta ? 0 : delta;
// Optimistic update
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);
// Mark card loading
const card = btn.closest('.other-answer-card, .best-answer-bubble, [id="bestAnswerText"]')?.parentElement;
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();
// Only update the vote row DOM, not full re-render
updateVoteBtn(btn, aid, vid, res.conversation);
} else {
// Revert
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);
const upLabel = `Upvote. Currently ${cnt} vote${cnt !== 1 ? 's' : ''}. ${vu ? 'You voted.' : 'Not voted.'}`;
if (btn.getAttribute('data-vote').endsWith('|1')) {
btn.setAttribute('aria-label', upLabel);
btn.setAttribute('aria-pressed', String(vu));
} else {
btn.setAttribute('aria-pressed', String(vd));
}
}
async function handleCopyAnswer(btn) {
const aid = btn.getAttribute('data-copy-answer');
const answers = S.conversation?.answers || [];
const answer = answers.find(a => a.id === aid);
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');
const 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');
const 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');
const 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);
const 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');
const 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');
const previewTab = $('writeTabPreview');
const editorPane = $('writeEditorPane');
const 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');
const 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');
// Focus new best answer
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, q);
}, 300);
function showAutocomplete(results, q) {
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() {
const dd = $('autocompleteDropdown');
if (dd) dd.classList.remove('open');
}
/* ═══════════════════════════════════════════════
SAVE / PERSIST
═══════════════════════════════════════════════ */
function save() {
if (S.conversation) localStorage.setItem('hi_last_cid', S.conversation.id);
}
/* ═══════════════════════════════════════════════
API
═══════════════════════════════════════════════ */
const inflightRequests = new Set();
async function callAPI(action, payload = {}) {
const key = action + ':' + JSON.stringify(payload);
// Only deduplicate safe read actions
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');
const text = p.value.trim();
if (!text || S.loading) return;
p.value = '';
autoGrow(p);
await askQuestion(text);
}
function autoGrow(el) {
// Use 'auto' so browsers recalculate scrollHeight reliably, then
// clamp to a sensible min/max so the compose area can shrink back.
el.style.height = 'auto';
let h = Math.min(el.scrollHeight, 180);
if (h < 40) h = 40;
requestAnimationFrame(() => { el.style.height = h + 'px'; });
}
/* ═══════════════════════════════════════════════
LOAD SAVED CONVERSATION
═══════════════════════════════════════════════ */
async function loadSaved() {
const id = localStorage.getItem('hi_last_cid');
if (!id) return;
// Show skeleton
const tr = $('transcript');
const 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() {
const hasContent = qsa('textarea').some(t => t.value.trim());
if (hasContent) {
const confirmed = await confirmModal('Start a new chat?', 'You have unsaved content. It will be lost.');
if (!confirmed) return;
}
S.conversation = null;
S.currentQuestion = '';
S.relatedAnswers = [];
S.atBottom = true;
localStorage.removeItem('hi_last_cid');
$('transcript').innerHTML = '';
const wl = $('welcome');
wl.style.display = '';
updateWelcomeState();
setJumpLatest(false);
$('prompt').value = '';
autoGrow($('prompt'));
history.replaceState({}, '', '/');
document.title = S.originalTitle;
$('prompt').focus();
}
/* ═══════════════════════════════════════════════
CONFIRM MODAL
═══════════════════════════════════════════════ */
function confirmModal(title, msg) {
return new Promise(resolve => {
$('confirmTitle').textContent = title;
$('confirmMsg').textContent = msg;
$('confirmBackdrop').classList.add('open');
$('confirmOk').focus();
function cleanup(result) {
$('confirmBackdrop').classList.remove('open');
$('confirmOk').onclick = null;
$('confirmCancel').onclick = null;
resolve(result);
}
$('confirmOk').onclick = () => cleanup(true);
$('confirmCancel').onclick = () => cleanup(false);
});
}
/* ═══════════════════════════════════════════════
SETTINGS
═══════════════════════════════════════════════ */
function initSettings() {
const panel = $('settingsPanel');
const btn = $('settingsBtn');
const 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) {
// Focus first option inside
const first = qs('.anim-option', panel);
if (first) first.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);
});
// Anim segment
const animOpts = qsa('.anim-option', $('animSegment'));
function syncAnim() {
animOpts.forEach(opt => {
const active = S.animMode === opt.getAttribute('data-anim');
opt.classList.toggle('active', active);
opt.setAttribute('aria-checked', String(active));
});
}
animOpts.forEach(opt => {
opt.addEventListener('click', () => {
S.animMode = opt.getAttribute('data-anim');
localStorage.setItem('hi_anim', S.animMode);
syncAnim();
});
opt.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); opt.click(); }
});
});
// Density segment
const densityOpts = qsa('.density-option', $('densitySegment'));
function syncDensity() {
densityOpts.forEach(opt => {
const active = S.density === opt.getAttribute('data-density');
opt.classList.toggle('active', active);
opt.setAttribute('aria-checked', String(active));
});
document.documentElement.setAttribute('data-density', S.density);
}
densityOpts.forEach(opt => {
opt.addEventListener('click', () => {
S.density = opt.getAttribute('data-density');
localStorage.setItem('hi_density', S.density);
syncDensity();
});
opt.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); opt.click(); }
});
});
// Jump latest
const jump = $('jumpLatest');
if (jump) jump.onclick = () => scrollBottom(true);
// Lightbox
$('lightbox').onclick = e => { if (e.target === $('lightbox')) closeLightbox(); };
$('lightboxClose').onclick = closeLightbox;
// Initial sync
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: () => {
const btn = $('writeAnswerBtn');
if (btn) btn.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');
const 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');
const input = $('cmdInput');
const 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);
if (cmdFocusIdx < 0) input.focus();
else 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;
// Ctrl+K / Cmd+K β†’ command palette
if (ctrl && e.key === 'k') { e.preventDefault(); openCommandPalette(); return; }
// Ctrl+N β†’ new chat
if (ctrl && e.key === 'n') { e.preventDefault(); newChat(); return; }
// Ctrl+Enter in any textarea β†’ submit that textarea
if (ctrl && e.key === 'Enter' && e.target.tagName === 'TEXTAREA') {
e.preventDefault();
if (e.target.id === 'writeTextarea') { handleWriteSubmit(); }
else {
// Propose textarea
const panel = e.target.closest('.propose-panel');
if (panel) {
const submitBtn = qs('.propose-submit', panel);
if (submitBtn) submitBtn.click();
}
}
return;
}
});
}
/* ═══════════════════════════════════════════════
SUGGESTION CHIPS
═══════════════════════════════════════════════ */
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();
});
});
}
/* ═══════════════════════════════════════════════
PULL TO REFRESH (mobile)
═══════════════════════════════════════════════ */
function initPullToRefresh() {
const chat = $('chat');
let pullStart = 0;
chat.addEventListener('touchstart', e => {
if (chat.scrollTop === 0) pullStart = e.touches[0].clientY;
else pullStart = 0;
}, { passive: true });
chat.addEventListener('touchend', e => {
if (!pullStart) return;
const diff = e.changedTouches[0].clientY - pullStart;
if (diff > 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);
}
// Reduced motion override
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 });
// Compose
$('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();
// Arrow down into autocomplete
if (e.key === 'ArrowDown') {
const first = qs('.autocomplete-item', $('autocompleteDropdown'));
if (first) { e.preventDefault(); first.focus(); }
}
});
// Click outside autocomplete
document.addEventListener('click', e => {
if (!e.target.closest('.compose-inner')) closeAutocomplete();
});
initSettings();
initCommandPalette();
initKeyboard();
initSuggestionChips();
initPullToRefresh();
// Server init data
const d = window.__HI_INIT__ || {};
if (d.client_id) S.clientId = d.client_id;
updateWelcomeState();
await loadSaved();
prompt.focus();
}
init();
})();
</script>
</body>
</html>