@nakedved/ano
Version:
Claude Code plugin for collaborative annotation and review
683 lines (682 loc) • 31.5 kB
JavaScript
/**
* ano serve command
*
* Start a local web server for viewing annotations.
*
* Usage:
* ano serve <file...> Start server for one or more files
* ano serve <file> --port 8080 Use custom port
*/
import { Command } from 'commander';
import chalk from 'chalk';
import { resolve, dirname, basename } from 'node:path';
import { readFile } from 'node:fs/promises';
import { createServer } from 'node:http';
import { existsSync, watch } from 'node:fs';
import { readAnnotationFile, addAnnotation, resolveAnnotation, setApproval, addReply, deleteAnnotation, reopenAnnotation } from '../../core/annotations.js';
import { getAuthorString } from '../../core/config.js';
// SSE client connections
const sseClients = new Set();
// File watchers
const fileWatchers = new Map();
// Broadcast to all SSE clients
function broadcast(event, data) {
const message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
for (const client of sseClients) {
client.write(message);
}
}
// Simple static file server
const MIME_TYPES = {
'.html': 'text/html',
'.css': 'text/css',
'.js': 'application/javascript',
'.json': 'application/json',
'.png': 'image/png',
'.svg': 'image/svg+xml',
};
// Helper to parse JSON body from request
async function parseJsonBody(req) {
return new Promise((resolve, reject) => {
let body = '';
req.on('data', chunk => body += chunk.toString());
req.on('end', () => {
try {
resolve(JSON.parse(body));
}
catch (e) {
reject(new Error('Invalid JSON'));
}
});
req.on('error', reject);
});
}
export const serveCommand = new Command('serve')
.description('Start web viewer for annotations')
.argument('<files...>', 'Files to view')
.option('-p, --port <port>', 'Port to use', '3000')
.option('--no-open', 'Don\'t open browser automatically')
.action(async (files, options) => {
// Resolve all file paths
const filePaths = files.map(f => resolve(f));
const port = parseInt(options.port, 10);
// Check all files exist
for (const fp of filePaths) {
if (!existsSync(fp)) {
console.error(chalk.red(`File not found: ${fp}`));
process.exit(1);
}
}
// Track currently selected file (default to first)
let currentFilePath = filePaths[0];
// Find web dist directory
const webDistPath = resolve(dirname(new URL(import.meta.url).pathname), '../../../web/dist');
const hasWebDist = existsSync(webDistPath);
if (!hasWebDist) {
console.error(chalk.yellow('Web UI not built. Building now...'));
console.error(chalk.dim(' Run: npm run build --prefix web'));
// For now, serve a simple HTML page
}
// Create HTTP server
const server = createServer(async (req, res) => {
const reqUrl = new URL(req.url || '/', `http://localhost:${port}`);
const url = reqUrl.pathname;
// API endpoint to list all available files
if (url === '/api/files' && req.method === 'GET') {
try {
const fileList = await Promise.all(filePaths.map(async (fp) => {
const annotations = await readAnnotationFile(fp);
const openCount = annotations?.annotations.filter(a => a.status === 'open').length || 0;
return {
path: fp,
name: basename(fp),
openAnnotations: openCount,
isCurrent: fp === currentFilePath,
};
}));
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ files: fileList }));
}
catch (error) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: error.message }));
}
return;
}
// API endpoint to switch current file
if (url === '/api/switch' && req.method === 'POST') {
try {
const body = await parseJsonBody(req);
const { filePath: newPath } = body;
if (!filePaths.includes(newPath)) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'File not in served files list' }));
return;
}
currentFilePath = newPath;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, currentFile: basename(currentFilePath) }));
}
catch (error) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: error.message }));
}
return;
}
// API endpoint for current file data
if (url === '/api/current' && req.method === 'GET') {
try {
const content = await readFile(currentFilePath, 'utf-8');
const annotations = await readAnnotationFile(currentFilePath);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
fileName: basename(currentFilePath),
filePath: currentFilePath,
content,
annotations: annotations || {
version: '1.0',
file: currentFilePath,
fileHash: '',
annotations: [],
approvals: [],
},
}));
}
catch (error) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: error.message }));
}
return;
}
// API endpoint to add an annotation
if (url === '/api/annotate' && req.method === 'POST') {
try {
const body = await parseJsonBody(req);
const { line, content, type, endLine } = body;
if (!line || !content || !type) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Missing required fields: line, content, type' }));
return;
}
const author = await getAuthorString();
await addAnnotation({
file: currentFilePath,
line,
endLine,
type,
author,
content,
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true }));
}
catch (error) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: error.message }));
}
return;
}
// API endpoint to resolve an annotation
if (url === '/api/resolve' && req.method === 'POST') {
try {
const body = await parseJsonBody(req);
const { annotationId } = body;
if (!annotationId) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Missing required field: annotationId' }));
return;
}
const resolved = await resolveAnnotation(currentFilePath, annotationId);
if (!resolved) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Annotation not found' }));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true }));
}
catch (error) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: error.message }));
}
return;
}
// API endpoint to add a reply to an annotation
if (url === '/api/reply' && req.method === 'POST') {
try {
const body = await parseJsonBody(req);
const { annotationId, content } = body;
if (!annotationId || !content) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Missing required fields: annotationId, content' }));
return;
}
const author = await getAuthorString();
const reply = await addReply(currentFilePath, annotationId, author, content);
if (!reply) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Annotation not found' }));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, reply }));
}
catch (error) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: error.message }));
}
return;
}
// API endpoint to delete an annotation
if (url === '/api/delete' && req.method === 'POST') {
try {
const body = await parseJsonBody(req);
const { annotationId } = body;
if (!annotationId) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Missing required field: annotationId' }));
return;
}
const deleted = await deleteAnnotation(currentFilePath, annotationId);
if (!deleted) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Annotation not found' }));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true }));
}
catch (error) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: error.message }));
}
return;
}
// API endpoint to reopen an annotation
if (url === '/api/reopen' && req.method === 'POST') {
try {
const body = await parseJsonBody(req);
const { annotationId } = body;
if (!annotationId) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Missing required field: annotationId' }));
return;
}
const reopened = await reopenAnnotation(currentFilePath, annotationId);
if (!reopened) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Annotation not found' }));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true }));
}
catch (error) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: error.message }));
}
return;
}
// API endpoint to approve/request changes
if (url === '/api/approve' && req.method === 'POST') {
try {
const body = await parseJsonBody(req);
const { status, comment } = body;
if (!status || !['approved', 'changes_requested'].includes(status)) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid status. Must be "approved" or "changes_requested"' }));
return;
}
const author = await getAuthorString();
await setApproval(currentFilePath, author, status, undefined, comment);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true }));
}
catch (error) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: error.message }));
}
return;
}
// API endpoint to save file content
if (url === '/api/save' && req.method === 'POST') {
try {
const body = await parseJsonBody(req);
const { content } = body;
if (content === undefined) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Missing required field: content' }));
return;
}
// Write the updated content to the file
const { writeFile } = await import('node:fs/promises');
await writeFile(currentFilePath, content, 'utf-8');
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true }));
}
catch (error) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: error.message }));
}
return;
}
// SSE endpoint for real-time updates
if (url === '/api/events' && req.method === 'GET') {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
});
// Send initial connection confirmation
res.write('event: connected\ndata: {}\n\n');
// Add to clients set
sseClients.add(res);
// Remove on disconnect
req.on('close', () => {
sseClients.delete(res);
});
return;
}
// API endpoint to share annotations via paste service
if (url === '/api/share' && req.method === 'POST') {
try {
const body = await parseJsonBody(req);
const { fileName, content, annotations } = body;
// Generate standalone HTML
const html = generateShareableHtml(fileName, content, annotations);
// Upload to paste.rs (simple: just POST content, get URL back)
const response = await fetch('https://paste.rs/', {
method: 'POST',
headers: { 'Content-Type': 'text/html' },
body: html
});
if (response.ok) {
const pasteUrl = await response.text();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ url: pasteUrl.trim() }));
}
else {
throw new Error('Failed to create paste: ' + response.status);
}
}
catch (error) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: error.message }));
}
return;
}
// Serve static files from web/dist
if (hasWebDist) {
let staticPath = url === '/' ? '/index.html' : url;
const fullPath = resolve(webDistPath, '.' + staticPath);
if (existsSync(fullPath)) {
const ext = staticPath.substring(staticPath.lastIndexOf('.'));
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
try {
const content = await readFile(fullPath);
res.writeHead(200, { 'Content-Type': contentType });
res.end(content);
return;
}
catch {
// Fall through to 404
}
}
// SPA fallback - serve index.html for all routes
if (!staticPath.includes('.')) {
try {
const indexPath = resolve(webDistPath, 'index.html');
const content = await readFile(indexPath);
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(content);
return;
}
catch {
// Fall through to 404
}
}
}
// Fallback: serve inline HTML if web dist not available
if (url === '/' || url === '/index.html') {
const content = await readFile(currentFilePath, 'utf-8');
const annotations = await readAnnotationFile(currentFilePath);
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(generateFallbackHtml(basename(currentFilePath), content, annotations));
return;
}
// 404
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not found');
});
server.listen(port, () => {
console.log();
console.log(chalk.green.bold(' Ano Web Viewer'));
console.log();
console.log(` ${chalk.bold('Files:')} ${filePaths.map(fp => basename(fp)).join(', ')}`);
console.log(` ${chalk.bold('URL:')} ${chalk.cyan(`http://localhost:${port}`)}`);
console.log(chalk.dim(' Live reload enabled'));
console.log();
console.log(chalk.dim(' Press Ctrl+C to stop'));
console.log();
// Set up file watchers for all served files
for (const fp of filePaths) {
// Watch the main file
if (existsSync(fp)) {
try {
const watcher = watch(fp, { persistent: true }, (eventType) => {
if (eventType === 'change') {
broadcast('fileChanged', { file: basename(fp), path: fp });
}
});
fileWatchers.set(fp, watcher);
}
catch (e) {
console.error(chalk.yellow(` Warning: Could not watch ${basename(fp)}`));
}
}
// Watch the annotation file
const annotationPath = `${fp}.annotations.json`;
if (existsSync(annotationPath)) {
try {
const watcher = watch(annotationPath, { persistent: true }, (eventType) => {
if (eventType === 'change') {
broadcast('annotationsChanged', { file: basename(fp), path: fp });
}
});
fileWatchers.set(annotationPath, watcher);
}
catch (e) {
console.error(chalk.yellow(` Warning: Could not watch annotations for ${basename(fp)}`));
}
}
}
// Open browser if requested
if (options.open) {
const openCommand = process.platform === 'darwin' ? 'open' :
process.platform === 'win32' ? 'start' : 'xdg-open';
import('node:child_process').then(({ exec }) => {
exec(`${openCommand} http://localhost:${port}`);
});
}
});
// Cleanup on exit
process.on('SIGINT', () => {
for (const watcher of fileWatchers.values()) {
watcher.close();
}
process.exit(0);
});
});
/**
* Generate fallback HTML when web dist is not available
*/
function generateFallbackHtml(fileName, content, annotations) {
const lines = content.split('\n');
const annotationMap = new Map();
if (annotations?.annotations) {
for (const ann of annotations.annotations) {
const line = ann.anchor.line;
if (!annotationMap.has(line)) {
annotationMap.set(line, []);
}
annotationMap.get(line).push(ann);
}
}
const lineHtml = lines.map((line, i) => {
const lineNum = i + 1;
const lineAnnotations = annotationMap.get(lineNum) || [];
const hasAnnotation = lineAnnotations.length > 0;
const type = lineAnnotations[0]?.type || '';
const bgClass = hasAnnotation ? `annotation-${type}` : '';
return `<div class="line ${bgClass}">
<span class="line-num">${lineNum}</span>
<span class="line-content">${escapeHtml(line) || ' '}</span>
${hasAnnotation ? `<span class="annotation-badge">${lineAnnotations.length}</span>` : ''}
</div>`;
}).join('\n');
return `
<html>
<head>
<title>${fileName} - Ano</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0; }
.header { background: #1e293b; padding: 1rem 2rem; border-bottom: 1px solid #334155; }
.header h1 { font-size: 1.25rem; color: #60a5fa; }
.content { padding: 1rem; font-family: 'SF Mono', Monaco, monospace; font-size: 14px; }
.line { display: flex; align-items: center; padding: 2px 0; }
.line:hover { background: rgba(51, 65, 85, 0.5); }
.line-num { width: 50px; text-align: right; padding-right: 1rem; color: #64748b; user-select: none; border-right: 1px solid #334155; }
.line-content { padding-left: 1rem; white-space: pre-wrap; flex: 1; }
.annotation-badge { background: #3b82f6; color: white; padding: 2px 8px; border-radius: 9999px; font-size: 12px; margin-left: auto; }
.annotation-blocker { background: rgba(239, 68, 68, 0.1); }
.annotation-blocker .annotation-badge { background: #ef4444; }
.annotation-concern { background: rgba(245, 158, 11, 0.1); }
.annotation-concern .annotation-badge { background: #f59e0b; }
.annotation-question { background: rgba(59, 130, 246, 0.1); }
.annotation-suggestion { background: rgba(34, 197, 94, 0.1); }
.annotation-suggestion .annotation-badge { background: #22c55e; }
</style>
</head>
<body>
<div class="header">
<h1>ano - ${escapeHtml(fileName)}</h1>
</div>
<div class="content">
${lineHtml}
</div>
</body>
</html>`;
}
function escapeHtml(text) {
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
/**
* Generate shareable HTML with all annotations visible
*/
function generateShareableHtml(fileName, content, annotationData) {
const lines = content.split('\n');
const annotationMap = new Map();
if (annotationData?.annotations) {
for (const ann of annotationData.annotations) {
const start = ann.anchor.line;
const end = ann.anchor.endLine || ann.anchor.line;
for (let i = start; i <= end; i++) {
if (!annotationMap.has(i))
annotationMap.set(i, []);
if (!annotationMap.get(i).find((a) => a.id === ann.id)) {
annotationMap.get(i).push(ann);
}
}
}
}
const openAnnotations = annotationData?.annotations?.filter((a) => a.status === 'open') || [];
const stats = {
open: openAnnotations.length,
blockers: openAnnotations.filter((a) => a.type === 'blocker').length,
approvals: annotationData?.approvals?.filter((a) => a.status === 'approved').length || 0
};
// Build annotation summary section
const summaryHtml = openAnnotations.length > 0 ? `
<div class="summary">
<h2>Annotations Summary</h2>
${openAnnotations.map((a) => {
const lineRef = a.anchor.endLine ? `L${a.anchor.line}-${a.anchor.endLine}` : `L${a.anchor.line}`;
return `<div class="summary-item ${a.type}">
<span class="type-badge">${a.type}</span>
<span class="line-ref">${lineRef}</span>
<p>${escapeHtml(a.content)}</p>
<span class="author">${escapeHtml(a.author.split('<')[0].trim())}</span>
</div>`;
}).join('')}
</div>
` : '<div class="summary"><p class="no-annotations">No open annotations</p></div>';
const lineHtml = lines.map((line, i) => {
const lineNum = i + 1;
const lineAnnotations = annotationMap.get(lineNum) || [];
const hasAnnotation = lineAnnotations.length > 0;
const type = lineAnnotations[0]?.type || '';
const bgClass = hasAnnotation ? `annotation-${type}` : '';
const annotationHtml = lineAnnotations.length > 0 ? `
<div class="inline-annotations">
${lineAnnotations.filter((a) => a.anchor.line === lineNum).map((a) => `
<div class="inline-ann ${a.type}">
<span class="type">${a.type}</span>
<span class="content">${escapeHtml(a.content)}</span>
</div>
`).join('')}
</div>
` : '';
return `<div class="line ${bgClass}">
<span class="line-num">${lineNum}</span>
<span class="line-content">${escapeHtml(line) || ' '}</span>
${hasAnnotation ? `<span class="annotation-badge">${lineAnnotations.length}</span>` : ''}
</div>${annotationHtml}`;
}).join('\n');
return `
<html>
<head>
<title>${escapeHtml(fileName)} - Ano Annotations</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; line-height: 1.5; }
.header { background: #1e293b; padding: 1rem 2rem; border-bottom: 1px solid #334155; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem; }
.header h1 { font-size: 1.25rem; color: #60a5fa; display: flex; align-items: center; gap: 0.75rem; }
.header h1 span { color: #94a3b8; font-weight: normal; font-size: 0.875rem; }
.stats { display: flex; gap: 1.5rem; font-size: 0.875rem; }
.stats .stat { display: flex; align-items: center; gap: 0.5rem; }
.stats .dot { width: 8px; height: 8px; border-radius: 50%; }
.stats .open .dot { background: #f59e0b; }
.stats .blocker .dot { background: #ef4444; }
.stats .approval .dot { background: #22c55e; }
.container { display: flex; flex-direction: column; }
@media (min-width: 1024px) { .container { flex-direction: row; } }
.summary { padding: 1.5rem; border-bottom: 1px solid #334155; background: #1e293b; }
@media (min-width: 1024px) { .summary { width: 350px; border-bottom: none; border-right: 1px solid #334155; max-height: calc(100vh - 60px); overflow-y: auto; } }
.summary h2 { font-size: 0.875rem; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 1rem; }
.summary-item { padding: 0.75rem; background: #0f172a; border-radius: 8px; margin-bottom: 0.75rem; border-left: 3px solid; }
.summary-item.blocker { border-color: #ef4444; }
.summary-item.concern { border-color: #f59e0b; }
.summary-item.question { border-color: #3b82f6; }
.summary-item.suggestion { border-color: #22c55e; }
.summary-item .type-badge { font-size: 10px; padding: 2px 6px; border-radius: 4px; text-transform: uppercase; }
.summary-item.blocker .type-badge { background: rgba(239, 68, 68, 0.2); color: #f87171; }
.summary-item.concern .type-badge { background: rgba(245, 158, 11, 0.2); color: #fbbf24; }
.summary-item.question .type-badge { background: rgba(59, 130, 246, 0.2); color: #60a5fa; }
.summary-item.suggestion .type-badge { background: rgba(34, 197, 94, 0.2); color: #4ade80; }
.summary-item .line-ref { font-size: 11px; color: #64748b; margin-left: 0.5rem; }
.summary-item p { font-size: 13px; margin: 0.5rem 0; }
.summary-item .author { font-size: 11px; color: #64748b; }
.no-annotations { color: #22c55e; text-align: center; padding: 2rem; }
.content { flex: 1; padding: 1rem; font-family: 'SF Mono', Monaco, Consolas, monospace; font-size: 14px; overflow-x: auto; }
.line { display: flex; align-items: flex-start; padding: 2px 0; }
.line:hover { background: rgba(51, 65, 85, 0.5); }
.line-num { min-width: 50px; text-align: right; padding-right: 1rem; color: #64748b; user-select: none; border-right: 1px solid #334155; flex-shrink: 0; }
.line-content { padding-left: 1rem; white-space: pre-wrap; word-break: break-all; flex: 1; }
.annotation-badge { background: #3b82f6; color: white; padding: 2px 8px; border-radius: 9999px; font-size: 11px; margin-left: auto; flex-shrink: 0; }
.annotation-blocker { background: rgba(239, 68, 68, 0.1); }
.annotation-blocker .annotation-badge { background: #ef4444; }
.annotation-concern { background: rgba(245, 158, 11, 0.1); }
.annotation-concern .annotation-badge { background: #f59e0b; }
.annotation-question { background: rgba(59, 130, 246, 0.1); }
.annotation-suggestion { background: rgba(34, 197, 94, 0.1); }
.annotation-suggestion .annotation-badge { background: #22c55e; }
.inline-annotations { margin-left: 66px; margin-bottom: 0.5rem; }
.inline-ann { font-size: 12px; padding: 0.5rem 0.75rem; background: #1e293b; border-radius: 6px; margin-top: 4px; border-left: 3px solid; font-family: system-ui; }
.inline-ann.blocker { border-color: #ef4444; }
.inline-ann.concern { border-color: #f59e0b; }
.inline-ann.question { border-color: #3b82f6; }
.inline-ann.suggestion { border-color: #22c55e; }
.inline-ann .type { font-size: 10px; text-transform: uppercase; margin-right: 0.5rem; color: #94a3b8; }
.footer { padding: 1rem 2rem; text-align: center; color: #64748b; font-size: 12px; border-top: 1px solid #334155; background: #1e293b; }
.footer a { color: #60a5fa; text-decoration: none; }
</style>
</head>
<body>
<div class="header">
<h1>ano <span>${escapeHtml(fileName)}</span></h1>
<div class="stats">
<div class="stat open"><span class="dot"></span>${stats.open} open</div>
${stats.blockers > 0 ? `<div class="stat blocker"><span class="dot"></span>${stats.blockers} blocker${stats.blockers !== 1 ? 's' : ''}</div>` : ''}
<div class="stat approval"><span class="dot"></span>${stats.approvals} approval${stats.approvals !== 1 ? 's' : ''}</div>
</div>
</div>
<div class="container">
${summaryHtml}
<div class="content">
${lineHtml}
</div>
</div>
<div class="footer">
Generated by <a href="https://github.com/anthropics/ano">ano</a> - Collaborative annotations for Claude Code
</div>
</body>
</html>`;
}