Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions docs/recipes/live-review-surface.md
Original file line number Diff line number Diff line change
@@ -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 <working_dir>/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
`<span data-cf-change="ch-<slug>">…</span>` 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
<span data-cf-change="ch-your-slug">…edited content…</span>
```

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
<link rel="stylesheet" href="overlay.css">
<!-- before </body>: -->
<script src="overlay.js"></script>
```

Or inline the contents directly into the page's `<head>` / before `</body>`.

---

## 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.
72 changes: 72 additions & 0 deletions examples/live-review-surface/detach_server.py
Original file line number Diff line number Diff line change
@@ -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 <working_dir> [--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],
)
126 changes: 126 additions & 0 deletions examples/live-review-surface/overlay.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/*
* overlay.css — persistent left-gutter change bars for agent-edited regions.
*
* Wrap any HTML region that Claude edited with:
* <span data-cf-change="ch-your-slug">…edited content…</span>
*
* 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;
}
Loading