Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/mockup/mockup.ts

Raw
import { randomUUID } from "node:crypto";
import {
	createServer,
	type IncomingMessage,
	type Server,
	type ServerResponse,
} from "node:http";
import { openUrl } from "./src/pi-ext-browser-open.ts";

export type MockupOption = {
	id: string;
	label: string;
	description?: string;
	html: string;
};

export type MockupRequest = {
	title: string;
	question: string;
	options: MockupOption[];
	multi?: boolean;
};

export type MockupResult = {
	action: "accept" | "iterate";
	selected: string[];
	notes: string;
	annotations: Array<{ optionId: string; x: number; y: number; note: string }>;
	review: {
		submittedAt: string;
		userAgent: string;
		viewport: { width: number; height: number; devicePixelRatio: number };
	};
};

export type MockupServer = {
	id: string;
	url: string;
	token: string;
	result: Promise<MockupResult>;
	present(request: MockupRequest): Promise<MockupResult>;
	connected(): boolean;
	open(): Promise<boolean | undefined>;
	progress(message: string): void;
	settled(): boolean;
	close(reason: string): void;
};

const COOKIE = "pi_mockup_session";
const BODY_LIMIT = 1_000_000;
const DEFAULT_TIMEOUT_MS = 3_600_000;
const ID_RE = /^[a-zA-Z0-9._-]+$/;

export function MOCKUP_FULL_DOCUMENT_ERROR(path: string): string {
	return `${path} must be an HTML fragment, not a full document. Remove <!doctype>, <html>, <head>, <body>; keep only content for the card.`;
}

export function validateMockupRequest(value: unknown): MockupRequest {
	if (!isRecord(value)) throw new Error("mockup args must be an object");
	assertKnownKeys(value, new Set(["title", "question", "options", "multi"]));
	const title = requiredString(value.title, "title").trim();
	const question = requiredString(value.question, "question").trim();
	if (!title) throw new Error("title must not be empty");
	if (!question) throw new Error("question must not be empty");
	if (!Array.isArray(value.options))
		throw new Error("options must be an array");
	if (value.options.length < 1)
		throw new Error("options must contain at least one item");
	if (value.options.length > 4)
		throw new Error(
			"options must contain at most four items; split large comparisons into multiple mockup calls.",
		);
	const ids = new Set<string>();
	const options = value.options.map((raw, index) => {
		if (!isRecord(raw)) throw new Error(`options[${index}] must be an object`);
		const id = requiredString(raw.id, `options[${index}].id`).trim();
		if (!ID_RE.test(id))
			throw new Error(
				`options[${index}].id must contain only letters, numbers, dot, underscore, or dash`,
			);
		if (ids.has(id))
			throw new Error(`options[${index}].id duplicates a previous option id`);
		ids.add(id);
		const label = requiredString(raw.label, `options[${index}].label`).trim();
		if (!label) throw new Error(`options[${index}].label must not be empty`);
		const html = requiredString(raw.html, `options[${index}].html`);
		if (isFullDocument(html))
			throw new Error(MOCKUP_FULL_DOCUMENT_ERROR(`options[${index}].html`));
		const description = optionalString(
			raw.description,
			`options[${index}].description`,
		)?.trim();
		return { id, label, html, ...(description && { description }) };
	});
	return {
		title,
		question,
		options,
		multi: value.multi === true,
	};
}

export async function startMockupServer(
	request: MockupRequest,
	options: {
		openBrowser?: boolean;
		browserOpener?: (url: string) => Promise<boolean>;
		abortAgent?: () => boolean;
	} = {},
): Promise<MockupServer> {
	let current = validateMockupRequest(request);
	const browserKey = `${randomUUID()}-${randomUUID()}`;
	const id = randomUUID();
	const clients = new Set<ServerResponse>();
	let server!: Server;
	let closed = false;
	let timeout: NodeJS.Timeout | undefined;
	let resolveResult!: (result: MockupResult) => void;
	let rejectResult!: (error: Error) => void;
	let result = round();
	let waiting = true;
	let stopRequested = false;

	function round(): Promise<MockupResult> {
		return new Promise<MockupResult>((resolve, reject) => {
			resolveResult = resolve;
			rejectResult = reject;
		});
	}
	function clearRoundTimeout(): void {
		if (timeout) clearTimeout(timeout);
		timeout = undefined;
	}
	function armRoundTimeout(): void {
		clearRoundTimeout();
		timeout = setTimeout(
			() => terminate(new Error("mockup timed out waiting for browser Submit")),
			DEFAULT_TIMEOUT_MS,
		);
		timeout.unref();
	}
	function broadcast(type: string, data: unknown): void {
		const event = `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
		for (const client of clients) client.write(event);
	}
	function finish(value: MockupResult): void {
		if (closed || !waiting) return;
		waiting = false;
		clearRoundTimeout();
		resolveResult(value);
	}
	function terminate(error: Error): void {
		if (closed) return;
		closed = true;
		clearRoundTimeout();
		broadcast("progress", { message: error.message, terminal: true });
		for (const client of clients) client.end();
		clients.clear();
		server.close();
		if (waiting) rejectResult(error);
	}

	server = createServer(
		(req, res) =>
			void dispatch(req, res, {
				get request() {
					return current;
				},
				["token"]: browserKey,
				clients,
				finish,
				abortAgent: () => {
					stopRequested = options.abortAgent?.() === true;
					return stopRequested;
				},
			}),
	);
	await listen(server);
	const address = server.address();
	if (!address || typeof address === "string")
		throw new Error("Failed to bind mockup server");
	const url = `http://127.0.0.1:${address.port}/?k=${browserKey}`;
	armRoundTimeout();
	return {
		id,
		url,
		["token"]: browserKey,
		result,
		present(request: MockupRequest) {
			if (closed) throw new Error("mockup server is closed");
			if (waiting) throw new Error("mockup already awaits browser feedback");
			current = validateMockupRequest(request);
			result = round();
			waiting = true;
			stopRequested = false;
			armRoundTimeout();
			broadcast("revision", {});
			return result;
		},
		connected() {
			return clients.size > 0;
		},
		open() {
			if (options.openBrowser === false) return Promise.resolve(undefined);
			return (options.browserOpener ?? openBrowser)(url);
		},
		progress(message: string) {
			if (!closed) broadcast("progress", { message, terminal: false });
		},
		settled() {
			if (closed || waiting) return false;
			terminate(
				new Error(
					stopRequested
						? "Agent stopped."
						: "Agent finished without another mockup.",
				),
			);
			return true;
		},
		close(reason: string) {
			terminate(new Error(`mockup closed: ${reason}`));
		},
	};
}

export function renderMockupPage(
	request: MockupRequest,
	browserKey: string,
): string {
	const options = request.options
		.map((option) => renderOption(option))
		.join("\n");
	return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${esc(request.title)}</title>
<style>${FRAME_CSS}</style>
</head>
<body data-multi="${request.multi ? "true" : "false"}">
<header class="topbar">
	<div>
		<div class="eyebrow">Pi mockup ${request.multi ? "· multi-select" : ""}</div>
		<h1>${esc(request.title)}</h1>
		<p>${esc(request.question)}</p>
	</div>
</header>
<main class="stage" id="options">${options}</main>
<section class="drawer" aria-label="Feedback drawer">
	<div class="drawer-head">
		<div id="selection-summary" class="selection-summary">No option selected</div>
		<div class="drawer-actions">
			<button id="iterate" type="button" class="secondary">Iterate</button>
			<button id="accept" type="button">Accept</button>
		</div>
	</div>
	<div class="feedback-grid">
		<section class="feedback-pane global-pane" aria-labelledby="global-title">
			<h2 id="global-title">Global notes</h2>
			<textarea id="global-notes" placeholder="what you like · what sucks · what to change"></textarea>
		</section>
		<section class="feedback-pane annotations-pane" aria-labelledby="annotations-title">
			<h2 id="annotations-title">Annotations <span id="annotation-count">0</span></h2>
			<div id="annotations-panel"></div>
		</section>
	</div>
</section>
<section id="progress" class="progress" hidden aria-live="polite">
	<div class="progress-card">
		<div class="spinner" aria-hidden="true"></div>
		<div>
			<h2 id="progress-title">Feedback received</h2>
			<p id="progress-time">0s elapsed</p>
		</div>
		<button id="stop-agent" type="button" class="secondary">Stop agent</button>
	</div>
</section>
<script>window.__MOCKUP_TOKEN__ = ${JSON.stringify(browserKey)};</script>
<script>${CLIENT_JS}</script>
</body>
</html>`;
}

function renderOption(option: MockupOption): string {
	return `<article class="option-card" data-option-id="${esc(option.id)}">
<header class="option-head">
	<label><input type="checkbox" data-select-option="${esc(option.id)}"> <strong>${esc(option.label)}</strong></label>
	<button type="button" class="pin-toggle" data-pin-option="${esc(option.id)}" hidden>Pin note</button>
</header>
${option.description ? `<p class="option-description">${esc(option.description)}</p>` : ""}
<div class="fragment" data-fragment="${esc(option.id)}"><div class="fragment-content" data-fragment-content></div><script type="application/json" data-fragment-html>${jsonScript(option.html)}</script></div>
</article>`;
}

type Ctx = {
	readonly request: MockupRequest;
	token: string;
	clients: Set<ServerResponse>;
	finish(result: MockupResult): void;
	abortAgent(): boolean;
};

async function dispatch(req: IncomingMessage, res: ServerResponse, ctx: Ctx) {
	try {
		const url = new URL(req.url ?? "/", "http://127.0.0.1");
		if (req.method === "GET" && url.pathname === "/") {
			if (url.searchParams.get("k") !== ctx.token)
				return json(res, 401, { error: "unauthorized" });
			res.setHeader(
				"Set-Cookie",
				`${COOKIE}=${ctx.token}; SameSite=Strict; Path=/`,
			);
			return html(res, 200, renderMockupPage(ctx.request, ctx.token));
		}
		if (!authorized(req, ctx.token))
			return json(res, 401, { error: "unauthorized" });
		if (req.method === "GET" && url.pathname === "/api/events") {
			res.writeHead(200, {
				"Content-Type": "text/event-stream; charset=utf-8",
				"Cache-Control": "no-store",
				Connection: "keep-alive",
			});
			res.write(": connected\n\n");
			ctx.clients.add(res);
			res.once("close", () => ctx.clients.delete(res));
			return;
		}
		if (req.method === "POST" && url.pathname === "/api/submit") {
			const result = parseResult(JSON.parse(await readBody(req)), ctx.request);
			json(res, 200, { ok: true });
			ctx.finish(result);
			return;
		}
		if (req.method === "POST" && url.pathname === "/api/abort") {
			if (!ctx.abortAgent())
				return json(res, 409, { error: "Pi has no active agent work" });
			json(res, 200, { ok: true });
			return;
		}
		json(res, 404, { error: "not found" });
	} catch (error) {
		json(res, 400, {
			error: error instanceof Error ? error.message : String(error),
		});
	}
}

function parseResult(value: unknown, request: MockupRequest): MockupResult {
	if (!isRecord(value)) throw new Error("submit body must be an object");
	const action = enumString(
		value.action,
		new Set<MockupResult["action"]>(["accept", "iterate"]),
		"action",
	);
	if (!Array.isArray(value.selected))
		throw new Error("selected must be an array");
	const optionIds = new Set(request.options.map((o) => o.id));
	const selected = value.selected.map((v, i) => {
		const id = requiredString(v, `selected[${i}]`);
		if (!optionIds.has(id))
			throw new Error(`selected[${i}] must match an option id`);
		return id;
	});
	if (!request.multi && selected.length > 1)
		throw new Error(
			"selected must contain at most one item unless multi is true",
		);
	const notes = optionalString(value.notes, "notes")?.trim() ?? "";
	if (!Array.isArray(value.annotations))
		throw new Error("annotations must be an array");
	const annotations = value.annotations.map((raw, index) =>
		parseAnnotation(raw, index, optionIds),
	);
	const client = isRecord(value.client) ? value.client : {};
	const viewport = isRecord(client.viewport) ? client.viewport : {};
	const review = {
		submittedAt: new Date().toISOString(),
		userAgent: optionalString(client.userAgent, "client.userAgent") ?? "",
		viewport: {
			width: nonNegativeNumber(viewport.width, "client.viewport.width"),
			height: nonNegativeNumber(viewport.height, "client.viewport.height"),
			devicePixelRatio: nonNegativeNumber(
				viewport.devicePixelRatio,
				"client.viewport.devicePixelRatio",
			),
		},
	};
	return { action, selected, notes, annotations, review };
}

function parseAnnotation(
	value: unknown,
	index: number,
	optionIds: Set<string>,
): MockupResult["annotations"][number] {
	if (!isRecord(value))
		throw new Error(`annotations[${index}] must be an object`);
	const optionId = requiredString(
		value.optionId,
		`annotations[${index}].optionId`,
	);
	if (!optionIds.has(optionId))
		throw new Error(`annotations[${index}].optionId must match an option id`);
	const x = unitNumber(value.x, `annotations[${index}].x`);
	const y = unitNumber(value.y, `annotations[${index}].y`);
	const note = requiredString(value.note, `annotations[${index}].note`).trim();
	if (!note) throw new Error(`annotations[${index}].note must not be empty`);
	return { optionId, x, y, note };
}

function isFullDocument(html: string): boolean {
	const stripped = html.replaceAll(
		/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi,
		"",
	);
	return /<!doctype\b|<\s*\/?\s*(html|head|body)\b/i.test(stripped);
}

function authorized(req: IncomingMessage, browserKey: string): boolean {
	return (
		req.headers.authorization === `Bearer ${browserKey}` ||
		(req.headers.cookie ?? "")
			.split(/;\s*/)
			.some((cookie) => cookie === `${COOKIE}=${browserKey}`)
	);
}

async function readBody(req: IncomingMessage): Promise<string> {
	req.setEncoding("utf8");
	let body = "";
	for await (const chunk of req) {
		body += String(chunk);
		if (Buffer.byteLength(body, "utf8") > BODY_LIMIT)
			throw new Error("request body too large");
	}
	return body;
}

function html(res: ServerResponse, status: number, body: string): void {
	res.statusCode = status;
	res.setHeader("Content-Type", "text/html; charset=utf-8");
	res.setHeader("Cache-Control", "no-store");
	res.end(body);
}

function json(res: ServerResponse, status: number, body: unknown): void {
	res.statusCode = status;
	res.setHeader("Content-Type", "application/json; charset=utf-8");
	res.end(JSON.stringify(body));
}

function listen(server: Server): Promise<void> {
	return new Promise((resolve, reject) => {
		server.once("error", reject);
		server.listen(0, "127.0.0.1", () => {
			server.off("error", reject);
			resolve();
		});
	});
}

async function openBrowser(url: string): Promise<boolean> {
	if (process.env.NODE_ENV === "test" || process.env.VITEST) return false;
	return (await openUrl(url, { timeoutMs: 1500 })) !== undefined;
}

function requiredString(value: unknown, name: string): string {
	if (typeof value !== "string") throw new Error(`${name} must be a string`);
	return value;
}

function optionalString(value: unknown, name: string): string | undefined {
	if (value === undefined) return undefined;
	return requiredString(value, name);
}

function unitNumber(value: unknown, name: string): number {
	const number = nonNegativeNumber(value, name);
	if (number > 1) throw new Error(`${name} must be a number between 0 and 1`);
	return number;
}

function nonNegativeNumber(value: unknown, name: string): number {
	if (typeof value !== "number" || !Number.isFinite(value) || value < 0)
		throw new Error(`${name} must be a non-negative number`);
	return value;
}

function enumString<T extends string>(
	value: unknown,
	choices: ReadonlySet<T>,
	name: string,
): T {
	if (typeof value === "string" && choices.has(value as T)) return value as T;
	throw new Error(`invalid ${name}`);
}

function isRecord(value: unknown): value is Record<string, unknown> {
	return !!value && typeof value === "object" && !Array.isArray(value);
}

function assertKnownKeys(
	value: Record<string, unknown>,
	allowed: ReadonlySet<string>,
): void {
	for (const key of Object.keys(value)) {
		if (!allowed.has(key))
			throw new Error(`${key} is not a supported mockup parameter`);
	}
}

function esc(value: string): string {
	return value
		.replaceAll("&", "&amp;")
		.replaceAll("<", "&lt;")
		.replaceAll(">", "&gt;")
		.replaceAll('"', "&quot;")
		.replaceAll("'", "&#39;");
}

function jsonScript(value: string): string {
	return JSON.stringify(value).replaceAll("<", "\\u003c");
}

const FRAME_CSS = String.raw`
:root { color-scheme: dark light; --bg:#101010; --panel:#181818; --text:#ededed; --muted:#9a9a9a; --line:#ededed; --soft:#2a2a2a; --accent:#d86dd8; --bad:#ff6b6b; font:14px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
@media (prefers-color-scheme: light) { :root { --bg:#f4f0f4; --panel:#fff; --text:#151515; --muted:#666; --line:#151515; --soft:#e4dfe4; --accent:#a900a9; } }
* { box-sizing:border-box; }
body { margin:0; min-height:100vh; display:grid; grid-template-rows:auto minmax(0, 1fr) auto; background:var(--bg); color:var(--text); }
button,input,textarea { font:inherit; }
button { border:2px solid var(--line); background:var(--text); color:var(--bg); padding:.55rem .8rem; font-weight:900; cursor:pointer; }
button.secondary { background:var(--panel); color:var(--text); }
button:focus-visible,input:focus-visible,textarea:focus-visible { outline:3px solid var(--accent); outline-offset:2px; }
.topbar { border-bottom:2px solid var(--line); background:var(--panel); padding:1rem; }
h1 { margin:0; font-size:1.15rem; text-transform:uppercase; letter-spacing:.04em; }
p { margin:.25rem 0 0; color:var(--muted); }
.eyebrow { color:var(--accent); font-weight:900; text-transform:uppercase; font-size:.72rem; }
.drawer-actions { display:flex; flex-wrap:wrap; gap:.6rem; justify-content:flex-end; }
.stage { padding:1rem; display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,280px),1fr)); gap:1rem; overflow:auto; }
.option-card { border:2px solid var(--line); background:var(--panel); display:grid; grid-template-rows:auto auto 1fr; min-height:280px; position:relative; }
.option-card.selected { border-color:var(--accent); box-shadow:6px 6px 0 color-mix(in srgb, var(--accent), transparent 55%); }
.option-head { border-bottom:2px solid var(--line); padding:.7rem; display:flex; justify-content:space-between; gap:.7rem; align-items:center; }
.option-head label { display:flex; align-items:center; gap:.55rem; cursor:pointer; }
.option-head input { width:1.1rem; height:1.1rem; accent-color:var(--accent); }
.pin-toggle[aria-pressed="true"] { background:var(--accent); color:white; border-color:var(--accent); }
.option-card.pin-armed .fragment { cursor:crosshair; }
.option-description { padding:0 .7rem .7rem; margin:.55rem 0 0; }
.fragment { min-height:180px; padding:1rem; position:relative; overflow:auto; }
.fragment-content { display:block; min-height:inherit; }
.pin { position:absolute; width:24px; height:24px; border:2px solid var(--accent); background:var(--bg); color:var(--accent); border-radius:50%; display:grid; place-items:center; font-weight:900; transform:translate(-50%,-50%); z-index:5; }
.pin-bubble { position:absolute; z-index:8; width:min(320px, calc(100% - 1rem)); border:2px solid var(--accent); background:var(--panel); padding:.6rem; display:grid; gap:.45rem; box-shadow:5px 5px 0 color-mix(in srgb, var(--accent), transparent 45%); }
.pin-bubble textarea { width:100%; min-height:4rem; border:1px solid var(--line); background:var(--bg); color:var(--text); padding:.4rem; }
.pin-bubble .bubble-actions { display:flex; gap:.45rem; justify-content:flex-end; }
.drawer { border-top:2px solid var(--line); background:var(--panel); padding:.75rem; display:grid; grid-template-rows:auto minmax(0, 1fr); gap:.6rem; min-height:190px; max-height:42vh; }
.drawer-head { grid-row:1; display:flex; align-items:center; justify-content:space-between; gap:1rem; position:relative; z-index:2; }
.selection-summary { color:var(--muted); }
.feedback-grid { grid-row:2; display:grid; grid-template-columns:minmax(220px,1fr) minmax(220px,1fr); gap:.75rem; min-height:0; }
.feedback-pane { border:1px solid var(--soft); background:var(--bg); padding:.6rem; display:grid; grid-template-rows:auto minmax(0,1fr); gap:.45rem; min-width:0; min-height:0; overflow:hidden; }
.feedback-pane h2 { margin:0; color:var(--accent); font-size:.8rem; text-transform:uppercase; letter-spacing:.04em; }
#annotations-panel { min-height:0; overflow:auto; }
#global-notes { width:100%; height:100%; min-height:5rem; resize:none; border:2px solid var(--line); background:var(--bg); color:var(--text); padding:.7rem; }
.annotation-row { display:grid; grid-template-columns:max-content 1fr max-content; gap:.6rem; align-items:center; border:1px solid var(--soft); padding:.45rem; margin-bottom:.45rem; }
.annotation-row textarea { width:100%; min-height:2.5rem; border:1px solid var(--line); background:var(--bg); color:var(--text); padding:.35rem; }
.empty { color:var(--muted); padding:.7rem; border:1px dashed var(--soft); }
.progress { position:fixed; inset:0; z-index:20; background:color-mix(in srgb, var(--bg), transparent 12%); display:grid; place-items:center; padding:1rem; }
.progress[hidden] { display:none; }
.progress-card { width:min(560px,100%); border:2px solid var(--line); background:var(--panel); padding:1rem; display:grid; grid-template-columns:auto 1fr auto; gap:1rem; align-items:center; box-shadow:8px 8px 0 color-mix(in srgb, var(--accent), transparent 45%); }
.progress-card h2 { margin:0; font-size:1rem; }
.spinner { width:1.4rem; height:1.4rem; border:3px solid var(--soft); border-top-color:var(--accent); border-radius:50%; animation:spin .8s linear infinite; }
.progress.terminal .spinner { animation:none; border-color:var(--accent); }
@keyframes spin { to { transform:rotate(360deg); } }
@media (max-width:720px) {
  body { grid-template-rows:auto minmax(260px,1fr) auto; }
  .topbar { padding:.75rem; }
  .stage { padding:.75rem; }
  .drawer { max-height:none; }
  .drawer-head { display:grid; align-items:start; }
  .drawer-actions { justify-content:start; }
  .feedback-grid { grid-template-columns:1fr; }
  .global-pane { order:1; }
  .annotations-pane { order:2; }
}
`;

const CLIENT_JS = String.raw`
const multi = document.body.dataset.multi === "true";
const selected = new Set();
const annotations = [];
let armed = null;
const $ = (s, r = document) => r.querySelector(s);
const $$ = (s, r = document) => [...r.querySelectorAll(s)];
function reviveFragments() {
  $$('[data-fragment]').forEach(frag => {
    const content = $('[data-fragment-content]', frag);
    const data = $('[data-fragment-html]', frag);
    if (!content || !data) return;
    const root = content.attachShadow({ mode:'open' });
    root.innerHTML = JSON.parse(data.textContent || '""');
    root.querySelectorAll('script').forEach(old => {
      const script = document.createElement('script');
      [...old.attributes].forEach(attr => script.setAttribute(attr.name, attr.value));
      script.textContent = old.textContent;
      // Shadow-tree scripts have no currentScript; expose their root on the executing script.
      Object.defineProperty(script, 'mockupRoot', { value:root });
      const pending = Boolean(script.src) || script.type === 'module';
      if (pending) {
        script.addEventListener('load', () => script.remove(), { once:true });
        script.addEventListener('error', () => script.remove(), { once:true });
      }
      // Retain inert originals so fragment-local data scripts remain readable.
      document.head.append(script);
      if (!pending) script.remove();
    });
    data.remove();
  });
}
function sync() {
  closeBubble();
  $$(".option-card").forEach(card => {
    const id = card.dataset.optionId;
    const is = selected.has(id);
    card.classList.toggle("selected", is);
    card.classList.toggle("pin-armed", armed === id);
    $("input", card).checked = is;
    const pin = $(".pin-toggle", card);
    pin.hidden = !is;
    pin.setAttribute("aria-pressed", String(armed === id));
    pin.textContent = armed === id ? "Pinning…" : "Pin note";
  });
  $("#selection-summary").textContent = selected.size ? "Selected: " + [...selected].join(", ") : "No option selected";
  $("#annotation-count").textContent = String(annotations.length);
  renderAnnotations();
}
function select(id) {
  if (!multi) selected.clear();
  if (multi && selected.has(id)) selected.delete(id); else selected.add(id);
  if (!selected.has(armed)) armed = null;
  sync();
}
function addPin(id, event) {
  const frag = event.currentTarget;
  const rect = frag.getBoundingClientRect();
  const pin = { optionId:id, x:(event.clientX - rect.left + frag.scrollLeft) / frag.scrollWidth, y:(event.clientY - rect.top + frag.scrollTop) / frag.scrollHeight, note:"" };
  annotations.push(pin);
  armed = null;
  sync();
  showBubble(annotations.indexOf(pin));
}
function closeBubble() {
  const bubble = $(".pin-bubble");
  if (bubble) bubble.remove();
}
function showBubble(index) {
  closeBubble();
  const a = annotations[index];
  if (!a) return;
  const frag = $("[data-fragment=\"" + CSS.escape(a.optionId) + "\"]");
  if (!frag) return;
  const bubble = document.createElement("div");
  bubble.className = "pin-bubble";
  if (a.x > 0.5) bubble.style.right = Math.min(82, Math.max(2, (1 - a.x) * 100)) + "%";
  else bubble.style.left = Math.min(82, Math.max(2, a.x * 100)) + "%";
  bubble.style.top = Math.min(82, Math.max(2, a.y * 100)) + "%";
  bubble.innerHTML = '<textarea aria-label="Pin note" placeholder="note for this spot"></textarea><div class="bubble-actions"><button type="button" class="secondary" data-close>Close</button><button type="button" data-delete>Delete pin</button></div>';
  const text = $("textarea", bubble);
  text.value = a.note;
  text.addEventListener("input", () => { a.note = text.value; renderAnnotations(false); });
  $("[data-close]", bubble).addEventListener("click", closeBubble);
  $("[data-delete]", bubble).addEventListener("click", () => { annotations.splice(index, 1); sync(); });
  frag.append(bubble);
  text.focus();
}
function renderPins() {
  $$(".pin").forEach(p => p.remove());
  annotations.forEach((a, i) => {
    const frag = $("[data-fragment=\"" + CSS.escape(a.optionId) + "\"]");
    if (!frag) return;
    const button = document.createElement("button");
    button.type = "button";
    button.className = "pin";
    button.textContent = String(i + 1);
    button.style.left = "clamp(14px, " + (a.x * 100) + "%, calc(100% - 14px))";
    button.style.top = "clamp(14px, " + (a.y * 100) + "%, calc(100% - 14px))";
    button.addEventListener("click", e => {
      e.stopPropagation();
      showBubble(i);
    });
    frag.append(button);
  });
}
function renderAnnotations(redrawPins = true) {
  if (redrawPins) renderPins();
  const panel = $("#annotations-panel");
  panel.replaceChildren();
  if (!annotations.length) {
    const empty = document.createElement("div");
    empty.className = "empty";
    empty.textContent = "No annotations. Select an option, press Pin note, then click inside its mockup.";
    panel.append(empty);
    return;
  }
  annotations.forEach((a, i) => {
    const row = document.createElement("div");
    row.className = "annotation-row";
    row.innerHTML = "<strong>" + (i + 1) + " · " + a.optionId + "</strong><textarea></textarea><button type=\"button\" class=\"secondary\">Delete</button>";
    const text = $("textarea", row);
    text.value = a.note;
    text.addEventListener("input", () => { a.note = text.value; });
    $("button", row).addEventListener("click", () => { annotations.splice(i, 1); sync(); });
    panel.append(row);
  });
}
$$(".option-card").forEach(card => {
  const id = card.dataset.optionId;
  $("input", card).addEventListener("change", () => select(id));
  $(".option-head", card).addEventListener("click", e => { if (!e.target.closest("label,button")) select(id); });
  $(".pin-toggle", card).addEventListener("click", e => { e.stopPropagation(); armed = armed === id ? null : id; sync(); });
  $(".fragment", card).addEventListener("click", e => { if (armed === id) addPin(id, e); else select(id); });
});
reviveFragments();
let progressStarted = 0;
let progressTimer = null;
function showProgress(message, terminal = false) {
  const progress = $("#progress");
  progress.hidden = false;
  progress.classList.toggle("terminal", terminal);
  $("#progress-title").textContent = message;
  $("#stop-agent").hidden = terminal;
  if (!progressStarted) progressStarted = Date.now();
  const updateTime = () => { $("#progress-time").textContent = Math.floor((Date.now() - progressStarted) / 1000) + "s elapsed"; };
  updateTime();
  if (progressTimer) clearInterval(progressTimer);
  if (!terminal) progressTimer = setInterval(updateTime, 1000);
}
const events = new EventSource("/api/events");
events.addEventListener("revision", () => location.reload());
events.addEventListener("progress", event => {
  const state = JSON.parse(event.data);
  showProgress(state.message, state.terminal === true);
});
async function submit(action) {
  if (action === "iterate") showProgress("Feedback received. Pi is working.");
  const body = {
    action,
    selected:[...selected],
    notes:$("#global-notes").value.trim(),
    annotations:annotations.filter(a => a.note.trim()),
    client:{
      userAgent:navigator.userAgent,
      viewport:{ width:window.innerWidth, height:window.innerHeight, devicePixelRatio:window.devicePixelRatio },
    },
  };
  const res = await fetch("/api/submit", { method:"POST", headers:{ Authorization:"Bearer " + window.__MOCKUP_TOKEN__, "Content-Type":"application/json" }, body:JSON.stringify(body) });
  if (!res.ok) {
    $("#progress").hidden = true;
    alert(await res.text());
    return;
  }
  if (action === "accept") {
    showProgress("Accepted. You can close this tab.", true);
    setTimeout(() => window.close(), 100);
  }
}
$("#stop-agent").addEventListener("click", async () => {
  showProgress("Stopping agent…");
  const res = await fetch("/api/abort", { method:"POST", headers:{ Authorization:"Bearer " + window.__MOCKUP_TOKEN__ } });
  if (!res.ok) showProgress("Pi has no active agent work.", true);
});
$("#accept").addEventListener("click", () => submit("accept"));
$("#iterate").addEventListener("click", () => submit("iterate"));
sync();
`;