/**
* HTML shell for the browser plan review (pure string building).
*
* The page is intentionally inert: it carries no plan text in its markup and
* no inline script. Everything the review needs travels in one JSON data
* island (`` cannot
* close the tag.
*
* It shares the Bytefly shell, components, and syntax colours with
* `file-browse` through `lib/web`; the server sets the CSP header, and the
* page loads only same-origin scripts.
*/
import {
brandLockup,
docHead,
themeToggle,
WEB_BASE_CSS,
WEB_COMPONENTS_CSS,
WEB_LAYOUT_CSS,
WEB_THEME_CSS,
} from "../../lib/web/index.ts";
import type { PlanBlock } from "./blocks.ts";
import type { PlanAnnotation } from "./feedback.ts";
/** A heading the outline can jump to. */
export interface ReviewOutlineItem {
/** DOM id the client gives the heading. */
id: string;
/** 0-based source line. */
line: number;
/** Heading level, 1-6. */
level: number;
/** Heading text. */
text: string;
/** True when this section changed since the previous review of the plan. */
changed?: boolean;
}
/** Everything the browser needs to render one plan review. */
export interface ReviewPageData {
/** Plan title: the document's H1, or the file-name slug when it has none. */
title: string;
/** Source-line blocks of the feedback-stripped plan. */
blocks: PlanBlock[];
/** Feedback already stored in the file. */
annotations: PlanAnnotation[];
/** Section outline, in document order. */
outline: ReviewOutlineItem[];
/**
* The plan's step progress, from the same `extractPlanSteps` the approval and
* `set_plan_steps` use: the `Steps` section when there is one, so the page
* agrees with what approval seeds rather than counting every checkbox.
*/
steps: { done: number; total: number };
/** Highlighted HTML per line, keyed by the code block's 0-based start line. */
code: Record;
/** Whether the review may approve the plan (only the `exit_plan_mode` flow). */
canApprove: boolean;
/** Per-session secret the client echoes on every request. */
token: string;
}
/** Page styles: the shared base + theme + shell + components, then the review. */
export const REVIEW_CSS = `${WEB_BASE_CSS}
${WEB_THEME_CSS}
${WEB_LAYOUT_CSS}
${WEB_COMPONENTS_CSS}
.bf-review{--review-measure:1012px;--review-rail:26px;--review-gutter:34px;flex:1 1 auto;padding:var(--space-5) var(--space-6) 120px}
/* The document is one content column at GitHub's Markdown body width, with the
comment rail inside it, so a plan reads the way the same file does on GitHub. */
/* Sticky header: identity, the connection/save chip, the actions, and a
hairline reading-progress bar pinned to the header's bottom edge. */
.review-bar{align-items:center;gap:var(--space-2) var(--space-4)}
.review-bar .bf-brand{flex:0 0 auto}
.review-heading{display:flex;align-items:center;flex:1 1 160px;min-width:0;overflow:hidden;border-left:1px solid var(--border);padding-left:var(--space-4)}
.review-title{display:block;flex:1 1 auto;min-width:0;font-size:var(--fs-lg);font-weight:var(--fw-semibold);line-height:var(--lh-tight);color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.review-state{flex:0 0 auto;white-space:nowrap}
.review-status{flex:0 0 auto;max-width:42ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.review-status:empty{display:none}
.review-controls{display:inline-flex;align-items:center;gap:var(--space-2);flex:0 0 auto}
.review-progress{position:absolute;left:0;right:0;bottom:-1px;height:2px;overflow:hidden}
.review-progress__fill{display:block;height:100%;width:0;background:var(--accent);transition:width var(--motion-fast) linear}
/* Review panel: the plan's review facts, the section outline, then the keys. */
.bf-app__body--single{grid-template-columns:minmax(0,1fr)}
.bf-app__sidebar .bf-panel{flex:1 1 auto;min-height:0;min-width:0}
.review-panel{display:flex;flex-direction:column;gap:var(--space-3);padding:var(--space-4) var(--space-4) var(--space-2)}
.review-facts{display:flex;flex-direction:column;gap:var(--space-2);padding-bottom:var(--space-3);border-bottom:1px solid var(--border)}
.review-fact{display:flex;align-items:center;justify-content:space-between;gap:var(--space-3);font-size:var(--fs-sm);color:var(--muted)}
.review-fact .bf-progress{color:var(--text)}
.review-fact .bf-progress__track{width:96px}
.review-fact__value{color:var(--text);font-family:var(--font-mono);font-variant-numeric:tabular-nums}
.review-nav{min-width:0}
.review-nav__title{margin:0;font:var(--fw-semibold) var(--fs-xs) var(--font-mono);color:var(--muted);text-transform:uppercase;letter-spacing:.06em}
.review-nav ul{list-style:none;margin:0;padding:0}
.review-nav a{display:flex;align-items:baseline;gap:var(--space-2);padding:4px 8px;border-radius:var(--radius-sm);color:var(--muted);font-size:var(--fs-sm);line-height:var(--lh-snug)}
.review-nav a:hover{background:var(--accent-soft);color:var(--accent);text-decoration:none}
.review-nav a[aria-current="true"]{background:var(--accent-soft);color:var(--accent);font-weight:var(--fw-semibold)}
.review-nav__link{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.review-nav__count{flex:0 0 auto;min-width:18px;padding:0 5px;border-radius:var(--radius-pill);background:var(--accent-soft);color:var(--accent);font:var(--fs-xs)/18px var(--font-mono);text-align:center}
.review-changed{flex:0 0 auto;width:6px;height:6px;border-radius:50%;background:var(--accent)}
.review-nav .lvl-2 a{padding-left:18px}
.review-nav .lvl-3 a{padding-left:30px}
.review-nav .lvl-4 a,.review-nav .lvl-5 a,.review-nav .lvl-6 a{padding-left:42px}
.review-hints{margin:auto 0 0;padding-top:var(--space-3);color:var(--muted);font-size:var(--fs-xs);line-height:var(--lh-relaxed)}
.review-hints .review-kbd{margin:0 1px}
/* The document: one measure, centred, with a comment rail in its left margin. */
.bf-review>.bf-block{position:relative;width:100%;max-width:var(--review-measure);margin:0 auto;padding:3px 8px 3px var(--review-gutter);border-radius:var(--radius-md);overflow-wrap:break-word}
.bf-block[id]{scroll-margin-top:88px}
.bf-block__rail{position:absolute;left:0;top:6px;display:flex;flex-direction:column;align-items:center;gap:6px;width:var(--review-rail)}
.bf-block__add{appearance:none;width:22px;height:22px;min-width:22px;padding:0;border:1px solid var(--border);border-radius:var(--radius-pill);background:var(--surface);color:var(--muted);font:var(--fs-md)/1 var(--font-ui);cursor:pointer;opacity:0;transition:opacity var(--motion-fast) var(--motion-ease)}
.bf-block__add:hover{border-color:var(--accent);color:var(--accent)}
.bf-block:hover .bf-block__add,.bf-block:focus-within .bf-block__add,.bf-block--active .bf-block__add,.bf-block--commented .bf-block__add{opacity:1}
.bf-block__count{display:inline-flex;align-items:center;justify-content:center;min-width:20px;height:20px;padding:0 5px;border-radius:var(--radius-pill);background:var(--accent-soft);color:var(--accent);font:var(--fw-semibold) var(--fs-xs)/1 var(--font-mono)}
.bf-block:hover,.bf-block--active{background:var(--surface)}
.bf-block--active{box-shadow:inset 0 0 0 1px var(--border)}
.bf-block h1,.bf-block h2,.bf-block h3,.bf-block h4,.bf-block h5,.bf-block h6{margin:24px 0 8px;line-height:var(--lh-tight)}
.bf-block h1{font-size:var(--fs-xl);padding-bottom:6px;border-bottom:1px solid var(--border)}
.bf-block h2{font-size:var(--fs-lg);padding-bottom:4px;border-bottom:1px solid var(--border)}
.bf-block h3{font-size:17px}
.bf-block h4,.bf-block h5,.bf-block h6{font-size:var(--fs-base)}
.bf-block p{margin:11px 0;line-height:var(--lh-relaxed)}
.bf-block blockquote{margin:11px 0;padding:2px 14px;border-left:3px solid var(--border);color:var(--muted)}
.bf-block hr{border:0;border-top:1px solid var(--border);margin:22px 0}
.bf-block__li{display:flex;gap:8px;margin:4px 0;line-height:var(--lh-relaxed)}
.bf-block__marker{flex:0 0 auto;min-width:1.4em;color:var(--muted)}
.bf-block__marker--done{color:var(--success)}
.bf-block code{font-family:var(--font-mono);font-size:.9em;background:var(--surface);border-radius:var(--radius-sm);padding:.1em .35em}
.bf-block pre.bf-code{margin:11px 0;padding:var(--space-3) 0;overflow:auto;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-md);font:var(--fs-code)/1.55 var(--font-mono);tab-size:4;flex:none}
.bf-block pre.bf-code code{background:none;border:0;padding:0;font-size:inherit}
.bf-block pre.bf-code .bf-code__line{display:block;padding:0 14px;white-space:pre;min-height:1.55em}
.review-table-wrap{margin:11px 0;overflow-x:auto}
.review-table{width:100%;border-collapse:collapse;font-size:var(--fs-sm)}
.review-table th,.review-table td{padding:6px 10px;border:1px solid var(--border);text-align:left;vertical-align:top}
.review-table th{background:var(--surface);color:var(--muted);font-weight:var(--fw-medium)}
/* Comment threads sit in the document flow, aligned with the block's text. */
.bf-comments{display:flex;flex-direction:column;gap:8px;width:100%;max-width:var(--review-measure);margin:0 auto 14px;padding-left:var(--review-gutter)}
.bf-comments:empty{display:none}
.bf-comments--orphans{margin-top:26px;padding:var(--space-3) 0 0 var(--review-gutter);border-top:1px dashed var(--border)}
.bf-comments--orphans>p{margin:0 0 8px;color:var(--muted);font-size:var(--fs-sm)}
.bf-comment{padding:var(--space-3) var(--space-4)}
.bf-comment__head{display:flex;align-items:center;gap:var(--space-2);margin-bottom:6px}
.bf-comment__label{font:var(--fw-semibold) var(--fs-xs) var(--font-mono);color:var(--warn);text-transform:uppercase;letter-spacing:.06em}
.bf-comment__spacer{flex:1 1 auto}
.bf-comment__actions{display:flex;gap:4px}
.bf-comment__actions .bf-btn{padding:1px 8px;font-size:var(--fs-xs)}
.bf-comment__quote{margin-bottom:6px;padding-left:8px;border-left:2px solid var(--border);color:var(--muted);font-size:var(--fs-sm);overflow-wrap:anywhere}
.bf-comment__body{white-space:pre-wrap;overflow-wrap:anywhere;line-height:var(--lh-snug)}
.bf-composer{margin:0}
.bf-block--composing .bf-block__add{opacity:0}
/* The keyboard sheet, reusing the shared dialog shell. */
.bf-dialog__head{display:flex;align-items:center;gap:var(--space-3);padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--border)}
.bf-dialog__head h2{margin:0;font-size:var(--fs-lg)}
.review-keys{display:grid;grid-template-columns:auto 1fr;gap:8px var(--space-4);margin:0;padding:var(--space-4)}
.review-keys dt{white-space:nowrap}
.review-keys dd{margin:0;color:var(--muted);font-size:var(--fs-sm)}
.review-kbd{display:inline-block;min-width:20px;padding:1px 6px;border:1px solid var(--border);border-bottom-width:2px;border-radius:var(--radius-sm);background:var(--surface);color:var(--text);font:var(--fs-xs) var(--font-mono);text-align:center}
@media (max-width:1180px){
.bf-app__body{grid-template-columns:1fr;align-content:start}
.bf-app__sidebar{position:static;max-height:none;border-right:0;border-bottom:1px solid var(--border)}
.review-panel{padding:var(--space-2) var(--space-4) var(--space-3)}
.review-facts{flex-direction:row;flex-wrap:wrap;gap:var(--space-3) var(--space-5);border-bottom:0;padding-bottom:0}
.review-nav__title{padding:0}
.review-nav ul{display:flex;flex-wrap:wrap;gap:2px}
.review-nav .lvl-2 a,.review-nav .lvl-3 a,.review-nav .lvl-4 a,.review-nav .lvl-5 a,.review-nav .lvl-6 a{padding-left:8px}
.review-hints{display:none}
}
@media (max-width:860px){
.bf-review{padding:var(--space-4) 18px 80px}
.review-nav__title{display:none}
}
@media (max-width:820px){
.review-bar{align-items:flex-start}
.review-heading{flex:1 1 100%;order:3;border-left:0;padding-left:0;margin-top:var(--space-1)}
.review-controls{order:2;margin-left:auto}
.review-status{order:4;flex:1 1 100%;max-width:none;white-space:normal}
}
@media (prefers-reduced-motion:no-preference){.bf-block{transition:background var(--motion-fast) var(--motion-ease)}}
@media (hover:none){.bf-block__add{opacity:1}}
`.trim();
/**
* The sidebar's one-line keyboard reminder. It names the keys a reviewer reaches
* for first and defers to the shortcut sheet (`?`) for the rest, so the line does
* not have to grow every time a binding is added.
*/
function reviewHints(canApprove: boolean): string {
const approve = canApprove ? `, a to approve` : "";
return `
Press j/k to move, c to comment${approve}, s to save, ? for all shortcuts.
`;
}
/** The keyboard sheet, hidden until the client opens it. */
function keysDialog(canApprove: boolean): string {
const approveRow = canApprove
? `
a
Approve and run the plan
`
: "";
return `
Keyboard shortcuts
${approveRow}
jk
Next and previous block
c
Comment on the current block
Enter
Add the comment you are writing
s
Save feedback and return to Pi
Esc
Close the composer, the sheet, or the review
?
Toggle this sheet
`;
}
/**
* Render the review page for `data`. The markup is a fixed shell; the client
* fills every dynamic field, so this function is the only place that needs the
* JSON-island escaping.
*/
export function renderReviewPage(data: ReviewPageData): string {
const island = JSON.stringify(data).replace(/ 0;
const bodyClass = hasOutline ? "bf-app__body" : "bf-app__body bf-app__body--single";
const outlinePanel = hasOutline
? `Sections
${reviewFacts()}
${reviewHints(data.canApprove)}
`
: "";
// Approve is the decisive action, so it takes the primary button and Save is
// demoted; a comment-only review has no approve button and keeps Save primary.
const approveButton = data.canApprove
? `
`
: "";
const saveButton = ``;
return `
${docHead({ title: "Plan review", css: REVIEW_CSS })}
Skip to the plan
${brandLockup("plan review")}
Plan reviewConnecting…
${themeToggle()}
${approveButton}
${saveButton}
${outlinePanel}
${keysDialog(data.canApprove)}
`;
}
/**
* The review facts panel: the plan's step progress and total comment count.
* Static placeholders; the client fills them from the blocks it rendered.
*/
function reviewFacts(): string {
return `