@moku-labs/web 1.1.0 → 1.2.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.cjs CHANGED
@@ -10,7 +10,9 @@ node_path = require_convention.__toESM(node_path);
10
10
  let feed = require("feed");
11
11
  let preact = require("preact");
12
12
  let preact_render_to_string = require("preact-render-to-string");
13
+ let node_child_process = require("node:child_process");
13
14
  let node_readline = require("node:readline");
15
+ let node_url = require("node:url");
14
16
  let node_os = require("node:os");
15
17
  let gray_matter = require("gray-matter");
16
18
  gray_matter = require_convention.__toESM(gray_matter, 1);
@@ -4890,10 +4892,47 @@ function findRootHtml(rendered) {
4890
4892
  return rendered.find((page) => page.url === "/" || page.url === "")?.html ?? null;
4891
4893
  }
4892
4894
  /**
4895
+ * Pages rendered concurrently per batch. Kept small so the macrotask yield between
4896
+ * batches fires frequently — a large batch renders for seconds before yielding, which
4897
+ * leaves a watching dev server's spinner repainting only every few seconds (sluggish).
4898
+ * Smaller batches trade a little write-concurrency for a smooth, responsive spinner.
4899
+ */
4900
+ const RENDER_BATCH_SIZE = 2;
4901
+ /**
4902
+ * Render `items` through `worker` in bounded-size batches, yielding a macrotask
4903
+ * (`setImmediate`) between batches. Beyond bounding peak concurrency/memory for large
4904
+ * sites, the yield lets the single JS thread breathe: one un-yielded `Promise.all` over
4905
+ * hundreds of synchronous `renderToString` calls starves the event loop, which freezes a
4906
+ * watching dev server's progress spinner until the whole phase resolves. Output order is
4907
+ * preserved (batch order + `Promise.all` order within a batch).
4908
+ *
4909
+ * @template Item - The input item type.
4910
+ * @template Out - The rendered output type.
4911
+ * @param items - The items to render.
4912
+ * @param batchSize - Maximum items rendered concurrently per batch.
4913
+ * @param worker - Renders one item to its output.
4914
+ * @returns All rendered outputs in input order.
4915
+ * @example
4916
+ * ```ts
4917
+ * const pages = await renderInBatches(instances, 32, i => renderInstance(ctx, i, shell));
4918
+ * ```
4919
+ */
4920
+ async function renderInBatches(items, batchSize, worker) {
4921
+ const out = [];
4922
+ for (let start = 0; start < items.length; start += batchSize) {
4923
+ const batch = items.slice(start, start + batchSize);
4924
+ out.push(...await Promise.all(batch.map((item) => worker(item))));
4925
+ if (start + batchSize < items.length) await new Promise((resolve) => {
4926
+ setImmediate(resolve);
4927
+ });
4928
+ }
4929
+ return out;
4930
+ }
4931
+ /**
4893
4932
  * Renders every route in the manifest to `outDir/<path>/index.html`. Reads as a
4894
- * pipeline: resolve deps → prepare the shared shell → expand instances → render all
4895
- * concurrently (`Promise.all`, legal intra-plugin concurrency) → write data sidecars
4896
- * (hybrid/spa) → capture the root page's HTML for the root-index phase.
4933
+ * pipeline: resolve deps → prepare the shared shell → expand instances → render in
4934
+ * bounded batches ({@link renderInBatches}) → write data sidecars (hybrid/spa) →
4935
+ * capture the root page's HTML for the root-index phase.
4897
4936
  *
4898
4937
  * @param ctx - Plugin context (provides `require`, `state`, `config`, `log`, `has`).
4899
4938
  * @returns The number of pages rendered and the captured default-page HTML.
@@ -4909,8 +4948,7 @@ async function renderPages(ctx) {
4909
4948
  const locales = ctx.require(i18nPlugin).locales();
4910
4949
  const byPattern = makeEntryMap(router);
4911
4950
  const shell = await prepareShell(ctx);
4912
- const instances = await expandAllInstances(manifest, locales, byPattern, ctx);
4913
- const rendered = await Promise.all(instances.map((instance) => renderInstance(ctx, instance, shell)));
4951
+ const rendered = await renderInBatches(await expandAllInstances(manifest, locales, byPattern, ctx), RENDER_BATCH_SIZE, (instance) => renderInstance(ctx, instance, shell));
4914
4952
  await writeDataSidecars(ctx, rendered, router.mode());
4915
4953
  ctx.log.debug("build:pages", { count: rendered.length });
4916
4954
  return {
@@ -6462,13 +6500,14 @@ const deployPlugin = createPlugin$1("deploy", {
6462
6500
  //#endregion
6463
6501
  //#region src/plugins/cli/deploy-wizard.ts
6464
6502
  /**
6465
- * @file cli plugin — the guided deploy wizard (`cli.deploy({ guided: true })`). Walks a
6466
- * human through a Cloudflare Pages deploy: checks prerequisites (wrangler config + the
6467
- * Cloudflare credentials) with concrete fix guidance, offers to scaffold/build what is
6468
- * missing, HARD-GATES the deploy on everything being green, runs a local build smoke
6469
- * test, confirms, deploys, then offers to scaffold a GitHub Actions workflow (auto on
6470
- * push to main, or a versioned/manual trigger). The non-guided `--cli` path stays in
6471
- * `api.ts`. Every prompt + line of output flows through injectable `state` seams.
6503
+ * @file cli plugin — the guided deploy wizard (`cli.deploy({ guided: true })`, the default
6504
+ * for `bun run deploy`; the direct `--cli` path stays in `api.ts`). Walks a human through a
6505
+ * Cloudflare Pages deploy: checks prerequisites (wrangler config + the Cloudflare
6506
+ * credentials) with concrete fix guidance, offers to scaffold what is missing (a
6507
+ * `wrangler.jsonc`, and a placeholder `.env` for any missing credentials), HARD-GATES the
6508
+ * deploy on everything being green, runs a local build smoke test, confirms, deploys, then
6509
+ * offers to scaffold a GitHub Actions workflow (auto on push to main, or a versioned/manual
6510
+ * trigger). Every prompt + line of output flows through injectable `state` seams.
6472
6511
  */
6473
6512
  /** How to create a Cloudflare API token + where to make it available locally. */
6474
6513
  const TOKEN_HELP = [
@@ -6535,6 +6574,43 @@ async function offerScaffold(ctx, prereqs) {
6535
6574
  await ctx.require(deployPlugin).init({});
6536
6575
  ctx.state.render.check(true, "wrangler.jsonc scaffolded");
6537
6576
  }
6577
+ /** The Cloudflare credentials the deploy needs, with the comment written above each in a scaffolded `.env`. */
6578
+ const ENV_CREDENTIALS = [{
6579
+ key: "CLOUDFLARE_API_TOKEN",
6580
+ comment: "# Cloudflare API token — https://dash.cloudflare.com/profile/api-tokens (template: Cloudflare Pages — Edit)"
6581
+ }, {
6582
+ key: "CLOUDFLARE_ACCOUNT_ID",
6583
+ comment: "# Cloudflare account id — dashboard → Workers & Pages → right-hand sidebar"
6584
+ }];
6585
+ /**
6586
+ * Offer to scaffold a `.env` with placeholders for whichever Cloudflare credentials are
6587
+ * missing — created when absent, appended to (never clobbering a key already present)
6588
+ * when it exists. The placeholders are empty, so the deploy still hard-gates until the
6589
+ * user fills them in; this just removes the "where do I even put these?" friction.
6590
+ *
6591
+ * @param ctx - The cli plugin context.
6592
+ * @param cwd - The project root (where `.env` lives).
6593
+ * @returns Resolves once any accepted scaffold has been written.
6594
+ * @example
6595
+ * await offerEnvScaffold(ctx, process.cwd());
6596
+ */
6597
+ async function offerEnvScaffold(ctx, cwd) {
6598
+ const missing = ENV_CREDENTIALS.filter(({ key }) => (process.env[key] ?? "") === "");
6599
+ if (missing.length === 0) return;
6600
+ const envPath = node_path$1.default.join(cwd, ".env");
6601
+ const exists = (0, node_fs.existsSync)(envPath);
6602
+ const verb = exists ? "Add placeholders for the missing secret(s) to" : "Create";
6603
+ if (!await ctx.state.confirm(`${verb} .env?`)) return;
6604
+ const lines = exists ? (0, node_fs.readFileSync)(envPath, "utf8").split(/\r?\n/) : [];
6605
+ const toAdd = missing.filter(({ key }) => !lines.some((line) => line.trimStart().startsWith(`${key}=`)));
6606
+ if (toAdd.length === 0) {
6607
+ ctx.state.render.info(".env already lists those keys — fill in their values, then re-run.");
6608
+ return;
6609
+ }
6610
+ (0, node_fs.appendFileSync)(envPath, `${exists ? "\n" : "# Cloudflare Pages deploy credentials — fill these in (keep .env gitignored).\n"}${toAdd.map(({ key, comment }) => `${comment}\n${key}=`).join("\n\n")}\n`);
6611
+ const names = toAdd.map(({ key }) => key).join(", ");
6612
+ ctx.state.render.check(true, `${exists ? "added placeholders to" : "created"} .env`, `fill in ${names}, then re-run \`bun run deploy\`.`);
6613
+ }
6538
6614
  /**
6539
6615
  * Map a top-level workflow choice (and, for the versioned option, a sub-choice) to the
6540
6616
  * concrete {@link WorkflowTrigger}, or `null` when the user chose to skip setup.
@@ -6618,6 +6694,7 @@ async function runDeployWizard(ctx, options) {
6618
6694
  ctx.state.render.heading("Checking prerequisites");
6619
6695
  for (const item of diagnose(cwd)) ctx.state.render.check(item.ok, item.label, item.detail);
6620
6696
  await offerScaffold(ctx, diagnose(cwd));
6697
+ await offerEnvScaffold(ctx, cwd);
6621
6698
  const blockers = diagnose(cwd).filter((item) => !item.ok);
6622
6699
  if (blockers.length > 0) {
6623
6700
  ctx.state.render.heading("Not ready to deploy");
@@ -7123,6 +7200,7 @@ async function runDevServer(ctx, port) {
7123
7200
  for (const watcher of watchers) watcher.close();
7124
7201
  hub.close();
7125
7202
  server.stop();
7203
+ ctx.state.render.dispose();
7126
7204
  });
7127
7205
  }
7128
7206
  //#endregion
@@ -7323,6 +7401,28 @@ async function confirmDeploy(ctx, yes) {
7323
7401
  if (!confirmed) ctx.state.render.warn("deploy skipped");
7324
7402
  return confirmed;
7325
7403
  }
7404
+ /** Matches the prerequisite/credential failures a direct deploy most often hits (missing token/account). */
7405
+ const PREREQUISITE_ERROR = /required variable|not defined|cloudflare|token|account|unauthor|wrangler/i;
7406
+ /**
7407
+ * A short, actionable "how to fix" hint for a failed deploy, rendered under the error so
7408
+ * the user is never left at a raw stack trace. A missing-credential/prerequisite failure
7409
+ * (the common case for a first direct deploy) gets the concrete secret-setup steps;
7410
+ * anything else points at the guided deploy, which diagnoses prerequisites step by step.
7411
+ *
7412
+ * @param error - The thrown deploy error.
7413
+ * @returns The multi-line hint (newline-separated; rendered indented under a `›`).
7414
+ * @example
7415
+ * render.info(deployFailureHint(err));
7416
+ */
7417
+ function deployFailureHint(error) {
7418
+ if (PREREQUISITE_ERROR.test(String(error))) return [
7419
+ "how to fix:",
7420
+ "1. run `bun run deploy` (without `--cli`) — the guided setup diagnoses prerequisites and offers to create a starter .env",
7421
+ "2. or set them yourself in .env: CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID",
7422
+ " token: https://dash.cloudflare.com/profile/api-tokens (template: Cloudflare Pages — Edit)"
7423
+ ].join("\n");
7424
+ return "how to fix: run `bun run deploy` (without `--cli`) — the guided setup diagnoses prerequisites — then retry";
7425
+ }
7326
7426
  /**
7327
7427
  * Create the cli plugin API surface — exactly `build`, `serve`, `preview`, `deploy`.
7328
7428
  * Each method renders `state.render.header(<command>)` first, then does its work;
@@ -7397,15 +7497,21 @@ function createApi$1(ctx) {
7397
7497
  const { branch, yes = false } = options;
7398
7498
  ctx.state.render.header("deploy");
7399
7499
  if (options.guided === true) return runDeployWizard(ctx, options);
7400
- await ctx.require(deployPlugin).init({ ci: true });
7401
- if (!await confirmDeploy(ctx, yes)) return {
7402
- deployed: false,
7403
- reason: "declined"
7404
- };
7405
- return {
7406
- deployed: true,
7407
- ...await ctx.require(deployPlugin).run(branch === void 0 ? {} : { branch })
7408
- };
7500
+ try {
7501
+ await ctx.require(deployPlugin).init({ ci: true });
7502
+ if (!await confirmDeploy(ctx, yes)) return {
7503
+ deployed: false,
7504
+ reason: "declined"
7505
+ };
7506
+ return {
7507
+ deployed: true,
7508
+ ...await ctx.require(deployPlugin).run(branch === void 0 ? {} : { branch })
7509
+ };
7510
+ } catch (error) {
7511
+ ctx.state.render.error("deploy failed", error);
7512
+ ctx.state.render.info(deployFailureHint(error));
7513
+ throw error;
7514
+ }
7409
7515
  }
7410
7516
  };
7411
7517
  }
@@ -7496,6 +7602,28 @@ const ANSI = {
7496
7602
  cyan: `${ESC}[36m`,
7497
7603
  gray: `${ESC}[90m`
7498
7604
  };
7605
+ /**
7606
+ * The Moku brand pink (`#FF1E6F`) as an RGB triple, used for 24-bit truecolor output.
7607
+ * Degrades to {@link ANSI.magenta} on a 16-color TTY and to plain text off a TTY.
7608
+ */
7609
+ const BRAND_PINK = {
7610
+ r: 255,
7611
+ g: 30,
7612
+ b: 111
7613
+ };
7614
+ /**
7615
+ * Build a 24-bit (truecolor) SGR foreground escape for the given RGB triple.
7616
+ *
7617
+ * @param r - Red channel (0–255).
7618
+ * @param g - Green channel (0–255).
7619
+ * @param b - Blue channel (0–255).
7620
+ * @returns The `ESC[38;2;r;g;bm` foreground sequence.
7621
+ * @example
7622
+ * fg24(255, 30, 111); // "\x1b[38;2;255;30;111m"
7623
+ */
7624
+ function fg24(r, g, b) {
7625
+ return `${ESC}[38;2;${r};${g};${b}m`;
7626
+ }
7499
7627
  /** ANSI: erase the entire current line, leaving the cursor where it is. */
7500
7628
  const CLEAR_LINE = `${ESC}[2K`;
7501
7629
  /** ANSI: erase from the cursor to the end of the screen (drops stale trailing rows). */
@@ -7567,6 +7695,36 @@ function supportsColor(stream = process.stdout, noColor = process.env.NO_COLOR)
7567
7695
  return stream.isTTY === true && noColor === void 0;
7568
7696
  }
7569
7697
  /**
7698
+ * Whether the terminal advertises 24-bit (truecolor) support via `COLORTERM`, so the
7699
+ * renderer may emit the exact brand pink ({@link BRAND_PINK}) instead of the 16-color
7700
+ * `magenta` approximation. Always layered on top of {@link supportsColor} — truecolor
7701
+ * is never used when color itself is disabled.
7702
+ *
7703
+ * @param colorTerm - The `COLORTERM` value (defaults to `process.env.COLORTERM`).
7704
+ * @returns `true` when `COLORTERM` is `truecolor` or `24bit`.
7705
+ * @example
7706
+ * supportsTruecolor("truecolor"); // true
7707
+ */
7708
+ function supportsTruecolor(colorTerm = process.env.COLORTERM) {
7709
+ return colorTerm === "truecolor" || colorTerm === "24bit";
7710
+ }
7711
+ /**
7712
+ * The braille spinner glyph for a given elapsed time, advancing one frame per
7713
+ * `frameMs`. Deriving the frame from wall-clock elapsed (rather than a tick counter)
7714
+ * keeps the spinner correct even when the animation ticker is briefly starved by a
7715
+ * synchronous build phase and several ticks coalesce — the glyph still reflects real
7716
+ * elapsed time instead of freezing on a stale frame.
7717
+ *
7718
+ * @param elapsedMs - Milliseconds since the live region opened.
7719
+ * @param frameMs - Milliseconds per frame (defaults to `80`).
7720
+ * @returns The active spinner glyph.
7721
+ * @example
7722
+ * spinnerFrameAt(240); // "⠹" (the 4th frame at 80ms/frame)
7723
+ */
7724
+ function spinnerFrameAt(elapsedMs, frameMs = 80) {
7725
+ return SPINNER_FRAMES[Math.floor(Math.max(0, elapsedMs) / frameMs) % SPINNER_FRAMES.length] ?? "⠋";
7726
+ }
7727
+ /**
7570
7728
  * Select the box glyph set for the given color mode (Unicode on a TTY, ASCII off it).
7571
7729
  *
7572
7730
  * @param color - Whether color/Unicode output is enabled.
@@ -7594,12 +7752,14 @@ function visibleWidth(text) {
7594
7752
  * output in CI/pipes.
7595
7753
  *
7596
7754
  * @param color - Whether color is enabled (typically `supportsColor()`).
7755
+ * @param truecolor - Whether 24-bit output is enabled (typically `supportsTruecolor()`);
7756
+ * only consulted by {@link Palette.pink}. Defaults to `false` (16-color magenta).
7597
7757
  * @returns The bound color palette.
7598
7758
  * @example
7599
- * const palette = makePalette(supportsColor());
7759
+ * const palette = makePalette(supportsColor(), supportsTruecolor());
7600
7760
  * const line = palette.green("done");
7601
7761
  */
7602
- function makePalette(color) {
7762
+ function makePalette(color, truecolor = false) {
7603
7763
  return {
7604
7764
  enabled: color,
7605
7765
  /**
@@ -7679,23 +7839,39 @@ function makePalette(color) {
7679
7839
  */
7680
7840
  cyan(text) {
7681
7841
  return this.paint(ANSI.cyan, text);
7842
+ },
7843
+ /**
7844
+ * Color the given text the Moku brand pink: exact `#FF1E6F` (24-bit) when truecolor
7845
+ * is enabled, the 16-color `magenta` approximation otherwise, unchanged in plain mode.
7846
+ *
7847
+ * @param text - The text to colorize.
7848
+ * @returns The pink (or unchanged) text.
7849
+ * @example
7850
+ * palette.pink("▟▙ moku web");
7851
+ */
7852
+ pink(text) {
7853
+ if (!color) return text;
7854
+ if (truecolor) return `${fg24(BRAND_PINK.r, BRAND_PINK.g, BRAND_PINK.b)}${text}${ANSI.reset}`;
7855
+ return this.paint(ANSI.magenta, text);
7682
7856
  }
7683
7857
  };
7684
7858
  }
7685
7859
  /**
7686
7860
  * Frame a list of already-rendered content lines in a box, padding each line to the
7687
- * width of the widest visible line. Uses Unicode borders when `color` is enabled and
7688
- * ASCII otherwise. Visible width ignores embedded ANSI so colored lines align.
7861
+ * widest visible line (or `minInnerWidth`, whichever is larger so several boxes can be
7862
+ * forced to a shared width). Uses Unicode borders when `color` is enabled and ASCII
7863
+ * otherwise. Visible width ignores embedded ANSI so colored lines align.
7689
7864
  *
7690
7865
  * @param lines - The content lines (may contain ANSI color codes).
7691
7866
  * @param color - Whether to use Unicode borders (and assume color-capable output).
7867
+ * @param minInnerWidth - Minimum inner (content) width to pad every row to. Defaults to `0`.
7692
7868
  * @returns The boxed lines (top border, content rows, bottom border).
7693
7869
  * @example
7694
- * box(["Local: http://localhost:4173"], true);
7870
+ * box(["Local: http://localhost:4173"], true, 62);
7695
7871
  */
7696
- function box(lines, color) {
7872
+ function box(lines, color, minInnerWidth = 0) {
7697
7873
  const glyphs = boxGlyphs(color);
7698
- const inner = Math.max(0, ...lines.map((line) => visibleWidth(line)));
7874
+ const inner = Math.max(0, minInnerWidth, ...lines.map((line) => visibleWidth(line)));
7699
7875
  const horizontal = glyphs.horizontal.repeat(inner + 2);
7700
7876
  const top = `${glyphs.topLeft}${horizontal}${glyphs.topRight}`;
7701
7877
  const bottom = `${glyphs.bottomLeft}${horizontal}${glyphs.bottomRight}`;
@@ -7710,13 +7886,70 @@ function box(lines, color) {
7710
7886
  }
7711
7887
  //#endregion
7712
7888
  //#region src/plugins/cli/render/panel.ts
7713
- /** Per-command label shown in the header badge beside the logo. */
7889
+ /** Per-command label shown beside the lockup wordmark. */
7714
7890
  const COMMAND_LABEL = {
7715
7891
  build: "build",
7716
7892
  serve: "serve · dev",
7717
7893
  preview: "preview",
7718
7894
  deploy: "deploy"
7719
7895
  };
7896
+ /** Total visible width the header rule spans and the per-row timing column right-aligns to. */
7897
+ const RAIL_WIDTH = 66;
7898
+ /** Animation repaint cadence (ms) — how often the live region is redrawn when the loop is free. */
7899
+ const TICK_MS = 40;
7900
+ /** Spinner frame interval (ms) — one braille glyph advance per this many elapsed ms. */
7901
+ const SPIN_MS = 60;
7902
+ /** Inner (content) width of the BUILD/server boxes so their right edge lines up with the phase tree. */
7903
+ const BOX_INNER = RAIL_WIDTH - 4;
7904
+ /** The eight block glyphs the per-phase time-profile sparkline maps durations onto. */
7905
+ const SPARK_BARS = "▁▂▃▄▅▆▇█";
7906
+ /**
7907
+ * Build a sparkline from a list of values — one block glyph per value, height scaled to
7908
+ * the largest value so the tallest bar is `█`. A real micro-histogram (no fake data):
7909
+ * under the BUILD summary each bar is one phase's duration, so the slowest phase stands
7910
+ * out at a glance. Returns `""` for an empty list.
7911
+ *
7912
+ * @param values - The values to plot (e.g. per-phase durations in ms).
7913
+ * @returns The sparkline string.
7914
+ * @example
7915
+ * sparkline([12, 1701, 19698, 9]); // "▁▁█▁"
7916
+ */
7917
+ function sparkline(values) {
7918
+ if (values.length === 0) return "";
7919
+ const max = Math.max(...values, 1);
7920
+ return values.map((value) => {
7921
+ return SPARK_BARS[Math.min(7, Math.floor(value / max * 7))] ?? SPARK_BARS[0];
7922
+ }).join("");
7923
+ }
7924
+ /**
7925
+ * The structural glyph set for the active color mode: Unicode on a color-capable TTY,
7926
+ * ASCII fallbacks off it. Only the NEW Velocity chrome (cube, rule, tree, bar, live
7927
+ * dot) degrades here — the `✓ ✗ ~ ➜ ›` status marks stay as-is in both modes.
7928
+ *
7929
+ * @param color - Whether color/Unicode output is enabled.
7930
+ * @returns The matching glyph set.
7931
+ * @example
7932
+ * const g = glyphSet(true);
7933
+ */
7934
+ function glyphSet(color) {
7935
+ return color ? {
7936
+ cube: "▟▙",
7937
+ rule: "─",
7938
+ tree: "├─",
7939
+ barFill: "━",
7940
+ barTrack: "╴",
7941
+ liveOn: "◍",
7942
+ liveOff: "○"
7943
+ } : {
7944
+ cube: "*",
7945
+ rule: "-",
7946
+ tree: "-",
7947
+ barFill: "#",
7948
+ barTrack: "-",
7949
+ liveOn: "*",
7950
+ liveOff: "*"
7951
+ };
7952
+ }
7720
7953
  /**
7721
7954
  * Render one human-readable duration suffix (e.g. `· 84ms`).
7722
7955
  *
@@ -7731,15 +7964,49 @@ function durationSuffix(palette, durationMs) {
7731
7964
  return ` ${palette.dim(`· ${durationMs}ms`)}`;
7732
7965
  }
7733
7966
  /**
7967
+ * Right-align `right` against `left` within {@link RAIL_WIDTH}, measuring visible width
7968
+ * so embedded ANSI never throws the timing column off.
7969
+ *
7970
+ * @param left - The left segment (may contain ANSI).
7971
+ * @param right - The right segment (may contain ANSI).
7972
+ * @param width - Total visible width to fill (defaults to {@link RAIL_WIDTH}).
7973
+ * @returns The padded line.
7974
+ * @example
7975
+ * railLine(" ├─ ✓ pages", "· 12ms");
7976
+ */
7977
+ function railLine(left, right, width = RAIL_WIDTH) {
7978
+ const gap = Math.max(1, width - visibleWidth(left) - visibleWidth(right));
7979
+ return `${left}${" ".repeat(gap)}${right}`;
7980
+ }
7981
+ /**
7982
+ * The runtime facts line shown under the banner: the pinned core version (when known)
7983
+ * plus the live Node/Bun versions + platform — the ACTUAL running runtime, not the
7984
+ * `engines` floor. Every value is real (read from `@moku-labs/core`'s pinned dependency
7985
+ * and `process.versions`), so nothing on this line is faked.
7986
+ *
7987
+ * @param coreVersion - The pinned `@moku-labs/core` version (appended last — it rarely
7988
+ * matters — and omitted entirely when unknown).
7989
+ * @returns The facts string (e.g. `node 24.3.0 · bun 1.3.9 · darwin arm64 · core 0.1.0-alpha.6`).
7990
+ * @example
7991
+ * runtimeFacts("0.1.0-alpha.6");
7992
+ */
7993
+ function runtimeFacts(coreVersion) {
7994
+ const node = `node ${process.versions.node}`;
7995
+ const bun = process.versions.bun ? ` · bun ${process.versions.bun}` : "";
7996
+ const core = coreVersion ? ` · core ${coreVersion}` : "";
7997
+ return `${node}${bun} · ${process.platform} ${process.arch}${core}`;
7998
+ }
7999
+ /**
7734
8000
  * Create the Panel {@link CliRenderer}. Output is written through the injected sink
7735
- * (default `console.log`/`console.error`) and colorized only when color is enabled,
7736
- * so the identical render path yields box-drawn color panels on a TTY and plain
7737
- * ASCII lines in CI/pipes.
8001
+ * (default `console.log`/`console.error`) and colorized only when color is enabled, so
8002
+ * the identical render path yields the animated, box-free Velocity UI on a TTY and
8003
+ * plain ASCII lines in CI/pipes.
7738
8004
  *
7739
- * @param options - Optional sinks + a color override (see {@link PanelOptions}).
8005
+ * @param options - Optional sinks, color/truecolor overrides, clock, and version (see
8006
+ * {@link PanelOptions}).
7740
8007
  * @returns The renderer mounted on `state.render` and driven by the API + hooks.
7741
8008
  * @example
7742
- * const render = createPanelRenderer();
8009
+ * const render = createPanelRenderer({ version: "0.1.0-alpha" });
7743
8010
  * render.header("build");
7744
8011
  */
7745
8012
  function createPanelRenderer(options = {}) {
@@ -7750,26 +8017,24 @@ function createPanelRenderer(options = {}) {
7750
8017
  });
7751
8018
  const now = options.now ?? Date.now;
7752
8019
  const color = options.color ?? supportsColor();
7753
- const palette = makePalette(color);
8020
+ const palette = makePalette(color, options.truecolor ?? (color && supportsTruecolor()));
8021
+ const version = options.version ?? "dev";
8022
+ const coreVersion = options.coreVersion;
8023
+ const g = glyphSet(color);
7754
8024
  let phaseRows = [];
7755
8025
  let phaseDrawn = 0;
7756
8026
  let phaseOpen = false;
8027
+ let blockStartedAt = 0;
7757
8028
  let rebuilding = false;
7758
8029
  let rebuildLabel = "";
7759
8030
  let rebuildStartedAt = 0;
7760
- let spinnerFrame = 0;
8031
+ let idle = false;
8032
+ let idleStartedAt = 0;
8033
+ let serveMode = false;
7761
8034
  let ticker;
7762
8035
  /**
7763
- * The current spinner glyph (with a static fallback under `noUncheckedIndexedAccess`).
7764
- *
7765
- * @returns The active braille spinner frame.
7766
- * @example
7767
- * frameGlyph(); // "⠙"
7768
- */
7769
- const frameGlyph = () => SPINNER_FRAMES[spinnerFrame % SPINNER_FRAMES.length] ?? "⠋";
7770
- /**
7771
- * Render one phase row: a green `✓ name · time` when done, else a spinning cyan glyph
7772
- * before the dim name.
8036
+ * Render one phase-tree row: a spinning cyan glyph + dim name while running, or a green
8037
+ * `✓` + name with the duration right-aligned in the dim timing column once done.
7773
8038
  *
7774
8039
  * @param row - The phase row to render.
7775
8040
  * @returns The rendered row line (no trailing newline).
@@ -7777,12 +8042,34 @@ function createPanelRenderer(options = {}) {
7777
8042
  * renderPhaseRow({ name: "pages", done: true, durationMs: 12 });
7778
8043
  */
7779
8044
  const renderPhaseRow = (row) => {
7780
- if (row.done) return ` ${palette.green("✓")} ${row.name}${durationSuffix(palette, row.durationMs)}`;
7781
- return ` ${palette.cyan(frameGlyph())} ${palette.dim(row.name)}`;
8045
+ const branch = palette.dim(g.tree);
8046
+ if (row.done) return railLine(` ${branch} ${palette.green("✓")} ${row.name}`, palette.dim(`· ${row.durationMs}ms`));
8047
+ return ` ${branch} ${palette.cyan(spinnerFrameAt(now() - blockStartedAt, SPIN_MS))} ${palette.dim(row.name)}`;
7782
8048
  };
7783
8049
  /**
7784
- * Repaint the live phase block in place: move up over the prior draw, then rewrite each
7785
- * row (clearing any stale trailing lines).
8050
+ * Render the indeterminate "comet" build bar a short pink fill window sweeping across
8051
+ * a dim track for the given elapsed time. Animated purely from wall-clock elapsed so
8052
+ * it never needs a known phase total.
8053
+ *
8054
+ * @param elapsedMs - Milliseconds since the phase block opened.
8055
+ * @returns The rendered bar row (no trailing newline).
8056
+ * @example
8057
+ * renderBuildBar(300);
8058
+ */
8059
+ const renderBuildBar = (elapsedMs) => {
8060
+ const length = 28;
8061
+ const window = 6;
8062
+ const head = Math.floor(elapsedMs / 28) % 34;
8063
+ let bar = "";
8064
+ for (let index = 0; index < length; index++) {
8065
+ const lit = index <= head && index > head - window;
8066
+ bar += lit ? palette.pink(g.barFill) : palette.dim(g.barTrack);
8067
+ }
8068
+ return ` ${bar}`;
8069
+ };
8070
+ /**
8071
+ * Repaint the live phase block in place (tree rows + animated build bar): move up over
8072
+ * the prior draw, rewrite each row, then the bar, clearing any stale trailing lines.
7786
8073
  *
7787
8074
  * @example
7788
8075
  * paintPhaseBlock();
@@ -7790,8 +8077,9 @@ function createPanelRenderer(options = {}) {
7790
8077
  const paintPhaseBlock = () => {
7791
8078
  let frame = cursorUp(phaseDrawn);
7792
8079
  for (const row of phaseRows) frame += `${CLEAR_LINE}${renderPhaseRow(row)}\n`;
8080
+ frame += `${CLEAR_LINE}${renderBuildBar(now() - blockStartedAt)}\n`;
7793
8081
  writeRaw(frame + CLEAR_BELOW);
7794
- phaseDrawn = phaseRows.length;
8082
+ phaseDrawn = phaseRows.length + 1;
7795
8083
  };
7796
8084
  /**
7797
8085
  * Repaint the single in-place rebuild line (spinner + label + live elapsed seconds).
@@ -7800,20 +8088,31 @@ function createPanelRenderer(options = {}) {
7800
8088
  * paintRebuildLine();
7801
8089
  */
7802
8090
  const paintRebuildLine = () => {
7803
- const elapsed = ((now() - rebuildStartedAt) / 1e3).toFixed(1);
7804
- const meta = palette.dim(`· ${elapsed}s`);
7805
- writeRaw(`\r${CLEAR_LINE} ${palette.cyan(frameGlyph())} rebuilding ${rebuildLabel} ${meta}`);
8091
+ const spinner = palette.cyan(spinnerFrameAt(now() - rebuildStartedAt, SPIN_MS));
8092
+ const elapsed = palette.dim(`· ${((now() - rebuildStartedAt) / 1e3).toFixed(1)}s`);
8093
+ writeRaw(`\r${CLEAR_LINE} ${spinner} rebuilding ${rebuildLabel} ${elapsed}`);
8094
+ };
8095
+ /**
8096
+ * Repaint the persistent in-place `◍ live` idle pulse beneath the serve panel — the
8097
+ * dot breathes (pink → dim) on a calm ~0.6s cycle so a quiet dev session always reads
8098
+ * as alive without strobing.
8099
+ *
8100
+ * @example
8101
+ * paintIdleLine();
8102
+ */
8103
+ const paintIdleLine = () => {
8104
+ writeRaw(`\r${CLEAR_LINE} ${Math.floor((now() - idleStartedAt) / 450) % 2 === 0 ? palette.pink(g.liveOn) : palette.dim(g.liveOff)} ${palette.dim("live · waiting for changes…")}`);
7806
8105
  };
7807
8106
  /**
7808
- * Advance the spinner one frame and repaint whichever live region is active.
8107
+ * Advance whichever live region is active by one frame (driven by the shared ticker).
7809
8108
  *
7810
8109
  * @example
7811
8110
  * onTick();
7812
8111
  */
7813
8112
  const onTick = () => {
7814
- spinnerFrame += 1;
7815
8113
  if (rebuilding) paintRebuildLine();
7816
8114
  else if (phaseOpen) paintPhaseBlock();
8115
+ else if (idle) paintIdleLine();
7817
8116
  };
7818
8117
  /**
7819
8118
  * Start the animation ticker (TTY only; idempotent; `unref`'d so it never blocks exit).
@@ -7823,7 +8122,7 @@ function createPanelRenderer(options = {}) {
7823
8122
  */
7824
8123
  const startTicker = () => {
7825
8124
  if (!color || ticker) return;
7826
- ticker = setInterval(onTick, 80);
8125
+ ticker = setInterval(onTick, TICK_MS);
7827
8126
  ticker.unref?.();
7828
8127
  };
7829
8128
  /**
@@ -7846,22 +8145,47 @@ function createPanelRenderer(options = {}) {
7846
8145
  const writeBlock = (lines) => {
7847
8146
  for (const line of lines) write(line);
7848
8147
  };
8148
+ /**
8149
+ * Resume the serve idle pulse on a fresh bottom line (TTY serve sessions only). A no-op
8150
+ * outside serve so standalone rebuild/error calls in unit tests never leave a ticker
8151
+ * running.
8152
+ *
8153
+ * @example
8154
+ * resumeIdle();
8155
+ */
8156
+ const resumeIdle = () => {
8157
+ if (!(color && serveMode)) {
8158
+ stopTicker();
8159
+ return;
8160
+ }
8161
+ idle = true;
8162
+ idleStartedAt = now();
8163
+ paintIdleLine();
8164
+ startTicker();
8165
+ };
7849
8166
  return {
7850
8167
  /**
7851
- * Render the boxed `MOKU WEB` logo + command label.
8168
+ * Render the `▟▙ moku web` lockup + per-command label, a dim rule, and the runtime
8169
+ * facts line (live Node/Bun versions + platform). Called once per command (one
8170
+ * command = one process), so it never repeats within a run.
7852
8171
  *
7853
- * @param command - The command being run, shown beside the logo.
8172
+ * @param command - The command being run, shown beside the wordmark.
7854
8173
  * @example
7855
8174
  * render.header("serve");
7856
8175
  */
7857
8176
  header(command) {
7858
- writeBlock(box([`${palette.bold(palette.cyan("MOKU WEB"))} ${palette.dim(COMMAND_LABEL[command])}`], color));
8177
+ writeBlock([
8178
+ railLine(` ${palette.pink(g.cube)} ${palette.pink(palette.bold("moku web"))} ${palette.dim(COMMAND_LABEL[command])}`, palette.dim(version)),
8179
+ ` ${palette.dim(g.rule.repeat(RAIL_WIDTH - 1))}`,
8180
+ ` ${palette.dim(runtimeFacts(coreVersion))}`
8181
+ ]);
7859
8182
  },
7860
8183
  /**
7861
- * Render a per-phase row from a `build:phase` event. On a TTY each phase is ONE row
7862
- * that updates in place (spinning glyph while running → green ✓ + duration when done);
7863
- * off a TTY one line is printed per completed phase (no start/done duplication). A
7864
- * no-op while a serve() rebuild is in flight — those show the compact rebuild line.
8184
+ * Render a live per-phase row from a `build:phase` event. On a TTY each phase is ONE
8185
+ * tree row that updates in place (spinning glyph while running → green ✓ + duration
8186
+ * when done) beneath an animated indeterminate build bar; off a TTY one line is
8187
+ * printed per completed phase (no start/done duplication). A no-op while a serve()
8188
+ * rebuild is in flight — those show the compact rebuild line.
7865
8189
  *
7866
8190
  * @param phase - The `build:phase` payload.
7867
8191
  * @example
@@ -7877,6 +8201,7 @@ function createPanelRenderer(options = {}) {
7877
8201
  phaseRows = [];
7878
8202
  phaseDrawn = 0;
7879
8203
  phaseOpen = true;
8204
+ blockStartedAt = now();
7880
8205
  }
7881
8206
  const done = phase.status === "done";
7882
8207
  const existing = phaseRows.find((row) => row.name === phase.phase);
@@ -7892,7 +8217,9 @@ function createPanelRenderer(options = {}) {
7892
8217
  startTicker();
7893
8218
  },
7894
8219
  /**
7895
- * Render the BUILD summary block from a `build:complete` event.
8220
+ * Render the BUILD summary line + a one-shot throughput sparkline from a
8221
+ * `build:complete` event, finalizing the live phase tree (dropping its animated bar)
8222
+ * first.
7896
8223
  *
7897
8224
  * @param summary - The `build:complete` payload.
7898
8225
  * @example
@@ -7900,33 +8227,52 @@ function createPanelRenderer(options = {}) {
7900
8227
  */
7901
8228
  built(summary) {
7902
8229
  if (rebuilding) return;
8230
+ if (color && phaseOpen) {
8231
+ let frame = cursorUp(phaseDrawn);
8232
+ for (const row of phaseRows) frame += `${CLEAR_LINE}${renderPhaseRow(row)}\n`;
8233
+ writeRaw(frame + CLEAR_BELOW);
8234
+ }
8235
+ const phaseDurations = phaseRows.map((row) => row.durationMs).filter((value) => value !== void 0);
7903
8236
  phaseOpen = false;
7904
8237
  phaseDrawn = 0;
7905
8238
  stopTicker();
7906
8239
  const pages = palette.bold(String(summary.pageCount));
7907
- writeBlock(box([
7908
- `${palette.green("✓")} ${palette.bold("BUILD")} complete`,
7909
- `${palette.dim("pages")} ${pages}`,
7910
- `${palette.dim("time")} ${summary.durationMs}ms`,
7911
- `${palette.dim("out")} ${summary.outDir}/`
7912
- ], color));
8240
+ const dot = palette.dim("·");
8241
+ const lines = [railLine(`${palette.green("✓")} ${palette.bold("BUILD")} ${dot} ${pages} pages`, `${summary.durationMs}ms ${dot} ${summary.outDir}/`, BOX_INNER)];
8242
+ if (color && summary.durationMs > 0) {
8243
+ const rate = Math.max(1, Math.round(summary.pageCount / (summary.durationMs / 1e3)));
8244
+ const spark = phaseDurations.length > 0 ? palette.pink(sparkline(phaseDurations)) : "";
8245
+ const rateLabel = palette.dim(`${rate} pages/s`);
8246
+ lines.push(railLine(spark, rateLabel, BOX_INNER));
8247
+ }
8248
+ writeBlock(box(lines, color, BOX_INNER));
7913
8249
  },
7914
8250
  /**
7915
- * Render the bordered server-ready panel (Local / Network URLs + watched dirs).
8251
+ * Render the server-ready rail (Local / Network URLs + watched dirs) and, on a TTY,
8252
+ * begin the persistent breathing `◍ live` idle pulse beneath it.
7916
8253
  *
7917
8254
  * @param info - Local/Network URLs and optionally the watched directories.
7918
8255
  * @example
7919
8256
  * render.serverReady({ local: "http://localhost:4173", network: null });
7920
8257
  */
7921
8258
  serverReady(info) {
7922
- const lines = [`${palette.green("➜")} ${palette.bold("Local")} ${palette.cyan(info.local)}`, `${palette.green("➜")} ${palette.bold("Network")} ${info.network ? palette.cyan(info.network) : palette.dim("unavailable")}`];
7923
- if (info.watching && info.watching.length > 0) lines.push(`${palette.dim("watching")} ${palette.dim(info.watching.join(", "))}`);
7924
- writeBlock(box(lines, color));
8259
+ const network = info.network ? palette.cyan(info.network) : palette.dim("unavailable");
8260
+ const lines = [`${palette.green("➜")} ${palette.bold("Local")} ${palette.cyan(info.local)}`, `${palette.green("➜")} ${palette.bold("Network")} ${network}`];
8261
+ if (info.watching && info.watching.length > 0) lines.push(`${palette.dim("watching")} ${palette.dim(info.watching.join(", "))}`);
8262
+ writeBlock(box(lines, color, BOX_INNER));
8263
+ if (color) {
8264
+ serveMode = true;
8265
+ idle = true;
8266
+ idleStartedAt = now();
8267
+ paintIdleLine();
8268
+ startTicker();
8269
+ }
7925
8270
  },
7926
8271
  /**
7927
8272
  * Begin a serve() rebuild: show ONE compact "rebuilding {label}" line (an animated
7928
- * spinner with live elapsed on a TTY; a plain "~ {label}" line otherwise) and mute
7929
- * the verbose phase rows + BUILD box until {@link reload}/{@link error} settles it.
8273
+ * spinner with live elapsed on a TTY; a plain "~ {label}" line otherwise), taking over
8274
+ * the idle-pulse line, and mute the verbose phase tree + BUILD summary until
8275
+ * {@link reload}/{@link error} settles it.
7930
8276
  *
7931
8277
  * @param label - The changed watch target shown in the line.
7932
8278
  * @example
@@ -7934,9 +8280,9 @@ function createPanelRenderer(options = {}) {
7934
8280
  */
7935
8281
  rebuildStart(label) {
7936
8282
  rebuilding = true;
8283
+ idle = false;
7937
8284
  rebuildLabel = label;
7938
8285
  rebuildStartedAt = now();
7939
- spinnerFrame = 0;
7940
8286
  if (!color) {
7941
8287
  write(` ${palette.yellow("~")} ${label}`);
7942
8288
  return;
@@ -7946,9 +8292,9 @@ function createPanelRenderer(options = {}) {
7946
8292
  },
7947
8293
  /**
7948
8294
  * Settle the current rebuild: replace the in-place "rebuilding…" line with a compact
7949
- * "✓ rebuilt N pages · Xms · reloaded" (on a TTY) and re-enable verbose build output.
7950
- * Called standalone (no preceding {@link rebuildStart}) it also prints the "~ file"
7951
- * line so the changed target stays visible.
8295
+ * "✓ rebuilt N pages · Xms · reloaded", then (in a serve session) resume the idle pulse
8296
+ * on a fresh bottom line. Called standalone (no preceding {@link rebuildStart}) it also
8297
+ * prints the "~ file" line so the changed target stays visible.
7952
8298
  *
7953
8299
  * @param info - The changed file plus the rebuild's page count and duration.
7954
8300
  * @example
@@ -7957,30 +8303,26 @@ function createPanelRenderer(options = {}) {
7957
8303
  reload(info) {
7958
8304
  const settledRebuild = rebuilding;
7959
8305
  rebuilding = false;
7960
- stopTicker();
7961
8306
  const line = ` ${palette.green("✓")} rebuilt ${palette.bold(String(info.pageCount))} pages ${palette.dim(`· ${info.durationMs}ms · reloaded`)}`;
7962
8307
  if (settledRebuild && color) {
7963
8308
  writeRaw(`\r${CLEAR_LINE}${line}\n`);
8309
+ resumeIdle();
7964
8310
  return;
7965
8311
  }
7966
8312
  if (!settledRebuild) write(` ${palette.yellow("~")} ${info.file}`);
7967
8313
  write(line);
7968
8314
  },
7969
8315
  /**
7970
- * Render the deploy result panel from a `deploy:complete` event.
8316
+ * Render the deploy result from a `deploy:complete` event: a `✓ DEPLOYED → url` line
8317
+ * with the URL the hero value, then a dim `branch · id · time` line beneath it.
7971
8318
  *
7972
8319
  * @param result - The `deploy:complete` payload.
7973
8320
  * @example
7974
8321
  * render.deployed({ url: "https://x.pages.dev", deploymentId: "id", branch: "main", durationMs: 1200 });
7975
8322
  */
7976
8323
  deployed(result) {
7977
- writeBlock(box([
7978
- `${palette.green("✓")} ${palette.bold("DEPLOYED")}`,
7979
- `${palette.dim("url")} ${palette.cyan(result.url)}`,
7980
- `${palette.dim("branch")} ${result.branch}`,
7981
- `${palette.dim("id")} ${result.deploymentId}`,
7982
- `${palette.dim("time")} ${result.durationMs}ms`
7983
- ], color));
8324
+ const meta = palette.dim(`branch ${result.branch} · ${result.deploymentId} · ${result.durationMs}ms`);
8325
+ writeBlock([` ${palette.green("✓")} ${palette.bold("DEPLOYED")} ${palette.dim("→")} ${palette.cyan(result.url)}`, ` ${meta}`]);
7984
8326
  },
7985
8327
  /**
7986
8328
  * Render a neutral informational line.
@@ -8005,7 +8347,8 @@ function createPanelRenderer(options = {}) {
8005
8347
  writeError(` ${palette.yellow("⚠")} ${message}`);
8006
8348
  },
8007
8349
  /**
8008
- * Render an error line (to stderr), optionally with a cause.
8350
+ * Render an error line (to stderr), optionally with a cause. A failing rebuild settles
8351
+ * its in-place spinner line first; in a serve session the idle pulse then resumes.
8009
8352
  *
8010
8353
  * @param message - The error summary to print.
8011
8354
  * @param cause - Optional underlying error/value to print beneath the summary.
@@ -8013,16 +8356,18 @@ function createPanelRenderer(options = {}) {
8013
8356
  * render.error("build failed", err);
8014
8357
  */
8015
8358
  error(message, cause) {
8359
+ const wasRebuilding = rebuilding;
8016
8360
  if (rebuilding) {
8017
8361
  rebuilding = false;
8018
- stopTicker();
8019
8362
  if (color) writeRaw(`\r${CLEAR_LINE}`);
8020
8363
  }
8021
8364
  writeError(` ${palette.red("✗")} ${message}`);
8022
8365
  if (cause !== void 0) writeError(String(cause));
8366
+ if (wasRebuilding) resumeIdle();
8367
+ else stopTicker();
8023
8368
  },
8024
8369
  /**
8025
- * Render a section heading (a blank line + a bold cyan label) for a multi-step flow.
8370
+ * Render a section heading (a blank line + a bold pink label) for a multi-step flow.
8026
8371
  *
8027
8372
  * @param text - The heading label.
8028
8373
  * @example
@@ -8030,7 +8375,7 @@ function createPanelRenderer(options = {}) {
8030
8375
  */
8031
8376
  heading(text) {
8032
8377
  write("");
8033
- write(` ${palette.bold(palette.cyan(text))}`);
8378
+ write(` ${palette.bold(palette.pink(text))}`);
8034
8379
  },
8035
8380
  /**
8036
8381
  * Render a diagnostic line: green `✓` (pass) or red `✗` (fail) + label, with optional
@@ -8045,6 +8390,19 @@ function createPanelRenderer(options = {}) {
8045
8390
  check(ok, label, detail) {
8046
8391
  write(` ${ok ? palette.green("✓") : palette.red("✗")} ${label}`);
8047
8392
  if (detail !== void 0) for (const line of detail.split("\n")) write(` ${palette.dim(line)}`);
8393
+ },
8394
+ /**
8395
+ * Stop every animation and release the interval timer (serve()'s teardown calls this).
8396
+ *
8397
+ * @example
8398
+ * render.dispose();
8399
+ */
8400
+ dispose() {
8401
+ stopTicker();
8402
+ idle = false;
8403
+ rebuilding = false;
8404
+ phaseOpen = false;
8405
+ serveMode = false;
8048
8406
  }
8049
8407
  };
8050
8408
  }
@@ -8201,6 +8559,101 @@ function defaultFileMtime(filePath) {
8201
8559
  function defaultNetworkUrl(port) {
8202
8560
  return networkUrl(port);
8203
8561
  }
8562
+ /** Memoized banner facts — resolution touches the filesystem + git once, then caches. */
8563
+ let cachedBanner;
8564
+ /**
8565
+ * Run a read-only `git` command in `dir`, returning its trimmed stdout (`undefined` on
8566
+ * any failure — not a checkout, git missing, etc.). A thin wrapper so the version
8567
+ * resolver can issue a couple of git queries without repeating the spawn boilerplate.
8568
+ *
8569
+ * @param dir - The working directory to run git in.
8570
+ * @param args - The git arguments (no user input is ever interpolated).
8571
+ * @returns The trimmed command output, or `undefined` on failure.
8572
+ * @example
8573
+ * git("/Users/me/moku/web", ["tag", "--list", "v*"]);
8574
+ */
8575
+ function git(dir, args) {
8576
+ try {
8577
+ return (0, node_child_process.execFileSync)("git", args, {
8578
+ cwd: dir,
8579
+ encoding: "utf8",
8580
+ stdio: [
8581
+ "ignore",
8582
+ "pipe",
8583
+ "ignore"
8584
+ ]
8585
+ }).trim();
8586
+ } catch {
8587
+ return;
8588
+ }
8589
+ }
8590
+ /**
8591
+ * The framework's source/dev version, derived the SAME way the publish workflow computes
8592
+ * a release: the highest semver `v*` tag is the source of truth (`@moku-labs/web` is
8593
+ * released tag-only — the working-tree `package.json` deliberately carries no `version`).
8594
+ * A `-dev` suffix marks it as a local build off that release line (e.g. `v1.1.0-dev`), so
8595
+ * it never masquerades as the published release. Falls back to the short commit (then
8596
+ * `undefined`) only when no tags exist. `undefined` when `dir` is not a git checkout (a
8597
+ * published npm install — which carries its real `version` instead).
8598
+ *
8599
+ * @param dir - A directory inside the framework's own repository (the realpath of the
8600
+ * package root, so a symlinked local checkout reports the framework's tag — not the
8601
+ * consumer's).
8602
+ * @returns The dev version (e.g. `v1.1.0-dev`), or `undefined`.
8603
+ * @example
8604
+ * devVersion("/Users/me/moku/web"); // "v1.1.0-dev"
8605
+ */
8606
+ function devVersion(dir) {
8607
+ const latestTag = git(dir, [
8608
+ "tag",
8609
+ "--list",
8610
+ "v*",
8611
+ "--sort=-v:refname"
8612
+ ])?.split("\n")[0]?.trim();
8613
+ if (latestTag) return `${latestTag}-dev`;
8614
+ const sha = git(dir, [
8615
+ "rev-parse",
8616
+ "--short",
8617
+ "HEAD"
8618
+ ]);
8619
+ return sha ? `${sha}-dev` : void 0;
8620
+ }
8621
+ /**
8622
+ * Resolve the real version/runtime facts shown in the Panel banner (memoized). Reads the
8623
+ * `package.json` shipped beside the built bundle (`dist/../package.json`): a PUBLISHED
8624
+ * release carries a `version` and reports `v{version}`; a source/dev build (no `version`
8625
+ * field — `@moku-labs/web` is released tag-only) reports the latest semver tag + `-dev`
8626
+ * (e.g. `v1.1.0-dev`, the same tag the publish workflow treats as the version source), or
8627
+ * `"dev"` when git is unavailable. The pinned `@moku-labs/core` version comes from the
8628
+ * same file's `dependencies`.
8629
+ *
8630
+ * @returns The resolved {@link BannerFacts}.
8631
+ * @example
8632
+ * resolveBanner(); // { version: "v1.1.0-dev", coreVersion: "0.1.0-alpha.6" }
8633
+ */
8634
+ function resolveBanner() {
8635
+ if (cachedBanner) return cachedBanner;
8636
+ let pkg = {};
8637
+ let pkgDir;
8638
+ try {
8639
+ const pkgUrl = new URL("../package.json", require("url").pathToFileURL(__filename).href);
8640
+ pkgDir = (0, node_fs.realpathSync)(node_path$1.default.dirname((0, node_url.fileURLToPath)(pkgUrl)));
8641
+ pkg = JSON.parse((0, node_fs.readFileSync)(pkgUrl, "utf8"));
8642
+ } catch {}
8643
+ const coreVersion = (pkg.dependencies?.["@moku-labs/core"] ?? "").replace(/^\D*/, "") || "unknown";
8644
+ const released = pkg.version;
8645
+ let version = "dev";
8646
+ if (released) version = `v${released}`;
8647
+ else {
8648
+ const dev = devVersion(pkgDir ?? process.cwd());
8649
+ if (dev) version = dev;
8650
+ }
8651
+ cachedBanner = {
8652
+ version,
8653
+ coreVersion
8654
+ };
8655
+ return cachedBanner;
8656
+ }
8204
8657
  /**
8205
8658
  * Create the initial cli plugin state with the production seams wired. Every field is
8206
8659
  * an injectable seam (`render`, `confirm`, `clock`, `watch`, the server factories,
@@ -8214,8 +8667,12 @@ function defaultNetworkUrl(port) {
8214
8667
  * const state = createState({ global: {}, config });
8215
8668
  */
8216
8669
  function createState$1(_ctx) {
8670
+ const banner = resolveBanner();
8217
8671
  return {
8218
- render: createPanelRenderer(),
8672
+ render: createPanelRenderer({
8673
+ version: banner.version,
8674
+ coreVersion: banner.coreVersion
8675
+ }),
8219
8676
  confirm: defaultConfirm,
8220
8677
  select: defaultSelect,
8221
8678
  clock: Date.now,