From ddebb34aa3c5551fe79c024f3b0bfce7ce07b091 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Wed, 3 Jun 2026 14:52:57 +0100 Subject: [PATCH] feat: live-preview mirror + branch/release workflow Every render now mirrors its output into a stable live/ folder (current.pdf, current-debug.pdf, current.png, current.txt) so a watching viewer like SumatraPDF reloads in place -- no hunting for the latest revision folder. The copy runs inside the render script (zero agent/token cost), is gitignored, and is overridable via GRAPHCOMPOSE_LIVE_DIR / RENDER_NO_LIVE. Adds scripts/preview-live.mjs (npm run preview) to open it in SumatraPDF. Also documents the branch-per-change + release-from-main workflow in CONTRIBUTING.md and AGENTS.md so main stays clean and renderable. --- .gitignore | 5 ++ AGENTS.md | 16 ++++ CHANGELOG.md | 20 +++++ CONTRIBUTING.md | 26 ++++++ docs/quickstart.md | 33 ++++++++ package.json | 4 +- scripts/lib/render-runtime.mjs | 144 +++++++++++++++++++++++++++++++++ scripts/preview-live.mjs | 121 +++++++++++++++++++++++++++ 8 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 scripts/preview-live.mjs diff --git a/.gitignore b/.gitignore index a2d97e0..e482b67 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,11 @@ docs/private/ /output.pdf /output.png +# Live-preview mirror — a stable copy of the most recent render, regenerated on +# every render so a watching viewer (SumatraPDF) reloads in place. Derived and +# per-machine; never committed. Location is overridable via GRAPHCOMPOSE_LIVE_DIR. +/live/ + # Shell interpolation accidents — files named after unexpanded variables or # stray shell punctuation. They come from PowerShell heredocs / pipelines that # write to ${command}, ${file}, etc. when the variable was empty. Never diff --git a/AGENTS.md b/AGENTS.md index cc15a32..fe550aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -217,8 +217,13 @@ scripts/ render.mjs project-agnostic render (examples/) render-cv-reference.mjs clean + debug render for the cv example render-invoice-reference.mjs same for invoice + preview-live.mjs open live/current.pdf in SumatraPDF (auto-reload) run-pipeline.mjs print / run the agent chain for one revision publish-template.mjs copy approved revision into templates/ + +live/ gitignored mirror of the most recent render + current.pdf / current-debug.pdf stable path to watch in SumatraPDF + current.png / current.txt page-1 raster + project/revision stamp ``` ## Quick recipes @@ -274,6 +279,17 @@ scripts/ revisions; quote the metric. - GraphCompose API is the source of truth; never invent. +## Working on the flow itself (branch first) + +Template/document work (new revisions under `examples//`) is the +product output and flows normally. But changes to the *flow itself* — `scripts/`, +the `tools/` modules, `prompts/`, `skills/`, the docs — must not land directly +on `main`: it is the clean state the user renders from. Cut a topic branch +(`feat/…`, `fix/…`, `docs/…`, `chore/…`), do the work and render there, and +merge to `main` only when it is finished. Releases are tagged from a +known-good `main`. Full rules: [`CONTRIBUTING.md`](CONTRIBUTING.md) → +"Branching and release workflow". + ## Editor / agent rule-packs If your tool supports project rules, it may have already loaded a thin pointer to diff --git a/CHANGELOG.md b/CHANGELOG.md index 05113ba..3c2775e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ The project follows [Semantic Versioning](https://semver.org/) and stays in `0.x` while the workflow stabilizes — skills are still `needs-validation`, and the full visual-baseline pass is the gate to `1.0.0`. +## Unreleased + +### Live preview +- **`live/` mirror.** Every render now also writes a single stable copy of the + latest output to `live/current.pdf` (plus `current-debug.pdf`, `current.png`, + `current.txt`) at the repo root, regardless of which project/revision produced + it. Open `live/current.pdf` once in SumatraPDF (auto-reloads on change, no + file lock) and watch every render refresh in place — no digging for the latest + revision folder. Override the location with `GRAPHCOMPOSE_LIVE_DIR`; disable + with `RENDER_NO_LIVE=1`. The folder is gitignored. +- **`scripts/preview-live.mjs`** (`npm run preview` / `npm run preview:debug`) + opens the live file in SumatraPDF with `-reuse-instance`, resolving it via + `SUMATRAPDF_PATH`, `PATH`, or the standard install locations, and falling back + to the OS default viewer. + +### Developer workflow +- `CONTRIBUTING.md` documents the branch-per-change + release-from-`main` + workflow that keeps `main` always renderable; `AGENTS.md` carries the + agent-facing summary ("Working on the flow itself"). + ## v0.1.0 — 2026-06-03 First tagged release. The kit already turned visual references into diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 18a7409..230af9e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -85,6 +85,32 @@ a buggy skill instead of fixing the skill will be rejected. - Reference the related issue when one exists. - One logical change per commit. +## Branching and release workflow + +`main` is always the clean, usable, releasable state of the kit. Never develop +the flow itself directly on `main` — a half-finished tooling change must not sit +on the branch a user renders from. + +- **Branch per change.** Cut a topic branch off `main` for every flow update: + `feat/`, `fix/`, `docs/`, or `chore/`. Do the work, + render, and review there; `main` stays usable the whole time. +- **Document work vs flow work.** Day-to-day template work (new revisions under + `examples//revisions/`) is the product output and lands through the + normal revision flow. Changes to the *tooling* — `scripts/`, the `tools/` + modules, `prompts/`, `skills/`, the docs — are "flow updates" and belong on a + topic branch. +- **Merge when it is done.** When the change is finished and reviewed, merge the + branch into `main` (fast-forward or PR) so `main` only ever moves forward in + releasable steps. +- **Release from a known-good `main`:** + 1. Move the `## Unreleased` notes in `CHANGELOG.md` under a new + `## vX.Y.Z — ` heading (SemVer; the kit stays in `0.x`). + 2. Tag it: `git tag vX.Y.Z && git push origin vX.Y.Z`. + 3. The tag is the citable version in the compatibility matrix. + +The commit rules above still apply on branches: explicit staging, one logical +change per commit, imperative subjects. + ## Pull request checklist Before requesting review: diff --git a/docs/quickstart.md b/docs/quickstart.md index c3e9fee..a7994c1 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -140,6 +140,39 @@ examples/cv-reference/revisions/revision-002/output.png examples/cv-reference/revisions/revision-002/output-page-2.png ``` +## Live preview (watch renders update) + +Every render also refreshes a single stable copy of the latest output under +`live/` at the repository root: + +```text +live/current.pdf clean render (open this) +live/current-debug.pdf debug render with guide lines +live/current.png page-1 raster +live/current.txt which project / revision / time it reflects +``` + +`live/current.pdf` always points at the most recent render, whatever project +or revision produced it — so you do not have to dig into +`examples//revisions//` to find the newest output. + +Open it once in [SumatraPDF](https://www.sumatrapdfreader.org/) — it reloads a +PDF automatically when the file changes and does not lock it — and leave it +open while you iterate; each render refreshes the view in place: + +```powershell +node scripts\preview-live.mjs # opens live\current.pdf +node scripts\preview-live.mjs --debug # opens live\current-debug.pdf +``` + +`npm run preview` is the same thing. The helper finds SumatraPDF on `PATH`, at +`%LOCALAPPDATA%\SumatraPDF`, or via `SUMATRAPDF_PATH`; with none found it falls +back to the OS default PDF viewer (which may not live-reload). + +The `live/` folder is gitignored. To keep it off OneDrive (avoiding sync churn) +point `GRAPHCOMPOSE_LIVE_DIR` at another path; disable the mirror entirely with +`RENDER_NO_LIVE=1`. + ## Inspect the Revision History ```powershell diff --git a/package.json b/package.json index 74ce761..8dcd005 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "type": "module", "scripts": { "setup": "node scripts/setup.mjs", - "setup:check": "node scripts/setup.mjs --check" + "setup:check": "node scripts/setup.mjs --check", + "preview": "node scripts/preview-live.mjs", + "preview:debug": "node scripts/preview-live.mjs --debug" }, "engines": { "node": ">=20" diff --git a/scripts/lib/render-runtime.mjs b/scripts/lib/render-runtime.mjs index 2db8f13..8119091 100644 --- a/scripts/lib/render-runtime.mjs +++ b/scripts/lib/render-runtime.mjs @@ -44,6 +44,137 @@ import path from "node:path"; import { ensureSkillValidationVerdict } from "./skill-validation-gate.mjs"; +// --- live preview mirror ----------------------------------------------------- +// A single, stable set of files that always reflects the MOST RECENT render, +// regardless of which project/revision produced it. Open live/current.pdf once +// in a viewer that auto-reloads on change and does not lock the file (e.g. +// SumatraPDF) and watch every render update live — no hunting for the latest +// revision folder. +// +// live/current.pdf clean render (the one to open) +// live/current-debug.pdf debug render with guide lines +// live/current.png page-1 raster of the clean render +// live/current-debug.png page-1 raster of the debug render +// live/current.txt which project / revision / time this reflects +// +// Location: /live by default; override with GRAPHCOMPOSE_LIVE_DIR +// (e.g. a path outside OneDrive to avoid sync churn). Disable with +// RENDER_NO_LIVE=1. Mirroring is best-effort: a failure here only warns, it +// never fails the render. + +const LIVE_README = `# Live preview + +This folder always reflects the MOST RECENT render, regardless of which +project or revision produced it. It is regenerated on every render and is +gitignored — do not edit by hand. + +Files: + current.pdf clean render (open this one) + current-debug.pdf debug render with guide lines + current.png page-1 raster of the clean render + current-debug.png page-1 raster of the debug render + current.txt which project / revision / time this reflects + +## Watch renders update live (SumatraPDF) + +SumatraPDF reloads a PDF automatically when the file changes on disk and does +not lock it. Open current.pdf once and leave it open; every render refreshes +the view in place. + + node scripts/preview-live.mjs # opens live/current.pdf + node scripts/preview-live.mjs --debug # opens live/current-debug.pdf + +Or open current.pdf in this folder manually in SumatraPDF. + +## Options + + GRAPHCOMPOSE_LIVE_DIR move this folder elsewhere (e.g. off OneDrive): + $env:GRAPHCOMPOSE_LIVE_DIR = "C:\\Temp\\gc-live" + RENDER_NO_LIVE=1 disable this live mirror entirely +`; + +function resolveLiveDir(repoRoot) { + const override = process.env.GRAPHCOMPOSE_LIVE_DIR; + if (override && override.trim()) return path.resolve(override.trim()); + return path.join(repoRoot, "live"); +} + +function mirrorFileToLive(liveDir, srcPath, destName) { + if (!srcPath || !fs.existsSync(srcPath)) return false; + const dest = path.join(liveDir, destName); + const tmp = path.join(liveDir, `.${destName}.tmp`); + // Copy to a temp file, then rename over the target. rename is atomic on the + // same volume, so a watching viewer never sees a half-written PDF (the same + // trick the LaTeX + SumatraPDF live-preview workflow relies on). Fall back to + // a direct copy if the rename is refused (e.g. a cross-volume live dir). + try { + fs.copyFileSync(srcPath, tmp); + fs.renameSync(tmp, dest); + return true; + } catch { + try { + fs.rmSync(tmp, { force: true }); + } catch { + /* ignore */ + } + try { + fs.copyFileSync(srcPath, dest); + return true; + } catch (err) { + console.warn(`> live mirror: could not update ${destName} (${err.message})`); + return false; + } + } +} + +function writeLiveManifest(liveDir, info) { + const lines = [ + `project: ${info.projectId}`, + `revision: ${info.revisionId}`, + `rendered: ${new Date().toISOString()}`, + `source: ${info.revisionDir}`, + ``, + `current.pdf <- output.pdf`, + ]; + if (info.hasDebug) lines.push(`current-debug.pdf <- output-debug.pdf`); + lines.push(""); + try { + fs.writeFileSync(path.join(liveDir, "current.txt"), lines.join("\n"), "utf8"); + } catch (err) { + console.warn(`> live mirror: could not write current.txt (${err.message})`); + } +} + +const NOOP_LIVE_MIRROR = { update() {}, manifest() {}, announce() {} }; + +function createLiveMirror(repoRoot) { + if (process.env.RENDER_NO_LIVE === "1") return NOOP_LIVE_MIRROR; + let dir; + try { + dir = resolveLiveDir(repoRoot); + fs.mkdirSync(dir, { recursive: true }); + const readme = path.join(dir, "README.md"); + if (!fs.existsSync(readme)) fs.writeFileSync(readme, LIVE_README, "utf8"); + } catch (err) { + console.warn(`> live mirror disabled (${err.message})`); + return NOOP_LIVE_MIRROR; + } + let updated = 0; + return { + update(srcPath, destName) { + if (mirrorFileToLive(dir, srcPath, destName)) updated += 1; + }, + manifest(info) { + writeLiveManifest(dir, info); + }, + announce() { + if (updated > 0) { + console.log(`> live preview updated -> ${path.join(dir, "current.pdf")}`); + } + }, + }; +} + export function runRender({ repoRoot, projectId, revisionId }) { const projectDir = path.join(repoRoot, "examples", projectId); const templateProjectPath = path.join(projectDir, "template-project.json"); @@ -118,6 +249,7 @@ export function runRender({ repoRoot, projectId, revisionId }) { const outputPdf = path.join(revisionDir, "output.pdf"); const debugPdf = path.join(revisionDir, "output-debug.pdf"); const dataFile = dataDriven ? path.join(revisionDir, dataFileName) : null; + const live = createLiveMirror(repoRoot); // 1. Skill validation gate ensureSkillValidationVerdict({ @@ -291,8 +423,14 @@ export function runRender({ repoRoot, projectId, revisionId }) { ); } + // Mirror the clean render into the live-preview folder (live/current.*). + live.update(outputPdf, "current.pdf"); + live.update(path.join(revisionDir, "output.png"), "current.png"); + live.manifest({ projectId, revisionId, revisionDir, hasDebug: false }); + if (!debugPass) { console.log(`> debug pass skipped by project config`); + live.announce(); return; } @@ -347,6 +485,12 @@ export function runRender({ repoRoot, projectId, revisionId }) { repoRoot, ); } + + // Mirror the debug render too, then point the user at the live file. + live.update(debugPdf, "current-debug.pdf"); + live.update(path.join(revisionDir, "output-debug.png"), "current-debug.png"); + live.manifest({ projectId, revisionId, revisionDir, hasDebug: true }); + live.announce(); } function runMaven(args, cwd) { diff --git a/scripts/preview-live.mjs b/scripts/preview-live.mjs new file mode 100644 index 0000000..5b3128d --- /dev/null +++ b/scripts/preview-live.mjs @@ -0,0 +1,121 @@ +#!/usr/bin/env node +/** + * Open the live-preview PDF in SumatraPDF (or the OS default viewer). + * + * node scripts/preview-live.mjs # opens live/current.pdf + * node scripts/preview-live.mjs --debug # opens live/current-debug.pdf + * + * The render pipeline keeps a single stable copy of the most recent render in + * `live/` (see scripts/lib/render-runtime.mjs). SumatraPDF reloads a PDF when + * the file changes on disk and does not lock it, so you open this file once and + * every subsequent render refreshes the view in place — no hunting for the + * latest revision folder. Re-running this command focuses the existing window + * (`-reuse-instance`) instead of spawning duplicates. + * + * Viewer resolution order: + * 1. $SUMATRAPDF_PATH + * 2. SumatraPDF on PATH + * 3. known install locations (LOCALAPPDATA / Program Files [(x86)]) + * 4. OS default PDF handler (start / open / xdg-open) — note: viewers that + * lock the file (e.g. Acrobat) will not live-reload; SumatraPDF is the + * recommended companion for this workflow. + * + * Honors GRAPHCOMPOSE_LIVE_DIR, exactly like the renderer. + */ + +import { spawn, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +const wantDebug = process.argv.slice(2).some((a) => a === "--debug" || a === "-d"); +const liveDir = resolveLiveDir(repoRoot); +const targetName = wantDebug ? "current-debug.pdf" : "current.pdf"; +const target = path.join(liveDir, targetName); + +if (!fs.existsSync(target)) { + console.error( + `[preview-live] nothing to open yet: ${target}\n` + + ` Render something first, e.g.:\n` + + ` node scripts/render.mjs \n` + + ` The render writes live/${targetName} and this command opens it.`, + ); + process.exit(1); +} + +const viewer = resolveSumatra(); +if (viewer) { + // -reuse-instance: focus the existing SumatraPDF window if one is already + // showing this file, rather than opening a second copy. + launchDetached(viewer, ["-reuse-instance", target]); + console.log(`> opened ${target}\n (SumatraPDF auto-reloads on every render — leave it open)`); + process.exit(0); +} + +console.warn( + "[preview-live] SumatraPDF not found — falling back to the OS default viewer.\n" + + " For live auto-reload, install SumatraPDF or set SUMATRAPDF_PATH to its .exe.", +); +openWithDefault(target); +console.log(`> opened ${target}`); + +// --- helpers ---------------------------------------------------------------- + +function resolveLiveDir(root) { + const override = process.env.GRAPHCOMPOSE_LIVE_DIR; + if (override && override.trim()) return path.resolve(override.trim()); + return path.join(root, "live"); +} + +function resolveSumatra() { + const fromEnv = process.env.SUMATRAPDF_PATH; + if (fromEnv && fs.existsSync(fromEnv)) return fromEnv; + + const onPath = whichSumatra(); + if (onPath) return onPath; + + const candidates = [ + process.env.LOCALAPPDATA && + path.join(process.env.LOCALAPPDATA, "SumatraPDF", "SumatraPDF.exe"), + process.env.ProgramFiles && + path.join(process.env.ProgramFiles, "SumatraPDF", "SumatraPDF.exe"), + process.env["ProgramFiles(x86)"] && + path.join(process.env["ProgramFiles(x86)"], "SumatraPDF", "SumatraPDF.exe"), + ].filter(Boolean); + for (const c of candidates) { + if (fs.existsSync(c)) return c; + } + return null; +} + +function whichSumatra() { + const finder = process.platform === "win32" ? "where" : "which"; + const res = spawnSync(finder, ["SumatraPDF"], { encoding: "utf8" }); + if (res.status === 0 && res.stdout) { + const first = res.stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0]; + if (first && fs.existsSync(first)) return first; + } + return null; +} + +function launchDetached(command, args) { + const child = spawn(command, args, { detached: true, stdio: "ignore" }); + child.on("error", (err) => { + console.error(`[preview-live] could not launch ${command}: ${err.message}`); + process.exit(1); + }); + child.unref(); +} + +function openWithDefault(file) { + if (process.platform === "win32") { + // `start` is a cmd builtin; the empty "" is the window title argument. + spawn("cmd", ["/c", "start", "", file], { detached: true, stdio: "ignore" }).unref(); + } else if (process.platform === "darwin") { + spawn("open", [file], { detached: true, stdio: "ignore" }).unref(); + } else { + spawn("xdg-open", [file], { detached: true, stdio: "ignore" }).unref(); + } +}