repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/mockup/mockup.ts
Rawimport { 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("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
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();
`;