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/bin.js CHANGED
@@ -287,6 +287,19 @@ function parseArgs(argv) {
287
287
  }
288
288
  return { kind: "requests", json: rest.includes("--json"), clear: rest.includes("--clear") };
289
289
  }
290
+ if (argv[0] === "log") {
291
+ const rest = argv.slice(1);
292
+ const flags = rest.filter((argument) => argument.startsWith("--"));
293
+ const unknown = flags.find((flag) => flag !== "--json");
294
+ if (unknown !== void 0) {
295
+ return { kind: "error", message: `leglas log does not take ${unknown}.` };
296
+ }
297
+ const names = rest.filter((argument) => !argument.startsWith("--"));
298
+ if (names.length > 1) {
299
+ return { kind: "error", message: "leglas log takes one entry at most." };
300
+ }
301
+ return { kind: "log", entry: names[0] ?? null, json: flags.includes("--json") };
302
+ }
290
303
  if (argv[0] === "list") {
291
304
  const rest = argv.slice(1);
292
305
  const unknown = rest.find((argument) => argument !== "--json");
@@ -409,6 +422,84 @@ function parseArgs(argv) {
409
422
  import { stat as stat4 } from "fs/promises";
410
423
  import { join as join13 } from "path";
411
424
 
425
+ // ../server/dist/log.js
426
+ var DEFAULT_LOG_DIR = "design-log";
427
+ function slugify(value) {
428
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "untitled";
429
+ }
430
+ function frameFor(title, requests) {
431
+ let found = null;
432
+ for (const request of requests) {
433
+ if (request.title !== title)
434
+ continue;
435
+ for (const attachment of request.attachments ?? []) {
436
+ if (attachment.kind === "frame")
437
+ found = attachment;
438
+ }
439
+ }
440
+ return found;
441
+ }
442
+ function askedOf(title, requests) {
443
+ return requests.filter((request) => request.title === title && request.status !== "failed" && request.intent.trim() !== "").map((request) => request.intent.trim());
444
+ }
445
+ function composeEntry(input) {
446
+ const slug = `${input.date}-${slugify(input.surface)}`;
447
+ const pictures = [];
448
+ const lines2 = [];
449
+ lines2.push(`# ${input.surface}, ${input.date}`);
450
+ lines2.push("");
451
+ 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.`}`);
452
+ lines2.push("");
453
+ for (const preview of input.previews) {
454
+ const won = preview.title === input.won.title;
455
+ lines2.push(`## ${preview.title}${won ? " \u2014 kept" : ""}`);
456
+ lines2.push("");
457
+ if (preview.note !== void 0 && preview.note.trim() !== "") {
458
+ lines2.push(preview.note.trim());
459
+ lines2.push("");
460
+ }
461
+ const frame = frameFor(preview.title, input.requests);
462
+ if (frame !== null) {
463
+ const name = `${slugify(preview.title)}.png`;
464
+ pictures.push({ from: frame.file, to: name });
465
+ lines2.push(`![${preview.title}](${slug}/${name})`);
466
+ lines2.push("");
467
+ }
468
+ if (preview.basedOn !== void 0) {
469
+ lines2.push(`A variant of ${preview.basedOn}.`);
470
+ lines2.push("");
471
+ }
472
+ const asked = askedOf(preview.title, input.requests);
473
+ if (asked.length > 0) {
474
+ lines2.push("Asked for:");
475
+ lines2.push("");
476
+ for (const words of asked)
477
+ lines2.push(`- ${words}`);
478
+ lines2.push("");
479
+ }
480
+ const notes = input.annotations.filter((note) => note.title === preview.title);
481
+ if (notes.length > 0) {
482
+ lines2.push("Marked on the design:");
483
+ lines2.push("");
484
+ for (const note of notes)
485
+ lines2.push(`- ${note.note}`);
486
+ lines2.push("");
487
+ }
488
+ }
489
+ const failed = input.requests.filter((request) => request.status === "failed");
490
+ if (failed.length > 0) {
491
+ lines2.push("## Changes that did not land");
492
+ lines2.push("");
493
+ for (const request of failed) {
494
+ const why = request.failure?.message;
495
+ lines2.push(`- ${request.title}: ${request.intent}${why === void 0 ? "" : ` (${why})`}`);
496
+ }
497
+ lines2.push("");
498
+ }
499
+ return { slug, markdown: `${lines2.join("\n").trimEnd()}
500
+ `, pictures };
501
+ }
502
+
412
503
  // ../server/dist/config.js
413
504
  var DEFAULT_DEV_SERVER = "http://localhost:3000";
414
505
  var DEFAULT_INSTALL_COMMAND = "npm install";
@@ -527,6 +618,10 @@ function normalizeConfig(raw, options = {}) {
527
618
  if (requireDevCommand && previews.some((preview) => preview.branch !== void 0) && devCommand === void 0) {
528
619
  errors.push("A preview names a branch, so devCommand is required: Leglas has to start that checkout itself.");
529
620
  }
621
+ const logDir = source["logDir"] ?? DEFAULT_LOG_DIR;
622
+ if (typeof logDir !== "string" || logDir.trim() === "") {
623
+ errors.push("logDir must be a non-empty string.");
624
+ }
530
625
  const installCommand = source["installCommand"] ?? DEFAULT_INSTALL_COMMAND;
531
626
  if (typeof installCommand !== "string" || installCommand.trim() === "") {
532
627
  errors.push("installCommand must be a non-empty string.");
@@ -543,7 +638,8 @@ function normalizeConfig(raw, options = {}) {
543
638
  previews,
544
639
  scanPreviews,
545
640
  devCommand: typeof devCommand === "string" ? devCommand : void 0,
546
- installCommand
641
+ installCommand,
642
+ logDir
547
643
  },
548
644
  errors: []
549
645
  };
@@ -1321,6 +1417,7 @@ import net from "net";
1321
1417
  function createProxyHandler(options) {
1322
1418
  const target = new URL(options.target);
1323
1419
  const host = target.hostname;
1420
+ const dialHost = host.replace(/^\[|\]$/g, "");
1324
1421
  const port = Number(target.port || (target.protocol === "https:" ? 443 : 80));
1325
1422
  const authority = target.port ? `${host}:${target.port}` : host;
1326
1423
  function upstreamHeaders(req) {
@@ -1337,7 +1434,19 @@ function createProxyHandler(options) {
1337
1434
  }
1338
1435
  return {
1339
1436
  request(req, res, publicOrigin) {
1340
- const upstream = http.request({ host, port, method: req.method, path: req.url, headers: upstreamHeaders(req) }, (upstreamRes) => {
1437
+ options.onActivity?.();
1438
+ options.onOpen?.();
1439
+ let open = true;
1440
+ const close = () => {
1441
+ if (!open)
1442
+ return;
1443
+ open = false;
1444
+ options.onActivity?.();
1445
+ options.onClose?.();
1446
+ };
1447
+ res.once("finish", close);
1448
+ res.once("close", close);
1449
+ const upstream = http.request({ host: dialHost, port, method: req.method, path: req.url, headers: upstreamHeaders(req) }, (upstreamRes) => {
1341
1450
  const headers = { ...upstreamRes.headers };
1342
1451
  const location = rewriteLocation(typeof headers.location === "string" ? headers.location : void 0, publicOrigin);
1343
1452
  if (location !== void 0)
@@ -1359,7 +1468,17 @@ Start it, or point Leglas somewhere else with --user-port.`);
1359
1468
  req.pipe(upstream);
1360
1469
  },
1361
1470
  upgrade(req, socket, head) {
1362
- const upstream = net.connect(port, host, () => {
1471
+ options.onActivity?.();
1472
+ options.onOpen?.();
1473
+ let open = true;
1474
+ const closeActivity = () => {
1475
+ if (!open)
1476
+ return;
1477
+ open = false;
1478
+ options.onActivity?.();
1479
+ options.onClose?.();
1480
+ };
1481
+ const upstream = net.connect(port, dialHost, () => {
1363
1482
  const headers = Object.entries(upstreamHeaders(req)).map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(", ") : value}\r
1364
1483
  `).join("");
1365
1484
  upstream.write(`${req.method} ${req.url} HTTP/1.1\r
@@ -1371,6 +1490,7 @@ ${headers}\r
1371
1490
  socket.pipe(upstream);
1372
1491
  });
1373
1492
  const shutdown = () => {
1493
+ closeActivity();
1374
1494
  upstream.destroy();
1375
1495
  socket.destroy();
1376
1496
  };
@@ -1381,6 +1501,62 @@ ${headers}\r
1381
1501
  }
1382
1502
  };
1383
1503
  }
1504
+ function startProxyServer(options) {
1505
+ return new Promise((resolve5, reject) => {
1506
+ let open = 0;
1507
+ const handler = createProxyHandler({
1508
+ ...options,
1509
+ onOpen: () => {
1510
+ open += 1;
1511
+ options.onOpen?.();
1512
+ },
1513
+ onClose: () => {
1514
+ open = Math.max(0, open - 1);
1515
+ options.onClose?.();
1516
+ }
1517
+ });
1518
+ const server = http.createServer((req, res) => {
1519
+ const address = server.address();
1520
+ const port = typeof address === "object" && address !== null ? address.port : 0;
1521
+ handler.request(req, res, `http://127.0.0.1:${port}`);
1522
+ });
1523
+ const sockets = /* @__PURE__ */ new Set();
1524
+ server.on("connection", (socket) => {
1525
+ sockets.add(socket);
1526
+ socket.once("close", () => sockets.delete(socket));
1527
+ });
1528
+ server.on("upgrade", (req, socket, head) => handler.upgrade(req, socket, head));
1529
+ const onError = (error) => {
1530
+ server.removeListener("listening", onListening);
1531
+ reject(error);
1532
+ };
1533
+ const onListening = () => {
1534
+ server.removeListener("error", onError);
1535
+ const address = server.address();
1536
+ const port = typeof address === "object" && address !== null ? address.port : 0;
1537
+ let closed = null;
1538
+ resolve5({
1539
+ active: () => open > 0,
1540
+ close: () => {
1541
+ if (closed !== null)
1542
+ return closed;
1543
+ closed = new Promise((done) => {
1544
+ for (const socket of sockets)
1545
+ socket.destroy();
1546
+ sockets.clear();
1547
+ server.closeAllConnections();
1548
+ server.close(() => done());
1549
+ });
1550
+ return closed;
1551
+ },
1552
+ url: `http://127.0.0.1:${port}`
1553
+ });
1554
+ };
1555
+ server.once("error", onError);
1556
+ server.once("listening", onListening);
1557
+ server.listen(0, "127.0.0.1");
1558
+ });
1559
+ }
1384
1560
 
1385
1561
  // ../server/dist/browser.js
1386
1562
  import { randomBytes } from "crypto";
@@ -1448,12 +1624,12 @@ function buildNumber(entry) {
1448
1624
  const digits = /(\d+)\s*$/.exec(entry)?.[1];
1449
1625
  return digits === void 0 ? 0 : Number(digits);
1450
1626
  }
1451
- function cacheRoots(platform, home, readdir4 = readableDirectories) {
1627
+ function cacheRoots(platform, home, readdir5 = readableDirectories) {
1452
1628
  const playwright = platform === "darwin" ? join4(home, "Library", "Caches", "ms-playwright") : join4(home, ".cache", "ms-playwright");
1453
1629
  const puppeteer = join4(home, ".cache", "puppeteer");
1454
1630
  const newestFirst = (dir, keep = () => true) => ({
1455
1631
  root: dir,
1456
- entries: readdir4(dir).filter(keep).sort((left, right) => buildNumber(right) - buildNumber(left))
1632
+ entries: readdir5(dir).filter(keep).sort((left, right) => buildNumber(right) - buildNumber(left))
1457
1633
  });
1458
1634
  return [
1459
1635
  newestFirst(playwright, (entry) => entry.startsWith("chromium-") || entry.startsWith("chromium_headless_shell-")),
@@ -1474,13 +1650,13 @@ function findBrowser(search = {}) {
1474
1650
  const home = search.home ?? homedir();
1475
1651
  const exists = search.exists ?? existsSync2;
1476
1652
  const onPath = search.onPath ?? ((name) => firstOnPath(name, env));
1477
- const readdir4 = search.readdir ?? readableDirectories;
1653
+ const readdir5 = search.readdir ?? readableDirectories;
1478
1654
  const firstExisting = (paths) => paths.find((path) => exists(path)) ?? null;
1479
1655
  for (const candidate of [env.LEGLAS_BROWSER, env.CHROME_PATH, env.PUPPETEER_EXECUTABLE_PATH]) {
1480
1656
  if (typeof candidate === "string" && candidate !== "" && exists(candidate))
1481
1657
  return candidate;
1482
1658
  }
1483
- const caches = cacheRoots(platform, home, readdir4);
1659
+ const caches = cacheRoots(platform, home, readdir5);
1484
1660
  const shell = firstExisting(caches.flatMap(({ root, entries }) => entries.flatMap((entry) => HEADLESS_SHELL.map((rest) => join4(root, entry, ...rest)))));
1485
1661
  if (shell !== null)
1486
1662
  return shell;
@@ -1510,7 +1686,7 @@ function findBrowser(search = {}) {
1510
1686
  }
1511
1687
  if (platform === "darwin" || platform === "linux") {
1512
1688
  const playwrightRoot = platform === "darwin" ? join4(home, "Library", "Caches", "ms-playwright") : join4(home, ".cache", "ms-playwright");
1513
- const playwright = readdir4(playwrightRoot).filter((entry) => entry.startsWith("chromium-") || entry.startsWith("chromium_headless_shell-")).sort((left, right) => buildNumber(right) - buildNumber(left)).flatMap((entry) => {
1689
+ const playwright = readdir5(playwrightRoot).filter((entry) => entry.startsWith("chromium-") || entry.startsWith("chromium_headless_shell-")).sort((left, right) => buildNumber(right) - buildNumber(left)).flatMap((entry) => {
1514
1690
  const root = join4(playwrightRoot, entry);
1515
1691
  return [
1516
1692
  ...FOR_TESTING.map((rest) => join4(root, ...rest)),
@@ -1527,7 +1703,7 @@ function findBrowser(search = {}) {
1527
1703
  const puppeteerCache = join4(home, ".cache", "puppeteer");
1528
1704
  for (const kind of ["chrome", "chrome-headless-shell"]) {
1529
1705
  const kindRoot = join4(puppeteerCache, kind);
1530
- const found = firstExisting(readdir4(kindRoot).sort((left, right) => buildNumber(right) - buildNumber(left)).flatMap((entry) => {
1706
+ const found = firstExisting(readdir5(kindRoot).sort((left, right) => buildNumber(right) - buildNumber(left)).flatMap((entry) => {
1531
1707
  const root = join4(kindRoot, entry);
1532
1708
  return [...FOR_TESTING, ...HEADLESS_SHELL].map((rest) => join4(root, ...rest));
1533
1709
  }));
@@ -2035,6 +2211,32 @@ function createBrowserPool(options = {}) {
2035
2211
  };
2036
2212
  }
2037
2213
 
2214
+ // ../server/dist/hydration.js
2215
+ function hydrationEvidence(messages) {
2216
+ for (const raw of messages) {
2217
+ const message2 = raw.split("\n", 1)[0]?.trim() ?? "";
2218
+ if (/Minified React error #(418|419|422|423|425)\b/.test(message2)) {
2219
+ return { framework: "React", message: message2 };
2220
+ }
2221
+ 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)) {
2222
+ return { framework: "React", message: message2 };
2223
+ }
2224
+ if (/Hydration (node|text|children|class|style|attribute) mismatch/i.test(message2) || /Hydration completed but contains mismatches/i.test(message2)) {
2225
+ return { framework: "Vue", message: message2 };
2226
+ }
2227
+ if (/hydration_mismatch/.test(message2)) {
2228
+ return { framework: "Svelte", message: message2 };
2229
+ }
2230
+ if (/Hydration Mismatch\. Unable to find DOM nodes/.test(message2)) {
2231
+ return { framework: "Solid", message: message2 };
2232
+ }
2233
+ 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))) {
2234
+ return { framework: "the app", message: message2 };
2235
+ }
2236
+ }
2237
+ return null;
2238
+ }
2239
+
2038
2240
  // ../server/dist/capture.js
2039
2241
  var FRAME_MAX_HEIGHT = 4e3;
2040
2242
  var MIN_WIDTH = 320;
@@ -2128,10 +2330,12 @@ function locatorExpression(focus) {
2128
2330
  async function render(page, input) {
2129
2331
  const width = clamp(Math.round(input.width), MIN_WIDTH, MAX_WIDTH);
2130
2332
  const errors = [];
2333
+ let hydration = null;
2131
2334
  const remember = (value) => {
2335
+ const message2 = String(value ?? "").trim().slice(0, 240);
2336
+ hydration ??= hydrationEvidence([message2]);
2132
2337
  if (errors.length >= 10)
2133
2338
  return;
2134
- const message2 = String(value ?? "").slice(0, 240);
2135
2339
  if (message2 === "" || /favicon/i.test(message2))
2136
2340
  return;
2137
2341
  errors.push(message2);
@@ -2279,7 +2483,7 @@ async function render(page, input) {
2279
2483
  resolved
2280
2484
  });
2281
2485
  }
2282
- return { frame, crops, errors, cut };
2486
+ return { frame, crops, errors, hydration, cut };
2283
2487
  } finally {
2284
2488
  for (const stop of unlisten)
2285
2489
  stop();
@@ -2423,7 +2627,13 @@ async function attachRequest(cwd, requestId, input, deps) {
2423
2627
  const capture = deps.capture ?? capturePage;
2424
2628
  const deadlineMs = deps.deadlineMs ?? 12e3;
2425
2629
  const destination = join5(cwd, CAPTURES_DIR, requestId);
2426
- const captured = { attachments: [], errors: [], cut: false, skipped: null };
2630
+ const captured = {
2631
+ attachments: [],
2632
+ errors: [],
2633
+ hydration: null,
2634
+ cut: false,
2635
+ skipped: null
2636
+ };
2427
2637
  const references = [];
2428
2638
  requestedWidths.set(captured, Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, Math.round(input.width))));
2429
2639
  const controller = new AbortController();
@@ -2473,6 +2683,7 @@ async function attachRequest(cwd, requestId, input, deps) {
2473
2683
  viewport: direction.frame.width
2474
2684
  });
2475
2685
  captured.errors = direction.errors;
2686
+ captured.hydration = direction.hydration;
2476
2687
  captured.cut = direction.cut;
2477
2688
  for (let index = 0; index < direction.crops.length; index += 1) {
2478
2689
  const crop = direction.crops[index];
@@ -2746,6 +2957,8 @@ async function startAppProcess(options) {
2746
2957
  }
2747
2958
 
2748
2959
  // ../server/dist/branches.js
2960
+ var BRANCH_IDLE_MS = 10 * 60 * 1e3;
2961
+ var BRANCH_SWEEP_MS = 3e4;
2749
2962
  function publicBranchState(state) {
2750
2963
  if (state.status === "ready")
2751
2964
  return { status: "ready" };
@@ -2755,7 +2968,11 @@ function createBranchRegistry(options) {
2755
2968
  const states = new Map(options.previews.map((preview) => [preview.title, { status: "idle" }]));
2756
2969
  const previews = new Map(options.previews.map((preview) => [preview.title, preview]));
2757
2970
  const inflight = /* @__PURE__ */ new Map();
2971
+ const stopping = /* @__PURE__ */ new Map();
2972
+ const lastActivity = /* @__PURE__ */ new Map();
2973
+ const proxies = /* @__PURE__ */ new Map();
2758
2974
  const boot = options.startWorktree ?? startWorktree;
2975
+ const proxy = options.startProxy ?? startProxyServer;
2759
2976
  let closed = false;
2760
2977
  let stopPromise = null;
2761
2978
  const transition = (title, state) => {
@@ -2767,6 +2984,43 @@ function createBranchRegistry(options) {
2767
2984
  options.onChange?.(title, state);
2768
2985
  return state;
2769
2986
  };
2987
+ const stopReady = (title, state, toIdle) => {
2988
+ const current = stopping.get(title);
2989
+ if (current !== void 0)
2990
+ return current;
2991
+ const pending = (async () => {
2992
+ await proxies.get(title)?.close().catch(() => {
2993
+ });
2994
+ await state.worktree.stop().catch(() => {
2995
+ });
2996
+ proxies.delete(title);
2997
+ lastActivity.delete(title);
2998
+ if (toIdle && !closed && states.get(title) === state) {
2999
+ transition(title, { status: "idle" });
3000
+ }
3001
+ })().finally(() => {
3002
+ stopping.delete(title);
3003
+ });
3004
+ stopping.set(title, pending);
3005
+ return pending;
3006
+ };
3007
+ const sweep = () => {
3008
+ const now = Date.now();
3009
+ for (const [title, state] of states) {
3010
+ const branchProxy = proxies.get(title);
3011
+ if (state.status !== "ready" || branchProxy === void 0 || stopping.has(title))
3012
+ continue;
3013
+ if (branchProxy.active()) {
3014
+ lastActivity.set(title, now);
3015
+ continue;
3016
+ }
3017
+ const seen = lastActivity.get(title) ?? now;
3018
+ if (now - seen >= BRANCH_IDLE_MS)
3019
+ void stopReady(title, state, true);
3020
+ }
3021
+ };
3022
+ const sweepTimer = setInterval(sweep, BRANCH_SWEEP_MS);
3023
+ sweepTimer.unref();
2770
3024
  const begin = (title) => {
2771
3025
  const preview = previews.get(title);
2772
3026
  const current = states.get(title);
@@ -2794,9 +3048,21 @@ function createBranchRegistry(options) {
2794
3048
  } catch (error) {
2795
3049
  checkout = Promise.reject(error);
2796
3050
  }
2797
- const starting = checkout.then((worktree) => {
3051
+ const starting = checkout.then(async (worktree) => {
2798
3052
  transition(title, { status: "starting", phase: "starting" });
2799
- return transition(title, { status: "ready", worktree });
3053
+ try {
3054
+ const branchProxy = await proxy({
3055
+ target: worktree.url,
3056
+ onActivity: () => lastActivity.set(title, Date.now())
3057
+ });
3058
+ proxies.set(title, branchProxy);
3059
+ lastActivity.set(title, Date.now());
3060
+ return transition(title, { status: "ready", worktree });
3061
+ } catch (error) {
3062
+ await worktree.stop().catch(() => {
3063
+ });
3064
+ throw error;
3065
+ }
2800
3066
  }).catch((error) => transition(title, {
2801
3067
  status: "failed",
2802
3068
  reason: error instanceof Error ? error.message : String(error)
@@ -2808,15 +3074,17 @@ function createBranchRegistry(options) {
2808
3074
  };
2809
3075
  return {
2810
3076
  state: (title) => states.get(title),
3077
+ url: (title) => proxies.get(title)?.url,
2811
3078
  start: begin,
2812
3079
  stop: () => {
2813
3080
  if (stopPromise !== null)
2814
3081
  return stopPromise;
2815
3082
  closed = true;
3083
+ clearInterval(sweepTimer);
2816
3084
  stopPromise = Promise.allSettled([...inflight.values()]).then(async () => {
2817
- const worktrees = [...states.values()].filter((state) => state.status === "ready").map((state) => state.worktree);
2818
- await Promise.all(worktrees.map((worktree) => worktree.stop().catch(() => {
2819
- })));
3085
+ await Promise.allSettled([...stopping.values()]);
3086
+ const ready = [...states.entries()].filter((entry) => entry[1].status === "ready" && proxies.has(entry[0]));
3087
+ await Promise.all(ready.map(([title, state]) => stopReady(title, state, false)));
2820
3088
  });
2821
3089
  return stopPromise;
2822
3090
  }
@@ -3111,10 +3379,10 @@ function scope(leglasCommand, quotedTitle) {
3111
3379
 
3112
3380
  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.
3113
3381
 
3114
- 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.`;
3382
+ 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.`;
3115
3383
  }
3116
3384
  function capturedBlock(captured) {
3117
- if (captured === null || captured.attachments.length === 0 && captured.errors.length === 0 && captured.skipped === null)
3385
+ if (captured === null || captured.attachments.length === 0 && captured.errors.length === 0 && captured.hydration === null && captured.skipped === null)
3118
3386
  return "";
3119
3387
  const lines2 = [];
3120
3388
  const frames = captured.attachments.filter((attachment) => attachment.kind === "frame" || attachment.kind === "note");
@@ -3146,6 +3414,9 @@ function capturedBlock(captured) {
3146
3414
  for (const error of captured.errors)
3147
3415
  lines2.push(` - ${error}`);
3148
3416
  }
3417
+ if (captured.hydration !== null) {
3418
+ 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.`);
3419
+ }
3149
3420
  if (captured.skipped !== null) {
3150
3421
  lines2.push(`(${captured.skipped} Use the live preview instead.)`);
3151
3422
  }
@@ -4913,6 +5184,7 @@ function resolveTitle(input, titles, renames) {
4913
5184
 
4914
5185
  // ../server/dist/server.js
4915
5186
  import { createReadStream, existsSync as existsSync3, statSync, unwatchFile, watch as watchFs, watchFile } from "fs";
5187
+ import { createHash as createHash2 } from "crypto";
4916
5188
  import { mkdir as mkdir8, readdir as readdir3, writeFile as writeFile8 } from "fs/promises";
4917
5189
  import http2 from "http";
4918
5190
  import net3 from "net";
@@ -4991,6 +5263,30 @@ function sendJson(res, status, body) {
4991
5263
  });
4992
5264
  res.end(payload);
4993
5265
  }
5266
+ function etagMatches(value, etag) {
5267
+ if (value === void 0)
5268
+ return false;
5269
+ const values = Array.isArray(value) ? value : [value];
5270
+ return values.some((header) => header.split(",").some((candidate) => {
5271
+ const tag = candidate.trim();
5272
+ return tag === "*" || tag === etag || tag === `W/${etag}`;
5273
+ }));
5274
+ }
5275
+ function sendConditionalJson(req, res, body) {
5276
+ const payload = JSON.stringify(body);
5277
+ const etag = `"${createHash2("sha256").update(payload).digest("base64url")}"`;
5278
+ if (etagMatches(req.headers["if-none-match"], etag)) {
5279
+ res.writeHead(304, { etag, "cache-control": "private, no-cache" });
5280
+ res.end();
5281
+ return;
5282
+ }
5283
+ res.writeHead(200, {
5284
+ "content-type": "application/json; charset=utf-8",
5285
+ "cache-control": "private, no-cache",
5286
+ etag
5287
+ });
5288
+ res.end(payload);
5289
+ }
4994
5290
  var CAPTURE_DEADLINE_MS = 15e3;
4995
5291
  var CAPTURE_LOAD_MS = Math.floor(CAPTURE_DEADLINE_MS * LOAD_SHARE);
4996
5292
  function captureSlug(title) {
@@ -5383,9 +5679,10 @@ async function startServer(options) {
5383
5679
  return preview;
5384
5680
  const state = branches.state(preview.title) ?? { status: "idle" };
5385
5681
  const { url: route, ...withoutUrl } = preview;
5682
+ const branchUrl = branches.url(preview.title);
5386
5683
  return state.status === "ready" ? {
5387
5684
  ...withoutUrl,
5388
- url: `${state.worktree.url}${route}`,
5685
+ url: `${branchUrl ?? state.worktree.url}${route}`,
5389
5686
  state: publicBranchState(state)
5390
5687
  } : { ...withoutUrl, state: publicBranchState(state) };
5391
5688
  };
@@ -5394,7 +5691,7 @@ async function startServer(options) {
5394
5691
  if (preview.branch === void 0)
5395
5692
  return preview;
5396
5693
  const state = branches.state(preview.title);
5397
- return state?.status === "ready" ? { ...preview, url: `${state.worktree.url}${preview.url}` } : null;
5694
+ return state?.status === "ready" ? { ...preview, url: `${branches.url(preview.title) ?? state.worktree.url}${preview.url}` } : null;
5398
5695
  };
5399
5696
  if (options.pool === void 0) {
5400
5697
  void reapOrphanedBrowsers().catch(() => {
@@ -5457,7 +5754,7 @@ async function startServer(options) {
5457
5754
  errors.push(notice);
5458
5755
  return void readLocalPreviews(cwd).then(({ previews: local, errors: localErrors }) => {
5459
5756
  if (localErrors.length > 0) {
5460
- return sendJson(res, 200, {
5757
+ return sendConditionalJson(req, res, {
5461
5758
  project,
5462
5759
  devServer: target,
5463
5760
  scanPreviews: config?.scanPreviews ?? true,
@@ -5470,7 +5767,7 @@ async function startServer(options) {
5470
5767
  const currentBoot = boot.filter((preview) => preview.local !== true || localTitles.has(preview.title));
5471
5768
  const known = new Set(currentBoot.map((preview) => preview.title));
5472
5769
  const fresh = local.filter((preview) => !known.has(preview.title) && preview.branch === void 0 && preview.file === void 0);
5473
- sendJson(res, 200, {
5770
+ sendConditionalJson(req, res, {
5474
5771
  project,
5475
5772
  devServer: target,
5476
5773
  scanPreviews: config?.scanPreviews ?? true,
@@ -5478,7 +5775,7 @@ async function startServer(options) {
5478
5775
  errors,
5479
5776
  warnings: configWarnings
5480
5777
  });
5481
- }).catch(() => sendJson(res, 200, {
5778
+ }).catch(() => sendConditionalJson(req, res, {
5482
5779
  project,
5483
5780
  devServer: target,
5484
5781
  scanPreviews: config?.scanPreviews ?? true,
@@ -5801,6 +6098,7 @@ async function startServer(options) {
5801
6098
  height: shot.height,
5802
6099
  viewport: result2.frame.width,
5803
6100
  errors: result2.errors,
6101
+ hydration: result2.hydration,
5804
6102
  cut: result2.cut
5805
6103
  });
5806
6104
  } catch (error) {
@@ -5913,7 +6211,7 @@ async function startServer(options) {
5913
6211
  waiting: null,
5914
6212
  failedIds: []
5915
6213
  };
5916
- return void readRequests(cwd).then((requests) => sendJson(res, 200, {
6214
+ return void readRequests(cwd).then((requests) => sendConditionalJson(req, res, {
5917
6215
  requests: requests.map(({ id, title, intent, mode, status, failure, notes }) => ({
5918
6216
  id,
5919
6217
  title,
@@ -6016,7 +6314,7 @@ async function startServer(options) {
6016
6314
  });
6017
6315
  }
6018
6316
  if (path === `${LEGLAS_PREFIX}/api/annotations` && req.method === "GET") {
6019
- return void readAnnotations(cwd).then((annotations) => sendJson(res, 200, { annotations }));
6317
+ return void readAnnotations(cwd).then((annotations) => sendConditionalJson(req, res, { annotations }));
6020
6318
  }
6021
6319
  if (path === `${LEGLAS_PREFIX}/api/annotations` && req.method === "POST") {
6022
6320
  if (!hasJsonBody(req)) {
@@ -6142,7 +6440,7 @@ async function startServer(options) {
6142
6440
  });
6143
6441
  }
6144
6442
  if (path === `${LEGLAS_PREFIX}/api/health`) {
6145
- return void probe(target).then((reachable) => sendJson(res, 200, { devServer: target, reachable, cwd }));
6443
+ return void probe(target).then((reachable) => sendConditionalJson(req, res, { devServer: target, reachable, cwd }));
6146
6444
  }
6147
6445
  if (path.startsWith(`${FILES_PREFIX}/`)) {
6148
6446
  const rest = path.slice(FILES_PREFIX.length + 1);
@@ -6544,6 +6842,14 @@ When asked for design variations, alternatives, or "a few options":
6544
6842
  and register it with \`npx leglas add --title "\u2026" --url "/" --branch <branch>\`
6545
6843
  (the config needs \`devCommand\` with \`{port}\`). Everything below is the
6546
6844
  ordinary, in-app path.
6845
+ A page the app rebuilds in the browser after load (anything that hydrates:
6846
+ Next, Nuxt, SvelteKit, a captured production site) is not its served HTML.
6847
+ Markup edited there shows for a moment and is then replaced from the app's
6848
+ own JavaScript and data, so make the change where that JavaScript gets what
6849
+ it renders. When that is a script other directions share, give it a
6850
+ per-direction override that defaults to what it renders today: every other
6851
+ direction renders exactly as before, which is adding beside, not rewriting.
6852
+ \`npx leglas show\` says when a page was rebuilt after load.
6547
6853
  2. Run \`npx leglas explore <surface> --count <n>\` first, adding
6548
6854
  \`--based-on "<title>"\` when the user wants variations of a direction they
6549
6855
  already like. It prints what the set needs and how to register it. In
@@ -6594,6 +6900,12 @@ out of the ignored directory, deletes the rest of the exploration, and drops
6594
6900
  them from the rail. Then change their component to use the kept component
6595
6901
  instead of the switcher.
6596
6902
 
6903
+ Keeping also writes what the exploration was into \`design-log/\`, which is
6904
+ committed. Before exploring a surface, read \`npx leglas log --json\` and any
6905
+ entry for that surface: it says what was already tried there, in the user's own
6906
+ words, and which direction won. Proposing something that was already rejected
6907
+ wastes their time, and the record is there so you do not have to ask.
6908
+
6597
6909
  Useful to know:
6598
6910
 
6599
6911
  - \`.leglas/\` is gitignored. Exploration is disposable and nothing in there
@@ -6693,7 +7005,7 @@ async function runInit(options, deps) {
6693
7005
 
6694
7006
  // src/run-keep.ts
6695
7007
  import { existsSync as existsSync4 } from "fs";
6696
- import { mkdir as mkdir9, readFile as readFile13, rm as rm5, writeFile as writeFile10 } from "fs/promises";
7008
+ import { copyFile as copyFile2, mkdir as mkdir9, readFile as readFile13, rm as rm5, writeFile as writeFile10 } from "fs/promises";
6697
7009
  import { dirname as dirname9, join as join15 } from "path";
6698
7010
 
6699
7011
  // src/keep.ts
@@ -6776,6 +7088,29 @@ function renameExport(source, to) {
6776
7088
  to
6777
7089
  );
6778
7090
  }
7091
+ async function writeLogEntry(options) {
7092
+ const entry = composeEntry({
7093
+ surface: options.surface,
7094
+ won: options.won,
7095
+ previews: options.previews,
7096
+ requests: await readRequests(options.cwd),
7097
+ annotations: await readAnnotations(options.cwd),
7098
+ date: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)
7099
+ });
7100
+ const dir = join15(options.cwd, options.logDir);
7101
+ await mkdir9(dir, { recursive: true });
7102
+ const file = join15(dir, `${entry.slug}.md`);
7103
+ await writeFile10(file, entry.markdown, "utf8");
7104
+ if (entry.pictures.length > 0) {
7105
+ const pictureDir = join15(dir, entry.slug);
7106
+ await mkdir9(pictureDir, { recursive: true });
7107
+ for (const picture of entry.pictures) {
7108
+ await copyFile2(join15(options.cwd, picture.from), join15(pictureDir, picture.to)).catch(() => {
7109
+ });
7110
+ }
7111
+ }
7112
+ return `${options.logDir}/${entry.slug}.md`;
7113
+ }
6779
7114
  async function runKeep(options, deps) {
6780
7115
  const loaded = await loadConfig(options.cwd);
6781
7116
  const local = await readLocalPreviews(options.cwd);
@@ -6804,6 +7139,20 @@ async function runKeep(options, deps) {
6804
7139
  const source = await readFile13(from, "utf8");
6805
7140
  await mkdir9(dirname9(to), { recursive: true });
6806
7141
  await writeFile10(to, renameExport(source, plan.exportName), "utf8");
7142
+ const surface = plan.removeDir.slice(plan.removeDir.lastIndexOf("/") + 1);
7143
+ let logged = null;
7144
+ let logError = null;
7145
+ try {
7146
+ logged = await writeLogEntry({
7147
+ cwd: options.cwd,
7148
+ logDir: loaded.config?.logDir ?? DEFAULT_LOG_DIR,
7149
+ surface,
7150
+ won: { title: resolved.title, to: plan.move.to },
7151
+ previews: previews.filter((preview) => plan.dropTitles.includes(preview.title))
7152
+ });
7153
+ } catch (error) {
7154
+ logError = error instanceof Error ? error.message : String(error);
7155
+ }
6807
7156
  await rm5(join15(options.cwd, plan.removeDir), { recursive: true, force: true });
6808
7157
  const dropped = await dropLocalPreviews(options.cwd, plan.dropTitles);
6809
7158
  if (options.json) {
@@ -6815,6 +7164,8 @@ async function runKeep(options, deps) {
6815
7164
  exportName: plan.exportName,
6816
7165
  removed: plan.removeDir,
6817
7166
  droppedPreviews: dropped,
7167
+ logged,
7168
+ logError,
6818
7169
  instructions: plan.instructions
6819
7170
  })
6820
7171
  );
@@ -6822,6 +7173,8 @@ async function runKeep(options, deps) {
6822
7173
  }
6823
7174
  deps.log(` kept ${plan.move.to}`);
6824
7175
  deps.log(` removed ${plan.removeDir}`);
7176
+ if (logged !== null) deps.log(` logged ${logged}`);
7177
+ if (logError !== null) deps.error(` The decision log could not be written: ${logError}`);
6825
7178
  if (dropped > 0) {
6826
7179
  deps.log(` dropped ${dropped} direction${dropped === 1 ? "" : "s"} from the rail`);
6827
7180
  }
@@ -6918,17 +7271,67 @@ async function runNew(options, deps) {
6918
7271
  return { exitCode: 0, written };
6919
7272
  }
6920
7273
 
6921
- // src/run-previews.ts
6922
- import { readFile as readFile15, writeFile as writeFile12 } from "fs/promises";
7274
+ // src/run-log.ts
7275
+ import { readFile as readFile15, readdir as readdir4 } from "fs/promises";
6923
7276
  import { join as join17 } from "path";
7277
+ function headline(markdown) {
7278
+ const first = markdown.split("\n", 1)[0] ?? "";
7279
+ return first.replace(/^#\s*/, "").trim();
7280
+ }
7281
+ async function runLog(options, deps) {
7282
+ const loaded = await loadConfig(options.cwd);
7283
+ const dir = loaded.config?.logDir ?? DEFAULT_LOG_DIR;
7284
+ let names;
7285
+ try {
7286
+ names = (await readdir4(join17(options.cwd, dir))).filter((name) => name.endsWith(".md")).sort().reverse();
7287
+ } catch {
7288
+ names = [];
7289
+ }
7290
+ if (options.entry !== null) {
7291
+ const wanted = options.entry.replace(/\.md$/, "");
7292
+ const found = names.find((name) => name === `${wanted}.md`);
7293
+ if (found === void 0) {
7294
+ const error = `No entry called ${JSON.stringify(options.entry)} in ${dir}.`;
7295
+ if (options.json) deps.log(JSON.stringify({ ok: false, error }));
7296
+ else deps.error(error);
7297
+ return { exitCode: 1 };
7298
+ }
7299
+ const markdown = await readFile15(join17(options.cwd, dir, found), "utf8");
7300
+ if (options.json) deps.log(JSON.stringify({ ok: true, entry: wanted, markdown }));
7301
+ else deps.log(markdown.trimEnd());
7302
+ return { exitCode: 0 };
7303
+ }
7304
+ const entries = await Promise.all(
7305
+ names.map(async (name) => ({
7306
+ entry: name.replace(/\.md$/, ""),
7307
+ title: headline(await readFile15(join17(options.cwd, dir, name), "utf8")),
7308
+ file: `${dir}/${name}`
7309
+ }))
7310
+ );
7311
+ if (options.json) {
7312
+ deps.log(JSON.stringify({ ok: true, dir, entries }));
7313
+ return { exitCode: 0 };
7314
+ }
7315
+ if (entries.length === 0) {
7316
+ deps.log(` No decisions recorded yet. One is written each time you run leglas keep.`);
7317
+ return { exitCode: 0 };
7318
+ }
7319
+ const width = Math.max(...entries.map((entry) => entry.entry.length));
7320
+ for (const entry of entries) deps.log(` ${entry.entry.padEnd(width)} ${entry.title}`);
7321
+ return { exitCode: 0 };
7322
+ }
7323
+
7324
+ // src/run-previews.ts
7325
+ import { readFile as readFile16, writeFile as writeFile12 } from "fs/promises";
7326
+ import { join as join18 } from "path";
6924
7327
  function envelope(deps, ok, body) {
6925
7328
  deps.log(JSON.stringify({ ok, ...body }));
6926
7329
  }
6927
7330
  async function ensureIgnored(cwd) {
6928
- const path = join17(cwd, ".gitignore");
7331
+ const path = join18(cwd, ".gitignore");
6929
7332
  let current = null;
6930
7333
  try {
6931
- current = await readFile15(path, "utf8");
7334
+ current = await readFile16(path, "utf8");
6932
7335
  } catch {
6933
7336
  current = null;
6934
7337
  }
@@ -7210,6 +7613,10 @@ async function runShow(options, deps) {
7210
7613
  height: captured.height,
7211
7614
  viewport: captured.viewport,
7212
7615
  errors: Array.isArray(captured.errors) ? captured.errors.filter((error) => typeof error === "string") : [],
7616
+ hydration: typeof captured.hydration === "object" && captured.hydration !== null && typeof captured.hydration.framework === "string" && typeof captured.hydration.message === "string" ? {
7617
+ framework: captured.hydration.framework,
7618
+ message: captured.hydration.message
7619
+ } : null,
7213
7620
  cut: captured.cut === true
7214
7621
  };
7215
7622
  }
@@ -7237,6 +7644,12 @@ async function runShow(options, deps) {
7237
7644
  if (envelope2.screenshot.cut) {
7238
7645
  deps.log(" the top of the page only; it is taller than one capture");
7239
7646
  }
7647
+ if (envelope2.screenshot.hydration !== null) {
7648
+ deps.log(
7649
+ ` hydration ${envelope2.screenshot.hydration.framework} rebuilt the page in the browser after load; the served markup is not what is on screen`
7650
+ );
7651
+ deps.log(` ${envelope2.screenshot.hydration.message}`);
7652
+ }
7240
7653
  if (envelope2.screenshot.errors.length > 0) {
7241
7654
  const count = envelope2.screenshot.errors.length;
7242
7655
  deps.log(` console ${count} ${count === 1 ? "error" : "errors"} on load`);
@@ -7255,15 +7668,15 @@ async function runShow(options, deps) {
7255
7668
 
7256
7669
  // src/run-watch.ts
7257
7670
  import { spawn as spawn3 } from "child_process";
7258
- import { mkdir as mkdir11, readFile as readFile16, writeFile as writeFile13 } from "fs/promises";
7259
- import { dirname as dirname11, join as join18 } from "path";
7671
+ import { mkdir as mkdir11, readFile as readFile17, writeFile as writeFile13 } from "fs/promises";
7672
+ import { dirname as dirname11, join as join19 } from "path";
7260
7673
  var POLL_MS2 = 2e3;
7261
7674
  var HEARTBEAT_TIMEOUT_MS = 1e3;
7262
7675
  async function saveTemplate(cwd, run4) {
7263
- const path = join18(cwd, WATCH_PATH);
7676
+ const path = join19(cwd, WATCH_PATH);
7264
7677
  let config = {};
7265
7678
  try {
7266
- const parsed2 = JSON.parse(await readFile16(path, "utf8"));
7679
+ const parsed2 = JSON.parse(await readFile17(path, "utf8"));
7267
7680
  if (typeof parsed2 === "object" && parsed2 !== null && !Array.isArray(parsed2)) {
7268
7681
  config = parsed2;
7269
7682
  }
@@ -7412,7 +7825,7 @@ async function runWatch(options, deps) {
7412
7825
  import { existsSync as existsSync6 } from "fs";
7413
7826
  import { realpath as realpath4 } from "fs/promises";
7414
7827
  import { createRequire } from "module";
7415
- import { basename as basename6, dirname as dirname12, join as join19, relative as relative5, resolve as resolve4 } from "path";
7828
+ import { basename as basename6, dirname as dirname12, join as join20, relative as relative5, resolve as resolve4 } from "path";
7416
7829
  import { fileURLToPath } from "url";
7417
7830
 
7418
7831
  // src/dev-server-owner.ts
@@ -7498,8 +7911,8 @@ function devServerOwnerWarning(origin, projectRoot, owners) {
7498
7911
 
7499
7912
  // src/run.ts
7500
7913
  function findShellDir() {
7501
- const bundled = join19(dirname12(fileURLToPath(import.meta.url)), "shell");
7502
- if (existsSync6(join19(bundled, "index.html"))) return bundled;
7914
+ const bundled = join20(dirname12(fileURLToPath(import.meta.url)), "shell");
7915
+ if (existsSync6(join20(bundled, "index.html"))) return bundled;
7503
7916
  try {
7504
7917
  const require2 = createRequire(import.meta.url);
7505
7918
  return dirname12(require2.resolve("@leglas/shell/dist/index.html"));
@@ -7513,7 +7926,7 @@ function shellWord(value) {
7513
7926
  return `'${value.replaceAll("'", `'\\''`)}'`;
7514
7927
  }
7515
7928
  function embeddedLeglasCommand() {
7516
- const entry = join19(dirname12(fileURLToPath(import.meta.url)), "bin.js");
7929
+ const entry = join20(dirname12(fileURLToPath(import.meta.url)), "bin.js");
7517
7930
  if (!existsSync6(entry)) return "npx -y leglas";
7518
7931
  return [process.execPath, entry].map(shellWord).join(" ");
7519
7932
  }
@@ -7545,7 +7958,7 @@ async function run3(options, deps) {
7545
7958
  const fileMounts = /* @__PURE__ */ new Map();
7546
7959
  for (const preview of merged?.previews ?? []) {
7547
7960
  if (preview.file !== void 0) {
7548
- const absolute = join19(options.cwd, preview.file);
7961
+ const absolute = join20(options.cwd, preview.file);
7549
7962
  if (!existsSync6(absolute)) {
7550
7963
  previewErrors.push(
7551
7964
  `"${preview.title}" names file ${preview.file}, which does not exist. The preview is skipped.`
@@ -7667,6 +8080,7 @@ Usage
7667
8080
  leglas classify Decide where a direction should live
7668
8081
  leglas add --title T --url U Register a preview on this machine
7669
8082
  leglas list Show every preview, shared and local
8083
+ leglas log [entry] What past explorations decided
7670
8084
  leglas show <title> Everything Leglas knows about one direction
7671
8085
  leglas requests Show change requests made from the interface
7672
8086
  leglas watch --run "<cmd>" Hand each request to your agent as it arrives
@@ -7804,6 +8218,13 @@ if (parsed.kind === "watch") {
7804
8218
  );
7805
8219
  process.exit(outcome.exitCode);
7806
8220
  }
8221
+ if (parsed.kind === "log") {
8222
+ const outcome = await runLog(
8223
+ { entry: parsed.entry, json: parsed.json, cwd: process.cwd() },
8224
+ previewDeps
8225
+ );
8226
+ process.exit(outcome.exitCode);
8227
+ }
7807
8228
  if (parsed.kind === "list") {
7808
8229
  const outcome = await runList({ json: parsed.json, cwd: process.cwd() }, previewDeps);
7809
8230
  process.exit(outcome.exitCode);