Spaces:
Running
Running
| <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 ; | |
| transition-duration: 0.01ms ; | |
| } | |
| } | |
| * { 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 ; | |
| 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 ; 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 ; 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></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, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>') | |
| .replace(/"/g, '"'); | |
| } | |
| 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> |