leglas 0.7.4 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -281,6 +281,19 @@ function parseArgs(argv) {
281
281
  }
282
282
  return { kind: "requests", json: rest.includes("--json"), clear: rest.includes("--clear") };
283
283
  }
284
+ if (argv[0] === "log") {
285
+ const rest = argv.slice(1);
286
+ const flags = rest.filter((argument) => argument.startsWith("--"));
287
+ const unknown = flags.find((flag) => flag !== "--json");
288
+ if (unknown !== void 0) {
289
+ return { kind: "error", message: `leglas log does not take ${unknown}.` };
290
+ }
291
+ const names = rest.filter((argument) => !argument.startsWith("--"));
292
+ if (names.length > 1) {
293
+ return { kind: "error", message: "leglas log takes one entry at most." };
294
+ }
295
+ return { kind: "log", entry: names[0] ?? null, json: flags.includes("--json") };
296
+ }
284
297
  if (argv[0] === "list") {
285
298
  const rest = argv.slice(1);
286
299
  const unknown = rest.find((argument) => argument !== "--json");
@@ -675,6 +688,14 @@ When asked for design variations, alternatives, or "a few options":
675
688
  and register it with \`npx leglas add --title "\u2026" --url "/" --branch <branch>\`
676
689
  (the config needs \`devCommand\` with \`{port}\`). Everything below is the
677
690
  ordinary, in-app path.
691
+ A page the app rebuilds in the browser after load (anything that hydrates:
692
+ Next, Nuxt, SvelteKit, a captured production site) is not its served HTML.
693
+ Markup edited there shows for a moment and is then replaced from the app's
694
+ own JavaScript and data, so make the change where that JavaScript gets what
695
+ it renders. When that is a script other directions share, give it a
696
+ per-direction override that defaults to what it renders today: every other
697
+ direction renders exactly as before, which is adding beside, not rewriting.
698
+ \`npx leglas show\` says when a page was rebuilt after load.
678
699
  2. Run \`npx leglas explore <surface> --count <n>\` first, adding
679
700
  \`--based-on "<title>"\` when the user wants variations of a direction they
680
701
  already like. It prints what the set needs and how to register it. In
@@ -725,6 +746,12 @@ out of the ignored directory, deletes the rest of the exploration, and drops
725
746
  them from the rail. Then change their component to use the kept component
726
747
  instead of the switcher.
727
748
 
749
+ Keeping also writes what the exploration was into \`design-log/\`, which is
750
+ committed. Before exploring a surface, read \`npx leglas log --json\` and any
751
+ entry for that surface: it says what was already tried there, in the user's own
752
+ words, and which direction won. Proposing something that was already rejected
753
+ wastes their time, and the record is there so you do not have to ask.
754
+
728
755
  Useful to know:
729
756
 
730
757
  - \`.leglas/\` is gitignored. Exploration is disposable and nothing in there
@@ -785,6 +812,84 @@ ${AGENTS_SECTION}`
785
812
  // src/keep.ts
786
813
  import { basename as basename4, extname as extname4, normalize as normalize2 } from "path";
787
814
 
815
+ // ../server/dist/log.js
816
+ var DEFAULT_LOG_DIR = "design-log";
817
+ function slugify(value) {
818
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "untitled";
819
+ }
820
+ function frameFor(title, requests) {
821
+ let found = null;
822
+ for (const request of requests) {
823
+ if (request.title !== title)
824
+ continue;
825
+ for (const attachment of request.attachments ?? []) {
826
+ if (attachment.kind === "frame")
827
+ found = attachment;
828
+ }
829
+ }
830
+ return found;
831
+ }
832
+ function askedOf(title, requests) {
833
+ return requests.filter((request) => request.title === title && request.status !== "failed" && request.intent.trim() !== "").map((request) => request.intent.trim());
834
+ }
835
+ function composeEntry(input) {
836
+ const slug = `${input.date}-${slugify(input.surface)}`;
837
+ const pictures = [];
838
+ const lines2 = [];
839
+ lines2.push(`# ${input.surface}, ${input.date}`);
840
+ lines2.push("");
841
+ lines2.push(`**${input.won.title}** won and became \`${input.won.to}\`. ${input.previews.length === 1 ? "It was the only direction." : `${input.previews.length} directions were compared.`}`);
842
+ lines2.push("");
843
+ for (const preview of input.previews) {
844
+ const won = preview.title === input.won.title;
845
+ lines2.push(`## ${preview.title}${won ? " \u2014 kept" : ""}`);
846
+ lines2.push("");
847
+ if (preview.note !== void 0 && preview.note.trim() !== "") {
848
+ lines2.push(preview.note.trim());
849
+ lines2.push("");
850
+ }
851
+ const frame = frameFor(preview.title, input.requests);
852
+ if (frame !== null) {
853
+ const name = `${slugify(preview.title)}.png`;
854
+ pictures.push({ from: frame.file, to: name });
855
+ lines2.push(`![${preview.title}](${slug}/${name})`);
856
+ lines2.push("");
857
+ }
858
+ if (preview.basedOn !== void 0) {
859
+ lines2.push(`A variant of ${preview.basedOn}.`);
860
+ lines2.push("");
861
+ }
862
+ const asked = askedOf(preview.title, input.requests);
863
+ if (asked.length > 0) {
864
+ lines2.push("Asked for:");
865
+ lines2.push("");
866
+ for (const words of asked)
867
+ lines2.push(`- ${words}`);
868
+ lines2.push("");
869
+ }
870
+ const notes = input.annotations.filter((note) => note.title === preview.title);
871
+ if (notes.length > 0) {
872
+ lines2.push("Marked on the design:");
873
+ lines2.push("");
874
+ for (const note of notes)
875
+ lines2.push(`- ${note.note}`);
876
+ lines2.push("");
877
+ }
878
+ }
879
+ const failed = input.requests.filter((request) => request.status === "failed");
880
+ if (failed.length > 0) {
881
+ lines2.push("## Changes that did not land");
882
+ lines2.push("");
883
+ for (const request of failed) {
884
+ const why = request.failure?.message;
885
+ lines2.push(`- ${request.title}: ${request.intent}${why === void 0 ? "" : ` (${why})`}`);
886
+ }
887
+ lines2.push("");
888
+ }
889
+ return { slug, markdown: `${lines2.join("\n").trimEnd()}
890
+ `, pictures };
891
+ }
892
+
788
893
  // ../server/dist/config.js
789
894
  var DEFAULT_DEV_SERVER = "http://localhost:3000";
790
895
  var DEFAULT_INSTALL_COMMAND = "npm install";
@@ -903,6 +1008,10 @@ function normalizeConfig(raw, options = {}) {
903
1008
  if (requireDevCommand && previews.some((preview) => preview.branch !== void 0) && devCommand === void 0) {
904
1009
  errors.push("A preview names a branch, so devCommand is required: Leglas has to start that checkout itself.");
905
1010
  }
1011
+ const logDir = source["logDir"] ?? DEFAULT_LOG_DIR;
1012
+ if (typeof logDir !== "string" || logDir.trim() === "") {
1013
+ errors.push("logDir must be a non-empty string.");
1014
+ }
906
1015
  const installCommand = source["installCommand"] ?? DEFAULT_INSTALL_COMMAND;
907
1016
  if (typeof installCommand !== "string" || installCommand.trim() === "") {
908
1017
  errors.push("installCommand must be a non-empty string.");
@@ -919,7 +1028,8 @@ function normalizeConfig(raw, options = {}) {
919
1028
  previews,
920
1029
  scanPreviews,
921
1030
  devCommand: typeof devCommand === "string" ? devCommand : void 0,
922
- installCommand
1031
+ installCommand,
1032
+ logDir
923
1033
  },
924
1034
  errors: []
925
1035
  };
@@ -1697,6 +1807,7 @@ import net from "net";
1697
1807
  function createProxyHandler(options) {
1698
1808
  const target = new URL(options.target);
1699
1809
  const host = target.hostname;
1810
+ const dialHost = host.replace(/^\[|\]$/g, "");
1700
1811
  const port = Number(target.port || (target.protocol === "https:" ? 443 : 80));
1701
1812
  const authority = target.port ? `${host}:${target.port}` : host;
1702
1813
  function upstreamHeaders(req) {
@@ -1713,7 +1824,19 @@ function createProxyHandler(options) {
1713
1824
  }
1714
1825
  return {
1715
1826
  request(req, res, publicOrigin) {
1716
- const upstream = http.request({ host, port, method: req.method, path: req.url, headers: upstreamHeaders(req) }, (upstreamRes) => {
1827
+ options.onActivity?.();
1828
+ options.onOpen?.();
1829
+ let open = true;
1830
+ const close = () => {
1831
+ if (!open)
1832
+ return;
1833
+ open = false;
1834
+ options.onActivity?.();
1835
+ options.onClose?.();
1836
+ };
1837
+ res.once("finish", close);
1838
+ res.once("close", close);
1839
+ const upstream = http.request({ host: dialHost, port, method: req.method, path: req.url, headers: upstreamHeaders(req) }, (upstreamRes) => {
1717
1840
  const headers = { ...upstreamRes.headers };
1718
1841
  const location = rewriteLocation(typeof headers.location === "string" ? headers.location : void 0, publicOrigin);
1719
1842
  if (location !== void 0)
@@ -1735,7 +1858,17 @@ Start it, or point Leglas somewhere else with --user-port.`);
1735
1858
  req.pipe(upstream);
1736
1859
  },
1737
1860
  upgrade(req, socket, head) {
1738
- const upstream = net.connect(port, host, () => {
1861
+ options.onActivity?.();
1862
+ options.onOpen?.();
1863
+ let open = true;
1864
+ const closeActivity = () => {
1865
+ if (!open)
1866
+ return;
1867
+ open = false;
1868
+ options.onActivity?.();
1869
+ options.onClose?.();
1870
+ };
1871
+ const upstream = net.connect(port, dialHost, () => {
1739
1872
  const headers = Object.entries(upstreamHeaders(req)).map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(", ") : value}\r
1740
1873
  `).join("");
1741
1874
  upstream.write(`${req.method} ${req.url} HTTP/1.1\r
@@ -1747,6 +1880,7 @@ ${headers}\r
1747
1880
  socket.pipe(upstream);
1748
1881
  });
1749
1882
  const shutdown = () => {
1883
+ closeActivity();
1750
1884
  upstream.destroy();
1751
1885
  socket.destroy();
1752
1886
  };
@@ -1757,6 +1891,62 @@ ${headers}\r
1757
1891
  }
1758
1892
  };
1759
1893
  }
1894
+ function startProxyServer(options) {
1895
+ return new Promise((resolve5, reject) => {
1896
+ let open = 0;
1897
+ const handler = createProxyHandler({
1898
+ ...options,
1899
+ onOpen: () => {
1900
+ open += 1;
1901
+ options.onOpen?.();
1902
+ },
1903
+ onClose: () => {
1904
+ open = Math.max(0, open - 1);
1905
+ options.onClose?.();
1906
+ }
1907
+ });
1908
+ const server = http.createServer((req, res) => {
1909
+ const address = server.address();
1910
+ const port = typeof address === "object" && address !== null ? address.port : 0;
1911
+ handler.request(req, res, `http://127.0.0.1:${port}`);
1912
+ });
1913
+ const sockets = /* @__PURE__ */ new Set();
1914
+ server.on("connection", (socket) => {
1915
+ sockets.add(socket);
1916
+ socket.once("close", () => sockets.delete(socket));
1917
+ });
1918
+ server.on("upgrade", (req, socket, head) => handler.upgrade(req, socket, head));
1919
+ const onError = (error) => {
1920
+ server.removeListener("listening", onListening);
1921
+ reject(error);
1922
+ };
1923
+ const onListening = () => {
1924
+ server.removeListener("error", onError);
1925
+ const address = server.address();
1926
+ const port = typeof address === "object" && address !== null ? address.port : 0;
1927
+ let closed = null;
1928
+ resolve5({
1929
+ active: () => open > 0,
1930
+ close: () => {
1931
+ if (closed !== null)
1932
+ return closed;
1933
+ closed = new Promise((done) => {
1934
+ for (const socket of sockets)
1935
+ socket.destroy();
1936
+ sockets.clear();
1937
+ server.closeAllConnections();
1938
+ server.close(() => done());
1939
+ });
1940
+ return closed;
1941
+ },
1942
+ url: `http://127.0.0.1:${port}`
1943
+ });
1944
+ };
1945
+ server.once("error", onError);
1946
+ server.once("listening", onListening);
1947
+ server.listen(0, "127.0.0.1");
1948
+ });
1949
+ }
1760
1950
 
1761
1951
  // ../server/dist/browser.js
1762
1952
  import { randomBytes } from "crypto";
@@ -2411,6 +2601,32 @@ function createBrowserPool(options = {}) {
2411
2601
  };
2412
2602
  }
2413
2603
 
2604
+ // ../server/dist/hydration.js
2605
+ function hydrationEvidence(messages) {
2606
+ for (const raw of messages) {
2607
+ const message2 = raw.split("\n", 1)[0]?.trim() ?? "";
2608
+ if (/Minified React error #(418|419|422|423|425)\b/.test(message2)) {
2609
+ return { framework: "React", message: message2 };
2610
+ }
2611
+ if (/Hydration failed because/.test(message2) || /error while hydrating/i.test(message2) || /Text content (did not|does not) match/i.test(message2) || /Expected server HTML to contain/i.test(message2) || /did not match\. Server:/.test(message2)) {
2612
+ return { framework: "React", message: message2 };
2613
+ }
2614
+ if (/Hydration (node|text|children|class|style|attribute) mismatch/i.test(message2) || /Hydration completed but contains mismatches/i.test(message2)) {
2615
+ return { framework: "Vue", message: message2 };
2616
+ }
2617
+ if (/hydration_mismatch/.test(message2)) {
2618
+ return { framework: "Svelte", message: message2 };
2619
+ }
2620
+ if (/Hydration Mismatch\. Unable to find DOM nodes/.test(message2)) {
2621
+ return { framework: "Solid", message: message2 };
2622
+ }
2623
+ if (/hydrat/i.test(message2) && (/expected .+ but found/i.test(message2) || /mismatch/i.test(message2) && /(node|element|markup|dom|tag|text|attribute|server|client)/i.test(message2))) {
2624
+ return { framework: "the app", message: message2 };
2625
+ }
2626
+ }
2627
+ return null;
2628
+ }
2629
+
2414
2630
  // ../server/dist/capture.js
2415
2631
  var FRAME_MAX_HEIGHT = 4e3;
2416
2632
  var MIN_WIDTH = 320;
@@ -2504,10 +2720,12 @@ function locatorExpression(focus) {
2504
2720
  async function render(page, input) {
2505
2721
  const width = clamp(Math.round(input.width), MIN_WIDTH, MAX_WIDTH);
2506
2722
  const errors = [];
2723
+ let hydration = null;
2507
2724
  const remember = (value) => {
2725
+ const message2 = String(value ?? "").trim().slice(0, 240);
2726
+ hydration ??= hydrationEvidence([message2]);
2508
2727
  if (errors.length >= 10)
2509
2728
  return;
2510
- const message2 = String(value ?? "").slice(0, 240);
2511
2729
  if (message2 === "" || /favicon/i.test(message2))
2512
2730
  return;
2513
2731
  errors.push(message2);
@@ -2655,7 +2873,7 @@ async function render(page, input) {
2655
2873
  resolved
2656
2874
  });
2657
2875
  }
2658
- return { frame, crops, errors, cut };
2876
+ return { frame, crops, errors, hydration, cut };
2659
2877
  } finally {
2660
2878
  for (const stop of unlisten)
2661
2879
  stop();
@@ -2799,7 +3017,13 @@ async function attachRequest(cwd, requestId, input, deps) {
2799
3017
  const capture = deps.capture ?? capturePage;
2800
3018
  const deadlineMs = deps.deadlineMs ?? 12e3;
2801
3019
  const destination = join5(cwd, CAPTURES_DIR, requestId);
2802
- const captured = { attachments: [], errors: [], cut: false, skipped: null };
3020
+ const captured = {
3021
+ attachments: [],
3022
+ errors: [],
3023
+ hydration: null,
3024
+ cut: false,
3025
+ skipped: null
3026
+ };
2803
3027
  const references = [];
2804
3028
  requestedWidths.set(captured, Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, Math.round(input.width))));
2805
3029
  const controller = new AbortController();
@@ -2849,6 +3073,7 @@ async function attachRequest(cwd, requestId, input, deps) {
2849
3073
  viewport: direction.frame.width
2850
3074
  });
2851
3075
  captured.errors = direction.errors;
3076
+ captured.hydration = direction.hydration;
2852
3077
  captured.cut = direction.cut;
2853
3078
  for (let index = 0; index < direction.crops.length; index += 1) {
2854
3079
  const crop = direction.crops[index];
@@ -3122,6 +3347,8 @@ async function startAppProcess(options) {
3122
3347
  }
3123
3348
 
3124
3349
  // ../server/dist/branches.js
3350
+ var BRANCH_IDLE_MS = 10 * 60 * 1e3;
3351
+ var BRANCH_SWEEP_MS = 3e4;
3125
3352
  function publicBranchState(state) {
3126
3353
  if (state.status === "ready")
3127
3354
  return { status: "ready" };
@@ -3131,7 +3358,11 @@ function createBranchRegistry(options) {
3131
3358
  const states = new Map(options.previews.map((preview) => [preview.title, { status: "idle" }]));
3132
3359
  const previews = new Map(options.previews.map((preview) => [preview.title, preview]));
3133
3360
  const inflight = /* @__PURE__ */ new Map();
3361
+ const stopping = /* @__PURE__ */ new Map();
3362
+ const lastActivity = /* @__PURE__ */ new Map();
3363
+ const proxies = /* @__PURE__ */ new Map();
3134
3364
  const boot = options.startWorktree ?? startWorktree;
3365
+ const proxy = options.startProxy ?? startProxyServer;
3135
3366
  let closed = false;
3136
3367
  let stopPromise = null;
3137
3368
  const transition = (title, state) => {
@@ -3143,6 +3374,43 @@ function createBranchRegistry(options) {
3143
3374
  options.onChange?.(title, state);
3144
3375
  return state;
3145
3376
  };
3377
+ const stopReady = (title, state, toIdle) => {
3378
+ const current = stopping.get(title);
3379
+ if (current !== void 0)
3380
+ return current;
3381
+ const pending = (async () => {
3382
+ await proxies.get(title)?.close().catch(() => {
3383
+ });
3384
+ await state.worktree.stop().catch(() => {
3385
+ });
3386
+ proxies.delete(title);
3387
+ lastActivity.delete(title);
3388
+ if (toIdle && !closed && states.get(title) === state) {
3389
+ transition(title, { status: "idle" });
3390
+ }
3391
+ })().finally(() => {
3392
+ stopping.delete(title);
3393
+ });
3394
+ stopping.set(title, pending);
3395
+ return pending;
3396
+ };
3397
+ const sweep = () => {
3398
+ const now = Date.now();
3399
+ for (const [title, state] of states) {
3400
+ const branchProxy = proxies.get(title);
3401
+ if (state.status !== "ready" || branchProxy === void 0 || stopping.has(title))
3402
+ continue;
3403
+ if (branchProxy.active()) {
3404
+ lastActivity.set(title, now);
3405
+ continue;
3406
+ }
3407
+ const seen = lastActivity.get(title) ?? now;
3408
+ if (now - seen >= BRANCH_IDLE_MS)
3409
+ void stopReady(title, state, true);
3410
+ }
3411
+ };
3412
+ const sweepTimer = setInterval(sweep, BRANCH_SWEEP_MS);
3413
+ sweepTimer.unref();
3146
3414
  const begin = (title) => {
3147
3415
  const preview = previews.get(title);
3148
3416
  const current = states.get(title);
@@ -3170,9 +3438,21 @@ function createBranchRegistry(options) {
3170
3438
  } catch (error) {
3171
3439
  checkout = Promise.reject(error);
3172
3440
  }
3173
- const starting = checkout.then((worktree) => {
3441
+ const starting = checkout.then(async (worktree) => {
3174
3442
  transition(title, { status: "starting", phase: "starting" });
3175
- return transition(title, { status: "ready", worktree });
3443
+ try {
3444
+ const branchProxy = await proxy({
3445
+ target: worktree.url,
3446
+ onActivity: () => lastActivity.set(title, Date.now())
3447
+ });
3448
+ proxies.set(title, branchProxy);
3449
+ lastActivity.set(title, Date.now());
3450
+ return transition(title, { status: "ready", worktree });
3451
+ } catch (error) {
3452
+ await worktree.stop().catch(() => {
3453
+ });
3454
+ throw error;
3455
+ }
3176
3456
  }).catch((error) => transition(title, {
3177
3457
  status: "failed",
3178
3458
  reason: error instanceof Error ? error.message : String(error)
@@ -3184,15 +3464,17 @@ function createBranchRegistry(options) {
3184
3464
  };
3185
3465
  return {
3186
3466
  state: (title) => states.get(title),
3467
+ url: (title) => proxies.get(title)?.url,
3187
3468
  start: begin,
3188
3469
  stop: () => {
3189
3470
  if (stopPromise !== null)
3190
3471
  return stopPromise;
3191
3472
  closed = true;
3473
+ clearInterval(sweepTimer);
3192
3474
  stopPromise = Promise.allSettled([...inflight.values()]).then(async () => {
3193
- const worktrees = [...states.values()].filter((state) => state.status === "ready").map((state) => state.worktree);
3194
- await Promise.all(worktrees.map((worktree) => worktree.stop().catch(() => {
3195
- })));
3475
+ await Promise.allSettled([...stopping.values()]);
3476
+ const ready = [...states.entries()].filter((entry) => entry[1].status === "ready" && proxies.has(entry[0]));
3477
+ await Promise.all(ready.map(([title, state]) => stopReady(title, state, false)));
3196
3478
  });
3197
3479
  return stopPromise;
3198
3480
  }
@@ -3487,10 +3769,10 @@ function scope(leglasCommand, quotedTitle) {
3487
3769
 
3488
3770
  This is a scoped design change: no test run, no build, and no survey of the rest of the project is needed. The result is checked visually in a live preview, not by tooling.
3489
3771
 
3490
- Leave every other direction exactly as it is; they are alternatives being compared side by side, so changing a sibling destroys the comparison. Keep the change additive: do not rewrite shared components that other directions rely on.`;
3772
+ Leave every other direction exactly as it is; they are alternatives being compared side by side, so changing a sibling destroys the comparison. Keep the change additive: do not rewrite shared components that other directions rely on. A shared script may gain one small per-direction override, at the point it reads what it renders, that defaults to what it renders today; every other direction then renders exactly as before, so that counts as additive.`;
3491
3773
  }
3492
3774
  function capturedBlock(captured) {
3493
- if (captured === null || captured.attachments.length === 0 && captured.errors.length === 0 && captured.skipped === null)
3775
+ if (captured === null || captured.attachments.length === 0 && captured.errors.length === 0 && captured.hydration === null && captured.skipped === null)
3494
3776
  return "";
3495
3777
  const lines2 = [];
3496
3778
  const frames = captured.attachments.filter((attachment) => attachment.kind === "frame" || attachment.kind === "note");
@@ -3522,6 +3804,9 @@ function capturedBlock(captured) {
3522
3804
  for (const error of captured.errors)
3523
3805
  lines2.push(` - ${error}`);
3524
3806
  }
3807
+ if (captured.hydration !== null) {
3808
+ lines2.push(`After load, ${captured.hydration.framework} rebuilt this page in the browser from the app's own JavaScript and data (${captured.hydration.message}). Markup edited in the served HTML shows for a moment and is then replaced, so make the change where that JavaScript gets what it renders: the data or source it reads, or a per-direction override that a shared script reads with the original as its default. Look at the result a few seconds after load, not at first paint.`);
3809
+ }
3525
3810
  if (captured.skipped !== null) {
3526
3811
  lines2.push(`(${captured.skipped} Use the live preview instead.)`);
3527
3812
  }
@@ -5289,6 +5574,7 @@ function resolveTitle(input, titles, renames) {
5289
5574
 
5290
5575
  // ../server/dist/server.js
5291
5576
  import { createReadStream, existsSync as existsSync3, statSync, unwatchFile, watch as watchFs, watchFile } from "fs";
5577
+ import { createHash as createHash2 } from "crypto";
5292
5578
  import { mkdir as mkdir8, readdir as readdir3, writeFile as writeFile8 } from "fs/promises";
5293
5579
  import http2 from "http";
5294
5580
  import net3 from "net";
@@ -5367,6 +5653,30 @@ function sendJson(res, status, body) {
5367
5653
  });
5368
5654
  res.end(payload);
5369
5655
  }
5656
+ function etagMatches(value, etag) {
5657
+ if (value === void 0)
5658
+ return false;
5659
+ const values = Array.isArray(value) ? value : [value];
5660
+ return values.some((header) => header.split(",").some((candidate) => {
5661
+ const tag = candidate.trim();
5662
+ return tag === "*" || tag === etag || tag === `W/${etag}`;
5663
+ }));
5664
+ }
5665
+ function sendConditionalJson(req, res, body) {
5666
+ const payload = JSON.stringify(body);
5667
+ const etag = `"${createHash2("sha256").update(payload).digest("base64url")}"`;
5668
+ if (etagMatches(req.headers["if-none-match"], etag)) {
5669
+ res.writeHead(304, { etag, "cache-control": "private, no-cache" });
5670
+ res.end();
5671
+ return;
5672
+ }
5673
+ res.writeHead(200, {
5674
+ "content-type": "application/json; charset=utf-8",
5675
+ "cache-control": "private, no-cache",
5676
+ etag
5677
+ });
5678
+ res.end(payload);
5679
+ }
5370
5680
  var CAPTURE_DEADLINE_MS = 15e3;
5371
5681
  var CAPTURE_LOAD_MS = Math.floor(CAPTURE_DEADLINE_MS * LOAD_SHARE);
5372
5682
  function captureSlug(title) {
@@ -5759,9 +6069,10 @@ async function startServer(options) {
5759
6069
  return preview;
5760
6070
  const state = branches.state(preview.title) ?? { status: "idle" };
5761
6071
  const { url: route, ...withoutUrl } = preview;
6072
+ const branchUrl = branches.url(preview.title);
5762
6073
  return state.status === "ready" ? {
5763
6074
  ...withoutUrl,
5764
- url: `${state.worktree.url}${route}`,
6075
+ url: `${branchUrl ?? state.worktree.url}${route}`,
5765
6076
  state: publicBranchState(state)
5766
6077
  } : { ...withoutUrl, state: publicBranchState(state) };
5767
6078
  };
@@ -5770,7 +6081,7 @@ async function startServer(options) {
5770
6081
  if (preview.branch === void 0)
5771
6082
  return preview;
5772
6083
  const state = branches.state(preview.title);
5773
- return state?.status === "ready" ? { ...preview, url: `${state.worktree.url}${preview.url}` } : null;
6084
+ return state?.status === "ready" ? { ...preview, url: `${branches.url(preview.title) ?? state.worktree.url}${preview.url}` } : null;
5774
6085
  };
5775
6086
  if (options.pool === void 0) {
5776
6087
  void reapOrphanedBrowsers().catch(() => {
@@ -5833,7 +6144,7 @@ async function startServer(options) {
5833
6144
  errors.push(notice);
5834
6145
  return void readLocalPreviews(cwd).then(({ previews: local, errors: localErrors }) => {
5835
6146
  if (localErrors.length > 0) {
5836
- return sendJson(res, 200, {
6147
+ return sendConditionalJson(req, res, {
5837
6148
  project,
5838
6149
  devServer: target,
5839
6150
  scanPreviews: config?.scanPreviews ?? true,
@@ -5846,7 +6157,7 @@ async function startServer(options) {
5846
6157
  const currentBoot = boot.filter((preview) => preview.local !== true || localTitles.has(preview.title));
5847
6158
  const known = new Set(currentBoot.map((preview) => preview.title));
5848
6159
  const fresh = local.filter((preview) => !known.has(preview.title) && preview.branch === void 0 && preview.file === void 0);
5849
- sendJson(res, 200, {
6160
+ sendConditionalJson(req, res, {
5850
6161
  project,
5851
6162
  devServer: target,
5852
6163
  scanPreviews: config?.scanPreviews ?? true,
@@ -5854,7 +6165,7 @@ async function startServer(options) {
5854
6165
  errors,
5855
6166
  warnings: configWarnings
5856
6167
  });
5857
- }).catch(() => sendJson(res, 200, {
6168
+ }).catch(() => sendConditionalJson(req, res, {
5858
6169
  project,
5859
6170
  devServer: target,
5860
6171
  scanPreviews: config?.scanPreviews ?? true,
@@ -6177,6 +6488,7 @@ async function startServer(options) {
6177
6488
  height: shot.height,
6178
6489
  viewport: result.frame.width,
6179
6490
  errors: result.errors,
6491
+ hydration: result.hydration,
6180
6492
  cut: result.cut
6181
6493
  });
6182
6494
  } catch (error) {
@@ -6289,7 +6601,7 @@ async function startServer(options) {
6289
6601
  waiting: null,
6290
6602
  failedIds: []
6291
6603
  };
6292
- return void readRequests(cwd).then((requests) => sendJson(res, 200, {
6604
+ return void readRequests(cwd).then((requests) => sendConditionalJson(req, res, {
6293
6605
  requests: requests.map(({ id, title, intent, mode, status, failure, notes }) => ({
6294
6606
  id,
6295
6607
  title,
@@ -6392,7 +6704,7 @@ async function startServer(options) {
6392
6704
  });
6393
6705
  }
6394
6706
  if (path === `${LEGLAS_PREFIX}/api/annotations` && req.method === "GET") {
6395
- return void readAnnotations(cwd).then((annotations) => sendJson(res, 200, { annotations }));
6707
+ return void readAnnotations(cwd).then((annotations) => sendConditionalJson(req, res, { annotations }));
6396
6708
  }
6397
6709
  if (path === `${LEGLAS_PREFIX}/api/annotations` && req.method === "POST") {
6398
6710
  if (!hasJsonBody(req)) {
@@ -6518,7 +6830,7 @@ async function startServer(options) {
6518
6830
  });
6519
6831
  }
6520
6832
  if (path === `${LEGLAS_PREFIX}/api/health`) {
6521
- return void probe(target).then((reachable) => sendJson(res, 200, { devServer: target, reachable, cwd }));
6833
+ return void probe(target).then((reachable) => sendConditionalJson(req, res, { devServer: target, reachable, cwd }));
6522
6834
  }
6523
6835
  if (path.startsWith(`${FILES_PREFIX}/`)) {
6524
6836
  const rest = path.slice(FILES_PREFIX.length + 1);
@@ -6708,7 +7020,7 @@ async function runInit(options, deps) {
6708
7020
 
6709
7021
  // src/run-keep.ts
6710
7022
  import { existsSync as existsSync4 } from "fs";
6711
- import { mkdir as mkdir9, readFile as readFile13, rm as rm5, writeFile as writeFile10 } from "fs/promises";
7023
+ import { copyFile as copyFile2, mkdir as mkdir9, readFile as readFile13, rm as rm5, writeFile as writeFile10 } from "fs/promises";
6712
7024
  import { dirname as dirname9, join as join14 } from "path";
6713
7025
 
6714
7026
  // src/resolve-title.ts
@@ -6736,6 +7048,29 @@ function renameExport(source, to) {
6736
7048
  to
6737
7049
  );
6738
7050
  }
7051
+ async function writeLogEntry(options) {
7052
+ const entry = composeEntry({
7053
+ surface: options.surface,
7054
+ won: options.won,
7055
+ previews: options.previews,
7056
+ requests: await readRequests(options.cwd),
7057
+ annotations: await readAnnotations(options.cwd),
7058
+ date: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)
7059
+ });
7060
+ const dir = join14(options.cwd, options.logDir);
7061
+ await mkdir9(dir, { recursive: true });
7062
+ const file = join14(dir, `${entry.slug}.md`);
7063
+ await writeFile10(file, entry.markdown, "utf8");
7064
+ if (entry.pictures.length > 0) {
7065
+ const pictureDir = join14(dir, entry.slug);
7066
+ await mkdir9(pictureDir, { recursive: true });
7067
+ for (const picture of entry.pictures) {
7068
+ await copyFile2(join14(options.cwd, picture.from), join14(pictureDir, picture.to)).catch(() => {
7069
+ });
7070
+ }
7071
+ }
7072
+ return `${options.logDir}/${entry.slug}.md`;
7073
+ }
6739
7074
  async function runKeep(options, deps) {
6740
7075
  const loaded = await loadConfig(options.cwd);
6741
7076
  const local = await readLocalPreviews(options.cwd);
@@ -6764,6 +7099,20 @@ async function runKeep(options, deps) {
6764
7099
  const source = await readFile13(from, "utf8");
6765
7100
  await mkdir9(dirname9(to), { recursive: true });
6766
7101
  await writeFile10(to, renameExport(source, plan.exportName), "utf8");
7102
+ const surface = plan.removeDir.slice(plan.removeDir.lastIndexOf("/") + 1);
7103
+ let logged = null;
7104
+ let logError = null;
7105
+ try {
7106
+ logged = await writeLogEntry({
7107
+ cwd: options.cwd,
7108
+ logDir: loaded.config?.logDir ?? DEFAULT_LOG_DIR,
7109
+ surface,
7110
+ won: { title: resolved.title, to: plan.move.to },
7111
+ previews: previews.filter((preview) => plan.dropTitles.includes(preview.title))
7112
+ });
7113
+ } catch (error) {
7114
+ logError = error instanceof Error ? error.message : String(error);
7115
+ }
6767
7116
  await rm5(join14(options.cwd, plan.removeDir), { recursive: true, force: true });
6768
7117
  const dropped = await dropLocalPreviews(options.cwd, plan.dropTitles);
6769
7118
  if (options.json) {
@@ -6775,6 +7124,8 @@ async function runKeep(options, deps) {
6775
7124
  exportName: plan.exportName,
6776
7125
  removed: plan.removeDir,
6777
7126
  droppedPreviews: dropped,
7127
+ logged,
7128
+ logError,
6778
7129
  instructions: plan.instructions
6779
7130
  })
6780
7131
  );
@@ -6782,6 +7133,8 @@ async function runKeep(options, deps) {
6782
7133
  }
6783
7134
  deps.log(` kept ${plan.move.to}`);
6784
7135
  deps.log(` removed ${plan.removeDir}`);
7136
+ if (logged !== null) deps.log(` logged ${logged}`);
7137
+ if (logError !== null) deps.error(` The decision log could not be written: ${logError}`);
6785
7138
  if (dropped > 0) {
6786
7139
  deps.log(` dropped ${dropped} direction${dropped === 1 ? "" : "s"} from the rail`);
6787
7140
  }
@@ -7168,6 +7521,10 @@ async function runShow(options, deps) {
7168
7521
  height: captured.height,
7169
7522
  viewport: captured.viewport,
7170
7523
  errors: Array.isArray(captured.errors) ? captured.errors.filter((error) => typeof error === "string") : [],
7524
+ hydration: typeof captured.hydration === "object" && captured.hydration !== null && typeof captured.hydration.framework === "string" && typeof captured.hydration.message === "string" ? {
7525
+ framework: captured.hydration.framework,
7526
+ message: captured.hydration.message
7527
+ } : null,
7171
7528
  cut: captured.cut === true
7172
7529
  };
7173
7530
  }
@@ -7195,6 +7552,12 @@ async function runShow(options, deps) {
7195
7552
  if (envelope2.screenshot.cut) {
7196
7553
  deps.log(" the top of the page only; it is taller than one capture");
7197
7554
  }
7555
+ if (envelope2.screenshot.hydration !== null) {
7556
+ deps.log(
7557
+ ` hydration ${envelope2.screenshot.hydration.framework} rebuilt the page in the browser after load; the served markup is not what is on screen`
7558
+ );
7559
+ deps.log(` ${envelope2.screenshot.hydration.message}`);
7560
+ }
7198
7561
  if (envelope2.screenshot.errors.length > 0) {
7199
7562
  const count = envelope2.screenshot.errors.length;
7200
7563
  deps.log(` console ${count} ${count === 1 ? "error" : "errors"} on load`);