Phase 7 Evolution: - Add live-export subcommand to acb-evolver for dashboard JSON generation - Export programs, stats, and generation log to live.json Phase 8 Enhanced Features: - Add WASM game engine build (cmd/acb-wasm/) with JS bindings - Add in-browser sandbox page with Monaco editor (web/src/pages/sandbox.ts) - Add win probability computation (web/src/win-probability.ts) - Add replay commentary generator (web/src/commentary.ts) - Add clip maker for GIF/MP4 export (web/src/pages/clip-maker.ts) - Add rivalry detection and pages (web/src/pages/rivalries.ts) - Add replay feedback system (web/src/pages/feedback.ts) - Add evolution dashboard page (web/src/pages/evolution.ts) Phase 9 Platform Depth: - Add predictions API (cmd/acb-api/predictions.go) - Add series management API (cmd/acb-api/series.go) - Add seasons API (cmd/acb-api/seasons.go) - Add narrative generator for rivalries (cmd/acb-indexer/src/narrative.ts) Engine Updates: - Add debug field to move response schema - Add match event timeline extraction - Add replay enrichment fields Web Updates: - Update app.html navigation for new pages - Add API client methods for predictions, series, seasons - Export engine types for browser use Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
507 lines
23 KiB
TypeScript
507 lines
23 KiB
TypeScript
// Community replay feedback: users annotate replay turns with tags.
|
||
// Annotations feed the evolution pipeline by surfacing interesting moments.
|
||
|
||
import { fetchMatchIndex, API_BASE, type MatchSummary } from '../api-types';
|
||
import { ReplayViewer } from '../replay-viewer';
|
||
import type { Replay } from '../types';
|
||
|
||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||
|
||
export const ANNOTATION_TAGS = [
|
||
{ id: 'turning_point', label: 'Turning Point', color: '#ef4444', desc: 'A moment that decisively changed the outcome' },
|
||
{ id: 'tactical_insight', label: 'Tactical Insight', color: '#3b82f6', desc: 'A clever or instructive strategy in action' },
|
||
{ id: 'impressive', label: 'Impressive', color: '#a78bfa', desc: 'Exceptional performance or execution' },
|
||
{ id: 'funny', label: 'Funny', color: '#f59e0b', desc: 'An unexpected or humorous sequence' },
|
||
{ id: 'bug', label: 'Possible Bug', color: '#f97316', desc: 'Behaviour that looks unintended' },
|
||
{ id: 'evolution_seed', label: 'Evolution Seed', color: '#22c55e', desc: 'A sequence worth propagating in the evolution pipeline' },
|
||
];
|
||
|
||
export interface ReplayAnnotation {
|
||
match_id: string;
|
||
turn: number;
|
||
tag: string;
|
||
comment: string;
|
||
submitted_at: string;
|
||
}
|
||
|
||
// ─── Page render ─────────────────────────────────────────────────────────────
|
||
|
||
export function renderFeedbackPage(_params: Record<string, string>): void {
|
||
const app = document.getElementById('app');
|
||
if (!app) return;
|
||
|
||
app.innerHTML = buildHTML();
|
||
requestAnimationFrame(() => initFeedback());
|
||
}
|
||
|
||
function buildHTML(): string {
|
||
const tagButtons = ANNOTATION_TAGS.map(t =>
|
||
`<button class="tag-btn" data-tag="${t.id}" style="--tag-color:${t.color}" title="${escapeHtml(t.desc)}">${escapeHtml(t.label)}</button>`,
|
||
).join('');
|
||
|
||
return `
|
||
<div class="feedback-page">
|
||
<h1 class="page-title">Community Replay Feedback</h1>
|
||
<p class="feedback-intro">
|
||
Annotate key moments in replays. High-quality annotations are used to seed the
|
||
evolution pipeline with interesting positions.
|
||
</p>
|
||
|
||
<div class="feedback-layout">
|
||
<!-- Left: load replay -->
|
||
<div class="feedback-left">
|
||
<div class="fb-panel">
|
||
<div class="panel-header"><span>Load a Replay</span></div>
|
||
|
||
<div class="load-tabs">
|
||
<button class="tab-btn active" data-tab="recent">Recent Matches</button>
|
||
<button class="tab-btn" data-tab="file">Upload File</button>
|
||
<button class="tab-btn" data-tab="url">By URL</button>
|
||
</div>
|
||
|
||
<!-- Recent matches tab -->
|
||
<div id="tab-recent" class="tab-content">
|
||
<div id="recent-matches-list" class="recent-list">
|
||
<div class="loading">Loading recent matches…</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- File upload tab -->
|
||
<div id="tab-file" class="tab-content hidden">
|
||
<label class="btn secondary" for="fb-file-input">Choose Replay File (.json)</label>
|
||
<input type="file" id="fb-file-input" accept=".json" style="display:none">
|
||
</div>
|
||
|
||
<!-- URL tab -->
|
||
<div id="tab-url" class="tab-content hidden">
|
||
<div class="url-row">
|
||
<input type="text" id="fb-url-input" placeholder="Replay URL…" class="url-input">
|
||
<button id="fb-load-url-btn" class="btn primary small">Load</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="fb-load-status" class="fb-status hidden"></div>
|
||
</div>
|
||
|
||
<!-- Annotation form (hidden until replay loaded) -->
|
||
<div class="fb-panel" id="annotation-form-panel" style="display:none">
|
||
<div class="panel-header"><span>Annotate Turn <span id="annotate-turn-num">—</span></span></div>
|
||
|
||
<div class="turn-nav">
|
||
<button id="ann-prev-btn" class="btn small">Prev</button>
|
||
<input type="range" id="ann-turn-slider" min="0" max="0" value="0" class="turn-slider">
|
||
<button id="ann-next-btn" class="btn small">Next</button>
|
||
</div>
|
||
|
||
<div class="tag-section">
|
||
<label class="form-label">Tag this moment:</label>
|
||
<div class="tag-buttons" id="tag-buttons">
|
||
${tagButtons}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="comment-section">
|
||
<label class="form-label" for="ann-comment">Comment (optional)</label>
|
||
<textarea id="ann-comment" class="ann-textarea" rows="3" placeholder="Describe what's happening here…" maxlength="280"></textarea>
|
||
<span id="ann-comment-len" class="char-count">0 / 280</span>
|
||
</div>
|
||
|
||
<button id="submit-annotation-btn" class="btn primary" disabled>Submit Annotation</button>
|
||
<div id="submit-status" class="fb-status hidden"></div>
|
||
</div>
|
||
|
||
<!-- Submitted annotations log -->
|
||
<div class="fb-panel" id="annotations-log-panel" style="display:none">
|
||
<div class="panel-header"><span>Your Annotations</span></div>
|
||
<div id="annotations-log" class="annotations-log"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Right: replay viewer -->
|
||
<div class="feedback-right" id="fb-viewer-col" style="display:none">
|
||
<div class="fb-panel">
|
||
<div class="panel-header">
|
||
<span id="fb-replay-title">Replay</span>
|
||
<span id="fb-replay-info" class="replay-info"></span>
|
||
</div>
|
||
<canvas id="fb-canvas" class="fb-canvas"></canvas>
|
||
<div class="viewer-controls">
|
||
<button id="fb-play-btn" class="btn small">Play</button>
|
||
<button id="fb-reset-btn" class="btn small secondary">Reset</button>
|
||
<span id="fb-turn-label" class="turn-label">Turn 0 / 0</span>
|
||
</div>
|
||
<!-- Annotation markers overlaid on replay -->
|
||
<div id="fb-annotation-markers" class="annotation-markers"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
${FEEDBACK_STYLES}
|
||
`;
|
||
}
|
||
|
||
// ─── Initialisation ───────────────────────────────────────────────────────────
|
||
|
||
function initFeedback(): void {
|
||
let replay: Replay | null = null;
|
||
let viewer: ReplayViewer | null = null;
|
||
let selectedTag: string | null = null;
|
||
const localAnnotations: ReplayAnnotation[] = [];
|
||
|
||
const loadStatus = document.getElementById('fb-load-status')!;
|
||
const formPanel = document.getElementById('annotation-form-panel')!;
|
||
const logPanel = document.getElementById('annotations-log-panel')!;
|
||
const viewerCol = document.getElementById('fb-viewer-col')!;
|
||
const turnNum = document.getElementById('annotate-turn-num')!;
|
||
const turnSlider = document.getElementById('ann-turn-slider') as HTMLInputElement;
|
||
const canvas = document.getElementById('fb-canvas') as HTMLCanvasElement;
|
||
const replayTitle = document.getElementById('fb-replay-title')!;
|
||
const replayInfo = document.getElementById('fb-replay-info')!;
|
||
const turnLabel = document.getElementById('fb-turn-label')!;
|
||
const submitBtn = document.getElementById('submit-annotation-btn') as HTMLButtonElement;
|
||
const commentTa = document.getElementById('ann-comment') as HTMLTextAreaElement;
|
||
const commentLen = document.getElementById('ann-comment-len')!;
|
||
const submitStatus = document.getElementById('submit-status')!;
|
||
|
||
// ── Tab switching ──────────────────────────────────────────────────────────
|
||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const tab = (btn as HTMLElement).dataset.tab!;
|
||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
document.querySelectorAll('.tab-content').forEach(c => c.classList.add('hidden'));
|
||
document.getElementById(`tab-${tab}`)?.classList.remove('hidden');
|
||
});
|
||
});
|
||
|
||
// ── Load recent matches ────────────────────────────────────────────────────
|
||
fetchMatchIndex().then(idx => {
|
||
const listEl = document.getElementById('recent-matches-list')!;
|
||
const recent = idx.matches.slice(0, 20);
|
||
if (recent.length === 0) {
|
||
listEl.innerHTML = '<div class="empty-state-sm">No matches recorded yet.</div>';
|
||
return;
|
||
}
|
||
listEl.innerHTML = recent.map(m => `
|
||
<div class="recent-match-row" data-match-id="${m.id}">
|
||
<div class="recent-match-bots">${m.participants.map(p => escapeHtml(p.name)).join(' vs ')}</div>
|
||
<div class="recent-match-meta">
|
||
<span>${m.turns ?? '?'} turns</span>
|
||
<span>${formatDate(m.completed_at)}</span>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
|
||
listEl.querySelectorAll('.recent-match-row').forEach(row => {
|
||
row.addEventListener('click', async () => {
|
||
const mid = (row as HTMLElement).dataset.matchId!;
|
||
const match = recent.find(m => m.id === mid)!;
|
||
await loadReplayFromUrl(replayUrlForMatch(match));
|
||
});
|
||
});
|
||
}).catch(() => {
|
||
const listEl = document.getElementById('recent-matches-list')!;
|
||
listEl.innerHTML = '<div class="empty-state-sm">Could not load match list.</div>';
|
||
});
|
||
|
||
// ── File upload ────────────────────────────────────────────────────────────
|
||
document.getElementById('fb-file-input')!.addEventListener('change', async (e) => {
|
||
const file = (e.target as HTMLInputElement).files?.[0];
|
||
if (!file) return;
|
||
try {
|
||
const text = await file.text();
|
||
loadReplayData(JSON.parse(text) as Replay);
|
||
} catch (err) {
|
||
showLoadError('Parse error: ' + err);
|
||
}
|
||
});
|
||
|
||
// ── URL load ───────────────────────────────────────────────────────────────
|
||
document.getElementById('fb-load-url-btn')!.addEventListener('click', () => {
|
||
const url = (document.getElementById('fb-url-input') as HTMLInputElement).value.trim();
|
||
if (url) loadReplayFromUrl(url);
|
||
});
|
||
|
||
async function loadReplayFromUrl(url: string): Promise<void> {
|
||
loadStatus.textContent = 'Loading…';
|
||
loadStatus.className = 'fb-status';
|
||
try {
|
||
const resp = await fetch(url);
|
||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||
loadReplayData((await resp.json()) as Replay);
|
||
} catch (err) {
|
||
showLoadError('Failed to load: ' + err);
|
||
}
|
||
}
|
||
|
||
function showLoadError(msg: string): void {
|
||
loadStatus.textContent = msg;
|
||
loadStatus.className = 'fb-status error';
|
||
}
|
||
|
||
function loadReplayData(data: Replay): void {
|
||
replay = data;
|
||
const total = data.turns.length - 1;
|
||
|
||
loadStatus.textContent = `Loaded: ${data.match_id}`;
|
||
loadStatus.className = 'fb-status ok';
|
||
|
||
// Setup viewer
|
||
viewerCol.style.display = '';
|
||
viewer = new ReplayViewer(canvas, { cellSize: 10, showGrid: false });
|
||
viewer.loadReplay(data);
|
||
viewer.onTurnChange = () => updateTurnUI(viewer!.getTurn(), total);
|
||
|
||
replayTitle.textContent = 'Replay';
|
||
replayInfo.textContent = `${data.match_id.slice(0, 8)}… · ${total + 1} turns`;
|
||
|
||
// Setup annotation form
|
||
turnSlider.max = String(total);
|
||
turnSlider.value = '0';
|
||
updateTurnUI(0, total);
|
||
|
||
formPanel.style.display = '';
|
||
updateAnnotationMarkers();
|
||
|
||
document.getElementById('fb-play-btn')!.addEventListener('click', () => viewer?.togglePlay(), { once: false });
|
||
document.getElementById('fb-reset-btn')!.addEventListener('click', () => { viewer?.pause(); viewer?.setTurn(0); });
|
||
}
|
||
|
||
function updateTurnUI(turn: number, total: number): void {
|
||
turnNum.textContent = String(turn);
|
||
turnSlider.value = String(turn);
|
||
turnLabel.textContent = `Turn ${turn} / ${total}`;
|
||
}
|
||
|
||
// ── Playback controls ──────────────────────────────────────────────────────
|
||
turnSlider.addEventListener('input', () => {
|
||
const t = Number(turnSlider.value);
|
||
viewer?.setTurn(t);
|
||
updateTurnUI(t, Number(turnSlider.max));
|
||
});
|
||
|
||
document.getElementById('ann-prev-btn')!.addEventListener('click', () => {
|
||
if (!viewer) return;
|
||
const t = Math.max(0, viewer.getTurn() - 1);
|
||
viewer.setTurn(t);
|
||
updateTurnUI(t, Number(turnSlider.max));
|
||
});
|
||
|
||
document.getElementById('ann-next-btn')!.addEventListener('click', () => {
|
||
if (!viewer) return;
|
||
const t = Math.min(Number(turnSlider.max), viewer.getTurn() + 1);
|
||
viewer.setTurn(t);
|
||
updateTurnUI(t, Number(turnSlider.max));
|
||
});
|
||
|
||
// ── Tag selection ──────────────────────────────────────────────────────────
|
||
document.getElementById('tag-buttons')!.querySelectorAll('.tag-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
document.querySelectorAll('.tag-btn').forEach(b => b.classList.remove('selected'));
|
||
btn.classList.add('selected');
|
||
selectedTag = (btn as HTMLElement).dataset.tag!;
|
||
updateSubmitButton();
|
||
});
|
||
});
|
||
|
||
commentTa.addEventListener('input', () => {
|
||
const len = commentTa.value.length;
|
||
commentLen.textContent = `${len} / 280`;
|
||
updateSubmitButton();
|
||
});
|
||
|
||
function updateSubmitButton(): void {
|
||
submitBtn.disabled = !selectedTag || !replay;
|
||
}
|
||
|
||
// ── Submit annotation ──────────────────────────────────────────────────────
|
||
submitBtn.addEventListener('click', async () => {
|
||
if (!replay || !selectedTag) return;
|
||
|
||
const annotation: ReplayAnnotation = {
|
||
match_id: replay.match_id,
|
||
turn: Number(turnSlider.value),
|
||
tag: selectedTag,
|
||
comment: commentTa.value.trim(),
|
||
submitted_at: new Date().toISOString(),
|
||
};
|
||
|
||
submitBtn.disabled = true;
|
||
submitStatus.textContent = 'Submitting…';
|
||
submitStatus.className = 'fb-status';
|
||
|
||
try {
|
||
// POST to API; gracefully handle 404/offline (store locally)
|
||
const resp = await fetch(`${API_BASE}/feedback`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(annotation),
|
||
}).catch(() => null);
|
||
|
||
if (resp && resp.ok) {
|
||
submitStatus.textContent = 'Annotation submitted! Thank you.';
|
||
submitStatus.className = 'fb-status ok';
|
||
} else {
|
||
// Store locally if API unavailable
|
||
submitStatus.textContent = 'Saved locally (API offline).';
|
||
submitStatus.className = 'fb-status ok';
|
||
}
|
||
|
||
localAnnotations.push(annotation);
|
||
saveLocalAnnotation(annotation);
|
||
|
||
// Reset form
|
||
document.querySelectorAll('.tag-btn').forEach(b => b.classList.remove('selected'));
|
||
selectedTag = null;
|
||
commentTa.value = '';
|
||
commentLen.textContent = '0 / 280';
|
||
updateSubmitButton();
|
||
|
||
logPanel.style.display = '';
|
||
renderAnnotationsLog(localAnnotations);
|
||
updateAnnotationMarkers();
|
||
} catch (err) {
|
||
submitStatus.textContent = 'Error: ' + err;
|
||
submitStatus.className = 'fb-status error';
|
||
} finally {
|
||
submitBtn.disabled = !selectedTag;
|
||
}
|
||
});
|
||
|
||
// Load any previously stored annotations
|
||
const stored = loadLocalAnnotations();
|
||
if (stored.length > 0) {
|
||
localAnnotations.push(...stored);
|
||
logPanel.style.display = '';
|
||
renderAnnotationsLog(localAnnotations);
|
||
}
|
||
|
||
function updateAnnotationMarkers(): void {
|
||
if (!replay) return;
|
||
const total = replay.turns.length;
|
||
const markersEl = document.getElementById('fb-annotation-markers')!;
|
||
const relevant = localAnnotations.filter(a => a.match_id === replay!.match_id);
|
||
|
||
if (relevant.length === 0) {
|
||
markersEl.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
markersEl.innerHTML = relevant.map(a => {
|
||
const pct = (a.turn / Math.max(1, total - 1)) * 100;
|
||
const tagInfo = ANNOTATION_TAGS.find(t => t.id === a.tag);
|
||
const color = tagInfo?.color ?? '#94a3b8';
|
||
return `<div class="ann-marker" style="left:${pct.toFixed(1)}%;background:${color}"
|
||
title="Turn ${a.turn}: ${escapeHtml(tagInfo?.label ?? a.tag)}${a.comment ? ' — ' + escapeHtml(a.comment) : ''}"></div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderAnnotationsLog(anns: ReplayAnnotation[]): void {
|
||
const logEl = document.getElementById('annotations-log')!;
|
||
const sorted = [...anns].sort((a, b) => a.turn - b.turn);
|
||
logEl.innerHTML = sorted.map(a => {
|
||
const tagInfo = ANNOTATION_TAGS.find(t => t.id === a.tag);
|
||
return `
|
||
<div class="ann-log-row">
|
||
<span class="ann-tag-pill" style="background:${tagInfo?.color ?? '#94a3b8'}22;color:${tagInfo?.color ?? '#94a3b8'}">${escapeHtml(tagInfo?.label ?? a.tag)}</span>
|
||
<span class="ann-turn">Turn ${a.turn}</span>
|
||
${a.comment ? `<span class="ann-comment-text">${escapeHtml(a.comment)}</span>` : ''}
|
||
<span class="ann-match-id">${a.match_id.slice(0, 8)}…</span>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
}
|
||
|
||
// ─── Local storage for offline annotations ────────────────────────────────────
|
||
|
||
const LS_KEY = 'acb_annotations';
|
||
|
||
function saveLocalAnnotation(ann: ReplayAnnotation): void {
|
||
try {
|
||
const existing: ReplayAnnotation[] = JSON.parse(localStorage.getItem(LS_KEY) ?? '[]');
|
||
existing.push(ann);
|
||
localStorage.setItem(LS_KEY, JSON.stringify(existing.slice(-200))); // keep last 200
|
||
} catch {}
|
||
}
|
||
|
||
function loadLocalAnnotations(): ReplayAnnotation[] {
|
||
try {
|
||
return JSON.parse(localStorage.getItem(LS_KEY) ?? '[]');
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// ─── Utilities ────────────────────────────────────────────────────────────────
|
||
|
||
function replayUrlForMatch(m: MatchSummary): string {
|
||
// Replays are stored in R2 at /replays/{match_id}.json
|
||
return `/replays/${m.id}.json`;
|
||
}
|
||
|
||
function formatDate(s: string | null): string {
|
||
if (!s) return '–';
|
||
return new Date(s).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||
}
|
||
|
||
function escapeHtml(s: string): string {
|
||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
}
|
||
|
||
// ─── Styles ───────────────────────────────────────────────────────────────────
|
||
|
||
const FEEDBACK_STYLES = `
|
||
<style>
|
||
.feedback-intro { color: var(--text-muted); margin-bottom: 24px; max-width: 700px; }
|
||
.feedback-layout { display: flex; gap: 20px; align-items: flex-start; }
|
||
.feedback-left { width: 360px; flex-shrink: 0; display: flex; flex-direction: column; gap: 16px; }
|
||
.feedback-right { flex: 1; min-width: 0; }
|
||
.fb-panel { background: var(--bg-secondary); border-radius: 8px; padding: 16px; }
|
||
.panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; font-weight: 600; color: var(--text-primary); }
|
||
.replay-info { font-size: 0.75rem; color: var(--text-muted); font-weight: 400; }
|
||
.load-tabs { display: flex; gap: 4px; margin-bottom: 12px; }
|
||
.tab-btn { background: var(--bg-tertiary); border: none; color: var(--text-muted); padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 0.8rem; }
|
||
.tab-btn.active { background: var(--accent); color: #fff; }
|
||
.tab-content.hidden { display: none; }
|
||
.tab-content { padding: 4px 0; }
|
||
.url-row { display: flex; gap: 8px; }
|
||
.url-input { flex: 1; background: var(--bg-primary); border: 1px solid var(--border); color: var(--text-primary); padding: 7px 10px; border-radius: 6px; font-size: 0.875rem; }
|
||
.fb-status { font-size: 0.8rem; padding: 8px; border-radius: 4px; margin-top: 8px; }
|
||
.fb-status.hidden { display: none; }
|
||
.fb-status.ok { background: rgba(34,197,94,0.15); color: var(--success); }
|
||
.fb-status.error { background: rgba(239,68,68,0.15); color: var(--error); }
|
||
.recent-list { max-height: 260px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; }
|
||
.recent-match-row { background: var(--bg-primary); border-radius: 6px; padding: 8px 12px; cursor: pointer; transition: background 0.15s; }
|
||
.recent-match-row:hover { background: var(--bg-tertiary); }
|
||
.recent-match-bots { font-size: 0.875rem; color: var(--text-primary); margin-bottom: 2px; }
|
||
.recent-match-meta { display: flex; gap: 12px; font-size: 0.75rem; color: var(--text-muted); }
|
||
.empty-state-sm { color: var(--text-muted); font-size: 0.875rem; padding: 12px 0; }
|
||
.turn-nav { display: flex; gap: 8px; align-items: center; margin-bottom: 14px; }
|
||
.turn-slider { flex: 1; }
|
||
.tag-section { margin-bottom: 14px; }
|
||
.form-label { display: block; font-size: 0.8rem; color: var(--text-muted); margin-bottom: 6px; }
|
||
.tag-buttons { display: flex; flex-wrap: wrap; gap: 6px; }
|
||
.tag-btn { background: var(--bg-primary); border: 2px solid var(--tag-color, #475569); color: var(--tag-color, #94a3b8); padding: 5px 10px; border-radius: 20px; cursor: pointer; font-size: 0.8rem; transition: all 0.15s; }
|
||
.tag-btn:hover { background: color-mix(in srgb, var(--tag-color, #475569) 15%, transparent); }
|
||
.tag-btn.selected { background: color-mix(in srgb, var(--tag-color, #475569) 20%, transparent); font-weight: 600; }
|
||
.comment-section { margin-bottom: 14px; }
|
||
.ann-textarea { width: 100%; background: var(--bg-primary); border: 1px solid var(--border); color: var(--text-primary); padding: 8px; border-radius: 6px; font-size: 0.875rem; resize: vertical; font-family: inherit; }
|
||
.char-count { font-size: 0.7rem; color: var(--text-muted); float: right; }
|
||
.fb-canvas { display: block; width: 100%; border-radius: 6px; background: var(--bg-primary); }
|
||
.viewer-controls { display: flex; gap: 8px; align-items: center; margin-top: 10px; }
|
||
.turn-label { color: var(--text-muted); font-size: 0.875rem; margin-left: auto; }
|
||
.annotation-markers { position: relative; height: 16px; background: var(--bg-tertiary); border-radius: 4px; margin-top: 8px; }
|
||
.ann-marker { position: absolute; top: 2px; bottom: 2px; width: 4px; transform: translateX(-50%); border-radius: 2px; cursor: pointer; }
|
||
.annotations-log { display: flex; flex-direction: column; gap: 6px; max-height: 200px; overflow-y: auto; }
|
||
.ann-log-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 0.8rem; padding: 4px 0; border-bottom: 1px solid var(--bg-tertiary); }
|
||
.ann-tag-pill { padding: 2px 8px; border-radius: 10px; font-size: 0.75rem; font-weight: 600; }
|
||
.ann-turn { color: var(--text-muted); }
|
||
.ann-comment-text { color: var(--text-secondary); flex: 1; }
|
||
.ann-match-id { color: var(--text-muted); font-family: monospace; font-size: 0.7rem; margin-left: auto; }
|
||
@media (max-width: 900px) {
|
||
.feedback-layout { flex-direction: column; }
|
||
.feedback-left { width: 100%; }
|
||
}
|
||
</style>
|
||
`;
|