← octto
/**
* Returns the bundled HTML for the octto UI.
* Uses nof1 design system - IBM Plex Mono, terminal aesthetic.
*/
export function getHtmlBundle(): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Octto</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
:root {
--background: #ffffff;
--surface: #ffffff;
--surface-elevated: #f8f9fa;
--surface-hover: #f1f3f4;
--foreground: #000000;
--foreground-muted: #333333;
--foreground-subtle: #666666;
--border: #000000;
--border-subtle: #cccccc;
--accent-success: #00aa00;
--accent-error: #ff0000;
}
*, *:before, *:after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
background: var(--background);
color: var(--foreground);
font-family: 'IBM Plex Mono', monospace;
font-size: 14px;
line-height: 1.5;
letter-spacing: -0.02em;
}
body {
position: relative;
}
body::before {
content: "";
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.6' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)' opacity='0.15'/%3E%3C/svg%3E");
background-size: 180px 180px;
pointer-events: none;
z-index: 1;
}
#root {
position: relative;
z-index: 2;
max-width: 640px;
margin: 0 auto;
padding: 2rem 1.5rem;
min-height: 100vh;
}
h1, h2, h3 {
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.header {
text-align: center;
padding: 3rem 0;
}
.header h1 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
.header p {
color: var(--foreground-subtle);
font-size: 0.875rem;
}
.spinner {
width: 24px;
height: 24px;
border: 2px solid var(--border-subtle);
border-top-color: var(--foreground);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 1.5rem auto 0;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.card {
background: var(--surface);
border: 1px solid var(--border);
padding: 1.5rem;
margin-bottom: 1rem;
}
.card-answered {
background: var(--surface-elevated);
border-color: var(--border-subtle);
opacity: 0.7;
padding: 1rem;
cursor: pointer;
transition: opacity 0.15s;
}
.card-answered:hover {
opacity: 0.85;
}
.card-answered.expanded {
opacity: 1;
cursor: default;
}
.card-answered .check {
color: var(--accent-success);
margin-right: 0.5rem;
}
.card-answered-header {
display: flex;
align-items: center;
cursor: pointer;
}
.card-answered-header .toggle {
margin-left: auto;
color: var(--foreground-subtle);
font-size: 0.75rem;
}
.card-answered-body {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-subtle);
}
.readonly-answer {
background: var(--surface-hover);
padding: 0.75rem;
margin-top: 0.5rem;
font-size: 0.875rem;
}
.readonly-answer-label {
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--foreground-subtle);
margin-bottom: 0.25rem;
}
.readonly-option {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-subtle);
margin-bottom: 0.25rem;
opacity: 0.6;
}
.readonly-option.selected {
opacity: 1;
border-color: var(--accent-success);
background: rgba(0, 170, 0, 0.05);
}
.readonly-option .check-mark {
color: var(--accent-success);
margin-right: 0.5rem;
}
.question-text {
font-size: 1rem;
font-weight: 600;
margin-bottom: 1.25rem;
line-height: 1.4;
}
.context {
color: var(--foreground-muted);
font-size: 0.875rem;
margin-bottom: 1rem;
padding-left: 1rem;
border-left: 2px solid var(--border-subtle);
}
.options {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.option {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem;
border: 1px solid var(--border-subtle);
cursor: pointer;
transition: none;
}
.option:hover {
background: var(--surface-hover);
border-color: var(--border);
}
.option.recommended {
border-color: var(--border);
background: var(--surface-elevated);
}
.option input {
margin-top: 0.125rem;
accent-color: var(--foreground);
}
.option-content {
flex: 1;
}
.option-label {
font-weight: 500;
}
.option-desc {
font-size: 0.8125rem;
color: var(--foreground-subtle);
margin-top: 0.25rem;
}
.option-tag {
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--foreground-muted);
margin-left: 0.5rem;
}
.btn {
display: inline-block;
background: var(--surface);
border: 1px solid var(--border);
color: var(--foreground);
font-family: 'IBM Plex Mono', monospace;
font-weight: 500;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.5rem 1rem;
cursor: pointer;
transition: none;
}
.btn:hover {
background: var(--surface-hover);
}
.btn:active {
background: var(--foreground);
color: var(--background);
}
.btn-primary {
background: var(--foreground);
color: var(--background);
}
.btn-primary:hover {
opacity: 0.9;
}
.btn-success {
border-color: var(--accent-success);
color: var(--accent-success);
}
.btn-success:hover {
background: var(--accent-success);
color: var(--background);
}
.btn-danger {
border-color: var(--accent-error);
color: var(--accent-error);
}
.btn-danger:hover {
background: var(--accent-error);
color: var(--background);
}
.btn-group {
display: flex;
gap: 0.5rem;
margin-top: 1.25rem;
}
.input, .textarea {
width: 100%;
padding: 0.75rem;
background: var(--surface);
border: 1px solid var(--border);
color: var(--foreground);
font-family: 'IBM Plex Mono', monospace;
font-size: 0.875rem;
}
.input:focus, .textarea:focus {
outline: none;
border-color: var(--foreground);
}
.textarea {
resize: vertical;
min-height: 100px;
}
.slider-container {
display: flex;
align-items: center;
gap: 1rem;
}
.slider-container input[type="range"] {
flex: 1;
height: 2px;
background: var(--border-subtle);
appearance: none;
-webkit-appearance: none;
}
.slider-container input[type="range"]::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
width: 16px;
height: 16px;
background: var(--foreground);
cursor: pointer;
}
.slider-value {
font-weight: 600;
min-width: 3rem;
text-align: center;
font-variant-numeric: tabular-nums;
}
.slider-labels {
color: var(--foreground-subtle);
font-size: 0.75rem;
}
.thumbs-container {
display: flex;
gap: 1rem;
}
.thumb-btn {
font-size: 2rem;
padding: 1rem 1.5rem;
border: 1px solid var(--border-subtle);
background: var(--surface);
cursor: pointer;
}
.thumb-btn:hover {
border-color: var(--border);
background: var(--surface-hover);
}
.queue-indicator {
text-align: center;
color: var(--foreground-subtle);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: 1rem;
}
.branch-subtitle {
font-size: 0.75rem;
color: var(--foreground-subtle);
margin-top: 0.25rem;
margin-bottom: 0.75rem;
}
.thinking {
text-align: center;
padding: 2rem;
margin-top: 2rem;
margin-bottom: 2rem;
border: 1px dashed var(--border-subtle);
}
.thinking-text {
color: var(--foreground-subtle);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 1rem;
}
.thinking .spinner {
margin: 0 auto;
}
.review-content {
background: var(--surface-elevated);
border: 1px solid var(--border-subtle);
padding: 1rem;
margin-bottom: 1rem;
font-size: 0.875rem;
line-height: 1.6;
max-height: 400px;
overflow-y: auto;
}
.review-content h1, .review-content h2, .review-content h3,
.review-content h4, .review-content h5, .review-content h6 {
font-weight: 600;
margin: 1rem 0 0.5rem 0;
}
.review-content h1 { font-size: 1.25rem; }
.review-content h2 { font-size: 1.125rem; }
.review-content h3 { font-size: 1rem; }
.review-content p {
margin: 0.5rem 0;
}
.review-content ul, .review-content ol {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
.review-content li {
margin: 0.25rem 0;
}
.review-content code {
background: var(--surface-hover);
padding: 0.125rem 0.25rem;
font-size: 0.8125rem;
}
.review-content pre {
background: var(--surface-hover);
padding: 0.75rem;
overflow-x: auto;
margin: 0.5rem 0;
}
.review-content pre code {
background: none;
padding: 0;
}
.review-content blockquote {
border-left: 2px solid var(--border);
padding-left: 1rem;
margin: 0.5rem 0;
color: var(--foreground-muted);
}
.other-input {
margin-top: 0.5rem;
}
.feedback-input {
margin-top: 1rem;
}
.feedback-input label {
display: block;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--foreground-subtle);
margin-bottom: 0.5rem;
}
.plan-section {
margin-bottom: 1.5rem;
}
.plan-section-title {
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.5rem;
padding-bottom: 0.25rem;
border-bottom: 1px solid var(--border-subtle);
}
.session-ended {
text-align: center;
padding: 4rem 0;
}
.session-ended h1 {
margin-bottom: 0.5rem;
}
.session-ended p {
color: var(--foreground-subtle);
}
/* Show Options */
.options-with-pros {
display: flex;
flex-direction: column;
gap: 1rem;
}
.option-card {
border: 1px solid var(--border-subtle);
padding: 1rem;
}
.option-card.recommended {
border-color: var(--border);
background: var(--surface-elevated);
}
.option-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.pros, .cons {
font-size: 0.8125rem;
margin-top: 0.5rem;
}
.pros { color: var(--accent-success); }
.cons { color: var(--accent-error); }
.pros ul, .cons ul {
margin: 0.25rem 0 0 1rem;
}
/* Show Diff */
.diff-filepath {
font-size: 0.75rem;
color: var(--foreground-subtle);
margin-bottom: 0.5rem;
font-family: monospace;
}
.diff-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
margin-bottom: 1rem;
}
.diff-side {
border: 1px solid var(--border-subtle);
overflow: auto;
max-height: 300px;
}
.diff-label {
font-size: 0.6875rem;
text-transform: uppercase;
padding: 0.25rem 0.5rem;
background: var(--surface-elevated);
border-bottom: 1px solid var(--border-subtle);
}
.diff-before .diff-label { color: var(--accent-error); }
.diff-after .diff-label { color: var(--accent-success); }
.diff-side pre {
margin: 0;
padding: 0.5rem;
font-size: 0.75rem;
white-space: pre-wrap;
}
/* Rank */
.rank-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.rank-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-subtle);
background: var(--surface);
cursor: grab;
}
.rank-item:active, .rank-item.dragging {
cursor: grabbing;
opacity: 0.5;
}
.rank-handle {
color: var(--foreground-subtle);
}
.rank-num {
font-weight: 600;
min-width: 1.5rem;
}
/* Rate */
.rate-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.rate-item {
display: flex;
justify-content: space-between;
align-items: center;
}
.rate-stars {
display: flex;
gap: 0.25rem;
}
.rate-star {
width: 2rem;
height: 2rem;
border: 1px solid var(--border-subtle);
background: var(--surface);
cursor: pointer;
font-size: 0.75rem;
}
.rate-star.selected {
background: var(--foreground);
color: var(--background);
border-color: var(--foreground);
}
/* Code Input */
.code-input {
font-family: 'IBM Plex Mono', monospace;
font-size: 0.8125rem;
}
.code-input-label {
font-size: 0.6875rem;
text-transform: uppercase;
color: var(--foreground-subtle);
margin-bottom: 0.25rem;
}
/* File Upload */
.file-upload {
margin-bottom: 1rem;
}
.file-upload input[type="file"] {
width: 100%;
padding: 0.5rem;
border: 1px dashed var(--border-subtle);
}
.image-preview {
display: flex;
flex-wrap: wrap;
margin-top: 0.5rem;
}
/* Emoji React */
.emoji-grid {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.emoji-btn {
font-size: 2rem;
padding: 0.75rem;
border: 1px solid var(--border-subtle);
background: var(--surface);
cursor: pointer;
}
.emoji-btn:hover {
background: var(--surface-hover);
border-color: var(--border);
}
/* Keyboard focus styles */
.thumb-btn:focus,
.emoji-btn:focus,
.rate-star:focus,
.btn:focus {
outline: 2px solid var(--foreground);
outline-offset: 2px;
}
</style>
</head>
<body>
<div id="root">
<div class="header">
<h1>Octto</h1>
<p>Connecting to session...</p>
<div class="spinner"></div>
</div>
</div>
<script>
const wsUrl = 'ws://' + window.location.host + '/ws';
let ws = null;
let ended = false;
let questions = [];
let expandedAnswers = new Set();
function connect() {
ws = new WebSocket(wsUrl);
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'connected' }));
render();
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'question') {
questions.push(msg);
render();
} else if (msg.type === 'cancel') {
questions = questions.filter(q => q.id !== msg.id);
render();
} else if (msg.type === 'end') {
ended = true;
document.getElementById('root').innerHTML =
'<div class="session-ended"><h1>Session Ended</h1><p>You can close this window.</p></div>';
ws.close();
}
};
ws.onclose = () => {
if (ended) return;
setTimeout(connect, 2000);
};
}
function render() {
const root = document.getElementById('root');
if (questions.length === 0) {
root.innerHTML = '<div class="header"><h1>Octto</h1><p>Waiting for questions...</p></div>';
return;
}
const pending = questions.filter(q => !q.answered);
const answered = questions.filter(q => q.answered);
let html = '';
// Show remaining count at top
if (pending.length > 1) {
html += '<div class="queue-indicator" style="margin-top: 0; margin-bottom: 1rem;">' + (pending.length - 1) + ' more question(s) remaining</div>';
}
// Show current question
if (pending.length > 0) {
const q = pending[0];
html += renderQuestion(q);
} else if (answered.length > 0) {
// All answered, waiting for more questions
html += '<div class="thinking">';
html += '<div class="thinking-text">Thinking...</div>';
html += '<div class="spinner"></div>';
html += '</div>';
}
// Show answered questions at bottom (collapsed or expanded)
for (const q of answered) {
const isExpanded = expandedAnswers.has(q.id);
// Extract branch name from context
let branchName = '';
const ctx = q.config.context || '';
const branchMatch = ctx.match(/^\\[([^\\]]+)\\]/);
if (branchMatch) branchName = branchMatch[1];
html += '<div class="card card-answered' + (isExpanded ? ' expanded' : '') + '" data-qid="' + q.id + '">';
html += '<div class="card-answered-header" onclick="toggleAnswered(\\'' + q.id + '\\')">';
html += '<span class="check">[OK]</span>';
html += '<div style="flex: 1;">';
html += '<span>' + escapeHtml(q.config.question) + '</span>';
if (branchName) html += '<div class="branch-subtitle" style="margin-bottom: 0; margin-top: 0.125rem;">' + escapeHtml(branchName) + '</div>';
html += '</div>';
html += '<span class="toggle">' + (isExpanded ? '▲ collapse' : '▼ view') + '</span>';
html += '</div>';
if (isExpanded) {
html += '<div class="card-answered-body">';
html += renderAnsweredQuestion(q);
html += '</div>';
}
html += '</div>';
}
root.innerHTML = html;
attachListeners();
}
function renderQuestion(q) {
const config = q.config;
let html = '<div class="card">';
// Extract branch from context if present: "[Branch Scope] rest of context"
let branchName = '';
let remainingContext = config.context || '';
const branchMatch = remainingContext.match(/^\\[([^\\]]+)\\]\\s*/);
if (branchMatch) {
branchName = branchMatch[1];
remainingContext = remainingContext.substring(branchMatch[0].length);
}
html += '<div class="question-text">' + escapeHtml(config.question) + '</div>';
if (branchName) {
html += '<div class="branch-subtitle">' + escapeHtml(branchName) + '</div>';
}
if (remainingContext) {
html += '<div class="context">' + escapeHtml(remainingContext) + '</div>';
}
switch (q.questionType) {
case 'pick_one':
html += renderPickOne(q);
break;
case 'pick_many':
html += renderPickMany(q);
break;
case 'confirm':
html += renderConfirm(q);
break;
case 'ask_text':
html += renderAskText(q);
break;
case 'thumbs':
html += renderThumbs(q);
break;
case 'slider':
html += renderSlider(q);
break;
case 'review_section':
html += renderReviewSection(q);
break;
case 'show_plan':
html += renderShowPlan(q);
break;
case 'show_options':
html += renderShowOptions(q);
break;
case 'show_diff':
html += renderShowDiff(q);
break;
case 'rank':
html += renderRank(q);
break;
case 'rate':
html += renderRate(q);
break;
case 'ask_code':
html += renderAskCode(q);
break;
case 'ask_image':
html += renderAskImage(q);
break;
case 'ask_file':
html += renderAskFile(q);
break;
case 'emoji_react':
html += renderEmojiReact(q);
break;
default:
html += '<p>Question type "' + q.questionType + '" not yet implemented.</p>';
html += '<div class="btn-group"><button onclick="submitAnswer(\\'' + q.id + '\\', {})" class="btn">Skip</button></div>';
}
html += '</div>';
return html;
}
function renderPickOne(q) {
const options = q.config.options || [];
let html = '<div class="options">';
for (const opt of options) {
const isRecommended = q.config.recommended === opt.id;
html += '<label class="option' + (isRecommended ? ' recommended' : '') + '">';
html += '<input type="radio" name="pick_' + q.id + '" value="' + opt.id + '">';
html += '<div class="option-content">';
html += '<div class="option-label">' + escapeHtml(opt.label);
if (isRecommended) html += '<span class="option-tag">(recommended)</span>';
html += '</div>';
if (opt.description) html += '<div class="option-desc">' + escapeHtml(opt.description) + '</div>';
html += '</div></label>';
}
if (q.config.allowOther) html += renderOtherOption(q, 'radio');
html += '</div>';
html += '<div class="btn-group"><button onclick="submitPickOne(\\'' + q.id + '\\')" class="btn btn-primary">Submit</button></div>';
return html;
}
function renderOtherOption(q, inputType) {
let html = '<label class="option">';
html += '<input type="' + inputType + '" name="pick_' + q.id + '" id="otherchoice_' + q.id + '" value="other">';
html += '<div class="option-content"><div class="option-label">Other</div></div>';
html += '</label>';
// Kept outside the label: nesting it would make a click activate the label's control instead of focusing the field.
html += '<input type="text" class="input other-input" id="other_' + q.id + '" placeholder="Type your own answer" onfocus="selectOther(\\'' + q.id + '\\')">';
return html;
}
function selectOther(questionId) {
const choice = document.getElementById('otherchoice_' + questionId);
if (choice) choice.checked = true;
}
function readOther(questionId) {
const input = document.getElementById('other_' + questionId);
return input ? input.value.trim() : '';
}
function renderPickMany(q) {
const options = q.config.options || [];
let html = '<div class="options">';
for (const opt of options) {
html += '<label class="option">';
html += '<input type="checkbox" name="pick_' + q.id + '" value="' + opt.id + '">';
html += '<div class="option-content">';
html += '<div class="option-label">' + escapeHtml(opt.label) + '</div>';
if (opt.description) html += '<div class="option-desc">' + escapeHtml(opt.description) + '</div>';
html += '</div></label>';
}
if (q.config.allowOther) html += renderOtherOption(q, 'checkbox');
html += '</div>';
html += '<div class="btn-group"><button onclick="submitPickMany(\\'' + q.id + '\\')" class="btn btn-primary">Submit</button></div>';
return html;
}
function renderConfirm(q) {
const yesLabel = q.config.yesLabel || 'Yes';
const noLabel = q.config.noLabel || 'No';
let html = '<div class="btn-group">';
html += '<button onclick="submitAnswer(\\'' + q.id + '\\', {choice: \\'yes\\'})" class="btn btn-success">' + escapeHtml(yesLabel) + '</button>';
html += '<button onclick="submitAnswer(\\'' + q.id + '\\', {choice: \\'no\\'})" class="btn btn-danger">' + escapeHtml(noLabel) + '</button>';
if (q.config.allowCancel) {
html += '<button onclick="submitAnswer(\\'' + q.id + '\\', {choice: \\'cancel\\'})" class="btn">Cancel</button>';
}
html += '</div>';
return html;
}
function renderAskText(q) {
const multiline = q.config.multiline;
let html = '';
if (multiline) {
html += '<textarea id="text_' + q.id + '" class="textarea" rows="4" placeholder="' + escapeHtml(q.config.placeholder || '') + '"></textarea>';
} else {
html += '<input type="text" id="text_' + q.id + '" class="input" placeholder="' + escapeHtml(q.config.placeholder || '') + '">';
}
html += '<div class="btn-group"><button onclick="submitText(\\'' + q.id + '\\')" class="btn btn-primary">Submit</button></div>';
return html;
}
function renderThumbs(q) {
let html = '<div class="thumbs-container">';
html += '<button onclick="submitAnswer(\\'' + q.id + '\\', {choice: \\'up\\'})" class="thumb-btn">\\uD83D\\uDC4D</button>';
html += '<button onclick="submitAnswer(\\'' + q.id + '\\', {choice: \\'down\\'})" class="thumb-btn">\\uD83D\\uDC4E</button>';
html += '</div>';
return html;
}
function renderSlider(q) {
const min = q.config.min;
const max = q.config.max;
const step = q.config.step || 1;
const defaultVal = q.config.defaultValue || Math.floor((min + max) / 2);
let html = '<div class="slider-container">';
html += '<span class="slider-labels">' + min + '</span>';
html += '<input type="range" id="slider_' + q.id + '" min="' + min + '" max="' + max + '" step="' + step + '" value="' + defaultVal + '">';
html += '<span class="slider-labels">' + max + '</span>';
html += '<span id="slider_val_' + q.id + '" class="slider-value">' + defaultVal + '</span>';
html += '</div>';
html += '<div class="btn-group"><button onclick="submitSlider(\\'' + q.id + '\\')" class="btn btn-primary">Submit</button></div>';
return html;
}
function renderReviewSection(q) {
let html = '';
// Render markdown content
const markdownHtml = typeof marked !== 'undefined' ? marked.parse(q.config.content || '') : escapeHtml(q.config.content || '');
html += '<div class="review-content">' + markdownHtml + '</div>';
html += '<div class="feedback-input">';
html += '<label for="feedback_' + q.id + '">Feedback (optional)</label>';
html += '<textarea id="feedback_' + q.id + '" class="textarea" rows="3" placeholder="Any suggestions or changes..."></textarea>';
html += '</div>';
html += '<div class="btn-group">';
html += '<button onclick="submitReview(\\'' + q.id + '\\', \\'approve\\')" class="btn btn-success">Approve</button>';
html += '<button onclick="submitReview(\\'' + q.id + '\\', \\'revise\\')" class="btn btn-danger">Needs Revision</button>';
html += '</div>';
return html;
}
function renderShowPlan(q) {
let html = '';
// Render sections if provided
if (q.config.sections && q.config.sections.length > 0) {
for (const section of q.config.sections) {
html += '<div class="plan-section">';
html += '<h3 class="plan-section-title">' + escapeHtml(section.title) + '</h3>';
const sectionHtml = typeof marked !== 'undefined' ? marked.parse(section.content || '') : escapeHtml(section.content || '');
html += '<div class="review-content">' + sectionHtml + '</div>';
html += '</div>';
}
} else if (q.config.markdown) {
// Fallback to raw markdown
const markdownHtml = typeof marked !== 'undefined' ? marked.parse(q.config.markdown) : escapeHtml(q.config.markdown);
html += '<div class="review-content">' + markdownHtml + '</div>';
}
html += '<div class="feedback-input">';
html += '<label for="feedback_' + q.id + '">Feedback (optional)</label>';
html += '<textarea id="feedback_' + q.id + '" class="textarea" rows="3" placeholder="Any suggestions or changes..."></textarea>';
html += '</div>';
html += '<div class="btn-group">';
html += '<button onclick="submitReview(\\'' + q.id + '\\', \\'approve\\')" class="btn btn-success">Approve Plan</button>';
html += '<button onclick="submitReview(\\'' + q.id + '\\', \\'revise\\')" class="btn btn-danger">Needs Changes</button>';
html += '</div>';
return html;
}
function renderShowOptions(q) {
const options = q.config.options || [];
let html = '<div class="options-with-pros">';
for (const opt of options) {
const isRecommended = q.config.recommended === opt.id;
html += '<div class="option-card' + (isRecommended ? ' recommended' : '') + '" data-id="' + opt.id + '">';
html += '<div class="option-header">';
html += '<input type="radio" name="opt_' + q.id + '" value="' + opt.id + '">';
html += '<span class="option-label">' + escapeHtml(opt.label);
if (isRecommended) html += ' <span class="option-tag">(recommended)</span>';
html += '</span></div>';
if (opt.description) html += '<div class="option-desc">' + escapeHtml(opt.description) + '</div>';
if (opt.pros && opt.pros.length > 0) {
html += '<div class="pros"><strong>Pros:</strong><ul>';
for (const pro of opt.pros) html += '<li>' + escapeHtml(pro) + '</li>';
html += '</ul></div>';
}
if (opt.cons && opt.cons.length > 0) {
html += '<div class="cons"><strong>Cons:</strong><ul>';
for (const con of opt.cons) html += '<li>' + escapeHtml(con) + '</li>';
html += '</ul></div>';
}
html += '</div>';
}
html += '</div>';
if (q.config.allowFeedback) {
html += '<div class="feedback-input"><label>Feedback (optional)</label>';
html += '<textarea id="feedback_' + q.id + '" class="textarea" rows="2"></textarea></div>';
}
html += '<div class="btn-group"><button onclick="submitShowOptions(\\'' + q.id + '\\')" class="btn btn-primary">Select</button></div>';
return html;
}
function renderShowDiff(q) {
let html = '';
if (q.config.filePath) {
html += '<div class="diff-filepath">' + escapeHtml(q.config.filePath) + '</div>';
}
html += '<div class="diff-container">';
html += '<div class="diff-side diff-before"><div class="diff-label">Before</div><pre><code>' + escapeHtml(q.config.before || '') + '</code></pre></div>';
html += '<div class="diff-side diff-after"><div class="diff-label">After</div><pre><code>' + escapeHtml(q.config.after || '') + '</code></pre></div>';
html += '</div>';
html += '<div class="feedback-input"><label>Comments (optional)</label>';
html += '<textarea id="feedback_' + q.id + '" class="textarea" rows="2"></textarea></div>';
html += '<div class="btn-group">';
html += '<button onclick="submitDiff(\\'' + q.id + '\\', \\'approve\\')" class="btn btn-success">Approve</button>';
html += '<button onclick="submitDiff(\\'' + q.id + '\\', \\'reject\\')" class="btn btn-danger">Reject</button>';
html += '<button onclick="submitDiff(\\'' + q.id + '\\', \\'edit\\')" class="btn">Edit</button>';
html += '</div>';
return html;
}
function renderRank(q) {
const options = q.config.options || [];
let html = '<div class="rank-list" id="rank_' + q.id + '">';
for (let i = 0; i < options.length; i++) {
const opt = options[i];
html += '<div class="rank-item" data-id="' + opt.id + '" draggable="true">';
html += '<span class="rank-handle">☰</span>';
html += '<span class="rank-num">' + (i + 1) + '</span>';
html += '<span class="rank-label">' + escapeHtml(opt.label) + '</span>';
html += '</div>';
}
html += '</div>';
html += '<div class="btn-group"><button onclick="submitRank(\\'' + q.id + '\\')" class="btn btn-primary">Submit Ranking</button></div>';
return html;
}
function renderRate(q) {
const options = q.config.options || [];
const min = q.config.min || 1;
const max = q.config.max || 5;
let html = '<div class="rate-list">';
for (const opt of options) {
html += '<div class="rate-item">';
html += '<div class="rate-label">' + escapeHtml(opt.label) + '</div>';
html += '<div class="rate-stars" id="rate_' + q.id + '_' + opt.id + '">';
for (let i = min; i <= max; i++) {
html += '<button class="rate-star" data-value="' + i + '" onclick="setRating(\\'' + q.id + '\\', \\'' + opt.id + '\\', ' + i + ')">' + i + '</button>';
}
html += '</div></div>';
}
html += '</div>';
html += '<div class="btn-group"><button onclick="submitRate(\\'' + q.id + '\\')" class="btn btn-primary">Submit Ratings</button></div>';
return html;
}
function renderAskCode(q) {
let html = '';
const lang = q.config.language || 'plaintext';
html += '<div class="code-input-label">Language: ' + escapeHtml(lang) + '</div>';
html += '<textarea id="code_' + q.id + '" class="textarea code-input" rows="10" placeholder="' + escapeHtml(q.config.placeholder || 'Enter code here...') + '"></textarea>';
html += '<div class="btn-group"><button onclick="submitCode(\\'' + q.id + '\\')" class="btn btn-primary">Submit Code</button></div>';
return html;
}
function renderAskImage(q) {
let html = '';
const multiple = q.config.multiple ? 'multiple' : '';
html += '<div class="file-upload">';
html += '<input type="file" id="image_' + q.id + '" accept="image/*" ' + multiple + ' onchange="previewImages(\\'' + q.id + '\\')">';
html += '<div id="preview_' + q.id + '" class="image-preview"></div>';
html += '</div>';
html += '<div class="btn-group"><button onclick="submitImages(\\'' + q.id + '\\')" class="btn btn-primary">Upload</button></div>';
return html;
}
function renderAskFile(q) {
let html = '';
const multiple = q.config.multiple ? 'multiple' : '';
const accept = q.config.accept ? q.config.accept.join(',') : '';
html += '<div class="file-upload">';
html += '<input type="file" id="file_' + q.id + '" ' + (accept ? 'accept="' + accept + '"' : '') + ' ' + multiple + '>';
html += '<div id="filelist_' + q.id + '" class="file-list"></div>';
html += '</div>';
html += '<div class="btn-group"><button onclick="submitFiles(\\'' + q.id + '\\')" class="btn btn-primary">Upload</button></div>';
return html;
}
function renderEmojiReact(q) {
let html = '';
const emojis = q.config.emojis || ['👍', '👎', '❤️', '🎉', '😕', '🚀'];
html += '<div class="emoji-grid">';
for (const emoji of emojis) {
html += '<button class="emoji-btn" onclick="submitAnswer(\\'' + q.id + '\\', {emoji: \\'' + emoji + '\\'})">' + emoji + '</button>';
}
html += '</div>';
return html;
}
function attachListeners() {
document.querySelectorAll('input[type="range"]').forEach(slider => {
const id = slider.id.replace('slider_', 'slider_val_');
slider.oninput = () => {
document.getElementById(id).textContent = slider.value;
};
});
}
function submitAnswer(questionId, answer) {
const q = questions.find(q => q.id === questionId);
if (q) {
q.answered = true;
q.answer = answer; // Store answer for read-only view
ws.send(JSON.stringify({ type: 'response', id: questionId, answer }));
render();
}
}
function showError(questionId, message) {
const existingError = document.getElementById('error_' + questionId);
if (existingError) existingError.remove();
const card = document.querySelector('[data-qid="' + questionId + '"]') || document.querySelector('.card:not(.card-answered)');
if (card) {
const errorDiv = document.createElement('div');
errorDiv.id = 'error_' + questionId;
errorDiv.style.cssText = 'color: var(--accent-error); font-size: 0.875rem; margin-top: 0.5rem;';
errorDiv.textContent = message;
const btnGroup = card.querySelector('.btn-group');
if (btnGroup) btnGroup.before(errorDiv);
}
}
function submitPickOne(questionId) {
const selected = document.querySelector('input[name="pick_' + questionId + '"]:checked');
if (!selected) {
showError(questionId, 'Please select an option');
return;
}
if (selected.value === 'other') {
const other = readOther(questionId);
if (!other) {
showError(questionId, 'Please describe your other answer');
return;
}
submitAnswer(questionId, { selected: 'other', other });
return;
}
submitAnswer(questionId, { selected: selected.value });
}
function submitPickMany(questionId) {
const selected = Array.from(document.querySelectorAll('input[name="pick_' + questionId + '"]:checked')).map(el => el.value);
if (selected.includes('other')) {
const other = readOther(questionId);
if (!other) {
showError(questionId, 'Please describe your other answer');
return;
}
submitAnswer(questionId, { selected, other });
return;
}
submitAnswer(questionId, { selected });
}
function submitText(questionId) {
const input = document.getElementById('text_' + questionId);
if (input) {
submitAnswer(questionId, { text: input.value });
}
}
function submitSlider(questionId) {
const slider = document.getElementById('slider_' + questionId);
if (slider) {
submitAnswer(questionId, { value: parseFloat(slider.value) });
}
}
function submitReview(questionId, decision) {
const feedbackEl = document.getElementById('feedback_' + questionId);
const feedback = feedbackEl ? feedbackEl.value : '';
submitAnswer(questionId, { decision, feedback: feedback || undefined });
}
function submitShowOptions(questionId) {
const selected = document.querySelector('input[name="opt_' + questionId + '"]:checked');
if (!selected) {
showError(questionId, 'Please select an option');
return;
}
const feedbackEl = document.getElementById('feedback_' + questionId);
const feedback = feedbackEl ? feedbackEl.value : '';
submitAnswer(questionId, { selected: selected.value, feedback: feedback || undefined });
}
function submitDiff(questionId, decision) {
const feedbackEl = document.getElementById('feedback_' + questionId);
const feedback = feedbackEl ? feedbackEl.value : '';
submitAnswer(questionId, { decision, feedback: feedback || undefined });
}
function submitRank(questionId) {
const container = document.getElementById('rank_' + questionId);
const items = container.querySelectorAll('.rank-item');
const ranking = Array.from(items).map((item, idx) => ({
id: item.dataset.id,
rank: idx + 1
}));
submitAnswer(questionId, { ranking });
}
function submitRate(questionId) {
const q = questions.find(q => q.id === questionId);
if (!q) return;
const ratings = {};
for (const opt of (q.config.options || [])) {
const container = document.getElementById('rate_' + questionId + '_' + opt.id);
const selected = container.querySelector('.rate-star.selected');
if (selected) {
ratings[opt.id] = parseInt(selected.dataset.value);
}
}
submitAnswer(questionId, { ratings });
}
function setRating(questionId, optId, value) {
const container = document.getElementById('rate_' + questionId + '_' + optId);
container.querySelectorAll('.rate-star').forEach(btn => {
btn.classList.toggle('selected', parseInt(btn.dataset.value) <= value);
});
}
function submitCode(questionId) {
const textarea = document.getElementById('code_' + questionId);
if (textarea) {
submitAnswer(questionId, { code: textarea.value });
}
}
function submitImages(questionId) {
const input = document.getElementById('image_' + questionId);
if (input && input.files.length > 0) {
// Convert to base64 for transport
const promises = Array.from(input.files).map(file => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve({ name: file.name, type: file.type, content: reader.result });
reader.readAsDataURL(file);
});
});
Promise.all(promises).then(images => {
submitAnswer(questionId, { images });
});
}
}
function previewImages(questionId) {
const input = document.getElementById('image_' + questionId);
const preview = document.getElementById('preview_' + questionId);
preview.innerHTML = '';
for (const file of input.files) {
const img = document.createElement('img');
img.src = URL.createObjectURL(file);
img.style.maxWidth = '100px';
img.style.maxHeight = '100px';
img.style.margin = '4px';
preview.appendChild(img);
}
}
function submitFiles(questionId) {
const input = document.getElementById('file_' + questionId);
if (input && input.files.length > 0) {
const promises = Array.from(input.files).map(file => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve({ name: file.name, type: file.type, size: file.size, content: reader.result });
reader.readAsDataURL(file);
});
});
Promise.all(promises).then(files => {
submitAnswer(questionId, { files });
});
}
}
// Drag and drop for ranking
document.addEventListener('dragstart', (e) => {
if (e.target.classList.contains('rank-item')) {
e.dataTransfer.setData('text/plain', e.target.dataset.id);
e.target.classList.add('dragging');
}
});
document.addEventListener('dragend', (e) => {
if (e.target.classList.contains('rank-item')) {
e.target.classList.remove('dragging');
}
});
document.addEventListener('dragover', (e) => {
e.preventDefault();
const dragging = document.querySelector('.rank-item.dragging');
const rankList = e.target.closest('.rank-list');
if (dragging && rankList) {
const siblings = [...rankList.querySelectorAll('.rank-item:not(.dragging)')];
const nextSibling = siblings.find(sibling => {
const rect = sibling.getBoundingClientRect();
return e.clientY < rect.top + rect.height / 2;
});
rankList.insertBefore(dragging, nextSibling);
// Update numbers
rankList.querySelectorAll('.rank-item').forEach((item, idx) => {
item.querySelector('.rank-num').textContent = idx + 1;
});
}
});
function toggleAnswered(questionId) {
if (expandedAnswers.has(questionId)) {
expandedAnswers.delete(questionId);
} else {
expandedAnswers.add(questionId);
}
render();
}
function renderAnsweredQuestion(q) {
const config = q.config;
const answer = q.answer || {};
let html = '';
switch (q.questionType) {
case 'pick_one':
html += renderAnsweredPickOne(q, answer);
break;
case 'pick_many':
html += renderAnsweredPickMany(q, answer);
break;
case 'confirm':
html += renderAnsweredConfirm(q, answer);
break;
case 'ask_text':
html += renderAnsweredText(q, answer);
break;
case 'thumbs':
html += renderAnsweredThumbs(q, answer);
break;
case 'slider':
html += renderAnsweredSlider(q, answer);
break;
case 'review_section':
case 'show_plan':
html += renderAnsweredReview(q, answer);
break;
case 'show_options':
html += renderAnsweredShowOptions(q, answer);
break;
case 'show_diff':
html += renderAnsweredDiff(q, answer);
break;
case 'rank':
html += renderAnsweredRank(q, answer);
break;
case 'rate':
html += renderAnsweredRate(q, answer);
break;
case 'ask_code':
html += renderAnsweredCode(q, answer);
break;
case 'ask_image':
case 'ask_file':
html += renderAnsweredFile(q, answer);
break;
case 'emoji_react':
html += renderAnsweredEmoji(q, answer);
break;
default:
html += '<div class="readonly-answer"><pre>' + escapeHtml(JSON.stringify(answer, null, 2)) + '</pre></div>';
}
return html;
}
function renderAnsweredPickOne(q, answer) {
const options = q.config.options || [];
let html = '<div class="options">';
for (const opt of options) {
const isSelected = answer.selected === opt.id;
html += '<div class="readonly-option' + (isSelected ? ' selected' : '') + '">';
if (isSelected) html += '<span class="check-mark">✓</span>';
html += '<span>' + escapeHtml(opt.label) + '</span>';
html += '</div>';
}
html += '</div>';
return html;
}
function renderAnsweredPickMany(q, answer) {
const options = q.config.options || [];
const selected = answer.selected || [];
let html = '<div class="options">';
for (const opt of options) {
const isSelected = selected.includes(opt.id);
html += '<div class="readonly-option' + (isSelected ? ' selected' : '') + '">';
if (isSelected) html += '<span class="check-mark">✓</span>';
html += '<span>' + escapeHtml(opt.label) + '</span>';
html += '</div>';
}
html += '</div>';
return html;
}
function renderAnsweredConfirm(q, answer) {
const choice = answer.choice;
const labels = { yes: q.config.yesLabel || 'Yes', no: q.config.noLabel || 'No', cancel: 'Cancel' };
let html = '<div class="readonly-answer">';
html += '<div class="readonly-answer-label">Answer</div>';
html += '<strong>' + escapeHtml(labels[choice] || choice) + '</strong>';
html += '</div>';
return html;
}
function renderAnsweredText(q, answer) {
let html = '<div class="readonly-answer">';
html += '<div class="readonly-answer-label">Response</div>';
html += '<div>' + escapeHtml(answer.text || '') + '</div>';
html += '</div>';
return html;
}
function renderAnsweredThumbs(q, answer) {
const emoji = answer.choice === 'up' ? '👍' : '👎';
let html = '<div class="readonly-answer">';
html += '<span style="font-size: 2rem;">' + emoji + '</span>';
html += '</div>';
return html;
}
function renderAnsweredSlider(q, answer) {
let html = '<div class="readonly-answer">';
html += '<div class="readonly-answer-label">Value</div>';
html += '<strong style="font-size: 1.25rem;">' + answer.value + '</strong>';
html += ' <span style="color: var(--foreground-subtle);">(range: ' + q.config.min + ' - ' + q.config.max + ')</span>';
html += '</div>';
return html;
}
function renderAnsweredReview(q, answer) {
let html = '<div class="readonly-answer">';
html += '<div class="readonly-answer-label">Decision</div>';
html += '<strong>' + (answer.decision === 'approve' ? '✓ Approved' : '✗ Needs Revision') + '</strong>';
if (answer.feedback) {
html += '<div style="margin-top: 0.5rem;"><em>Feedback:</em> ' + escapeHtml(answer.feedback) + '</div>';
}
html += '</div>';
return html;
}
function renderAnsweredShowOptions(q, answer) {
const options = q.config.options || [];
let html = '<div class="options">';
for (const opt of options) {
const isSelected = answer.selected === opt.id;
html += '<div class="readonly-option' + (isSelected ? ' selected' : '') + '">';
if (isSelected) html += '<span class="check-mark">✓</span>';
html += '<span>' + escapeHtml(opt.label) + '</span>';
html += '</div>';
}
html += '</div>';
if (answer.feedback) {
html += '<div class="readonly-answer"><div class="readonly-answer-label">Feedback</div>' + escapeHtml(answer.feedback) + '</div>';
}
return html;
}
function renderAnsweredDiff(q, answer) {
let html = '<div class="readonly-answer">';
html += '<div class="readonly-answer-label">Decision</div>';
const decisions = { approve: '✓ Approved', reject: '✗ Rejected', edit: '✎ Edit Requested' };
html += '<strong>' + (decisions[answer.decision] || answer.decision) + '</strong>';
if (answer.feedback) {
html += '<div style="margin-top: 0.5rem;"><em>Comments:</em> ' + escapeHtml(answer.feedback) + '</div>';
}
html += '</div>';
return html;
}
function renderAnsweredRank(q, answer) {
const ranking = answer.ranking || [];
let html = '<div class="readonly-answer-label">Final Ranking</div>';
html += '<div class="options">';
for (const item of ranking) {
const opt = (q.config.options || []).find(o => o.id === item.id);
html += '<div class="readonly-option selected">';
html += '<strong>' + item.rank + '.</strong> ' + escapeHtml(opt ? opt.label : item.id);
html += '</div>';
}
html += '</div>';
return html;
}
function renderAnsweredRate(q, answer) {
const ratings = answer.ratings || {};
let html = '<div class="readonly-answer-label">Ratings</div>';
html += '<div class="options">';
for (const opt of (q.config.options || [])) {
const rating = ratings[opt.id];
html += '<div class="readonly-option' + (rating ? ' selected' : '') + '">';
html += '<span>' + escapeHtml(opt.label) + '</span>';
html += ' <strong style="margin-left: auto;">' + (rating || '-') + '</strong>';
html += '</div>';
}
html += '</div>';
return html;
}
function renderAnsweredCode(q, answer) {
let html = '<div class="readonly-answer">';
html += '<div class="readonly-answer-label">Code (' + escapeHtml(q.config.language || 'plaintext') + ')</div>';
html += '<pre style="margin: 0; white-space: pre-wrap;"><code>' + escapeHtml(answer.code || '') + '</code></pre>';
html += '</div>';
return html;
}
function renderAnsweredFile(q, answer) {
const files = answer.images || answer.files || [];
let html = '<div class="readonly-answer">';
html += '<div class="readonly-answer-label">Uploaded ' + files.length + ' file(s)</div>';
html += '<ul style="margin: 0.5rem 0 0 1rem;">';
for (const f of files) {
html += '<li>' + escapeHtml(f.name) + '</li>';
}
html += '</ul>';
html += '</div>';
return html;
}
function renderAnsweredEmoji(q, answer) {
let html = '<div class="readonly-answer">';
html += '<span style="font-size: 2rem;">' + (answer.emoji || '') + '</span>';
html += '</div>';
return html;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
connect();
</script>
</body>
</html>`;
}