mirror of
https://github.com/Nutlope/hallmark.git
synced 2026-08-14 12:35:33 +02:00
Variants v2: speed (progressive, sketch-default, parallel, analysis-once) + smoothness (grafts, section-zoom, decisions) + thumbnails + scoped injection
- scripts: core.mjs picker gains PNG-thumbnail grid (falls back to the
scaled iframe), a Graft button + G key; serve.mjs adds /thumb + /inject
routes and a graft action on /api/pick; new thumbs.mjs (dual-engine,
zero-install); buildInjectJs for dev-only Vite/Astro/SvelteKit preview
injection. All v1 behavior preserved (smoke-tested end to end + browser).
- verbs/variants.md v2 (280->423): progressive-first flow, parallel default
+ analysis-once + shared head, sketch-depth drafts by default (--full),
optional --fast-drafts, compositional grafts, section-zoom after the pick,
decisions.md log, thumbnail step, Vite/Astro/SvelteKit injection recipes.
- eval/variants-bench.{md,mjs}: speed-bench doc + a mechanical timing harness.
- site/_proposals/variants-demo.html: client-only progressive-picker demo
(gradient text removed to stay on-brand); variants-vs.md head-to-head.
Verified in the browser: progressive fill (building -> thumbnail), labeled
counter, guarded arrows, Pick/Riff/Graft, live single-view iframe.
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
# Benchmarking `hallmark variants` speed
|
||||
|
||||
How to measure what the v2 changes actually buy: faster time to the first
|
||||
direction, faster time to all directions ready, and fewer output tokens. Two
|
||||
halves, because a variants run has two kinds of cost that need two kinds of
|
||||
measurement:
|
||||
|
||||
- **Agent-side cost** (model latency and output tokens) needs a live,
|
||||
authenticated agent driving the real skill. It cannot be faked in this repo,
|
||||
so this doc gives you a hand-run protocol you execute on your own machine.
|
||||
- **Mechanical cost** (server cold-start, picker first-paint, thumbnail
|
||||
generation) is deterministic plumbing with no model in the loop, so
|
||||
`eval/variants-bench.mjs` measures it directly and prints a table.
|
||||
|
||||
Nothing here calls a model or the network beyond loopback. The mechanical
|
||||
harness stands up a throwaway greenfield run in the OS temp dir, drives the
|
||||
real `scripts/variants/{start,thumbs,await}.mjs`, and tears it down.
|
||||
|
||||
## The four configurations
|
||||
|
||||
A variants run is a matrix of two independent choices: **how the directions are
|
||||
generated** (sequentially in one context, or in parallel subagents) and **how
|
||||
much each direction contains** (full pages, or `--sketch`: hero plus one
|
||||
signature section plus footer). Layer progressive reveal on top and you get the
|
||||
ladder worth measuring:
|
||||
|
||||
| # | Config | Generation | Depth | Reveal | Role |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| A | `sequential-full` | one context, v1 then v2 then v3 | full | all at end | v1 baseline |
|
||||
| B | `parallel-full` | one subagent per direction | full | all at end | parallelism only |
|
||||
| C | `parallel-sketch` | one subagent per direction | sketch | all at end | parallelism + cheaper drafts |
|
||||
| D | `parallel-sketch+progressive` | one subagent per direction | sketch | each flips to `ready` as it lands | the v2 default |
|
||||
|
||||
Config A is exactly the v1 sequential fallback. Config D is what v2 aims users
|
||||
at: parallel subagents, sketch depth by default on heavy briefs, and a manifest
|
||||
that flips each direction to `status:"ready"` the moment its files land so the
|
||||
picker reveals it immediately instead of waiting for the slowest sibling.
|
||||
|
||||
## What to record per configuration
|
||||
|
||||
Three numbers, plus the mechanical baseline the harness prints:
|
||||
|
||||
1. **Time-to-first-direction (TTFD)** - wall-clock from "the agent starts
|
||||
building" to the first direction being viewable in the picker. This is the
|
||||
number the user feels first. With progressive reveal it is roughly the time
|
||||
to build **one** direction, not three.
|
||||
2. **Time-to-all-ready (TTAR)** - wall-clock until every planned direction is
|
||||
`status:"ready"` in the manifest. This is where parallelism pays.
|
||||
3. **Total output tokens** - summed generated tokens across the run (all
|
||||
directions). This is where `--sketch` pays. Read it from the harness the
|
||||
agent runs in (Claude Code reports usage per turn; the eval Tier-B runner in
|
||||
`gen-direct.py` records `completion_tokens` in each `run.json`).
|
||||
|
||||
## Expected deltas (from the research)
|
||||
|
||||
Targets to check your measurements against, not guarantees. They compound.
|
||||
|
||||
- **Progressive reveal cuts TTFD to about one third.** In config A the user
|
||||
waits for all three directions before seeing anything; in config D they see
|
||||
the first direction after roughly one direction's worth of work. For three
|
||||
directions that is about `1/3` of the old wait to first pixels. This delta is
|
||||
the same shape whether depth is full or sketch, because it is about *when* the
|
||||
picker reveals, not *how much* each direction contains.
|
||||
- **Sketch depth removes about 40% of output.** Hero plus one signature section
|
||||
plus footer is roughly 60% of a full page's markup and CSS, so drafting three
|
||||
sketches instead of three full pages trims total output tokens by about 40%.
|
||||
The winner is completed to full depth only after the pick, so the finished
|
||||
deliverable is unchanged; only the two archived losers stay as sketches.
|
||||
- **Parallel generation compresses wall-clock about 2 to 2.8x.** Three
|
||||
directions built by three subagents finish in about the time of the slowest
|
||||
one plus fan-out overhead, versus the sum of three in sequence. The realized
|
||||
factor depends on how even the three are and on harness scheduling; treat
|
||||
anything in the `2x` to `2.8x` band as on-target for TTAR.
|
||||
|
||||
Rough model, three directions, per-direction build time `t`:
|
||||
|
||||
| | TTFD | TTAR | Output |
|
||||
| --- | --- | --- | --- |
|
||||
| A `sequential-full` | `3t` (all at end) | `3t` | `3 x full` |
|
||||
| B `parallel-full` | `t` | `~t` + fan-out | `3 x full` |
|
||||
| C `parallel-sketch` | `~0.6t` | `~0.6t` + fan-out | `~0.6 x (3 x full)` |
|
||||
| D `parallel-sketch+progressive` | `~0.6t`, revealed on landing | `~0.6t` + fan-out | `~0.6 x (3 x full)` |
|
||||
|
||||
The point of the table is the shape, not the constants: A pays the full cost
|
||||
before the user sees anything, D reveals the first direction fast, finishes all
|
||||
three in about one direction's wall-clock, and spends the least output getting
|
||||
there.
|
||||
|
||||
## Measuring the agent-side numbers on your own machine
|
||||
|
||||
You need the authenticated agent, so run this by hand:
|
||||
|
||||
1. Pick one brief and hold it fixed across all four configs. Use a heavy brief
|
||||
(the kind where `--sketch` is offered unprompted), or one of `eval/briefs.json`
|
||||
`b1`..`b3`.
|
||||
2. For each config, note three timestamps: **start** (agent begins building),
|
||||
**first ready** (first direction shows in the picker), **all ready** (last
|
||||
direction flips). TTFD = first - start; TTAR = all - start. The manifest is
|
||||
the source of truth: each direction row carries `status`, and the picker's
|
||||
2s poll flips it, so watching the picker is enough. For an exact log, tail
|
||||
the run's `manifest.json` and stamp when each `status` becomes `"ready"`.
|
||||
3. Record total output tokens from your harness's usage report.
|
||||
4. Repeat 2 to 3 times per config and take the median; model latency is noisy.
|
||||
5. Keep the brief, model, and machine identical across configs. You are
|
||||
measuring the config, not the weather.
|
||||
|
||||
Report as a 4-row table (one row per config) with TTFD, TTAR, output tokens,
|
||||
and the derived ratios against config A.
|
||||
|
||||
## Measuring the mechanical numbers here
|
||||
|
||||
```sh
|
||||
node eval/variants-bench.mjs # n=3 pages, table
|
||||
node eval/variants-bench.mjs --n 5 # stress the thumbnail step
|
||||
node eval/variants-bench.mjs --json # machine-readable
|
||||
node eval/variants-bench.mjs --keep # leave the run + server up to poke at
|
||||
```
|
||||
|
||||
It reports:
|
||||
|
||||
- **server cold-start** - `start.mjs` spawning `serve.mjs` until the picker is
|
||||
listening and the `PICKER` line prints. This is the fixed startup tax paid
|
||||
once per run, independent of how many directions exist.
|
||||
- **picker first-paint** - `GET /` for the static picker shell (median of 5).
|
||||
The shell is a constant string, so it paints in well under a millisecond and,
|
||||
crucially, paints *before any direction exists*. That is the mechanical proof
|
||||
of progressive-first: `start.mjs` can bring the picker up over an empty or
|
||||
all-`generating` manifest, so the user gets skeleton cards immediately and
|
||||
each direction fills in on a later poll.
|
||||
- **state read** - `GET /api/state` (median of 5), which re-reads the manifest
|
||||
from disk on every hit. This is what the 2s poll costs; it stays flat as
|
||||
directions are added.
|
||||
- **thumbnails** - `thumbs.mjs` screenshotting every `ready` page to
|
||||
`<run>/thumbs/<n>.png`, plus the per-thumbnail average. Skipped cleanly with a
|
||||
`pending` note when `thumbs.mjs` is not present, and `n/a` when no screenshot
|
||||
engine is installed (set `CHROME_PATH`, or install `puppeteer-core`, same
|
||||
dual-engine pattern as `eval/screenshot.mjs`).
|
||||
|
||||
Sample shape (numbers vary by machine):
|
||||
|
||||
```
|
||||
server cold-start ~0.3 s start.mjs -> serve.mjs listening + PICKER
|
||||
picker first-paint ~0.5 ms GET / (static shell, median of 5)
|
||||
state read ~0.5 ms GET /api/state (manifest reread, median of 5)
|
||||
thumbnails (3/3) ~1.8 s thumbs.mjs, all ready pages
|
||||
per-thumbnail ~0.6 s avg over 3 pages
|
||||
```
|
||||
|
||||
Read the mechanical table as the floor under the agent-side numbers: cold-start
|
||||
and first-paint are the plumbing cost the user pays regardless of model speed,
|
||||
and they are small enough that TTFD is dominated by generation time, which is
|
||||
exactly why parallel + sketch + progressive is the configuration that moves it.
|
||||
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env node
|
||||
// variants-bench.mjs: time the MECHANICAL parts of a hallmark variants run that
|
||||
// we can measure WITHOUT an agent in the loop - server cold-start, picker
|
||||
// first-paint, and thumbnail generation for N pages. Prints a small table.
|
||||
//
|
||||
// This is the "measurable-here" half of eval/variants-bench.md. The other half
|
||||
// (time-to-first-direction, time-to-all-ready, output tokens) needs a live
|
||||
// authenticated agent and is measured by hand per the doc; nothing here calls a
|
||||
// model or the network beyond loopback.
|
||||
//
|
||||
// Usage:
|
||||
// node eval/variants-bench.mjs [--n 3] [--keep] [--json]
|
||||
// --n how many greenfield pages to stand up and thumbnail (default 3)
|
||||
// --keep leave the temp run dir on disk and the picker running (for poking)
|
||||
// --json emit the results as one JSON object instead of the table
|
||||
//
|
||||
// Zero npm dependencies. Spins a throwaway run dir in the OS temp dir, drives
|
||||
// the real scripts/variants/{start,thumbs,await}.mjs, then tears everything
|
||||
// down. Thumbnail timing is skipped cleanly when thumbs.mjs is absent (it is a
|
||||
// v2 addition) or when no screenshot engine is installed.
|
||||
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import http from "node:http";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const VARIANTS = path.resolve(HERE, "..", "skills", "hallmark", "scripts", "variants");
|
||||
const START = path.join(VARIANTS, "start.mjs");
|
||||
const THUMBS = path.join(VARIANTS, "thumbs.mjs");
|
||||
const AWAIT = path.join(VARIANTS, "await.mjs");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// args
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { n: 3, keep: false, json: false };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === "--n") out.n = Math.max(1, Math.min(9, parseInt(argv[++i], 10) || 3));
|
||||
else if (a === "--keep") out.keep = true;
|
||||
else if (a === "--json") out.json = true;
|
||||
else if (a === "--help" || a === "-h") {
|
||||
console.log("usage: node eval/variants-bench.mjs [--n 3] [--keep] [--json]");
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// timing helpers
|
||||
|
||||
function now() { return Number(process.hrtime.bigint() / 1000n) / 1000; } // ms, sub-ms resolution
|
||||
|
||||
function median(nums) {
|
||||
if (!nums.length) return null;
|
||||
const s = nums.slice().sort((a, b) => a - b);
|
||||
const mid = s.length >> 1;
|
||||
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
|
||||
}
|
||||
|
||||
/** One raw GET against loopback; resolves { ms, code, bytes } or { error }. */
|
||||
function timedGet(port, pathname, timeoutMs = 4000) {
|
||||
return new Promise((resolve) => {
|
||||
const t0 = now();
|
||||
const req = http.get({ host: "127.0.0.1", port, path: pathname, timeout: timeoutMs }, (res) => {
|
||||
let bytes = 0;
|
||||
res.on("data", (c) => (bytes += c.length));
|
||||
res.on("end", () => resolve({ ms: now() - t0, code: res.statusCode, bytes }));
|
||||
});
|
||||
req.on("error", (e) => resolve({ error: String(e.message || e) }));
|
||||
req.on("timeout", () => { req.destroy(); resolve({ error: "timeout" }); });
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// a throwaway greenfield run: N tiny self-contained pages + a ready manifest
|
||||
|
||||
const HUES = [210, 28, 152, 320, 48, 265, 0, 180, 96];
|
||||
|
||||
function mockPage(n) {
|
||||
const hue = HUES[(n - 1) % HUES.length];
|
||||
// Deliberately trivial but distinct per n, so thumbnails differ and render fast.
|
||||
return "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">" +
|
||||
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">" +
|
||||
"<title>bench direction " + n + "</title><style>" +
|
||||
"html,body{margin:0;height:100%}" +
|
||||
"body{display:grid;place-items:center;font-family:system-ui,sans-serif;" +
|
||||
"background:hsl(" + hue + " 70% 96%);color:hsl(" + hue + " 60% 22%)}" +
|
||||
".b{text-align:center}.b h1{font-size:64px;margin:0 0 12px;letter-spacing:-.02em}" +
|
||||
".b p{font:14px ui-monospace,monospace;color:hsl(" + hue + " 30% 45%)}" +
|
||||
".bar{height:8px;width:220px;margin:20px auto 0;border-radius:99px;background:hsl(" + hue + " 70% 50%)}" +
|
||||
"</style></head><body><div class=\"b\"><h1>Direction " + n + "</h1>" +
|
||||
"<p>variants-bench synthetic page</p><div class=\"bar\"></div></div></body></html>\n";
|
||||
}
|
||||
|
||||
function scaffold(n) {
|
||||
const base = fs.mkdtempSync(path.join(os.tmpdir(), "hm-variants-bench-"));
|
||||
const RUN = path.join(base, "2026-bench-a");
|
||||
const directions = [];
|
||||
for (let i = 1; i <= n; i++) {
|
||||
fs.mkdirSync(path.join(RUN, "v" + i), { recursive: true });
|
||||
fs.writeFileSync(path.join(RUN, "v" + i, "index.html"), mockPage(i));
|
||||
directions.push({
|
||||
n: i, title: "Bench " + i, macrostructure: "Synthetic", theme: "Hue " + HUES[(i - 1) % HUES.length],
|
||||
nav: "N0", footer: "Ft0", axes: "bench", url: "/frame/" + i + "/", status: "ready",
|
||||
});
|
||||
}
|
||||
fs.mkdirSync(path.join(RUN, "requests", "done"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(RUN, "manifest.json"),
|
||||
JSON.stringify({ run: "2026-bench-a", mode: "greenfield", brief: "variants-bench", devServer: null, directions, picked: null }, null, 2) + "\n",
|
||||
);
|
||||
return { base, RUN };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// steps
|
||||
|
||||
/** Cold-start the picker via start.mjs; returns { ms, port } or { ms, error }. */
|
||||
function coldStart(RUN) {
|
||||
const t0 = now();
|
||||
const r = spawnSync(process.execPath, [START, "--run", RUN], { encoding: "utf8", timeout: 20000 });
|
||||
const ms = now() - t0;
|
||||
const line = String(r.stdout || "").split("\n").find((l) => l.startsWith("PICKER"));
|
||||
const port = line && line.match(/127\.0\.0\.1:(\d+)/)?.[1];
|
||||
if (!port) return { ms, error: (r.stderr || r.stdout || "no PICKER line").trim().slice(0, 160) };
|
||||
return { ms, port: Number(port) };
|
||||
}
|
||||
|
||||
async function firstPaint(port, pathname, samples = 5) {
|
||||
const runs = [];
|
||||
let code = null, err = null;
|
||||
for (let i = 0; i < samples; i++) {
|
||||
const r = await timedGet(port, pathname);
|
||||
if (r.error) { err = r.error; break; }
|
||||
code = r.code; runs.push(r.ms);
|
||||
}
|
||||
return { ms: median(runs), samples: runs.length, code, error: err };
|
||||
}
|
||||
|
||||
/** Time thumbs.mjs over the run; returns a status object. Never throws. */
|
||||
function thumbnails(RUN, n) {
|
||||
if (!fs.existsSync(THUMBS)) return { state: "pending", note: "thumbs.mjs not present yet (v2 addition)" };
|
||||
const t0 = now();
|
||||
const r = spawnSync(process.execPath, [THUMBS, "--run", RUN], { encoding: "utf8", timeout: 120000 });
|
||||
const ms = now() - t0;
|
||||
let made = 0;
|
||||
try { made = fs.readdirSync(path.join(RUN, "thumbs")).filter((f) => f.endsWith(".png")).length; } catch { /* none */ }
|
||||
if (made === 0) {
|
||||
const why = (r.stderr || r.stdout || "no thumbs written").toString().trim().slice(0, 160);
|
||||
return { state: "unavailable", ms, note: why || "no screenshot engine (install Chrome or puppeteer-core)" };
|
||||
}
|
||||
return { state: "ok", ms, made, perThumb: made ? ms / made : null, wanted: n };
|
||||
}
|
||||
|
||||
function stopServer(RUN) {
|
||||
try { spawnSync(process.execPath, [AWAIT, "--run", RUN, "--stop"], { encoding: "utf8", timeout: 6000 }); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// output
|
||||
|
||||
function fmtMs(ms) { return ms == null ? "-" : (ms >= 1000 ? (ms / 1000).toFixed(2) + " s" : ms.toFixed(1) + " ms"); }
|
||||
|
||||
function printTable(rows) {
|
||||
const wLabel = Math.max(...rows.map((r) => r[0].length), 5);
|
||||
const wVal = Math.max(...rows.map((r) => r[1].length), 5);
|
||||
const line = " " + "-".repeat(wLabel) + " " + "-".repeat(wVal) + " " + "-".repeat(28);
|
||||
console.log(line);
|
||||
for (const [label, val, note] of rows) {
|
||||
console.log(" " + label.padEnd(wLabel) + " " + val.padEnd(wVal) + " " + (note || ""));
|
||||
}
|
||||
console.log(line);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// main
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const engines = { chrome: fs.existsSync(process.env.CHROME_PATH || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome") };
|
||||
|
||||
const { base, RUN } = scaffold(args.n);
|
||||
const result = { n: args.n, coldStartMs: null, port: null, pickerPaintMs: null, statePaintMs: null, thumbs: null };
|
||||
|
||||
let cs;
|
||||
try {
|
||||
cs = coldStart(RUN);
|
||||
result.coldStartMs = cs.ms;
|
||||
if (cs.error) {
|
||||
result.error = cs.error;
|
||||
} else {
|
||||
result.port = cs.port;
|
||||
const picker = await firstPaint(cs.port, "/");
|
||||
const state = await firstPaint(cs.port, "/api/state");
|
||||
result.pickerPaintMs = picker.ms;
|
||||
result.statePaintMs = state.ms;
|
||||
result.thumbs = thumbnails(RUN, args.n);
|
||||
}
|
||||
} finally {
|
||||
if (!args.keep) {
|
||||
stopServer(RUN);
|
||||
try { fs.rmSync(base, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
if (args.json) { console.log(JSON.stringify(result, null, 2)); return; }
|
||||
|
||||
console.log("");
|
||||
console.log("hallmark variants - mechanical bench (n=" + args.n + " greenfield pages)");
|
||||
console.log("");
|
||||
if (result.error) {
|
||||
console.log(" cold-start FAILED: " + result.error);
|
||||
console.log(" (nothing else could run; check the variants scripts)");
|
||||
return;
|
||||
}
|
||||
const t = result.thumbs || {};
|
||||
const rows = [
|
||||
["step", "wall-clock", "detail"],
|
||||
["server cold-start", fmtMs(result.coldStartMs), "start.mjs -> serve.mjs listening + PICKER"],
|
||||
["picker first-paint", fmtMs(result.pickerPaintMs), "GET / (static shell, median of 5)"],
|
||||
["state read", fmtMs(result.statePaintMs), "GET /api/state (manifest reread, median of 5)"],
|
||||
];
|
||||
if (t.state === "ok") {
|
||||
rows.push(["thumbnails (" + t.made + "/" + t.wanted + ")", fmtMs(t.ms), "thumbs.mjs, all ready pages"]);
|
||||
rows.push(["per-thumbnail", fmtMs(t.perThumb), "avg over " + t.made + " pages"]);
|
||||
} else if (t.state === "unavailable") {
|
||||
rows.push(["thumbnails", "n/a", t.note]);
|
||||
} else {
|
||||
rows.push(["thumbnails", "pending", t.note]);
|
||||
}
|
||||
printTable(rows.slice(1)); // header row handled by columns themselves
|
||||
console.log("");
|
||||
console.log(" port " + result.port + " · picker paints before any direction exists (progressive-first).");
|
||||
console.log(" This measures the plumbing only. Agent-side timings live in eval/variants-bench.md.");
|
||||
if (!engines.chrome && (!t || t.state !== "ok")) {
|
||||
console.log(" Note: no Chrome at the default path; set CHROME_PATH to enable thumbnail timing.");
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,388 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Hallmark variants · the picker loop</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#141416; --card:#1d1d21; --line:#2c2c31; --fg:#e9e7e2; --mut:#8f8d86;
|
||||
--acc:#6ea8fe; --ok:#3ecf8e; --mono:ui-monospace,"SF Mono",Menlo,monospace;
|
||||
}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
html,body{overflow-x:clip}
|
||||
body{
|
||||
background:var(--bg); color:var(--fg);
|
||||
font-family:system-ui,-apple-system,"Segoe UI",sans-serif;
|
||||
-webkit-font-smoothing:antialiased; min-height:100vh; padding-bottom:64px;
|
||||
}
|
||||
header{
|
||||
display:flex; align-items:center; gap:12px; flex-wrap:wrap;
|
||||
padding:16px clamp(18px,4vw,28px); border-bottom:1px solid var(--line);
|
||||
position:sticky; top:0; background:var(--bg); z-index:5;
|
||||
}
|
||||
header b{font-size:15px; letter-spacing:.01em}
|
||||
header .tag{font-family:var(--mono); font-size:11px; color:var(--mut)}
|
||||
#note{margin-left:auto; font-family:var(--mono); font-size:11.5px; color:var(--mut)}
|
||||
#note.ok{color:var(--ok)}
|
||||
.hbtn{
|
||||
font:inherit; font-size:12px; color:var(--fg); cursor:pointer;
|
||||
border:1px solid var(--line); background:var(--card); border-radius:999px; padding:5px 12px;
|
||||
}
|
||||
.hbtn:hover{border-color:var(--acc)}
|
||||
|
||||
/* -------- grid of directions -------- */
|
||||
.grid{
|
||||
display:grid; grid-template-columns:repeat(auto-fill,minmax(280px,1fr));
|
||||
gap:26px 22px; padding:26px clamp(18px,4vw,28px) 8px;
|
||||
max-width:1160px; margin:0 auto;
|
||||
}
|
||||
.card{background:none; border:0; padding:0; text-align:left; cursor:pointer; color:inherit; font:inherit}
|
||||
.thumb{
|
||||
position:relative; width:100%; aspect-ratio:1024/640; overflow:hidden;
|
||||
border:1px solid var(--line); border-radius:10px; background:#0c0c0e;
|
||||
}
|
||||
.card:hover .thumb,.card:focus-visible .thumb{border-color:var(--acc)}
|
||||
.card:focus-visible{outline:none}
|
||||
.meta{display:flex; flex-direction:column; gap:3px; padding:10px 2px 0}
|
||||
.meta .t{font-weight:650; font-size:14px}
|
||||
.meta .t i{font-style:normal; color:var(--mut); font-family:var(--mono); font-size:12px; margin-right:8px}
|
||||
.meta .m{font-family:var(--mono); font-size:11px; color:var(--mut)}
|
||||
|
||||
/* -------- skeleton (building) -------- */
|
||||
.ph{position:absolute; inset:0; display:flex; flex-direction:column; gap:10px; padding:16px; background:var(--card)}
|
||||
.ph .b{background:linear-gradient(90deg,#232329,#2c2c33,#232329); background-size:200% 100%; border-radius:6px; animation:sh 1.4s ease-in-out infinite}
|
||||
.ph .b1{height:16px; width:52%}
|
||||
.ph .b2{height:44px; width:82%; margin-top:4px}
|
||||
.ph .b3{flex:1; width:100%}
|
||||
.ph .lbl{position:absolute; left:16px; bottom:14px; font-family:var(--mono); font-size:11px; color:var(--mut)}
|
||||
@keyframes sh{0%{background-position:200% 0}100%{background-position:-200% 0}}
|
||||
|
||||
/* -------- scaled mock stage -------- */
|
||||
.stage{position:absolute; inset:0; background:#fff}
|
||||
.stage .page{position:absolute; top:0; left:0; width:1024px; height:640px; transform:scale(var(--s,.27)); transform-origin:top left}
|
||||
|
||||
/* -------- single view -------- */
|
||||
#single{position:fixed; inset:0; background:var(--bg); display:none; flex-direction:column; z-index:20}
|
||||
#single.on{display:flex}
|
||||
#sbar{display:flex; align-items:center; gap:8px; flex-wrap:wrap; padding:10px clamp(12px,3vw,18px); border-bottom:1px solid var(--line)}
|
||||
#sbar button{font:inherit; font-size:12px; color:var(--fg); border:1px solid var(--line); background:var(--card); border-radius:999px; padding:6px 13px; cursor:pointer}
|
||||
#sbar button:hover{border-color:var(--acc)}
|
||||
#sbar .pick{background:var(--acc); border-color:var(--acc); color:#0d1420; font-weight:650}
|
||||
#sbar .count{font-family:var(--mono); font-size:11px; color:var(--mut); padding:0 2px}
|
||||
#sbar .st{font-weight:650; font-size:13px}
|
||||
#sbar .sm{font-family:var(--mono); font-size:11px; color:var(--mut)}
|
||||
#sbar .keys{margin-left:auto; font-family:var(--mono); font-size:10.5px; color:var(--mut)}
|
||||
#swrap{flex:1; display:grid; place-items:center; padding:clamp(14px,3vw,30px); min-height:0}
|
||||
#sstage{position:relative; width:min(1024px,100%); max-height:100%; aspect-ratio:1024/640; overflow:hidden; border:1px solid var(--line); border-radius:12px; background:#fff}
|
||||
#sstage .page{position:absolute; top:0; left:0; width:1024px; height:640px; transform:scale(var(--s,.6)); transform-origin:top left}
|
||||
|
||||
/* graft prompt */
|
||||
.graftbox{
|
||||
position:absolute; top:52px; right:clamp(12px,3vw,18px); z-index:30;
|
||||
background:var(--card); border:1px solid var(--line); border-radius:12px;
|
||||
padding:14px; width:min(320px,88vw); box-shadow:0 18px 44px -16px rgba(0,0,0,.7);
|
||||
display:none; flex-direction:column; gap:10px;
|
||||
}
|
||||
.graftbox.on{display:flex}
|
||||
.graftbox p{font-size:12.5px; color:var(--mut); line-height:1.45}
|
||||
.graftbox input{
|
||||
font:inherit; font-size:13px; color:var(--fg); background:#141417;
|
||||
border:1px solid var(--line); border-radius:8px; padding:8px 10px;
|
||||
}
|
||||
.graftbox input:focus{outline:none; border-color:var(--acc)}
|
||||
.graftbox .rowb{display:flex; gap:8px; justify-content:flex-end}
|
||||
.graftbox button{font:inherit; font-size:12px; cursor:pointer; border-radius:999px; padding:6px 13px}
|
||||
.graftbox .go{background:var(--acc); border:1px solid var(--acc); color:#0d1420; font-weight:650}
|
||||
.graftbox .cancel{background:none; border:1px solid var(--line); color:var(--fg)}
|
||||
|
||||
/* caption */
|
||||
.caption{max-width:760px; margin:34px auto 0; padding:0 clamp(18px,4vw,28px)}
|
||||
.caption h2{font-size:16px; font-weight:650; margin-bottom:6px}
|
||||
.caption p{font-size:13.5px; color:var(--mut); line-height:1.6; margin-top:10px}
|
||||
.caption b{color:var(--fg); font-weight:600}
|
||||
.caption .legend{
|
||||
margin-top:16px; display:grid; gap:8px; font-family:var(--mono); font-size:11.5px; color:var(--mut);
|
||||
border:1px solid var(--line); border-radius:10px; padding:14px;
|
||||
}
|
||||
.caption .legend span b{color:var(--fg)}
|
||||
|
||||
/* ============ the three mock layouts (base 1024x640) ============ */
|
||||
/* 1 · the broadsheet - warm newsprint, roman serif, columned */
|
||||
.m1{background:#f4efe4; color:#23201a; font-family:Georgia,"Times New Roman",serif; padding:44px 52px}
|
||||
.m1-mast{display:flex; justify-content:space-between; align-items:baseline; font-family:var(--mono); font-size:13px; letter-spacing:.28em; text-transform:uppercase; color:#6b5f4d; border-bottom:3px solid #23201a; padding-bottom:12px}
|
||||
.m1-h{font-size:74px; line-height:1.02; letter-spacing:-.015em; margin:26px 0 8px; max-width:15ch}
|
||||
.m1-deck{font-style:italic; font-size:22px; color:#5a5140; margin-bottom:24px}
|
||||
.m1-body{column-count:3; column-gap:34px; font-size:15px; line-height:1.62; color:#3a352b; text-align:justify}
|
||||
.m1-body p{margin-bottom:12px}
|
||||
.m1-drop{float:left; font-size:58px; line-height:.8; padding:4px 8px 0 0; font-weight:700}
|
||||
.m1-cut{break-inside:avoid; height:120px; margin:6px 0 12px; border-radius:4px; background:linear-gradient(135deg,#c9b48f,#9c8560)}
|
||||
|
||||
/* 2 · the control room - dark cobalt workbench, mono, tiles */
|
||||
.m2{background:#0f1620; color:#d7e3f2; font-family:var(--mono); padding:38px 44px}
|
||||
.m2-bar{display:flex; align-items:center; gap:12px; font-size:16px; letter-spacing:.02em; padding-bottom:22px; border-bottom:1px solid #1f2e40}
|
||||
.m2-dot{width:11px; height:11px; border-radius:99px; background:#3ecf8e; box-shadow:0 0 12px #3ecf8e}
|
||||
.m2-chips{margin-left:auto; display:flex; gap:8px}
|
||||
.m2-chips i{font-style:normal; font-size:12px; color:#8fb4e0; border:1px solid #26405c; border-radius:6px; padding:4px 10px}
|
||||
.m2-grid{display:grid; grid-template-columns:1.4fr 1fr 1fr; grid-auto-rows:118px; gap:16px; margin-top:24px}
|
||||
.m2-kpi{grid-row:span 2; background:#14202e; border:1px solid #22384f; border-radius:12px; padding:22px; display:flex; flex-direction:column; justify-content:space-between}
|
||||
.m2-kpi b{font-size:64px; font-weight:600; color:#eaf2fc; letter-spacing:-.02em}
|
||||
.m2-kpi span{font-size:14px; color:#7f9cbc}
|
||||
.m2-tile{background:#14202e; border:1px solid #22384f; border-radius:12px; padding:16px; display:flex; flex-direction:column; gap:12px}
|
||||
.m2-tile u{font-style:normal; text-decoration:none; font-size:13px; color:#7f9cbc}
|
||||
.m2-tile b{font-size:30px; font-weight:600; color:#eaf2fc}
|
||||
.m2-spark{margin-top:auto; display:flex; align-items:flex-end; gap:4px; height:34px}
|
||||
.m2-spark i{flex:1; background:#2f6fd6; border-radius:2px}
|
||||
.m2-chart{grid-column:span 2; background:#14202e; border:1px solid #22384f; border-radius:12px; padding:16px 18px; display:flex; align-items:flex-end; gap:10px}
|
||||
.m2-chart i{flex:1; background:linear-gradient(180deg,#4b8ef0,#2f6fd6); border-radius:3px 3px 0 0}
|
||||
|
||||
/* 3 · the poster wall - midnight manifesto, chromatic, display type */
|
||||
.m3{background:#1b1822; color:#efe9f5; font-family:"Helvetica Neue",Arial,sans-serif; padding:48px 52px; display:flex; flex-direction:column; justify-content:center}
|
||||
.m3-eye{font-family:var(--mono); font-size:13px; letter-spacing:.34em; text-transform:uppercase; color:#b39cf0}
|
||||
.m3-h{font-size:118px; line-height:.9; font-weight:800; letter-spacing:-.03em; margin:18px 0 26px; color:#f7d774}
|
||||
.m3-h em{font-style:normal; color:#f78f6a}
|
||||
.m3-row{display:flex; gap:12px; flex-wrap:wrap}
|
||||
.m3-row i{font-style:normal; font-size:16px; color:#efe9f5; border:1px solid #4a4160; border-radius:999px; padding:9px 20px}
|
||||
.m3-row i:first-child{background:#efe9f5; color:#1b1822; border-color:#efe9f5; font-weight:600}
|
||||
|
||||
@media (max-width:520px){
|
||||
.m1-body{column-count:1}
|
||||
.m3-h{font-size:92px}
|
||||
}
|
||||
@media (prefers-reduced-motion:reduce){
|
||||
.ph .b{animation:none; background:#26262d}
|
||||
*{transition:none !important}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<b>hallmark variants</b>
|
||||
<span class="tag">· interactive demo (no server)</span>
|
||||
<button class="hbtn" id="replay" type="button">Replay reveal</button>
|
||||
<span id="note"></span>
|
||||
</header>
|
||||
|
||||
<main class="grid" id="grid" aria-label="direction previews"></main>
|
||||
|
||||
<section class="caption">
|
||||
<h2>What you are looking at</h2>
|
||||
<p>This is a client-only mock of the real <b>hallmark variants</b> picker. One
|
||||
brief, three structurally distinct directions, side by side in your own browser.
|
||||
In a real run each card is a live full-size page; here they are simple CSS mocks
|
||||
so the loop is the point, not the pixels.</p>
|
||||
<p>The cards fill in <b>one at a time</b>: that is progressive reveal. The real
|
||||
picker writes each direction to the manifest as <b>generating</b>, then flips it
|
||||
to <b>ready</b> the moment its files land, so the first direction is viewable
|
||||
after roughly one direction's worth of work instead of waiting for all three.</p>
|
||||
<p>Click a card, or press <b>1</b> / <b>2</b> / <b>3</b>, to open the single
|
||||
surface. <b>Arrow keys</b> flip between directions on the same spot, so you
|
||||
compare by muscle memory rather than by hunting tabs. <b>Pick</b> keeps one
|
||||
direction: only the winner gets finished and shipped. <b>Graft</b> composes,
|
||||
it takes one section from another direction into the winner (try "pricing from
|
||||
3"). And in a real run a plain chat reply naming a direction is always a valid
|
||||
pick, so the flow never dead-ends.</p>
|
||||
<div class="legend" aria-hidden="true">
|
||||
<span><b>arrows</b> flip directions in place</span>
|
||||
<span><b>1 2 3</b> jump to a direction</span>
|
||||
<span><b>P</b> pick this one (only the winner ships)</span>
|
||||
<span><b>G</b> graft a section from another direction</span>
|
||||
<span><b>Esc</b> back to the grid</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- single view -->
|
||||
<div id="single" role="dialog" aria-modal="true" aria-label="direction single view">
|
||||
<div id="sbar">
|
||||
<button id="back" type="button" title="back to the grid (Esc)">‹ grid</button>
|
||||
<button id="prev" type="button" title="previous (ArrowLeft)">‹</button>
|
||||
<button id="next" type="button" title="next (ArrowRight)">›</button>
|
||||
<span class="count" id="count"></span>
|
||||
<span class="st" id="st"></span><span class="sm" id="sm"></span>
|
||||
<span class="keys">arrows flip · P pick · G graft · Esc grid</span>
|
||||
<button class="pick" id="pickBtn" type="button">Pick this (P)</button>
|
||||
<button id="graftBtn" type="button">Graft (G)</button>
|
||||
</div>
|
||||
<div id="swrap"><div id="sstage"></div></div>
|
||||
<div class="graftbox" id="graftbox">
|
||||
<p>Take a section from another direction into this one. Format: <b>section from N</b>.</p>
|
||||
<input id="graftInput" type="text" value="pricing from 3" aria-label="graft instruction" />
|
||||
<div class="rowb">
|
||||
<button class="cancel" id="graftCancel" type="button">Cancel</button>
|
||||
<button class="go" id="graftGo" type="button">Graft</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- mock templates, base 1024x640 -->
|
||||
<template id="tpl1"><div class="page m1">
|
||||
<div class="m1-mast"><span>The Broadsheet</span><span>Vol. IX · Friday</span></div>
|
||||
<h1 class="m1-h">A quieter way to read the long stuff</h1>
|
||||
<p class="m1-deck">Unhurried, columned, set in a warm roman serif.</p>
|
||||
<div class="m1-body">
|
||||
<p><span class="m1-drop">O</span>ne column bleeds into the next, the way a
|
||||
printed page asks you to slow down and follow a single thread from the top
|
||||
of the sheet to the fold without a card or a tile in the way.</p>
|
||||
<div class="m1-cut"></div>
|
||||
<p>The measure is narrow on purpose. Short lines keep the eye from losing
|
||||
its place, and the justification squares the page into something that feels
|
||||
edited rather than generated.</p>
|
||||
<p>A footer rule closes it, the masthead opens it, and everything between is
|
||||
body text doing the work that body text is supposed to do.</p>
|
||||
</div>
|
||||
</div></template>
|
||||
|
||||
<template id="tpl2"><div class="page m2">
|
||||
<div class="m2-bar"><span class="m2-dot"></span>control room<span class="m2-chips"><i>live</i><i>v2.4</i><i>ok</i></span></div>
|
||||
<div class="m2-grid">
|
||||
<div class="m2-kpi"><b>98.6%</b><span>fleet uptime · trailing 30 days</span></div>
|
||||
<div class="m2-tile"><u>throughput</u><b>1.2k/s</b><div class="m2-spark"><i style="height:40%"></i><i style="height:65%"></i><i style="height:48%"></i><i style="height:82%"></i><i style="height:70%"></i><i style="height:100%"></i></div></div>
|
||||
<div class="m2-tile"><u>p95 latency</u><b>42ms</b><div class="m2-spark"><i style="height:70%"></i><i style="height:55%"></i><i style="height:60%"></i><i style="height:40%"></i><i style="height:35%"></i><i style="height:30%"></i></div></div>
|
||||
<div class="m2-chart"><i style="height:52%"></i><i style="height:68%"></i><i style="height:44%"></i><i style="height:80%"></i><i style="height:62%"></i><i style="height:90%"></i><i style="height:58%"></i><i style="height:74%"></i></div>
|
||||
</div>
|
||||
</div></template>
|
||||
|
||||
<template id="tpl3"><div class="page m3">
|
||||
<div class="m3-eye">Manifesto / N.03</div>
|
||||
<h1 class="m3-h">Say it<br>once.<br>Say it loud.</h1>
|
||||
<div class="m3-row"><i>bold</i><i>plain</i><i>certain</i><i>no filler</i></div>
|
||||
</div></template>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
"use strict";
|
||||
var DIRS=[
|
||||
{n:1,title:"The broadsheet",macro:"Long Document",theme:"Newsprint",delay:700,ready:false},
|
||||
{n:2,title:"The control room",macro:"Workbench",theme:"Cobalt",delay:1500,ready:false},
|
||||
{n:3,title:"The poster wall",macro:"Manifesto",theme:"Midnight",delay:2400,ready:false}
|
||||
];
|
||||
var view="grid"; // "grid" or a direction number
|
||||
var timers=[];
|
||||
var reduce=false; try{reduce=window.matchMedia("(prefers-reduced-motion: reduce)").matches}catch(e){}
|
||||
|
||||
function $(id){return document.getElementById(id)}
|
||||
function note(msg,ok){var el=$("note");el.textContent=msg||"";el.className=ok?"ok":""}
|
||||
function dir(n){for(var i=0;i<DIRS.length;i++)if(DIRS[i].n===n)return DIRS[i];return null}
|
||||
function readyList(){return DIRS.filter(function(d){return d.ready})}
|
||||
|
||||
// one shared observer keeps every scaled mock fit to its container
|
||||
var ro=new ResizeObserver(function(entries){
|
||||
for(var i=0;i<entries.length;i++){
|
||||
var w=entries[i].contentRect.width;
|
||||
entries[i].target.style.setProperty("--s",(w/1024).toFixed(4));
|
||||
}
|
||||
});
|
||||
function mockPage(n){return $("tpl"+n).content.firstElementChild.cloneNode(true)}
|
||||
function makeStage(n){
|
||||
var s=document.createElement("div");s.className="stage";
|
||||
s.appendChild(mockPage(n));ro.observe(s);return s;
|
||||
}
|
||||
|
||||
// ---- grid ----
|
||||
function skeleton(){
|
||||
var ph=document.createElement("div");ph.className="ph";
|
||||
ph.innerHTML='<div class="b b1"></div><div class="b b2"></div><div class="b b3"></div><span class="lbl">building...</span>';
|
||||
return ph;
|
||||
}
|
||||
function buildGrid(){
|
||||
var g=$("grid");g.innerHTML="";
|
||||
DIRS.forEach(function(d){
|
||||
var card=document.createElement("button");card.className="card";card.type="button";
|
||||
card.setAttribute("data-n",d.n);
|
||||
var thumb=document.createElement("div");thumb.className="thumb";
|
||||
thumb.appendChild(d.ready?makeStage(d.n):skeleton());
|
||||
var meta=document.createElement("div");meta.className="meta";
|
||||
meta.innerHTML='<span class="t"><i>'+d.n+'</i>'+d.title+'</span><span class="m">'+d.macro+' · '+d.theme+'</span>';
|
||||
card.appendChild(thumb);card.appendChild(meta);
|
||||
card.addEventListener("click",function(){if(d.ready)openDir(d.n)});
|
||||
g.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- progressive reveal ----
|
||||
function startReveal(){
|
||||
timers.forEach(clearTimeout);timers=[];
|
||||
DIRS.forEach(function(d){d.ready=false});
|
||||
view="grid";$("single").classList.remove("on");
|
||||
buildGrid();
|
||||
note("building 3 directions...");
|
||||
DIRS.forEach(function(d){
|
||||
timers.push(setTimeout(function(){
|
||||
d.ready=true;buildGrid();
|
||||
var done=readyList().length;
|
||||
if(done===DIRS.length)note("3 directions ready · flip with arrows, Pick when one clicks",true);
|
||||
else note("direction "+done+" ready · still building "+(DIRS.length-done)+"...");
|
||||
},reduce?60*d.n:d.delay));
|
||||
});
|
||||
}
|
||||
|
||||
// ---- single view ----
|
||||
function openDir(n){var d=dir(n);if(!d||!d.ready)return;view=n;$("single").classList.add("on");sync()}
|
||||
function closeSingle(){view="grid";$("single").classList.remove("on");closeGraft()}
|
||||
function cycle(step){
|
||||
var list=readyList();if(!list.length)return;
|
||||
var idx=0;for(var i=0;i<list.length;i++)if(list[i].n===view)idx=i;
|
||||
idx=(idx+step+list.length)%list.length;view=list[idx].n;sync();
|
||||
}
|
||||
function sync(){
|
||||
var d=dir(view);if(!d){closeSingle();return}
|
||||
var list=readyList(),pos=1;for(var i=0;i<list.length;i++)if(list[i].n===view)pos=i+1;
|
||||
$("count").textContent="Direction "+pos+"/"+list.length;
|
||||
$("st").textContent=d.n+" · "+d.title;
|
||||
$("sm").textContent=d.macro+" · "+d.theme;
|
||||
var wrap=$("sstage");wrap.innerHTML="";
|
||||
var page=mockPage(d.n);wrap.appendChild(page);ro.observe(wrap);
|
||||
$("pickBtn").textContent="Pick this (P)";
|
||||
closeGraft();
|
||||
}
|
||||
function pick(){
|
||||
if(view==="grid")return;var d=dir(view);
|
||||
note("picked: "+d.title+" ✓ · only the winner gets finished and shipped",true);
|
||||
$("pickBtn").textContent="Picked ✓";
|
||||
}
|
||||
|
||||
// ---- graft ----
|
||||
function openGraft(){if(view==="grid")return;$("graftbox").classList.add("on");var i=$("graftInput");i.focus();i.select()}
|
||||
function closeGraft(){$("graftbox").classList.remove("on")}
|
||||
function doGraft(){
|
||||
var raw=$("graftInput").value.trim();
|
||||
var m=raw.match(/^(.+?)\s+from\s+(\d+)$/i);
|
||||
if(!m){note("graft needs the form: section from N (e.g. pricing from 3)");return}
|
||||
var section=m[1].trim(),from=parseInt(m[2],10),into=dir(view);
|
||||
if(!dir(from)||from===view){note("pick a different, ready direction to graft from");return}
|
||||
closeGraft();
|
||||
note("grafted the "+section+" section from direction "+from+" into "+into.title+" ✓",true);
|
||||
}
|
||||
|
||||
// ---- keys (guarded against focused inputs) ----
|
||||
document.addEventListener("keydown",function(e){
|
||||
var el=document.activeElement;
|
||||
if(el&&(el.tagName==="INPUT"||el.tagName==="TEXTAREA"||el.isContentEditable)){
|
||||
if(e.key==="Escape"){closeGraft();el.blur()}
|
||||
return;
|
||||
}
|
||||
var k=parseInt(e.key,10);
|
||||
if(view==="grid"){if(k>=1&&k<=DIRS.length)openDir(k);return}
|
||||
if(e.key==="Escape")closeSingle();
|
||||
else if(e.key==="ArrowLeft"){e.preventDefault();cycle(-1)}
|
||||
else if(e.key==="ArrowRight"){e.preventDefault();cycle(1)}
|
||||
else if(e.key==="p"||e.key==="P")pick();
|
||||
else if(e.key==="g"||e.key==="G")openGraft();
|
||||
else if(k>=1&&k<=DIRS.length)openDir(k);
|
||||
});
|
||||
|
||||
$("back").addEventListener("click",closeSingle);
|
||||
$("prev").addEventListener("click",function(){cycle(-1)});
|
||||
$("next").addEventListener("click",function(){cycle(1)});
|
||||
$("pickBtn").addEventListener("click",pick);
|
||||
$("graftBtn").addEventListener("click",function(){$("graftbox").classList.contains("on")?closeGraft():openGraft()});
|
||||
$("graftCancel").addEventListener("click",closeGraft);
|
||||
$("graftGo").addEventListener("click",doGraft);
|
||||
$("graftInput").addEventListener("keydown",function(e){if(e.key==="Enter")doGraft()});
|
||||
$("replay").addEventListener("click",startReveal);
|
||||
|
||||
startReveal();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,95 @@
|
||||
# Hallmark variants v2 vs the field
|
||||
|
||||
An honest look at where the Hallmark variants loop sits next to the other tools
|
||||
that let you see more than one design before you commit. The competitors are
|
||||
described by shape, not by name, because the point is the pattern, not the logo.
|
||||
Everything claimed for Hallmark below is a real v2 feature, cited to the
|
||||
machinery in `skills/hallmark/scripts/variants/` and the flow in
|
||||
`references/verbs/variants.md`.
|
||||
|
||||
## The shared promise
|
||||
|
||||
Every tool in this space sells the same insight: a single generated design is a
|
||||
bet, and you make a better call when you can hold two or three side by side. The
|
||||
disagreement is entirely about the loop around that comparison, and that is
|
||||
where the friction lives.
|
||||
|
||||
## The four shapes in the field
|
||||
|
||||
**Canvas-branching tools.** You work on an infinite spatial canvas. Each
|
||||
generation drops in as a node; you branch, fork, and fan out variations as more
|
||||
nodes, then pan and zoom to compare. Powerful for open-ended ideation, but the
|
||||
comparison is spatial: the two things you want to weigh are rarely the same size
|
||||
in the same place, so you judge by panning between them, and the canvas state is
|
||||
a separate artifact from your codebase.
|
||||
|
||||
**The section-by-section studio pattern.** A dedicated studio UI builds a page
|
||||
one region at a time and lets you iterate each region in place. Precise for
|
||||
refining a single composition, but it optimizes depth on one design rather than
|
||||
breadth across several, and the studio is another surface to live in alongside
|
||||
your editor.
|
||||
|
||||
**The injected-overlay tools.** A widget or toolbar is injected into your
|
||||
running app so you can select and tweak elements where they actually render.
|
||||
Honest about context because it is your real app, but it is built to edit one
|
||||
live thing, and the panel is a persistent passenger in your app while you use it.
|
||||
|
||||
**The tab-switcher generators.** You prompt, get N options rendered into tabs or
|
||||
a strip, click through them, and keep one. Fast and familiar, but the options
|
||||
usually all arrive at once after the full wait, the losers evaporate when you
|
||||
close the tab, and "keep one" is the only verb: there is no way to take the good
|
||||
section from option 3 into option 1.
|
||||
|
||||
## Friction-point comparison
|
||||
|
||||
| Friction point | The field's usual shape | Hallmark variants v2 |
|
||||
| --- | --- | --- |
|
||||
| Where you compare | A canvas app, a studio UI, an in-app panel, or a web tool's tab strip: a surface away from your terminal | Your own terminal starts it; your own browser shows it. The picker is a single dark surface you already trust. |
|
||||
| When the first option appears | Most wait for all options before showing anything | Progressive reveal: each direction is written to the manifest as `generating` and flipped to `ready` the moment its files land, so the first is viewable after about one direction's work, not three. |
|
||||
| How you weigh two designs | Pan across a canvas, or click between tabs that reflow | Single-surface flip: arrow keys and number keys swap directions on the exact same spot, so differences register by muscle memory instead of by hunting. |
|
||||
| Composing across options | "Keep one" is usually the only move | Compositional graft: from the winner you post `section from N` (for example "pricing from 3") and the good section from another direction comes into the one you are keeping. |
|
||||
| Rendering fidelity | Often a sandbox preview divorced from your stack | Routes mode renders each direction as a throwaway route inside your real app, so your fonts, components, and tokens are the ones being compared. Greenfield mode serves standalone frames when there is no dev server. |
|
||||
| What survives the decision | Canvas nodes and tabs are ephemeral; losers vanish | Durable decisions: all three directions are archived to `.hallmark/variants/<run-id>/`, the winner's tuple is logged, and the rotation memory steers future runs away from all three. "Show me the 3 again" is free. |
|
||||
| Failure behavior | If the tool's service or preview breaks, the loop stalls | The flow never dead-ends: a plain chat reply naming a direction is always a valid pick, a serverless static `compare.html` is one flag away, and the on-page chip degrades to "tell your agent: pick 2" if the helper dies. |
|
||||
| Cost of looking | Full generation of every option, every time | Sketch-default speed: `--sketch` builds hero plus one signature section plus footer per direction, and only the winner is completed to full depth after the pick, so you pay full price once, not three times. |
|
||||
| Seeing it over your real app | The in-app tools own this; most others cannot | Scoped preview injection (v2): a small dev-only overlay drops the chosen direction over your running app as a preview, dismissible, keyboard-guarded, and strictly preview-only. It never writes to your source; the winner still ships as a normal Hallmark build. |
|
||||
|
||||
## Where Hallmark variants wins
|
||||
|
||||
1. **Terminal-native single surface.** No new app to learn and no canvas to pan.
|
||||
The agent you are already talking to opens one picker in your browser, and
|
||||
arrows plus number keys do all the comparing.
|
||||
2. **Progressive reveal.** You are looking at the first direction while the
|
||||
others are still being written, which is the difference between waiting and
|
||||
deciding.
|
||||
3. **Compositional grafts.** The loop has a verb beyond "keep one." Taking a
|
||||
section from one direction into another turns three finished bets into a
|
||||
parts bin you can assemble from.
|
||||
4. **Durable decisions.** Nothing you generated is thrown away. The archive plus
|
||||
the rotation log means a comparison you ran today informs the next run instead
|
||||
of evaporating when you close a tab.
|
||||
5. **Never dead-ends.** Server down, offline, or a harness with no browser at
|
||||
all: a chat reply still picks, and a self-contained static page still lets you
|
||||
look. The decision is never hostage to a running process.
|
||||
6. **Sketch-default speed.** Looking at three directions should not cost three
|
||||
times a build. Sketch depth trims the drafts and completes only the winner, so
|
||||
breadth is cheap and finish is reserved for the page that earns it.
|
||||
|
||||
## Where the other shapes still lead (the honest part)
|
||||
|
||||
- **Open-ended spatial ideation.** If your goal is to fan out twenty loosely
|
||||
related concepts and rearrange them by hand, a branching canvas is a better
|
||||
home than a three-up picker. Hallmark variants is opinionated: it defaults to
|
||||
three structurally distinct directions and asks you to decide, not to sprawl.
|
||||
- **Deep single-composition refinement.** The section-by-section studio pattern
|
||||
is built to polish one design region by region. Hallmark keeps drafts cheap on
|
||||
purpose and saves the deep finish for after the pick, so mid-run it is
|
||||
deliberately shallower on any one direction.
|
||||
- **Live in-app editing.** The injected-overlay tools edit your real, running
|
||||
app directly. Hallmark's v2 injection is preview-only by design: it shows the
|
||||
variant over your app but never touches your source, so if you specifically
|
||||
want to nudge the live DOM in place, that is not what this loop is for.
|
||||
|
||||
The trade Hallmark makes is clear and intentional: less sprawl and less in-place
|
||||
editing, in exchange for a faster, cheaper, single-surface comparison whose
|
||||
decisions are durable and whose flow cannot get stuck.
|
||||
@@ -1,26 +1,29 @@
|
||||
# `hallmark variants`
|
||||
|
||||
One brief, three full directions, side by side, in the user's own browser. The user flips between them, picks one, and only the winner ships. A single build is a bet; three structurally distinct builds are a conversation.
|
||||
One brief, several distinct directions, side by side, in the user's own browser. The user flips between them, picks one, and only the winner ships. A single build is a bet; several structurally distinct builds are a conversation. v2 makes that conversation fast (progressive reveal, parallel drafts, sketch depth) and smooth (thumbnails, compositional grafts, section-zoom, a decisions log).
|
||||
|
||||
## Flow at a glance
|
||||
|
||||
1. Run SKILL.md Steps 0-1 once; read `.hallmark/log.json` once.
|
||||
2. Detect the mode from the pre-flight: greenfield or routes; routes mode asks for the dev server URL.
|
||||
1. Run SKILL.md Steps 0-1 once; read `.hallmark/log.json` once. On a resumed run, read `decisions.md` first (§ Smoothness) so a frozen decision is never re-litigated.
|
||||
2. Detect the mode from the pre-flight: greenfield, routes, or scoped injection (Vite/Astro/SvelteKit). Routes and injection ask for the dev server URL.
|
||||
3. Say the direction plan table; end with "Redirect now or I build all three."
|
||||
4. Build three self-contained directions: parallel subagents or sequential fallback.
|
||||
5. `start.mjs`; relay the picker URL in one line.
|
||||
6. Await the pick via the poll ladder; a chat reply always works.
|
||||
7. Promote the winner: full 58-gate sweep, archive all three, ack, stop, log.
|
||||
4. Write the manifest with N directions at `status:"generating"`, then run `start.mjs` NOW: the picker opens on skeleton cards while you generate (§ Speed, progressive-first).
|
||||
5. Build the directions at sketch depth: parallel subagents by default, sequential fallback. Flip each row to `status:"ready"` the instant its files land; run `thumbs.mjs`; say "Direction 1 ready" in chat per landing.
|
||||
6. Relay the picker URL in one line.
|
||||
7. Await the verdict via the poll ladder; a chat reply always works. Dispatch pick / riff / graft.
|
||||
8. Promote the winner: complete the sketch to full depth, full 58-gate sweep, apply any graft, archive all directions, append the decisions log, ack, stop, log.
|
||||
|
||||
## The machinery
|
||||
|
||||
The verb rides on four zero-dependency scripts in `<skill-dir>/scripts/variants/` (`core.mjs` · `serve.mjs` · `start.mjs` · `await.mjs`) and one run directory inside the USER'S project:
|
||||
The verb rides on five zero-dependency scripts in `<skill-dir>/scripts/variants/` (`core.mjs` · `serve.mjs` · `start.mjs` · `await.mjs` · `thumbs.mjs`) and one run directory inside the USER'S project:
|
||||
|
||||
```
|
||||
.hallmark/variants/<run-id>/ run-id: date + letter, e.g. 2026-07-23-a
|
||||
manifest.json run state (shape below)
|
||||
v1/ v2/ v3/ greenfield direction folders (index.html + optional css)
|
||||
requests/ requests/done/ the pick/riff queue
|
||||
thumbs/<n>.png 1280x800 PNG thumbnails (thumbs.mjs writes them)
|
||||
requests/ requests/done/ the pick / riff / graft queue
|
||||
decisions.md one entry per round (§ Smoothness)
|
||||
server.log serve.mjs stdout/err
|
||||
.hallmark/variants/server.json live-server identity: {port, pid, run, startedAt}
|
||||
```
|
||||
@@ -35,33 +38,38 @@ Second run the same day: increment the letter (`2026-07-23-b`). Respect any exis
|
||||
"directions": [
|
||||
{ "n": 1, "title": "The broadsheet", "macrostructure": "Long Document",
|
||||
"theme": "Newsprint", "nav": "N6", "footer": "Ft2",
|
||||
"axes": "light / roman-serif / warm", "url": "/frame/1/", "status": "ready" }
|
||||
"axes": "light / roman-serif / warm", "url": "/frame/1/",
|
||||
"status": "generating", "thumb": false }
|
||||
],
|
||||
"picked": null }
|
||||
```
|
||||
|
||||
`mode` is `"greenfield"` or `"routes"`. In routes mode `devServer` holds the user's dev-server origin and each direction's `url` is absolute into it (e.g. `http://localhost:3000/hallmark-v1`). Write `mode`, `brief`, and `devServer` as soon as § 2 resolves; add each direction row when you plan it, flip its `status` to `"ready"` when its files land.
|
||||
`mode` is `"greenfield"`, `"routes"`, or `"inject"`. In routes/inject mode `devServer` holds the user's dev-server origin and each direction's `url` is absolute into it (e.g. `http://localhost:3000/hallmark-v1`). Write `mode`, `brief`, and `devServer` as soon as § 2 resolves. Add each direction row when you plan it at `status:"generating"` with no `url` yet; write its `url` and flip `status` to `"ready"` the instant its files land; `thumbs.mjs` sets `thumb:true` after it screenshots that row. Every write of the manifest is atomic (write temp, rename).
|
||||
|
||||
---
|
||||
|
||||
## 1 · Trigger and arguments
|
||||
|
||||
- `hallmark variants <brief>` : the full run described here. Default depth: **3 full directions**.
|
||||
- Bare `hallmark variants` : continue an in-flight brief. If this conversation already carries one (a default build was being scoped, the user just answered the Step 1 gate, or the latest run's manifest has `"picked": null`), reuse it and say so in one line. No brief anywhere: ask for it, once, and nothing else.
|
||||
- `hallmark variants <brief>` : the full run described here. Default: **3 directions at sketch depth**.
|
||||
- Bare `hallmark variants` : continue an in-flight brief. If this conversation already carries one (a default build was being scoped, the user just answered the Step 1 gate, or the latest run's manifest has `"picked": null`), reuse it and say so in one line. Read `decisions.md` before continuing (§ Smoothness). No brief anywhere: ask for it, once, and nothing else.
|
||||
- **Count override.** "Give me 4" / "five directions" is honored. The divergence rules below stay pairwise; with only three paper bands, directions 4+ relax the theme rule to >= 2 axes distinct against each earlier direction. Say so in the plan. Never offer more than 3 unprompted.
|
||||
- **`--sketch`** : each direction is hero + one signature section + footer, nothing more. The winner is completed to full depth after the pick. This is the cheap path; offer it unprompted on heavy briefs (see § 9, token budget).
|
||||
- **Depth.** Sketch-depth drafts are the default: each direction ships hero + one signature section + footer, about 40% of a full page (§ Speed). Only the winner is completed to full depth after the pick (§ 7).
|
||||
- **`--full`** : override to three full pages when the user wants finished directions to compare. Costlier and slower; rarely needed before a pick.
|
||||
- **`--fast-drafts`** : draft subagents run on a faster model (Sonnet, never Haiku); the winner completes on the session model (§ Speed).
|
||||
|
||||
"Show me 3 again" after a finished run: a new run-id, same ceremony reuse, and the archived directions stay where they are.
|
||||
|
||||
## 2 · Context detection
|
||||
|
||||
Two modes. Decide before the ceremony, state the mode in one line.
|
||||
Three modes. Decide before the ceremony, state the mode in one line.
|
||||
|
||||
**Greenfield** : no project, or a project without a framework dev server. Directions are standalone pages in `v1/ v2/ v3/`; the picker serves them itself at `/frame/<n>/`.
|
||||
|
||||
**Routes mode** : a framework with file-based routing AND a running dev server. The directions become throwaway routes inside the user's own app, rendered by their real stack: their fonts load, their components import, the comparison is honest. **Ask for the dev server URL: never guess a port, never start a server yourself.** One line: *"Is your dev server running? Give me its URL (e.g. http://localhost:3000)."* No running server, or no answer: greenfield.
|
||||
|
||||
Detect the framework from the Step 0 pre-flight you already ran (`package.json` deps + directory shape): `next` with `app/` = app router · `next` with `pages/` = pages router · `@sveltejs/kit` = SvelteKit · `astro` = Astro · `nuxt` = Nuxt · `@remix-run/*` = Remix.
|
||||
**Scoped injection** : Vite, Astro, or SvelteKit with a running dev server, when you want each direction to preview as a full-viewport overlay ON TOP of the real app without adding routes. Preview-only, no write-back (§ Scoped preview injection). Offer it when routes mode would fight the app's root shell, or when the user wants to see a direction over their live app. Same dev-server-URL ask as routes mode.
|
||||
|
||||
Detect the framework from the Step 0 pre-flight you already ran (`package.json` deps + directory shape): `next` with `app/` = app router · `next` with `pages/` = pages router · `@sveltejs/kit` = SvelteKit · `astro` = Astro · `nuxt` = Nuxt · `@remix-run/*` = Remix · `vite` present without a file router = Vite SPA.
|
||||
|
||||
Route recipes, direction `n`:
|
||||
|
||||
@@ -74,13 +82,13 @@ Route recipes, direction `n`:
|
||||
| Nuxt | `pages/hallmark-v<n>.vue` |
|
||||
| Remix | `app/routes/hallmark-v<n>.tsx` |
|
||||
|
||||
**No file router** (CRA, Vite SPA with react-router, anything unrecognized): do not wire router config; touching a routes array is exactly the shared-file edit § 4 forbids. Fall back to greenfield-style standalone sketches built on the pre-flight tokens, and tell the user the winner gets implemented into the real app afterwards.
|
||||
**No file router** (CRA, Vite SPA with react-router, anything unrecognized): do not wire router config; touching a routes array is exactly the shared-file edit § 4 forbids. Fall back to greenfield-style standalone sketches built on the pre-flight tokens (or scoped injection when the stack is Vite/Astro/SvelteKit), and tell the user the winner gets implemented into the real app afterwards.
|
||||
|
||||
**Root-layout warning.** If the app's root layout ships its own nav or shell, the directions will render inside it, chrome and all. Say so before building, and offer sketch mode (greenfield frames) when the shell would drown the comparison. A direction's own nav inside the app's nav is a confusing artifact, not a bug; name it once so the user does not think it is one.
|
||||
**Root-layout warning.** If the app's root layout ships its own nav or shell, routes-mode directions render inside it, chrome and all. Say so before building, and offer scoped injection (the overlay sits above the shell) or greenfield frames when the shell would drown the comparison. A direction's own nav inside the app's nav is a confusing artifact, not a bug; name it once so the user does not think it is one.
|
||||
|
||||
## 3 · Shared ceremony
|
||||
|
||||
**Do:** run SKILL.md Steps 0-1 exactly ONCE for the whole run: one pre-flight scan, one three-question gate, one genre detection. Read `.hallmark/log.json` once. Three directions never means three interrogations, and "go ahead" at the gate covers all three.
|
||||
**Do:** run SKILL.md Steps 0-1 exactly ONCE for the whole run: one pre-flight scan, one three-question gate, one genre detection, one shared read of the structural family map. Read `.hallmark/log.json` once. Three directions never means three interrogations, and "go ahead" at the gate covers all three. Only the per-direction tuple in the plan table differs; do not re-run the gate or re-derive the genre per direction (§ Speed, analysis-once).
|
||||
|
||||
Then state **the direction plan**: a markdown table, said before any code. It replaces three separate Picks blocks; do not also narrate per-direction picks.
|
||||
|
||||
@@ -103,15 +111,33 @@ Then state **the direction plan**: a markdown table, said before any code. It re
|
||||
|
||||
End the plan with the accountability beat, verbatim: *"Redirect now or I build all three."* A beat, not a blocking question; silence means build.
|
||||
|
||||
## Speed
|
||||
|
||||
The user's pain is wall-clock. Every default here trades nothing the user can see for time they can feel.
|
||||
|
||||
1. **Progressive-first (the default).** Start the picker BEFORE generating. Write the manifest with N directions at `status:"generating"` (no `url` yet), run `start.mjs`, hand over the URL: the picker opens on skeleton "building..." cards. Then generate; the instant a direction's files land, write its `url` and flip its row to `status:"ready"`. The picker's 2s poll reveals it while the others finish, so the user studies direction 1 while 2 and 3 are still drawing. Emit one line in chat as each lands: *"Direction 1 ready."*
|
||||
|
||||
2. **Parallel by default (subagents).** When the harness has subagents, spawn one per direction; each gets the shared analysis + its tuple row + the fragment contract (§ 4). Sequential-in-one-conversation is the fallback: it keeps the prompt prefix cached and leans on progressive reveal to hide the wait. Honest tradeoff: parallel subagents each re-pay the shared prefix cold (no cache reuse across agents), but they win roughly 3x on wall-clock, and wall-clock is the pain, so parallel is the default.
|
||||
|
||||
3. **Sketch-depth drafts (the default).** Each direction ships hero + one signature section + footer, about 40% of a full page: enough to judge structure, theme, and voice, not a line more. Only the WINNER completes to full depth after the pick (§ 7). `--full` overrides to full directions when the user wants finished pages to compare.
|
||||
|
||||
4. **Analysis-once + shared head.** Run the shared analysis once (§ 3): audience, use, tone, genre, family map. Generate the head, reset, and font-load boilerplate once as a shared base each direction references, so three drafts do not each re-derive the same reset and font loading. The token-variable NAMES can be shared; the VALUES must diverge per direction (a shared theme block would homogenize the three systems, § 4). The shared base is a draft-time head start, not the shipped system; the winner still earns its own real `:root` tokenization at promotion.
|
||||
|
||||
5. **`--fast-drafts` (optional).** Draft subagents run on a faster model (Sonnet); the winner completes on the session model. Never Haiku: Haiku's HTML craft sits below the bar, and a draft too rough to judge defeats the point.
|
||||
|
||||
Expected speedup: **time-to-first-direction ~60s -> ~15-20s; full run ~2.3x a build -> ~1.1-1.3x a build.**
|
||||
|
||||
## 4 · Build recipes
|
||||
|
||||
**Parallel path** (harness supports subagents): after the table, spawn one subagent per direction. Each receives the brief + the inferred audience/use/tone + its tuple row + its output target + the **fragment contract**. Briefing template:
|
||||
**Parallel path** (harness supports subagents, the default): after the table, spawn one subagent per direction. Each receives the brief + the inferred audience/use/tone + its tuple row + its output target + the shared base + the **fragment contract**. Briefing template:
|
||||
|
||||
```
|
||||
You are building direction <n> of 3 for a Hallmark variants run.
|
||||
Brief: <brief>. Inferred: audience <a> · use <u> · tone <t> · genre <g>.
|
||||
Tuple: "<title>" · <macro> · <theme> · axes <axes> · nav <N#> · footer <Ft#> · enrichment <E# or none>.
|
||||
Depth: SKETCH - hero + one signature section + footer only (unless the run is --full).
|
||||
Output: <absolute path to v<n>/index.html, or the route file from the recipes table>.
|
||||
Reference the shared base at <path> for head/reset/font-load; define your OWN distinct theme token values.
|
||||
Include this chip tag verbatim: <the § 4 snippet with data-direction="<n>">.
|
||||
Load ONLY references/macrostructures/<picked file>, the picked component archetype
|
||||
files, and the universal set (typography · color · layout-and-space · motion ·
|
||||
@@ -120,11 +146,13 @@ the full gate sweep: the Core-15 sweep only (contrast gates 40-41 are in it). St
|
||||
self-contained. Stamp the CSS with `direction: <n> of 3 · run: <run-id>`.
|
||||
```
|
||||
|
||||
**Sequential fallback** (no subagents): same table, build v1 then v2 then v3 in one context, universal references loaded once and reused. Same fragment contract per direction, minus the spawning.
|
||||
**Sequential fallback** (no subagents): same table, build v1 then v2 then v3 in one context, universal references loaded once and reused. Same fragment contract and sketch depth per direction, minus the spawning. Flip each row to `ready` before starting the next so progressive reveal still works.
|
||||
|
||||
**Draft quality bar.** Drafts get an abbreviated pass: run `sloplint.mjs` on each draft and fix FAILs (it is cheap and mechanical), then sweep only the **Core-15** ([`slop-test.md`](../slop-test.md) § Core-15, which includes contrast gates 40-41) by judgment. ONLY THE WINNER runs the full 58-gate sweep, later, at § 7. Do not spend three full sweeps on two pages that will be archived.
|
||||
**Progressive reveal wiring.** Whichever path: the manifest row starts at `status:"generating"` with no `url`. When a direction's files exist and are servable, write its `url` and set `status:"ready"` in one atomic manifest write, then say "Direction <n> ready" in chat. Do not wait for all three before the first flip.
|
||||
|
||||
**Self-containment.** Each direction is fully self-contained: its own inline styles or a sibling css file in its folder. No shared `tokens.css` across directions; shared tokens would quietly homogenize the three systems you are trying to keep apart. In routes mode, hang each direction's token block on the route's own root element (a wrapper class), not `:root`, so three simultaneous routes cannot fight each other or leak into the app shell; every colour still references a `var(--*)` per the critical floor. The winner gets properly tokenized at promotion.
|
||||
**Draft quality bar.** Drafts are sketch depth and get an abbreviated pass: run `sloplint.mjs` on each draft and fix FAILs (it is cheap and mechanical), then sweep only the **Core-15** ([`slop-test.md`](../slop-test.md) § Core-15, which includes contrast gates 40-41) by judgment. ONLY THE WINNER runs the full 58-gate sweep, later, at § 7. Do not spend three full sweeps on sketches that will be archived.
|
||||
|
||||
**Self-containment.** Each direction is fully self-contained: its own inline styles or a sibling css file in its folder. No shared `tokens.css` across directions; shared theme tokens would quietly homogenize the three systems you are trying to keep apart. The shared base of § Speed is head/reset/font-load only, copied in, not a live shared theme file. In routes mode, hang each direction's token block on the route's own root element (a wrapper class), not `:root`, so three simultaneous routes cannot fight each other or leak into the app shell; every colour still references a `var(--*)` per the critical floor. The winner gets properly tokenized at promotion.
|
||||
|
||||
**Greenfield target:** `.hallmark/variants/<run-id>/v<n>/index.html`. Sibling assets referenced by relative path (`./style.css`), never root-absolute, because the frame serves under `/frame/<n>/`.
|
||||
|
||||
@@ -137,18 +165,32 @@ self-contained. Stamp the CSS with `direction: <n> of 3 · run: <run-id>`.
|
||||
Greenfield frames carry the chip too, with `data-mode="greenfield"` and `data-base="http://127.0.0.1:<port>/frame/"`. What the chip does (so you can describe it, not so you can rebuild it):
|
||||
|
||||
- Renders a small fixed bottom-center pill: "Direction 2/3", dismissible with an x.
|
||||
- Arrow buttons and ArrowLeft/ArrowRight flip to the sibling direction via `data-base` + n.
|
||||
- Arrow buttons and ArrowLeft/ArrowRight flip to the sibling direction via `data-base` + n, guarded against focused inputs.
|
||||
- A Pick and a Riff button POST to `/api/pick` on the chip's own origin.
|
||||
- On fetch failure the pill swaps to the text *"picker offline: tell your agent - pick 2"*, so the flow survives the server dying.
|
||||
- Respects `prefers-reduced-motion`; nothing animates beyond opacity.
|
||||
|
||||
Assume port 4180 when writing the tags; § 9 covers the walk-up case.
|
||||
|
||||
**Routes-mode discipline:** never touch shared stylesheets, config files, or `package.json` during variant generation. Each direction is additive route files only. Write all of a direction's files in **one batch** so the dev server hot-reloads once per direction, not once per file.
|
||||
**Routes-mode discipline:** never touch shared stylesheets, config files, or `package.json` during variant generation. Each direction is additive route files only. Write all of a direction's files in **one batch** so the dev server hot-reloads once per direction, not once per file. (Scoped injection is the one exception that touches a config file, and only with consent, § Scoped preview injection.)
|
||||
|
||||
## Thumbnails
|
||||
|
||||
After the directions land (`status:"ready"`), render PNG thumbnails so the grid does not depend on live iframes:
|
||||
|
||||
```
|
||||
node <skill-dir>/scripts/variants/thumbs.mjs --run <run-dir>
|
||||
```
|
||||
|
||||
`thumbs.mjs` (zero-install, dual-engine: try `puppeteer-core`, else spawn the installed Chrome, the same pattern the eval screenshots use) reads the manifest, screenshots each ready direction's `url` to `<run-dir>/thumbs/<n>.png` at 1280x800, and sets `thumb:true` on that direction (atomic manifest write). The picker grid then renders `<img src="/thumb/<n>.png?<cachebust>">` for any direction with `thumb===true`, falling back to the scaled live iframe when `thumb` is unset, and the "building..." placeholder when the direction is not yet ready. Single view stays a live iframe, so the winner is always judged live.
|
||||
|
||||
This also fixes **iframe-blocked dev servers**: a routes-mode app that sends `X-Frame-Options` or CSP `frame-ancestors` refuses to render inside the grid iframe, but a PNG always shows. Run `thumbs.mjs` whenever the grid thumbnails come up blank, and re-run it after a riff so the new direction gets a thumbnail too.
|
||||
|
||||
serve.mjs serves `GET /thumb/<n>.png` from `<run-dir>/thumbs/<n>.png` (200 `image/png`, or 404 when absent).
|
||||
|
||||
## 5 · Serve and hand over
|
||||
|
||||
Start the picker (idempotent; safe to re-run):
|
||||
Start the picker (idempotent; safe to re-run). Do this BEFORE the directions finish, right after you write the `generating` manifest rows (§ Speed, progressive-first):
|
||||
|
||||
```
|
||||
node <skill-dir>/scripts/variants/start.mjs --run <run-dir>
|
||||
@@ -162,21 +204,84 @@ AWAIT node <abs-path>/await.mjs --run <run-dir> --timeout 540
|
||||
DRAIN node <abs-path>/await.mjs --run <run-dir> --drain
|
||||
```
|
||||
|
||||
Relay the picker URL to the user in one line: *"Flip with arrows or the number keys; Pick when one clicks; Riff deals a fourth."* If the harness has a browser preview pane, open the picker there too.
|
||||
The server starts even when no direction is `ready` yet: an empty or all-`generating` manifest opens the picker on skeleton cards, so the user watches the directions appear. Do not require all directions present at start.
|
||||
|
||||
Relay the picker URL to the user in one line: *"Flip with arrows or the number keys; Pick when one clicks; Riff deals a fourth; Graft borrows a section from another."* If the harness has a browser preview pane, open the picker there too.
|
||||
|
||||
What the user sees (built by `core.mjs`, dark neutral shell, system-ui, self-contained):
|
||||
|
||||
- An overview grid of the three directions as scaled live iframes: real 1280x800 frames scaled down via transform, non-interactive in the grid.
|
||||
- Each frame labelled with the direction's title and its macro/theme/nav/footer meta.
|
||||
- Click or 1/2/3 enters full-size single view; arrows cycle directions.
|
||||
- P or the Pick button confirms; R or the Riff button deals a fourth, with an optional one-line steer prompt.
|
||||
- The page binds to 127.0.0.1 only and polls `/api/state` every 2 seconds, so manifest updates (a riff landing) appear without a restart.
|
||||
- An overview grid of the directions: a PNG thumbnail per direction once `thumbs.mjs` has run (a scaled live 1280x800 iframe when no thumbnail yet), and a "building..." skeleton card for any direction still at `status:"generating"`.
|
||||
- Each card labelled with the direction's title (as `n · title`) and its macro/theme/nav/footer meta.
|
||||
- Click or 1/2/3 enters full-size single view (a live iframe); a labeled counter and input-guarded arrows cycle directions.
|
||||
- P or the Pick button confirms; R or the Riff button deals a fourth, with an optional one-line steer; G or the Graft button borrows a section from another direction (§ Smoothness).
|
||||
- The page binds to 127.0.0.1 only and polls `/api/state` every 2 seconds, so manifest updates (a direction flipping to ready, a thumbnail arriving, a riff landing, the pick recorded) appear without a restart.
|
||||
|
||||
**No-server variant:** `node <skill-dir>/scripts/variants/start.mjs --run <run-dir> --static` skips the server, writes `compare.html` into the run dir (self-contained, srcdoc-inlined iframes of v1-v3, keyboard 1/2/3 and arrows, a banner telling the user to reply "pick N" in chat), and prints its path. Reach for it when a long-lived process is unwelcome but node exists.
|
||||
|
||||
## Scoped preview injection
|
||||
|
||||
Vite, Astro, and SvelteKit only. **Preview-only: it shows the variant over your running app, it never edits your app's source.** Next.js is out (no clean dev-only injection hook that stays preview-only); for Next, use routes mode or greenfield sketches. There is no write-back: the winner still ships as a normal Hallmark build after the pick.
|
||||
|
||||
When the app runs a Vite/Astro/SvelteKit dev server, preview each direction as a full-viewport overlay inside the real app without adding throwaway routes. serve.mjs serves `GET /inject/<n>.js` (built by `core.mjs buildInjectJs(n,total,port)`); loaded inside the dev app it mounts a fixed, max-z-index, full-viewport overlay iframe pointing at the direction's preview URL, plus the chip controls (the same flip/pick/riff, posting to the helper origin). It guards arrow keys against focused inputs, is dev-only (a no-op unless `location.hostname` is `localhost` or `127.0.0.1`), and is dismissible.
|
||||
|
||||
Wire it in dev config only, and get the user's ok first (it loads a script from the helper origin into their dev app). Swap the `1` in `/inject/1.js` for the direction you want on top first; the overlay's own flip controls move between directions once loaded. Use the picker port that `start.mjs` printed for `<port>`.
|
||||
|
||||
**Vite** - a dev-only plugin using `transformIndexHtml`:
|
||||
|
||||
```js
|
||||
// vite.config.js - dev only
|
||||
export default {
|
||||
plugins: [{
|
||||
name: 'hallmark-variants',
|
||||
apply: 'serve',
|
||||
transformIndexHtml: () => [{
|
||||
tag: 'script',
|
||||
attrs: { src: 'http://127.0.0.1:<port>/inject/1.js' },
|
||||
injectTo: 'body',
|
||||
}],
|
||||
}],
|
||||
};
|
||||
```
|
||||
|
||||
**Astro** - `injectScript('page', ...)` from a dev-only integration:
|
||||
|
||||
```js
|
||||
// astro.config.mjs - dev only
|
||||
export default {
|
||||
integrations: [{
|
||||
name: 'hallmark-variants',
|
||||
hooks: {
|
||||
'astro:config:setup': ({ injectScript, command }) => {
|
||||
if (command !== 'dev') return;
|
||||
injectScript('page', 'import("http://127.0.0.1:<port>/inject/1.js")');
|
||||
},
|
||||
},
|
||||
}],
|
||||
};
|
||||
```
|
||||
|
||||
**SvelteKit** - a `handle` hook rewriting the page chunk in dev:
|
||||
|
||||
```js
|
||||
// src/hooks.server.js - dev only
|
||||
import { dev } from '$app/environment';
|
||||
export async function handle({ event, resolve }) {
|
||||
return resolve(event, {
|
||||
transformPageChunk: ({ html }) =>
|
||||
dev ? html.replace('</body>', '<script src="http://127.0.0.1:<port>/inject/1.js"></script></body>') : html,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Caveats.**
|
||||
|
||||
- **Consent.** Adding the hook touches a config file. Show the exact diff, get a yes before writing it, and offer to revert it after the pick.
|
||||
- **CSP / X-Frame-Options.** If the dev app sets a strict `Content-Security-Policy` (`script-src`, `frame-src`) or `X-Frame-Options`, the injected script or its overlay iframe is blocked. This is a dev-only caveat; do not weaken production CSP for a preview.
|
||||
- **Fallback.** When injection is not possible (Next, no dev server, strict CSP, or the user declines), fall back to greenfield standalone sketches built on the pre-flight tokens. The winner still ships as a normal build either way.
|
||||
|
||||
## 6 · The poll ladder
|
||||
|
||||
The pick comes back through `await.mjs`:
|
||||
The verdict comes back through `await.mjs`:
|
||||
|
||||
```
|
||||
node <abs-path>/await.mjs --run <run-dir> [--drain] [--timeout <sec>] [--ack <id>] [--note "<line>"] [--stop]
|
||||
@@ -192,8 +297,14 @@ Handling on exit 0:
|
||||
{ "id": "0001", "action": "pick", "choice": 2 }
|
||||
```
|
||||
|
||||
A riff carries `"action": "riff"`, always a `"steer"` field (possibly empty), and may carry `"choice"`: the direction on screen when the user riffed. Requests also carry `"createdAt"`.
|
||||
2. Dispatch on `action`: `"pick"` → § 7, `"riff"` → § 8.
|
||||
A riff carries `"action": "riff"`, always a `"steer"` field (possibly empty), and may carry `"choice"`: the direction on screen when the user riffed. A graft looks like:
|
||||
|
||||
```json
|
||||
{ "id": "0002", "action": "graft", "choice": 2, "from": 3, "section": "pricing" }
|
||||
```
|
||||
|
||||
Requests also carry `"createdAt"`. `await.mjs` is generic: it claims, prints, and drains any action, so graft requests flow through the same path with no special handling in the script.
|
||||
2. Dispatch on `action`: `"pick"` → § 7, `"riff"` → § 8, `"graft"` → § Smoothness (graft the named section into the winner, then promote via § 7).
|
||||
3. Ack: `--ack <id>` moves the claimed file to `requests/done/`; `--note "<line>"` records what you did with it.
|
||||
4. A request carrying `"redelivered": true` is an orphaned claim older than 5 minutes (a previous attempt died mid-handle); handle it normally.
|
||||
|
||||
@@ -215,36 +326,37 @@ Pick the rung your harness supports:
|
||||
node <abs-path>/await.mjs --run <run-dir> --timeout 60
|
||||
```
|
||||
|
||||
After 3 consecutive idle exits, stop polling and ask in chat: *"Reply 1, 2, or 3 (or riff)."*
|
||||
After 3 consecutive idle exits, stop polling and ask in chat: *"Reply 1, 2, or 3 (or riff, or graft e.g. 'pricing from 3')."*
|
||||
|
||||
3. **No node / no scripts installed** (the Cursor `.mdc` install channel ships no scripts): skip the server entirely. Write `compare.html` by hand into the run dir, in the static template shape, and ask for the pick in chat. The hand-written file must be:
|
||||
3. **No node / no scripts installed** (the Cursor `.mdc` install channel ships no scripts): skip the server entirely. Write `compare.html` by hand into the run dir, in the static template shape, and ask for the verdict in chat. The hand-written file must be:
|
||||
|
||||
- Fully self-contained: each direction's page inlined into an iframe `srcdoc` attribute (escape quotes), no external requests.
|
||||
- Navigable: keyboard 1/2/3 jumps to a direction, arrow keys cycle, plus visible buttons for mouse users.
|
||||
- Honest about the channel: a fixed banner reading "Reply in chat: pick 1, 2, or 3 (or riff)". No Pick button that pretends to work.
|
||||
- Honest about the channel: a fixed banner reading "Reply in chat: pick 1, 2, or 3 (or riff, or graft)". No Pick button that pretends to work.
|
||||
- Labelled: each frame shows its direction title and macro/theme meta from the plan table.
|
||||
|
||||
**THE FLOW NEVER DEAD-ENDS.** A chat reply naming a direction ("2", "pick 2", "the poster one") is a valid pick channel at every rung, always, even while the server is up. After acting on a chat pick, run `--drain` once and ack anything stale so `requests/` ends empty.
|
||||
**THE FLOW NEVER DEAD-ENDS.** A chat reply naming a verdict ("2", "pick 2", "the poster one", "2 but the pricing from 3") is a valid channel at every rung, always, even while the server is up. After acting on a chat verdict, run `--drain` once and ack anything stale so `requests/` ends empty.
|
||||
|
||||
## 7 · Pick and continue
|
||||
|
||||
On `{"action": "pick", "choice": n}` or the chat equivalent:
|
||||
On `{"action": "pick", "choice": n}` or the chat equivalent (and after any graft, § Smoothness, has been transplanted into the winner):
|
||||
|
||||
**Greenfield:**
|
||||
|
||||
1. Copy the winner into place as the normal build output (wherever a default Hallmark build would land for this project).
|
||||
2. If `--sketch` was used, complete it to full depth first: remaining sections, states, responsive pass.
|
||||
2. Complete the sketch to full depth first (unless `--full` already built full pages): remaining sections, all states, responsive pass.
|
||||
3. Run the FULL 58-gate sweep + sloplint, fix every FAIL, stamp, emit `tokens.css`, exactly as SKILL.md Steps 6-7 demand. The draft's abbreviated pass counts for nothing here; the winner earns the whole bar.
|
||||
|
||||
**Routes mode:** state the file plan first: the standard safety rail, promoting into real targets needs the user's ok. For example:
|
||||
|
||||
> Promoting direction 2. Plan: modify `app/page.tsx` (the new design), create `app/tokens.css`, delete `app/hallmark-v1/`, `app/hallmark-v2/`, `app/hallmark-v3/`. Ok?
|
||||
|
||||
Then implement the winning direction into the real app as a normal Hallmark build: tokenize into the project's system (real `:root` tokens now, not the draft's scoped block), strip the chip tag, delete the other variant routes.
|
||||
Then implement the winning direction into the real app as a normal Hallmark build: tokenize into the project's system (real `:root` tokens now, not the draft's scoped block), strip the chip tag, delete the other variant routes. In scoped-injection mode, also revert the dev-config hook you added with consent (§ Scoped preview injection).
|
||||
|
||||
**Always, both modes:**
|
||||
**Always, all modes:**
|
||||
|
||||
- Archive all three directions to `.hallmark/variants/<run-id>/` (never hard-delete; this is what makes "show me the 3 again" free). In routes mode, copy the route files into the run dir before deleting them from the app.
|
||||
- Archive all directions to `.hallmark/variants/<run-id>/` (never hard-delete; this is what makes "show me the 3 again" free). In routes mode, copy the route files into the run dir before deleting them from the app.
|
||||
- Append the round to `decisions.md` (§ Smoothness): the winner, any graft, any section rounds.
|
||||
- Ack the request (`--ack <id>`), stop the server (`await.mjs --run <run-dir> --stop`, which POSTs `/api/shutdown` via `server.json` and clears it), and report the cleanup in one line: *"Archived 3 directions to .hallmark/variants/2026-07-23-a/, picker stopped, log updated."*
|
||||
- Set `"picked": n` in the manifest.
|
||||
- Append the log entry, winner fields at top level so SKILL.md § Rotation reads it like any other run:
|
||||
@@ -258,23 +370,54 @@ Then implement the winning direction into the real app as a normal Hallmark buil
|
||||
|
||||
Each `variants[]` row carries that direction's title, macrostructure, theme, nav, footer, and enrichment, so a future run can rotate away from all three, not just the winner.
|
||||
|
||||
## Smoothness
|
||||
|
||||
The point of variants is a fast, honest decision loop. The picker already carries the ergonomics: a labeled counter (`n · title`), input-guarded arrow flip (arrows never fire while a prompt input is focused), and side-by-side thumbnails in the grid (§ Thumbnails). Build on that with three moves.
|
||||
|
||||
**Compositional grafts (the key unlock).** The verdict is rarely "2 is perfect." It is usually "2, but the pricing section from 3." Handle it whether it arrives in chat or through the picker's Graft button.
|
||||
|
||||
- Picker: single view has a **Graft** button (key `g`) that prompts *"Take which section from which direction? e.g. 'pricing from 3'"* and POSTs `{action:"graft", choice:<winner n>, from:<other n>, section:"<name>"}` to `/api/pick`, which writes `requests/NNNN-graft.json`.
|
||||
- Chat: the same verdict typed out ("2 but the pricing from 3") is a graft; parse `choice`, `from`, and `section` from the sentence.
|
||||
- The move: before promoting, open the winner (`choice`) and the donor (`from`). Lift the named `section` markup plus its scoped styles out of the donor and transplant it into the winner where that section belongs, replacing the winner's own version. Reconcile tokens: the donor's section references the donor's theme vars, so remap them to the winner's token names, and re-check contrast, so the graft reads as one page, not a seam. Then promote the grafted winner through § 7.
|
||||
- Log every graft in `decisions.md` so a resumed run does not undo it.
|
||||
|
||||
**Section-zoom (after the pick).** Once a direction wins, zoom to one section instead of whole pages: riff a single section in place, *"3 heroes for this direction"*, each rendered in the real page context, not in isolation. The user picks one or asks to merge two; freeze it; advance to the next section. Fast, because a section is about 1/5 of a page, so three section variants cost roughly what one page draft did. Reach for it when the pick was "close, but the hero is not there yet": finish the winner section by section rather than re-rolling whole pages.
|
||||
|
||||
**Decisions log.** Append to `.hallmark/variants/<run-id>/decisions.md` every round: the question asked, the directions offered (their tuples), the verdict, any grafts, and each section round's outcome. One short entry per round, newest at the bottom. Read it at the START of a resumed run (a bare `hallmark variants` that continues an in-flight run, § 1) so a frozen decision is never re-litigated: if the log says direction 2 won and the hero was frozen in section round 3, resume from there, do not re-offer three fresh directions.
|
||||
|
||||
Example `decisions.md` entry:
|
||||
|
||||
```
|
||||
## round 1 · 2026-07-23-a
|
||||
brief: pricing page for a solo dev tool
|
||||
directions: 1 broadsheet (Long Document/Newsprint) · 2 control room (Workbench/Cobalt) · 3 poster wall (Manifesto/Midnight)
|
||||
verdict: pick 2
|
||||
graft: pricing section from 3 into 2
|
||||
frozen: macrostructure Workbench, theme Cobalt
|
||||
|
||||
## round 2 · section-zoom
|
||||
section: hero (3 variants)
|
||||
verdict: hero B
|
||||
frozen: hero B
|
||||
```
|
||||
|
||||
## 8 · Riff
|
||||
|
||||
On `{"action": "riff", "steer": "..."}` (steer optional) or a chat ask ("riff", "deal another", "none of these"):
|
||||
|
||||
- Plan direction 4: a macrostructure different from all three, from whichever structural family remains; a theme distinct on as many axes as remain. With only three paper bands, direction 4 relaxes to >= 2 axes distinct against each earlier direction; say so in the one-line plan.
|
||||
- Honor the steer line as art direction ("warmer", "like 2 but dark"). Where the steer and the divergence default conflict, the steer wins; the user is telling you where the target is.
|
||||
- Build v4 under the same fragment contract, write `v4/` (or the `hallmark-v4` route with a chip tag reading `data-direction="4" data-of="4"`), append its tuple to `manifest.json` `directions` with `"status": "ready"`, then ack the riff request. The picker's 2-second poll shows the new direction without a restart. Bump the earlier chips' `data-of` to 4 when you touch those files anyway; stale chips wrap at 3 and never reach direction 4, so mention direction 4 in chat either way.
|
||||
- Build v4 under the same fragment contract and sketch depth, write `v4/` (or the `hallmark-v4` route with a chip tag reading `data-direction="4" data-of="4"`), append its tuple to `manifest.json` `directions` at `status:"generating"` then flip to `"ready"` when it lands, and ack the riff request. Run `thumbs.mjs` again so direction 4 gets a thumbnail. The picker's 2-second poll shows the new direction without a restart. Bump the earlier chips' `data-of` to 4 when you touch those files anyway; stale chips wrap at 3 and never reach direction 4, so mention direction 4 in chat either way.
|
||||
- A second riff repeats the ritual as direction 5. If the user riffs twice without picking, ask what is missing instead of dealing a sixth.
|
||||
|
||||
## 9 · Risks and edge notes
|
||||
|
||||
- **Iframe-refusing dev servers** (`X-Frame-Options` / CSP `frame-ancestors`): the grid thumbnails render blank; tell the user to open the routes directly in tabs, where the chip still flips and picks. Nothing else to fix.
|
||||
- **Iframe-refusing dev servers** (`X-Frame-Options` / CSP `frame-ancestors`): the grid iframes render blank. Run `thumbs.mjs` (§ Thumbnails); the PNG grid renders regardless, and single view still opens the route directly where the chip flips and picks. This is the v2 fix for what used to need opening tabs by hand.
|
||||
- **File watchers that restart on new files:** batch writes (§ 4). One write per direction, never a file-by-file trickle that restarts the dev server three times.
|
||||
- **Tailwind content globs** pick up new `hallmark-v*` routes automatically. Fine; no config edit, and config edits are forbidden anyway.
|
||||
- **Port conflicts:** `serve.mjs` tries 4180 and walks up to 4189. If the printed PICKER port differs from the 4180 you stamped into chip tags, update `src` and `data-base` in each direction once.
|
||||
- **Tailwind content globs** pick up new `hallmark-v*` routes automatically. Fine; no config edit, and config edits are forbidden anyway (the scoped-injection dev hook is the one consented exception, § Scoped preview injection).
|
||||
- **Port conflicts:** `serve.mjs` tries 4180 and walks up to 4189. If the printed PICKER port differs from the 4180 you stamped into chip tags or an inject hook, update `src` and `data-base` in each direction once.
|
||||
- **Stale server from a previous run:** `start.mjs` checks the live server's `/api/state` identity against this run; a matching run is reused, anything else is replaced and `server.json` rewritten. The server also self-shuts after 30 minutes without picker polls, so a forgotten run does not linger.
|
||||
- **Dev server restarts mid-run** (routes mode): the routes are plain files, so they come back with it; the chip reconnects on its next click. Nothing to do.
|
||||
- **Abandoned run:** the user walks away without picking. The server self-shuts, the manifest keeps `"picked": null`, and a later bare `hallmark variants` resumes exactly this run (§ 1). Do not delete an unpicked run.
|
||||
- **Abandoned run:** the user walks away without picking. The server self-shuts, the manifest keeps `"picked": null`, and a later bare `hallmark variants` resumes exactly this run (§ 1), reading `decisions.md` first. Do not delete an unpicked run.
|
||||
- **Uncommitted variant routes:** the `hallmark-v*` routes are throwaway; if the user mentions committing mid-run, suggest waiting until § 7 deletes them.
|
||||
- **Token budget:** a variants run costs roughly 2.3x one build. `--sketch` is the cheap path: hero + one signature section + footer per direction, winner completed after the pick. Offer it unprompted when the brief is heavy or the model context is tight.
|
||||
- **Token budget:** sketch-depth drafts (default) plus progressive reveal bring a run to roughly 1.1-1.3x one build in wall-clock (§ Speed). `--full` restores the fuller ~2.3x full-page run when the user wants three finished directions to compare; offer sketch depth (the default) when the brief is heavy or the model context is tight.
|
||||
|
||||
@@ -181,6 +181,57 @@ export function buildChipJs() {
|
||||
'})();\n';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// inject.js: the scoped-preview overlay served at /inject/<n>.js. The user
|
||||
// drops <script src="http://127.0.0.1:<port>/inject/2.js"></script> into their
|
||||
// own running dev app (Vite/Astro/SvelteKit) - or the agent injects it - and it
|
||||
// mounts a fixed full-viewport iframe of the direction's route ON TOP of the
|
||||
// app, plus the chip controls (flip / pick / riff / dismiss). PREVIEW ONLY: it
|
||||
// paints the variant over the app, it never edits the app's source. It no-ops
|
||||
// off localhost, guards arrows against focused inputs, sits at max z-index, and
|
||||
// is fully dismissible (x, Esc). Flip and pick/riff post to the helper origin.
|
||||
// The iframe points at <appOrigin>/hallmark-v<n> (the routes-mode recipe).
|
||||
|
||||
export function buildInjectJs(n, total, port) {
|
||||
const origin = "http://127.0.0.1:" + port;
|
||||
return '(function(){\n' +
|
||||
'"use strict";\n' +
|
||||
'var h=location.hostname;\n' +
|
||||
'if(h!=="localhost"&&h!=="127.0.0.1"&&h!=="[::1]")return;\n' +
|
||||
'if(window.__hallmarkInject)return;window.__hallmarkInject=true;\n' +
|
||||
'var n=' + Number(n) + ',total=' + Number(total) + ',origin="' + origin + '";\n' +
|
||||
'var routeBase=location.origin+"/hallmark-v";\n' +
|
||||
'var reduce=false;try{reduce=window.matchMedia("(prefers-reduced-motion: reduce)").matches}catch(e){}\n' +
|
||||
'var wrap=document.createElement("div");\n' +
|
||||
'wrap.setAttribute("data-hallmark-inject","");\n' +
|
||||
'wrap.style.cssText="position:fixed;inset:0;z-index:2147483646;background:#0b0b0d;";\n' +
|
||||
'var frame=document.createElement("iframe");\n' +
|
||||
'frame.setAttribute("title","hallmark variant preview");\n' +
|
||||
'frame.style.cssText="position:absolute;inset:0;width:100%;height:100%;border:0;background:#fff;";\n' +
|
||||
'wrap.appendChild(frame);\n' +
|
||||
'function urlFor(k){return routeBase+k}\n' +
|
||||
'function load(){frame.src=urlFor(n)}\n' +
|
||||
'var pill=document.createElement("div");\n' +
|
||||
'pill.setAttribute("data-hallmark-chip","");\n' +
|
||||
'pill.style.cssText="position:fixed;left:50%;bottom:16px;transform:translateX(-50%);z-index:2147483647;display:flex;align-items:center;gap:2px;background:rgba(17,17,19,.94);color:#e9e7e2;font:12px/1 system-ui,sans-serif;border-radius:999px;padding:5px 7px;box-shadow:0 10px 30px -10px rgba(0,0,0,.55);"+(reduce?"":"transition:opacity .2s ease;");\n' +
|
||||
'function btn(label,title,fn,pad){var b=document.createElement("button");b.type="button";b.textContent=label;b.title=title;b.style.cssText="font:inherit;border:0;background:none;color:inherit;cursor:pointer;padding:6px "+(pad||10)+"px;border-radius:999px;";b.addEventListener("mouseenter",function(){b.style.background="rgba(255,255,255,.14)"});b.addEventListener("mouseleave",function(){b.style.background=b.getAttribute("data-bg")||"none"});b.addEventListener("click",fn);pill.appendChild(b);return b}\n' +
|
||||
'function go(d){var k=n+d;if(k<1)k=total;if(k>total)k=1;n=k;label.textContent="Direction "+n+"/"+total;load()}\n' +
|
||||
'function dismiss(){wrap.remove();pill.remove();document.removeEventListener("keydown",onKey,true)}\n' +
|
||||
'btn("\\u2039","previous direction (ArrowLeft)",function(){go(-1)});\n' +
|
||||
'var label=document.createElement("span");label.textContent="Direction "+n+"/"+total;label.style.cssText="padding:6px 5px;white-space:nowrap;letter-spacing:.02em;";pill.appendChild(label);\n' +
|
||||
'btn("\\u203a","next direction (ArrowRight)",function(){go(1)});\n' +
|
||||
'var pickBtn=btn("Pick","keep this direction",function(){post({action:"pick",choice:n},"Picked "+n+" \\u2713")});\n' +
|
||||
'pickBtn.style.background="rgba(255,255,255,.12)";pickBtn.setAttribute("data-bg","rgba(255,255,255,.12)");pickBtn.style.fontWeight="600";pickBtn.style.margin="0 2px";\n' +
|
||||
'btn("Riff","ask for one more direction",function(){var steer=window.prompt("Optional steer for the new direction (leave blank for a free riff):","");if(steer===null)return;post({action:"riff",choice:n,steer:steer.trim()},"Riff queued \\u2713")});\n' +
|
||||
'btn("\\u00d7","close preview (Esc)",dismiss,8);\n' +
|
||||
'function post(body,ok){fetch(origin+"/api/pick",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)}).then(function(r){if(!r.ok)throw new Error("bad status");label.textContent=ok}).catch(function(){label.textContent="picker offline: tell your agent - pick "+n})}\n' +
|
||||
'function onKey(e){var el=document.activeElement;if(el&&(el.tagName==="INPUT"||el.tagName==="TEXTAREA"||el.isContentEditable))return;if(e.key==="ArrowLeft"){e.preventDefault();go(-1)}else if(e.key==="ArrowRight"){e.preventDefault();go(1)}else if(e.key==="Escape"){e.preventDefault();dismiss()}}\n' +
|
||||
'document.addEventListener("keydown",onKey,true);\n' +
|
||||
'function mount(){var root=document.body||document.documentElement;root.appendChild(wrap);root.appendChild(pill);load()}\n' +
|
||||
'if(document.body)mount();else document.addEventListener("DOMContentLoaded",mount);\n' +
|
||||
'})();\n';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// shared shell CSS for the two host pages (dark neutral, system-ui, no
|
||||
// external fonts, fully self-contained)
|
||||
@@ -213,6 +264,7 @@ export function buildPickerPage() {
|
||||
'.thumb{width:var(--tw);height:var(--th);overflow:hidden;position:relative;border:1px solid var(--line);border-radius:10px;background:#fff}\n' +
|
||||
'.card:hover .thumb,.card:focus-visible .thumb{border-color:var(--acc)}\n' +
|
||||
'.thumb iframe{width:1280px;height:800px;border:0;transform:scale(var(--scale));transform-origin:top left;pointer-events:none}\n' +
|
||||
'.thumb img{display:block;width:100%;height:100%;object-fit:cover;object-position:top left}\n' +
|
||||
'.thumb .ph{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:var(--card);color:var(--mut);font-family:var(--mono);font-size:12px}\n' +
|
||||
'.meta{display:flex;flex-direction:column;gap:3px;padding:10px 2px 0}\n' +
|
||||
'.meta .t{font-weight:650;font-size:14px}.meta .t i{font-style:normal;color:var(--mut);font-family:var(--mono);font-size:12px;margin-right:8px}\n' +
|
||||
@@ -234,8 +286,8 @@ export function buildPickerPage() {
|
||||
'<button id="back" title="back to the grid (Esc)">‹ grid</button>\n' +
|
||||
'<button id="prev" title="previous (ArrowLeft)">‹</button><button id="next" title="next (ArrowRight)">›</button>\n' +
|
||||
'<span class="t" id="st"></span><span class="m" id="sm"></span>\n' +
|
||||
'<span class="keys">arrows flip · P pick · R riff · Esc grid</span>\n' +
|
||||
'<button class="pick" id="pickBtn">Pick this (P)</button>\n<button id="riffBtn">Riff (R)</button>\n' +
|
||||
'<span class="keys">arrows flip · P pick · R riff · G graft · Esc grid</span>\n' +
|
||||
'<button class="pick" id="pickBtn">Pick this (P)</button>\n<button id="riffBtn">Riff (R)</button>\n<button id="graftBtn">Graft (G)</button>\n' +
|
||||
'</div>\n<iframe id="sframe" title="direction preview"></iframe>\n</div>\n' +
|
||||
'<script>\n(function(){\n' +
|
||||
'var st=null,lastJson="",view="grid",dirs=[],picked=null;\n' +
|
||||
@@ -249,13 +301,18 @@ export function buildPickerPage() {
|
||||
' picked=j.manifest?j.manifest.picked:null;render();\n' +
|
||||
'}).catch(function(){note("server offline","bad")})}\n' +
|
||||
'function render(){\n' +
|
||||
' var bust=Date.now();\n' +
|
||||
' $("run").textContent=st.run+" · "+((st.manifest&&st.manifest.mode)||"");\n' +
|
||||
' if(picked)note("picked: direction "+picked+" ✓ - back to your chat","ok");else note("");\n' +
|
||||
' var g=$("grid");g.innerHTML="";$("empty").hidden=dirs.length>0;\n' +
|
||||
' dirs.forEach(function(d){\n' +
|
||||
' var ready=!d.status||d.status==="ready";\n' +
|
||||
' var inner;\n' +
|
||||
' if(!ready)inner=\'<div class="ph">building...</div>\';\n' +
|
||||
' else if(d.thumb===true)inner=\'<img loading="lazy" alt="" src="/thumb/\'+d.n+\'.png?\'+bust+\'">\';\n' +
|
||||
' else inner=\'<iframe loading="lazy" scrolling="no" tabindex="-1" src="\'+esc(d.url)+\'"></iframe>\';\n' +
|
||||
' var b=document.createElement("button");b.className="card";\n' +
|
||||
' b.innerHTML=\'<div class="thumb">\'+(ready?\'<iframe loading="lazy" scrolling="no" tabindex="-1" src="\'+esc(d.url)+\'"></iframe>\':\'<div class="ph">building...</div>\')+\'</div>\'+\n' +
|
||||
' b.innerHTML=\'<div class="thumb">\'+inner+\'</div>\'+\n' +
|
||||
' \'<div class="meta"><span class="t"><i>\'+d.n+\'</i>\'+esc(d.title||("Direction "+d.n))+(picked===d.n?" ✓":"")+\'</span><span class="m">\'+esc(metaOf(d))+\'</span>\'+(d.axes?\'<span class="m">\'+esc(d.axes)+\'</span>\':"")+\'</div>\';\n' +
|
||||
' b.addEventListener("click",function(){openDir(d.n)});g.appendChild(b);\n' +
|
||||
' });\n' +
|
||||
@@ -277,11 +334,20 @@ export function buildPickerPage() {
|
||||
'function riff(){var steer=window.prompt("Optional steer for the new direction (leave blank for a free riff):","");if(steer===null)return;\n' +
|
||||
' var body={action:"riff",steer:steer.trim()};if(view!=="grid")body.choice=view;\n' +
|
||||
' api(body,"riff queued ✓ - a new direction will appear here")}\n' +
|
||||
'function graft(){if(view==="grid")return;\n' +
|
||||
' var ans=window.prompt("Take which section from which direction? e.g. \\u2018pricing from 3\\u2019","");\n' +
|
||||
' if(ans===null)return;ans=ans.trim();if(!ans)return;\n' +
|
||||
' var m=ans.match(/^(.*?)\\s+from\\s+(\\d+)$/i);\n' +
|
||||
' if(!m){note("could not parse - try \\u2018pricing from 3\\u2019","bad");return}\n' +
|
||||
' var section=m[1].trim(),from=parseInt(m[2],10);\n' +
|
||||
' if(!section||!from){note("could not parse - try \\u2018pricing from 3\\u2019","bad");return}\n' +
|
||||
' api({action:"graft",choice:view,from:from,section:section},"graft queued ✓ - \'"+section+"\' from "+from+" into "+view)}\n' +
|
||||
'$("back").addEventListener("click",closeSingle);\n' +
|
||||
'$("prev").addEventListener("click",function(){cycle(-1)});\n' +
|
||||
'$("next").addEventListener("click",function(){cycle(1)});\n' +
|
||||
'$("pickBtn").addEventListener("click",pick);\n' +
|
||||
'$("riffBtn").addEventListener("click",riff);\n' +
|
||||
'$("graftBtn").addEventListener("click",graft);\n' +
|
||||
'document.addEventListener("keydown",function(e){\n' +
|
||||
' var el=document.activeElement;if(el&&(el.tagName==="INPUT"||el.tagName==="TEXTAREA"||el.isContentEditable))return;\n' +
|
||||
' var k=parseInt(e.key,10);\n' +
|
||||
@@ -291,6 +357,7 @@ export function buildPickerPage() {
|
||||
' else if(e.key==="ArrowRight")cycle(1);\n' +
|
||||
' else if(e.key==="p"||e.key==="P")pick();\n' +
|
||||
' else if(e.key==="r"||e.key==="R")riff();\n' +
|
||||
' else if(e.key==="g"||e.key==="G")graft();\n' +
|
||||
' else if(k>=1&&k<=dirs.length)openDir(dirs[k-1].n);\n' +
|
||||
'});\n' +
|
||||
'setInterval(poll,2000);poll();\n' +
|
||||
|
||||
@@ -20,7 +20,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
parseArgs, runPaths, readJsonSafe, atomicWrite, nowIso, defaultManifest,
|
||||
nextRequestId, MIME, buildPickerPage, buildChipJs,
|
||||
nextRequestId, MIME, buildPickerPage, buildChipJs, buildInjectJs,
|
||||
} from "./core.mjs";
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
@@ -109,6 +109,36 @@ function serveFrame(res, pathname) {
|
||||
return send(res, 200, body, { "Content-Type": MIME[path.extname(file).toLowerCase()] ?? "application/octet-stream" });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// /thumb/<n>.png : the pre-rendered thumbnail thumbs.mjs wrote to
|
||||
// <RUN>/thumbs/<n>.png. 200 image/png, or 404 before thumbs has run.
|
||||
|
||||
function serveThumb(res, pathname) {
|
||||
const m = pathname.match(/^\/thumb\/(\d+)\.png$/);
|
||||
if (!m) return send(res, 404, "expected /thumb/<n>.png");
|
||||
const n = Number(m[1]);
|
||||
if (!Number.isInteger(n) || n < 1 || n > 99) return send(res, 404, "not found");
|
||||
const file = path.join(P.RUN, "thumbs", n + ".png");
|
||||
let body;
|
||||
try { body = fs.readFileSync(file); } catch { return send(res, 404, "not found"); }
|
||||
return send(res, 200, body, { "Content-Type": MIME[".png"] });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// /inject/<n>.js : the scoped-preview overlay script (core.buildInjectJs). The
|
||||
// direction total is read fresh so a riff's later directions flip correctly.
|
||||
|
||||
function serveInject(res, pathname) {
|
||||
const m = pathname.match(/^\/inject\/(\d+)\.js$/);
|
||||
if (!m) return send(res, 404, "expected /inject/<n>.js");
|
||||
const n = Number(m[1]);
|
||||
if (!Number.isInteger(n) || n < 1 || n > 99) return send(res, 404, "not found");
|
||||
const manifest = readManifest();
|
||||
const total = Math.max(n, (manifest.directions ?? []).length || n);
|
||||
const js = buildInjectJs(n, total, boundPort ?? PORT_WANTED);
|
||||
return send(res, 200, js, { "Content-Type": MIME[".js"], "Access-Control-Allow-Origin": "*" });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// server
|
||||
|
||||
@@ -136,6 +166,8 @@ const server = http.createServer(async (req, res) => {
|
||||
return send(res, 200, CHIP_JS, { "Content-Type": MIME[".js"], "Access-Control-Allow-Origin": "*" });
|
||||
}
|
||||
if (p.startsWith("/frame/")) return serveFrame(res, p);
|
||||
if (p.startsWith("/thumb/")) return serveThumb(res, p);
|
||||
if (p.startsWith("/inject/")) return serveInject(res, p);
|
||||
return send(res, 404, "not found");
|
||||
}
|
||||
|
||||
@@ -143,12 +175,24 @@ const server = http.createServer(async (req, res) => {
|
||||
if (p === "/api/pick") {
|
||||
const body = await readBody(req);
|
||||
const action = body?.action;
|
||||
if (action !== "pick" && action !== "riff") return sendJson(res, 400, { error: 'action must be "pick" or "riff"' }, CORS);
|
||||
if (action !== "pick" && action !== "riff" && action !== "graft") {
|
||||
return sendJson(res, 400, { error: 'action must be "pick", "riff", or "graft"' }, CORS);
|
||||
}
|
||||
const fields = {};
|
||||
if (action === "pick") {
|
||||
const choice = Number(body.choice);
|
||||
if (!Number.isInteger(choice) || choice < 1) return sendJson(res, 400, { error: "pick needs a direction number in choice" }, CORS);
|
||||
fields.choice = choice;
|
||||
} else if (action === "graft") {
|
||||
const choice = Number(body.choice);
|
||||
const from = Number(body.from);
|
||||
const section = String(body.section ?? "").trim().slice(0, 120);
|
||||
if (!Number.isInteger(choice) || choice < 1) return sendJson(res, 400, { error: "graft needs the winner direction number in choice" }, CORS);
|
||||
if (!Number.isInteger(from) || from < 1) return sendJson(res, 400, { error: "graft needs the source direction number in from" }, CORS);
|
||||
if (!section) return sendJson(res, 400, { error: "graft needs a section name" }, CORS);
|
||||
fields.choice = choice;
|
||||
fields.from = from;
|
||||
fields.section = section;
|
||||
} else {
|
||||
if (body.choice != null && Number.isInteger(Number(body.choice))) fields.choice = Number(body.choice);
|
||||
fields.steer = String(body.steer ?? "").slice(0, 500);
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env node
|
||||
// hallmark variants thumbnailer. Zero npm dependencies. For each ready
|
||||
// direction in the run manifest it screenshots the direction to
|
||||
// <RUN>/thumbs/<n>.png at 1280x800 and flips thumb:true on that direction in
|
||||
// the manifest (atomic write). The picker then swaps its scaled live iframe for
|
||||
// a static <img>, which is lighter and also survives iframe-blocked
|
||||
// (X-Frame-Options / CSP frame-ancestors) routes-mode dev servers.
|
||||
//
|
||||
// node thumbs.mjs --run <run-dir> [--port 4180] [--force] [--n 2]
|
||||
//
|
||||
// Engine: puppeteer-core driving the installed Chrome when the package is
|
||||
// available; otherwise spawns the Chrome binary headless with --screenshot
|
||||
// (the same dual-engine pattern as eval/screenshot.mjs). CHROME_PATH overrides
|
||||
// the Chrome binary.
|
||||
//
|
||||
// URL per direction: an absolute http(s) url (routes mode) is shot as-is;
|
||||
// anything else (greenfield /frame/<n>/) is shot from the local v<n>/index.html
|
||||
// file, or from the picker server when --port is given.
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { parseArgs, runPaths, readJsonSafe, atomicWrite, defaultManifest } from "./core.mjs";
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (!args.run || args.run === true) { console.error("thumbs.mjs: --run <run-dir> is required"); process.exit(1); }
|
||||
const P = runPaths(args.run);
|
||||
const THUMBS = path.join(P.RUN, "thumbs");
|
||||
const PORT = args.port && args.port !== true ? Number(args.port) : null;
|
||||
const ONLY_N = args.n && args.n !== true ? Number(args.n) : null;
|
||||
const FORCE = !!args.force;
|
||||
|
||||
const DEFAULT_CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
|
||||
const CHROME = process.env.CHROME_PATH || DEFAULT_CHROME;
|
||||
const WIDTH = 1280, HEIGHT = 800;
|
||||
|
||||
fs.mkdirSync(THUMBS, { recursive: true });
|
||||
|
||||
function readManifest() {
|
||||
const { json } = readJsonSafe(P.MANIFEST);
|
||||
return json && typeof json === "object" && !Array.isArray(json) ? json : defaultManifest(P.RUN_ID);
|
||||
}
|
||||
|
||||
/** Where to point the browser for direction d: absolute url as-is; a relative
|
||||
* greenfield url against the picker port when --port is set; else the local
|
||||
* v<n>/index.html file. Returns null when nothing shootable exists. */
|
||||
function shootUrl(d) {
|
||||
const u = String(d.url ?? "");
|
||||
if (/^https?:\/\//i.test(u)) return u;
|
||||
if (PORT) return `http://127.0.0.1:${PORT}` + (u.startsWith("/") ? u : `/frame/${d.n}/`);
|
||||
const file = path.join(P.RUN, "v" + d.n, "index.html");
|
||||
return fs.existsSync(file) ? pathToFileURL(file).href : null;
|
||||
}
|
||||
|
||||
/** Re-read the manifest fresh (the agent may edit it between shots) and flip
|
||||
* thumb:true on direction n, then atomic-write. */
|
||||
function markThumb(n) {
|
||||
const manifest = readManifest();
|
||||
const dir = (manifest.directions ?? []).find((x) => x.n === n);
|
||||
if (!dir) return;
|
||||
dir.thumb = true;
|
||||
atomicWrite(P.MANIFEST, JSON.stringify(manifest, null, 2) + "\n");
|
||||
}
|
||||
|
||||
function planJobs() {
|
||||
const manifest = readManifest();
|
||||
const jobs = [];
|
||||
for (const d of manifest.directions ?? []) {
|
||||
if (ONLY_N != null && d.n !== ONLY_N) continue;
|
||||
if (d.status && d.status !== "ready") continue; // only ready directions
|
||||
const out = path.join(THUMBS, d.n + ".png");
|
||||
if (!FORCE && fs.existsSync(out)) continue;
|
||||
const url = shootUrl(d);
|
||||
if (!url) { console.error(`skip direction ${d.n}: no shootable url or v${d.n}/index.html`); continue; }
|
||||
jobs.push({ n: d.n, url, out });
|
||||
}
|
||||
return jobs;
|
||||
}
|
||||
|
||||
async function tryPuppeteer() {
|
||||
try { const mod = await import("puppeteer-core"); return mod.default ?? mod; }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
async function shootWithPuppeteer(puppeteer, jobs) {
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: CHROME,
|
||||
headless: true,
|
||||
args: ["--disable-gpu", "--hide-scrollbars", "--force-device-scale-factor=1"],
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: WIDTH, height: HEIGHT });
|
||||
for (const job of jobs) {
|
||||
try {
|
||||
await page.goto(job.url, { waitUntil: "networkidle0", timeout: 30000 });
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
await page.screenshot({ path: job.out, fullPage: false });
|
||||
markThumb(job.n);
|
||||
console.log(`thumb ${path.relative(P.RUN, job.out)}`);
|
||||
} catch (err) {
|
||||
console.error(`FAIL direction ${job.n}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
function shootWithChromeBinary(jobs) {
|
||||
if (!fs.existsSync(CHROME)) {
|
||||
console.error(`Chrome binary not found at ${CHROME}; set CHROME_PATH.`);
|
||||
process.exit(1);
|
||||
}
|
||||
for (const job of jobs) {
|
||||
const res = spawnSync(
|
||||
CHROME,
|
||||
[
|
||||
"--headless=new",
|
||||
"--disable-gpu",
|
||||
"--hide-scrollbars",
|
||||
"--force-device-scale-factor=1",
|
||||
`--window-size=${WIDTH},${HEIGHT}`,
|
||||
`--screenshot=${job.out}`,
|
||||
"--virtual-time-budget=4000",
|
||||
job.url,
|
||||
],
|
||||
{ stdio: "ignore", timeout: 60000 },
|
||||
);
|
||||
if (res.status === 0 && fs.existsSync(job.out)) {
|
||||
markThumb(job.n);
|
||||
console.log(`thumb ${path.relative(P.RUN, job.out)} (chrome fallback)`);
|
||||
} else {
|
||||
console.error(`FAIL direction ${job.n} (chrome fallback, exit ${res.status})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const jobs = planJobs();
|
||||
if (!jobs.length) { console.log("nothing to shoot (all thumbs exist; use --force to redo)"); return; }
|
||||
const puppeteer = await tryPuppeteer();
|
||||
if (puppeteer) {
|
||||
console.log("engine: puppeteer-core");
|
||||
await shootWithPuppeteer(puppeteer, jobs);
|
||||
} else {
|
||||
console.log("engine: chrome --screenshot fallback (puppeteer-core not installed)");
|
||||
shootWithChromeBinary(jobs);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => { console.error(err); process.exit(1); });
|
||||
Reference in New Issue
Block a user