diff --git a/docs/recipes/live-review-surface.md b/docs/recipes/live-review-surface.md new file mode 100644 index 0000000..aefa895 --- /dev/null +++ b/docs/recipes/live-review-surface.md @@ -0,0 +1,173 @@ +# Recipe: Build a Live Review Surface for Agent-Generated Outputs + +*A step-by-step guide for using `make-pages-interactive` to turn routine markdown outputs into a +persistent, live commenting surface — including the detached-server pattern and active watch loop +that the base skill leaves as an exercise.* + +--- + +## The gap this recipe fills + +The base skill's README shows the happy path: you have a folder of HTML, you say "make these pages +interactive," and Claude injects the library, starts the server, and watches. What's underspecified: + +- **How to survive across multiple comment rounds.** The server has a parent-death watchdog. If + launched as a child of a Bash call that returns, the server gets reparented and the watchdog shuts + it down within ~10 s — right between comment rounds. +- **How to keep Claude watching without re-prompting.** After the server starts, comments land in + `feedback/inbox.jsonl` but nothing processes them unless Claude is actively monitoring. The skill + doesn't prescribe a pattern for the persistent watch loop. +- **How to render markdown → HTML in a consistent, scannable style** before injecting the feedback + layer. + +This recipe covers all three. The four helper files live in +[`examples/live-review-surface/`](../../examples/live-review-surface/). + +--- + +## Step 1 — Render markdown → HTML + +If your outputs are markdown, convert them to HTML before injecting the feedback library. A minimal +renderer (`render.py`) uses the `markdown` package and needs no external framework: + +```bash +pip3 install --user markdown +``` + +**Render a single file:** +```bash +python render.py report.md output/report.html --title "My Report" +``` + +**Render several files with cross-page nav:** +```bash +python render.py doc1.md output/doc1.html --title "Doc 1" --nav "doc2.html:Doc 2,doc3.html:Doc 3" +python render.py doc2.md output/doc2.html --title "Doc 2" --nav "doc1.html:Doc 1,doc3.html:Doc 3" +python render.py --index output/ # generates index.html linking to all *.html +``` + +Then inject and serve as normal (Step 2 onwards). If your outputs are already HTML, skip Step 1. + +--- + +## Step 2 — Start the server DETACHED + +This is the critical pattern. The server has a parent-death watchdog: it records its PPID at +startup and polls every 5 s. If launched as a direct child of a Bash call (Claude's default), the +call returns, the kernel reparents the server to PID 1, and the watchdog shuts it down — right +between comment rounds. + +**The fix: double-fork so the server is already orphaned (PPID=1) before the watchdog fires.** + +`detach_server.py` handles this. Usage: + +```bash +python detach_server.py ./output --port 5050 --idle-timeout 0 +sleep 1 +``` + +**Verify it's running and orphaned:** +```bash +lsof -ti:5050 # prints the PID +ps -o ppid= -p $(lsof -ti:5050) # must print 1 +``` + +If PPID is 1, the server will survive across comment rounds until you stop it manually: +```bash +lsof -ti:5050 | xargs kill +``` + +> **Windows note.** The double-fork is Unix-only (macOS + Linux). On Windows, replace the +> `os.fork()` calls with +> `subprocess.Popen([...], creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP)`. + +--- + +## Step 3 — Arm the active watch loop + +Starting the server is not enough. Comments land in `feedback/inbox.jsonl` but nothing acts on +them until Claude reads the file. After the server is confirmed up, arm a persistent `Monitor` on +the inbox so every submitted comment batch wakes Claude to process it: + +``` +Monitor( + persistent=true, + description="review: new comment batches", + command="tail -n0 -F /feedback/inbox.jsonl" +) +``` + +Each new line in `inbox.jsonl` is one submitted batch (all comments from a single click). Claude +processes it, edits the HTML, appends to `feedback/history.json`, and goes back to watching. The +page auto-reloads and shows the walkthrough. + +**If `Monitor` is unavailable** in your session (older Claude Code, restricted context): tell the +user explicitly — "I'm not watching live — ping me after you comment and I'll process the batch." +The loop degrades gracefully to manual round-trips; nothing breaks. + +--- + +## Step 4 — (Optional) Visual edit cues + +After Claude edits HTML in response to a comment, wrapping the changed region with +`` lets the page show a persistent left-gutter bar and a +numbered floating "Changes (N)" chip. The CSS + JS (`overlay.css` / `overlay.js`) are self-contained +and layer on top of `make-pages-interactive`'s own feedback styles without touching them. + +**How Claude marks an edit:** +```html +…edited content… +``` + +The slug becomes the change title in the chip (hyphens → spaces, first char uppercased). Keep +slugs short and kebab-case: `ch-section-rewritten`, `ch-table-clarified`. + +To use, add to each rendered HTML page: +```html + + + +``` + +Or inline the contents directly into the page's `` / before ``. + +--- + +## Full spin-up sequence (all four steps together) + +```bash +# 1. Render your markdown +python render.py report.md output/report.html --title "Report" +python render.py --index output/ + +# 2. Inject the feedback library +python ~/.claude/skills/make-pages-interactive/scripts/inject.py output/ + +# 3. Start the server detached +python detach_server.py output/ --port 5050 --idle-timeout 0 +sleep 1 +lsof -ti:5050 # confirm it's up + +# 4. Open in browser +open http://localhost:5050/index.html + +# 5. Arm Monitor in the Claude session +Monitor(persistent=true, description="watch inbox", command="tail -n0 -F output/feedback/inbox.jsonl") +``` + +Now: highlight text → leave a note → Claude edits → page auto-reloads → repeat. + +--- + +## Notes & caveats + +- **`--idle-timeout 0`**: disables auto-shutdown. Use `lsof -ti:5050 | xargs kill` to stop + manually when done. +- **`render.py` is optional**: skip Step 1 if your outputs are already HTML. +- **`Monitor` + `persistent=true`**: tested in Claude Code ≥ 1.x. In older versions, replace with + a manual poll loop or prompt the user to re-invoke after commenting. +- **`overlay.css` / `overlay.js`**: purely cosmetic — no dependency on `make-pages-interactive` + internals. They work on any page with `[data-cf-change]` elements. +- **Attribution**: `render.py`, `detach_server.py`, `overlay.css`, and `overlay.js` were developed + as part of a personal Claude Code skill workflow and contributed here under the same MIT license + as this repository. diff --git a/examples/live-review-surface/detach_server.py b/examples/live-review-surface/detach_server.py new file mode 100644 index 0000000..5a7823c --- /dev/null +++ b/examples/live-review-surface/detach_server.py @@ -0,0 +1,72 @@ +""" +detach_server.py — start make-pages-interactive's server.py pre-orphaned so the +parent-death watchdog disables itself. + +Usage: + python detach_server.py [--port 5050] [--idle-timeout 0] + +Why this is needed: + server.py records its PPID at startup and polls every 5 s. If you start it as a + direct child of a Bash call (Claude's default), the Bash call returns, the kernel + reparents the server to PID 1, and the watchdog shuts it down within ~10 s — right + between comment rounds. The double-fork here ensures the server's PPID is already 1 + before the watchdog first fires, so the watchdog disables itself at startup. + +Platform note: + Double-fork is Unix-only (macOS + Linux). On Windows, use: + subprocess.Popen([...], creationflags=subprocess.DETACHED_PROCESS + | subprocess.CREATE_NEW_PROCESS_GROUP) + +License: MIT (same as the make-pages-interactive repository). +""" +import os, sys + +# ── Parse args to forward to server.py ────────────────────────────────────── +wd = sys.argv[1] if len(sys.argv) > 1 else "." +port = "5050" +idle = "0" # 0 = never auto-shutdown; pass a positive int for normal idle timeout + +i = 2 +while i < len(sys.argv): + if sys.argv[i] == "--port" and i + 1 < len(sys.argv): + port = sys.argv[i + 1] + i += 2 + elif sys.argv[i] == "--idle-timeout" and i + 1 < len(sys.argv): + idle = sys.argv[i + 1] + i += 2 + else: + i += 1 + +# server.py lives in lib/ relative to this skill's install root +SKILL_ROOT = os.path.expanduser("~/.claude/skills/make-pages-interactive") +server_py = os.path.join(SKILL_ROOT, "lib", "server.py") +log_path = os.path.join(os.path.abspath(wd), "server.log") + +if not os.path.isfile(server_py): + sys.exit( + f"server.py not found at {server_py}\n" + "Is make-pages-interactive installed at ~/.claude/skills/make-pages-interactive ?" + ) + +# ── Double-fork ────────────────────────────────────────────────────────────── +# First fork: detach from the caller's session. +if os.fork() != 0: + sys.exit(0) # first parent exits immediately + +os.setsid() # new session — no controlling terminal + +# Second fork: ensures we are not a session leader (can't reacquire a terminal). +# The server is now orphaned; the kernel sets its PPID to 1. +if os.fork() != 0: + sys.exit(0) # second parent exits + +# We are now the server process (PPID == 1). +# server.py will see PPID == 1 at startup and disable the watchdog. +log = open(log_path, "a") +os.dup2(log.fileno(), 1) # stdout → log file +os.dup2(log.fileno(), 2) # stderr → log file + +os.execvp( + "python3", + ["python3", server_py, wd, "--port", port, "--idle-timeout", idle], +) diff --git a/examples/live-review-surface/overlay.css b/examples/live-review-surface/overlay.css new file mode 100644 index 0000000..bdb179f --- /dev/null +++ b/examples/live-review-surface/overlay.css @@ -0,0 +1,126 @@ +/* + * overlay.css — persistent left-gutter change bars for agent-edited regions. + * + * Wrap any HTML region that Claude edited with: + * …edited content… + * + * Works alongside make-pages-interactive's own feedback styles without touching them. + * Pair with overlay.js to number the changes and build the floating "Changes (N)" chip. + * + * License: MIT (same as the make-pages-interactive repository). + */ + +/* Left-gutter bar on every changed region. */ +[data-cf-change] { + position: relative; + border-left: 4px solid #ffd33d; + padding-left: 14px !important; + margin-left: -18px !important; + background: linear-gradient(90deg, rgba(255, 211, 61, 0.10) 0%, rgba(255, 211, 61, 0) 60%); + border-radius: 2px; + transition: background 0.3s; +} + +[data-cf-change]:hover { + background: linear-gradient(90deg, rgba(255, 211, 61, 0.18) 0%, rgba(255, 211, 61, 0.02) 60%); +} + +/* Numbered badge — overlay.js sets data-cf-num. */ +[data-cf-change]::before { + content: attr(data-cf-num); + position: absolute; + left: -32px; + top: 4px; + background: #ffd33d; + color: #1f2328; + font-size: 11px; + font-weight: 700; + width: 20px; + height: 20px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-family: -apple-system, sans-serif; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); +} + +/* Floating "Changes (N)" jump chip, top-right corner. */ +#changes-chip { + position: fixed; + top: 16px; + right: 16px; + background: #fff; + border: 1px solid #d0d7de; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + font-size: 13px; + z-index: 9999; + max-width: 320px; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; +} + +#changes-chip-header { + padding: 8px 14px; + cursor: pointer; + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; + user-select: none; +} + +#changes-chip-header::before { + content: "●"; + color: #ffd33d; + font-size: 14px; +} + +#changes-chip-list { + display: none; + border-top: 1px solid #d0d7de; + max-height: 60vh; + overflow-y: auto; +} + +#changes-chip.open #changes-chip-list { + display: block; +} + +#changes-chip-list a { + display: block; + padding: 8px 14px; + color: #1f2328; + text-decoration: none; + border-bottom: 1px solid #f0f3f6; + font-size: 12px; + line-height: 1.4; +} + +#changes-chip-list a:hover { + background: #f6f8fa; +} + +#changes-chip-list a:last-child { + border-bottom: 0; +} + +#changes-chip-list .num { + display: inline-block; + width: 18px; + height: 18px; + background: #ffd33d; + color: #1f2328; + border-radius: 50%; + text-align: center; + font-size: 10px; + font-weight: 700; + line-height: 18px; + margin-right: 8px; + vertical-align: middle; +} + +/* Reserve left-margin space so gutter badges have room. */ +body { + padding-left: 56px !important; +} diff --git a/examples/live-review-surface/overlay.js b/examples/live-review-surface/overlay.js new file mode 100644 index 0000000..0f612c4 --- /dev/null +++ b/examples/live-review-surface/overlay.js @@ -0,0 +1,80 @@ +/** + * overlay.js — numbers [data-cf-change] blocks and builds a floating + * "Changes (N)" chip that lets you jump to each edited region. + * + * Usage: + * Drop this script before on any page that uses overlay.css. + * Claude marks an edited region with: + * …edited content… + * The slug becomes the jump label in the chip (hyphens → spaces, first + * char uppercased). Keep slugs short and kebab-case: + * ch-section-rewritten, ch-table-clarified + * + * No dependencies. Works alongside make-pages-interactive's own feedback + * styles without touching them. + * + * License: MIT (same as the make-pages-interactive repository). + */ +(function () { + "use strict"; + + function build() { + var changes = Array.from(document.querySelectorAll("[data-cf-change]")); + if (!changes.length) return; + + // Number each block and ensure it has a stable id for deep-linking. + changes.forEach(function (el, i) { + el.setAttribute("data-cf-num", String(i + 1)); + if (!el.id) el.id = el.getAttribute("data-cf-change"); + }); + + // Build the floating chip. + var chip = document.createElement("div"); + chip.id = "changes-chip"; + + var hdr = document.createElement("div"); + hdr.id = "changes-chip-header"; + hdr.textContent = "Changes (" + changes.length + ")"; + hdr.addEventListener("click", function () { + chip.classList.toggle("open"); + }); + + var list = document.createElement("div"); + list.id = "changes-chip-list"; + + changes.forEach(function (el, i) { + var slug = el.getAttribute("data-cf-change") || ""; + var title = slug.replace(/^ch-/, "").replace(/-/g, " "); + title = title.charAt(0).toUpperCase() + title.slice(1); + + var a = document.createElement("a"); + a.href = "#" + slug; + a.innerHTML = '' + (i + 1) + "" + title; + + a.addEventListener("click", function (e) { + e.preventDefault(); + el.scrollIntoView({ behavior: "smooth", block: "center" }); + // Brief highlight flash. + var orig = el.style.background; + el.style.transition = "background .2s"; + el.style.background = "rgba(255,211,61,.35)"; + setTimeout(function () { + el.style.background = orig; + }, 800); + chip.classList.remove("open"); + }); + + list.appendChild(a); + }); + + chip.appendChild(hdr); + chip.appendChild(list); + document.body.appendChild(chip); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", build); + } else { + build(); + } +}()); diff --git a/examples/live-review-surface/render.py b/examples/live-review-surface/render.py new file mode 100644 index 0000000..ade87c5 --- /dev/null +++ b/examples/live-review-surface/render.py @@ -0,0 +1,112 @@ +""" +render.py — markdown → GitHub-ish HTML, ready for make-pages-interactive injection. + +Usage: + python render.py [--title "Page title"] [--nav "a.html:Label,b.html:Label"] + python render.py --index # write an index.html linking to every *.html + +Requires: + pip3 install --user markdown + +License: MIT (same as the make-pages-interactive repository). +""" +import argparse, sys +from pathlib import Path + +try: + import markdown +except ImportError: + sys.exit("pip3 install --user markdown") + +BASE_CSS = """ +:root { --fg:#1f2328; --muted:#57606a; --bg:#ffffff; --accent:#0969da; --border:#d0d7de; --code-bg:#f6f8fa; } +* { box-sizing:border-box; } +body { font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif; + font-size:16px; line-height:1.6; color:var(--fg); background:var(--bg); + max-width:820px; margin:0 auto; padding:40px 32px 120px; } +h1,h2,h3,h4 { line-height:1.25; margin-top:1.8em; } +h1 { font-size:1.9em; border-bottom:1px solid var(--border); padding-bottom:.3em; margin-top:0; } +h2 { font-size:1.4em; } h3 { font-size:1.15em; } h4 { font-size:1em; color:var(--muted); } +hr { border:0; border-top:1px solid var(--border); margin:2em 0; } +a { color:var(--accent); text-decoration:none; } a:hover { text-decoration:underline; } +code { background:var(--code-bg); padding:.15em .4em; border-radius:4px; font-size:.92em; } +pre { background:var(--code-bg); padding:14px 16px; border-radius:6px; overflow-x:auto; font-size:.88em; } +pre code { background:transparent; padding:0; } +ul,ol { padding-left:1.6em; } li { margin:.25em 0; } +blockquote { border-left:3px solid var(--border); margin:1em 0; padding:.4em 1em; + color:var(--muted); background:#f8fafc; } +details { background:#f6f8fa; border:1px solid var(--border); border-radius:6px; + padding:8px 14px; margin:1em 0; } +details summary { cursor:pointer; font-weight:600; } +table { border-collapse:collapse; margin:1em 0; } +th,td { border:1px solid var(--border); padding:6px 10px; } +th { background:var(--code-bg); } +.nav { margin-bottom:2em; padding-bottom:1em; border-bottom:1px solid var(--border); + font-size:.92em; color:var(--muted); } +.nav a { margin-right:1em; } +""" + + +def _build_nav(nav_spec): + if not nav_spec: + return "" + pairs = [] + for chunk in nav_spec.split(","): + chunk = chunk.strip() + if not chunk: + continue + href, label = (chunk.split(":", 1) if ":" in chunk else (chunk, chunk)) + pairs.append(f'{label.strip()}') + return '" if pairs else "" + + +def render(md_text, title, nav_html=""): + body = markdown.markdown(md_text, extensions=["extra", "sane_lists", "fenced_code", "tables"]) + return f""" +{title} + +{nav_html}

{title}

{body}""" + + +def build_index(out_dir): + pages = sorted(p for p in Path(out_dir).glob("*.html") if p.name != "index.html") + items = [] + for p in pages: + text = p.read_text(encoding="utf-8") + title = text.split("", 1)[1].split("", 1)[0].strip() if "" in text else p.stem + items.append(f'<li><a href="{p.name}">{title}</a></li>') + html = f"""<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"> +<title>Review — Index +

Review

    {"".join(items)}
""" + (Path(out_dir) / "index.html").write_text(html, encoding="utf-8") + print(f"wrote {Path(out_dir) / 'index.html'}") + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("src", nargs="?", help="Source .md file") + ap.add_argument("out", nargs="?", help="Output .html file") + ap.add_argument("--title", help="Page title (defaults to filename stem)") + ap.add_argument("--nav", help="Cross-page nav spec: 'a.html:Label A,b.html:Label B'") + ap.add_argument("--index", metavar="OUT_DIR", help="Build index.html from all *.html in OUT_DIR") + args = ap.parse_args() + if args.index: + build_index(args.index) + return 0 + if not args.src or not args.out: + ap.error("src and out are required unless --index is given") + src = Path(args.src).resolve() + out = Path(args.out).resolve() + if not src.is_file(): + sys.exit(f"not a file: {src}") + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text( + render(src.read_text(encoding="utf-8"), args.title or src.stem, _build_nav(args.nav or "")), + encoding="utf-8", + ) + print(f"wrote {out}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())