diff --git a/README.md b/README.md index aa6de4c..9e797f8 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,38 @@ Human Review opens the file in your browser. Make direct edits, leave comments, Note: For HTML files, direct edits and resizes save automatically. For Markdown and localhost pages, click Send so your agent can apply them to the source. +## Reviewing from another device (phone, LAN, Tailscale) + +By default human-review binds to `127.0.0.1` and serves the reviewed app from the *other* loopback name (`localhost` ↔ `127.0.0.1`). The shell and the reviewed page are deliberately **two different origins** — that's what stops the reviewed app's scripts from reading the session token off the shell. + +To review from a phone or another machine, configure both sides explicitly. **This is for private networks only (Tailscale / VPN / trusted LAN)** — see the exposure warning below. + +| Env var | Effect | Default | +|---|---|---| +| `HUMAN_REVIEW_HOST` | Server bind address | `127.0.0.1` | +| `HUMAN_REVIEW_ALLOWED_HOSTS` | Comma-separated extra `Host`-header allowlist | loopback only | +| `HUMAN_REVIEW_PUBLIC_URL` | URL printed/used for the session (e.g. `http://100.x.y.z:8124`); when set, the CLI skips auto-opening the browser | — | +| `HUMAN_REVIEW_ARTIFACT_HOST` | Hostname the reviewed page's iframe loads from | the other loopback name | +| `HUMAN_REVIEW_CHROME_ORIGIN` | Origin the SDK posts messages to (the shell) | auto-derived | + +⚠️ **You must use two distinct hostnames** for the shell and the artifact. If `HUMAN_REVIEW_ARTIFACT_HOST` resolves to the same origin as the shell, the reviewed app's scripts would gain same-origin access to the shell — including the session token. human-review refuses to serve the session in that case; the error page explains the requirement. + +Example (Tailscale): shell on the tailnet IP, artifact on the MagicDNS name: + +```sh +HUMAN_REVIEW_HOST=0.0.0.0 +HUMAN_REVIEW_ALLOWED_HOSTS=100.101.102.103:8124,my-laptop.tailnet-name.ts.net:8124 +HUMAN_REVIEW_PUBLIC_URL=http://100.101.102.103:8124 +HUMAN_REVIEW_ARTIFACT_HOST=my-laptop.tailnet-name.ts.net +human-review my-page.html +``` + +Open `http://100.101.102.103:8124/s/` on your phone. + +> ⚠️ **Exposure model — private networks only.** With `HUMAN_REVIEW_HOST=0.0.0.0` the server is reachable by anyone who can reach the port *and* sends an allowed `Host` header. Token-free routes become network-readable: `/artifact//…` (keys are `sha256(realpath)` truncated to 16 hex chars, derivable from file paths) serves the reviewed page and sibling assets, and `/s/` carries the session token over plain HTTP. On a private tailnet that's fine; on shared or public Wi-Fi it is **not** — anyone sniffing or on the network could read your reviewed files. Don't bind to `0.0.0.0` on untrusted networks. + +> ℹ️ Env vars are read by the server when it **starts**. If you already have a server running, stop it first before changing these values, or the old settings stay in effect. The detached server exits on its own after being idle (default 45 min, `HUMAN_REVIEW_IDLE_MS`) — or kill the stale one via its PID in `server.json` / Task Manager. + ## What this skill lets you do - **Edit text directly and tweak basic formatting** (e.g., bold, italic). diff --git a/src/chrome-client.js b/src/chrome-client.js index 01fcab8..c97eb89 100644 --- a/src/chrome-client.js +++ b/src/chrome-client.js @@ -59,9 +59,11 @@ async function api(path, options) { return res.json(); } -// Keep the reviewed app on a different loopback origin from the review shell. -// This gives route-aware frameworks a real origin without exposing the parent UI. -const ARTIFACT_HOST = location.hostname === "127.0.0.1" ? "localhost" : "127.0.0.1"; +// Keep the reviewed app on a different origin from the review shell (two +// loopback names on desktop; an env-provided host like a Tailscale DNS name +// when the shell is served over the network). This gives route-aware +// frameworks a real origin without exposing the parent UI. +const ARTIFACT_HOST = document.body.dataset.artifactHost || (location.hostname === "127.0.0.1" ? "localhost" : "127.0.0.1"); const ARTIFACT_ORIGIN = `${location.protocol}//${ARTIFACT_HOST}:${location.port}`; // URL reviews keep a real origin. File reviews use an opaque sandbox origin, @@ -713,15 +715,24 @@ $("note").addEventListener("input", (event) => { $("handle").addEventListener("click", () => { const collapsed = document.body.classList.toggle("collapsed"); - const handle = $("handle"); - handle.textContent = collapsed ? "‹" : "›"; - handle.title = collapsed ? "Show comments panel" : "Hide comments panel"; - handle.setAttribute("aria-label", handle.title); + syncHandle(); try { localStorage.setItem("human-review:collapsed", collapsed ? "1" : "0"); } catch {} }); +/** Match the toggle glyph to the current layout: side arrows for the desktop + * rail, up/down arrows for the mobile bottom sheet. */ +function syncHandle() { + const handle = $("handle"); + const collapsed = document.body.classList.contains("collapsed"); + const desktop = window.matchMedia("(min-width: 900px)").matches; + handle.textContent = collapsed ? (desktop ? "‹" : "▲") : (desktop ? "›" : "▼"); + handle.title = collapsed ? "Show comments panel" : "Hide comments panel"; + handle.setAttribute("aria-label", handle.title); +} +window.matchMedia("(min-width: 900px)").addEventListener("change", syncHandle); + $("theme").addEventListener("click", () => { const dark = document.documentElement.dataset.theme !== "dark"; applyTheme(dark); @@ -803,6 +814,7 @@ function connect() { try { applyTheme(localStorage.getItem("human-review:theme") === "dark"); if (localStorage.getItem("human-review:collapsed") === "1") $("handle").click(); + syncHandle(); } catch {} const bootstrap = await api(`/api/session/${state.sessionId}/page`).catch(() => null); diff --git a/src/chrome.css b/src/chrome.css index 6440d14..c218e30 100644 --- a/src/chrome.css +++ b/src/chrome.css @@ -25,6 +25,7 @@ --btn-bg: #1b1a16; --btn-fg: #fbfaf7; --rail-w: 352px; + --sheet-h: clamp(300px, 55vh, 520px); } :root[data-theme="dark"] { @@ -74,48 +75,100 @@ body { button, textarea { font: inherit; color: inherit; } -.app { display: flex; height: 100vh; } +.app { height: 100vh; } -/* The artifact takes all remaining width and styles itself. */ -.stage { flex: 1; min-width: 0; background: var(--canvas); } +/* The artifact takes the whole viewport; the rail floats over it. */ +.stage { height: 100%; width: 100%; background: var(--canvas); } #frame { width: 100%; height: 100%; border: 0; display: block; background: #fff; } +/* Bottom-sheet toggle: a centered grabber tab that rides the sheet's top + edge when open, and drops to the bottom edge when closed. */ .handle { position: fixed; - top: 50%; - right: var(--rail-w); - z-index: 40; - width: 22px; - height: 46px; - margin-top: -23px; + left: 50%; + bottom: var(--sheet-h); + transform: translateX(-50%); + z-index: 50; + width: 76px; + height: 28px; display: flex; align-items: center; justify-content: center; border: 1px solid var(--hair); - border-right: 0; - border-radius: 7px 0 0 7px; + border-bottom: 0; + border-radius: 12px 12px 0 0; background: var(--rail); color: var(--mute); font-size: 11px; line-height: 1; cursor: pointer; - box-shadow: -2px 0 8px -6px rgba(0, 0, 0, 0.28); - transition: right 140ms ease-out; + box-shadow: 0 -4px 14px -8px rgba(0, 0, 0, 0.3); + transition: bottom 200ms ease-out; } .handle:hover { color: var(--strong-txt); background: var(--soft); } +body.collapsed .handle { bottom: 0; } +/* Bottom-sheet rail: floats over the artifact, never disturbs the page. */ .rail { - flex: none; - width: var(--rail-w); + position: fixed; + left: 50%; + bottom: 0; + width: min(720px, 100%); + height: var(--sheet-h); display: flex; flex-direction: column; overflow: hidden; - border-left: 1px solid var(--hair); + border-top: 1px solid var(--hair); + border-radius: 14px 14px 0 0; background: var(--rail); - transition: width 140ms ease-out; + box-shadow: 0 -10px 34px rgba(0, 0, 0, 0.16); + transform: translate(-50%, 105%); + transition: transform 200ms ease-out; + z-index: 40; +} +body:not(.collapsed) .rail { transform: translate(-50%, 0); } + +/* Desktop: keep the classic right-side rail. The mobile bottom sheet applies + below this width; the handle returns to a right-edge tab and the rail + becomes a fixed-width flex column that the artifact shares the row with. */ +@media (min-width: 900px) { + .app { display: flex; height: 100vh; } + .stage { flex: 1; min-width: 0; width: auto; } + + .handle { + top: 50%; + bottom: auto; + left: auto; + right: var(--rail-w); + transform: none; + width: 22px; + height: 46px; + margin-top: -23px; + border: 1px solid var(--hair); + border-right: 0; + border-radius: 7px 0 0 7px; + box-shadow: -2px 0 8px -6px rgba(0, 0, 0, 0.28); + transition: right 140ms ease-out; + } + body.collapsed .handle { right: 0; } + + .rail { + position: static; + left: auto; + bottom: auto; + width: var(--rail-w); + height: auto; + flex: none; + transform: none; + border-top: 0; + border-left: 1px solid var(--hair); + border-radius: 0; + box-shadow: none; + transition: width 140ms ease-out; + } + body:not(.collapsed) .rail { transform: none; } + body.collapsed .rail { width: 0; border-left-width: 0; } } -body.collapsed .rail { width: 0; border-left-width: 0; } -body.collapsed .handle { right: 0; } .rail-scroll { flex: 1; min-height: 0; overflow-y: auto; padding: 16px 12px 12px; } diff --git a/src/chrome.html b/src/chrome.html index f651a70..5360e3a 100644 --- a/src/chrome.html +++ b/src/chrome.html @@ -6,7 +6,7 @@ human-review - +
- +