@loadmap/cli 0.4.0 → 0.6.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/README.md CHANGED
@@ -3,14 +3,17 @@
3
3
  ```bash
4
4
  npx @loadmap/cli check shop.loadmap # exit 1 when an expect line fails; GitHub annotations under Actions
5
5
  npx @loadmap/cli fmt shop.loadmap # lays it out the one way; --check exits 1 instead (for CI)
6
- npx @loadmap/cli record shop.loadmap --out shop.gif --caption
7
- npx @loadmap/cli record http://localhost:5173/ --out poster.png --poster
6
+
7
+ npm i -D @loadmap/cli playwright && npx playwright install chromium # once, to record
8
+ npx loadmap record shop.loadmap --out shop.gif --caption
9
+ npx loadmap record http://localhost:5173/ --out poster.png --poster
8
10
  ```
9
11
 
10
12
  `loadmap check` compiles each `.loadmap` and tests its `expect` lines on the pessimistic edge (every capacity at its
11
13
  low bound). `loadmap fmt` rewrites each file laid out the one way (two spaces a block, a statement a line, one space
12
14
  around operators; numbers, texts and formulas as written), `--check` only names the ones that are not, `-` reads
13
15
  standard input. `loadmap record` draws a `.loadmap` (or opens a page that calls `expose()` from `@loadmap/export`) in
14
- headless Chromium and records it on a fixed clock: the same options give the same file. Recording needs Chromium
15
- once: `npx playwright install chromium`. `loadmap --help` lists every option.
16
+ headless Chromium and records it on a fixed clock: the same options give the same file. Only recording needs
17
+ Playwright and its Chromium, installed next to the command (`npx @loadmap/cli record` alone cannot find them);
18
+ `check` and `fmt` run without them. `loadmap --help` lists every option.
16
19
  Docs: https://jaim3r0jas.github.io/loadmap/ · MIT
package/dist/bin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { n as main } from "./src-CKZ8ncFf.js";
2
+ import { n as main } from "./src-KVw61wOu.js";
3
3
  // src/bin.ts
4
4
  main(process.argv.slice(2)).then((code) => process.exit(code), (err) => {
5
5
  console.error(err instanceof Error ? err.message : err);
package/dist/index.d.ts CHANGED
@@ -40,7 +40,7 @@ interface FmtOptions {
40
40
  * file has a syntax error, cannot be read, or (with --check) needs formatting; 2 for a usage mistake.
41
41
  */
42
42
  export declare function fmt(argv: string[], o?: FmtOptions): Promise<number>;
43
- export declare const USAGE = "loadmap check <file.loadmap>… [--format text|github]\nloadmap fmt <file.loadmap>… [--check] | loadmap fmt -\nloadmap record <url | file.loadmap> --out <file.gif|.webm|.mp4|.png|.svg> [options]\n\ncheck compiles each definition and tests its expect lines on the pessimistic edge (every capacity at its low\nbound). It exits 1 when a definition has an error or an expectation fails, 0 when everything holds.\n\n --format <f> text, or github to add annotations on the lines (the default under GitHub Actions)\n\nfmt lays each definition out the one way, in place: two spaces a block, a statement a line, one space around\noperators; numbers, texts and formulas stay as written. A file with a syntax error is left alone (exit 1).\n\n --check change nothing; exit 1 when a file is not laid out so (for CI)\n - read standard input, write standard output\n\nrecord opens the page in headless Chromium, finds the loadmap it exposes (expose() from @loadmap/export) and\nrecords it on a fixed clock, so the same options give the same file. A .loadmap file is drawn by the viewer that\ncomes with the command.\n\n --out <file> the extension picks the format: gif, webm, mp4, png or svg\n --script <file.json> [{ \"at\": 0, \"set\": { \"load\": 5 } }, { \"at\": 3, \"scenario\": \"Proposed\" }, …]\n or { \"seconds\", \"fps\", \"script\": […] }\n --scenario <label> start from a scenario's values (--set goes on top)\n --set <key=value> a value before recording starts; repeatable (--set load=30 --set pods=6)\n --caption the title, scenario and load above the board, the verdict below it\n --story play the definition's story, a step at a time (with --caption, its text below the board)\n --poster with .png: the board, the verdict, the limits and the scenarios compared\n --seconds <n> length of a video or GIF (default 8, or the script's)\n --fps <n> frames per second (default 30; GIF 15, at most 50)\n --scale <n> pixels per diagram unit (default 1, PNG 2)\n --seed <n> the sim's seed (default 1)\n --name <name> which exposed loadmap, when the page has several (default \"default\")\n --width <px> the browser window (default 1280; below 640 the diagram runs top to bottom)\n --color-scheme <s> light or dark, for a page that follows the system theme (default light)";
43
+ export declare const USAGE = "loadmap check <file.loadmap>… [--format text|github]\nloadmap fmt <file.loadmap>… [--check] | loadmap fmt -\nloadmap record <url | file.loadmap> --out <file.gif|.webm|.mp4|.png|.svg> [options]\n\ncheck compiles each definition and tests its expect lines on the pessimistic edge (every capacity at its low\nbound). It exits 1 when a definition has an error or an expectation fails, 0 when everything holds.\n\n --format <f> text, or github to add annotations on the lines (the default under GitHub Actions)\n\nfmt lays each definition out the one way, in place: two spaces a block, a statement a line, one space around\noperators; numbers, texts and formulas stay as written. A file with a syntax error is left alone (exit 1).\n\n --check change nothing; exit 1 when a file is not laid out so (for CI)\n - read standard input, write standard output\n\nrecord opens the page in headless Chromium, finds the loadmap it exposes (expose() from @loadmap/export) and\nrecords it on a fixed clock, so the same options give the same file. A .loadmap file is drawn by the viewer that\ncomes with the command. It needs Playwright next to the command: npm i -D playwright, then npx playwright install\nchromium (check and fmt run without it).\n\n --out <file> the extension picks the format: gif, webm, mp4, png or svg\n --script <file.json> [{ \"at\": 0, \"set\": { \"load\": 5 } }, { \"at\": 3, \"scenario\": \"Proposed\" }, …]\n or { \"seconds\", \"fps\", \"script\": […] }\n --scenario <label> start from a scenario's values (--set goes on top)\n --set <key=value> a value before recording starts; repeatable (--set load=30 --set pods=6)\n --caption the title, scenario and load above the board, the verdict below it\n --story play the definition's story, a step at a time (with --caption, its text below the board)\n --poster with .png: the board, the verdict, the limits and the scenarios compared\n --seconds <n> length of a video or GIF (default 8, or the script's)\n --fps <n> frames per second (default 30; GIF 15, at most 50)\n --scale <n> pixels per diagram unit (default 1, PNG 2)\n --seed <n> the sim's seed (default 1)\n --name <name> which exposed loadmap, when the page has several (default \"default\")\n --width <px> the browser window (default 1280; below 640 the diagram runs top to bottom)\n --color-scheme <s> light or dark, for a page that follows the system theme (default light)";
44
44
  export interface RecordArgs {
45
45
  /** A page that calls expose(); empty with `file`. */
46
46
  url: string;
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- export { t as USAGE, o as check, a as fmt, s as github, n as main, r as parseRecord, i as record } from "./src-CKZ8ncFf.js";
1
+ export { t as USAGE, o as check, a as fmt, s as github, n as main, r as parseRecord, i as record } from "./src-KVw61wOu.js";
@@ -2,7 +2,6 @@ import { readFile, writeFile } from "node:fs/promises";
2
2
  import { createServer } from "node:http";
3
3
  import { extname, join, normalize } from "node:path";
4
4
  import { parseArgs } from "node:util";
5
- import { chromium } from "playwright";
6
5
  // ../core/src/capacity.ts
7
6
  /** A capacity read off a dashboard or a load test. */
8
7
  function measured(value, unit, o) {
@@ -154,6 +153,13 @@ function dateAt(g, multiple) {
154
153
  const at = Date.UTC(y, mo, Math.min(start.getUTCDate(), days));
155
154
  return new Date(at + (months - whole) * days * 864e5);
156
155
  }
156
+ // ../core/src/internal.ts
157
+ const blamed = /* @__PURE__ */ new WeakMap();
158
+ /** The flow an error was raised computing: by that flow itself, not by a component or another flow it read. */
159
+ const flowOf = (err) => typeof err === "object" && err !== null ? blamed.get(err) : void 0;
160
+ function blame(err, flow) {
161
+ if (typeof err === "object" && err !== null && !blamed.has(err)) blamed.set(err, flow);
162
+ }
157
163
  // ../core/src/params.ts
158
164
  const p = {
159
165
  /** The load, as a multiple of today. One per model; it drives the limits. */
@@ -721,14 +727,25 @@ function defineModel(def) {
721
727
  function context(def, values, bound, pinned, fixed) {
722
728
  const cache = /* @__PURE__ */ new Map();
723
729
  const stack = [];
724
- const memo = (key, f) => {
730
+ const seen = /* @__PURE__ */ new WeakSet();
731
+ const memo = (key, f, flow) => {
725
732
  if (cache.has(key)) return cache.get(key);
726
- if (stack.includes(key)) throw new Error(`loadmap: ${[...stack.slice(stack.indexOf(key)), key].join(" → ")} is a loop`);
733
+ if (stack.includes(key)) {
734
+ const loop = /* @__PURE__ */ new Error(`loadmap: ${[...stack.slice(stack.indexOf(key)), key].join(" → ")} is a loop`);
735
+ seen.add(loop);
736
+ throw loop;
737
+ }
727
738
  stack.push(key);
728
739
  try {
729
740
  const v = f();
730
741
  cache.set(key, v);
731
742
  return v;
743
+ } catch (err) {
744
+ if (typeof err === "object" && err !== null && !seen.has(err)) {
745
+ seen.add(err);
746
+ if (flow) blame(err, flow);
747
+ }
748
+ throw err;
732
749
  } finally {
733
750
  stack.pop();
734
751
  }
@@ -765,11 +782,15 @@ function context(def, values, bound, pinned, fixed) {
765
782
  if (r.budget != null && !(r.budget >= 0 && Number.isFinite(r.budget))) throw new Error(`loadmap: flow "${f}" needs a retry budget of 0 or more, not ${r.budget}`);
766
783
  return r.times > 0 && r.budget !== 0 ? r : void 0;
767
784
  };
768
- const rate = (f) => memo(`rate of ${f}`, () => def.flows[f].rate(ctx));
785
+ const rateOf = (f) => {
786
+ const r = def.flows[f].rate;
787
+ return typeof r === "function" ? r(ctx) : r;
788
+ };
789
+ const rate = (f) => memo(`rate of ${f}`, () => rateOf(f), f);
769
790
  ctx.flows = table(flowIds, (f) => memo(`flow ${f}`, () => {
770
791
  if (fixed && Object.hasOwn(fixed, f)) return fixed[f];
771
- return retryOf(f) ? retries()[f].attempts : def.flows[f].rate(ctx);
772
- }));
792
+ return retryOf(f) ? retries()[f].attempts : rateOf(f);
793
+ }, f));
773
794
  /** The share a stage turns away: past its component's `full`, what is above it. */
774
795
  const rejects = (s) => memo(`rejects at ${s}`, () => {
775
796
  const st = stages[s];
@@ -1478,6 +1499,8 @@ const PANELS = [
1478
1499
  "compare",
1479
1500
  "assumptions"
1480
1501
  ];
1502
+ /** What full screen opens with, as the `fullScreenMode` prop of `<Loadmap>` names it. */
1503
+ const FULL_SCREEN_MODES = ["panels", "focus"];
1481
1504
  const COLORS = [
1482
1505
  "accent",
1483
1506
  "alt",
@@ -1509,11 +1532,22 @@ const SCHEMA = {
1509
1532
  scope: "const",
1510
1533
  doc: "Simulated seconds per real second. Default 10."
1511
1534
  },
1535
+ transition: {
1536
+ value: "number",
1537
+ scope: "const",
1538
+ doc: "Seconds the diagram takes to move to a new structure (a scenario, a story step, a control that adds pods); 0 cuts. Default 0.8, at most 5; with reduced motion it cuts."
1539
+ },
1512
1540
  fullScreen: {
1513
1541
  value: "names",
1514
1542
  scope: "const",
1515
1543
  values: PANELS,
1516
- doc: "The panels below the diagram in full screen, which keep their order. Default all of them."
1544
+ doc: "The panels full screen shows, compact, below the diagram. Default all of them."
1545
+ },
1546
+ fullScreenMode: {
1547
+ value: "enum",
1548
+ scope: "const",
1549
+ values: FULL_SCREEN_MODES,
1550
+ doc: "What full screen opens with: the panels, or focus (the diagram, the story, the verdict and the load). Default panels."
1517
1551
  },
1518
1552
  assume: {
1519
1553
  value: "string",
@@ -2224,11 +2258,25 @@ const STAGES = [
2224
2258
  /** A formula failed while the model ran: where, and why. */
2225
2259
  var FormulaError = class extends Error {
2226
2260
  span;
2227
- constructor(span, message) {
2261
+ hint;
2262
+ constructor(span, message, hint) {
2228
2263
  super(message);
2229
2264
  this.span = span;
2265
+ this.hint = hint;
2230
2266
  }
2231
2267
  };
2268
+ const ISSUES = "https://github.com/jaim3r0jas/loadmap/issues";
2269
+ /**
2270
+ * A run-time error in the definition's words: core's own (`loadmap: …`) as written; a TypeError or ReferenceError, which
2271
+ * no definition can cause, as a bug in loadmap, its text kept for the report.
2272
+ */
2273
+ function failure(err, what = "this formula") {
2274
+ if (err instanceof TypeError || err instanceof ReferenceError) return {
2275
+ message: `${what} could not be computed: a bug in loadmap`,
2276
+ hint: `Please report it at ${ISSUES} (${err.name}: ${err.message})`
2277
+ };
2278
+ return { message: err instanceof Error ? err.message.replace(/^loadmap: /, "") : String(err) };
2279
+ }
2232
2280
  const num = (f) => f;
2233
2281
  /** Functions renamed before 0.1: a definition from before says what to write instead. */
2234
2282
  const RENAMED = { kfmt: "compact" };
@@ -2580,11 +2628,21 @@ var Compiler$1 = class {
2580
2628
  };
2581
2629
  }
2582
2630
  const host = own(this.names.functions, name);
2583
- if (host) return {
2584
- type: "any",
2585
- constant: false,
2586
- run: (c, l) => host(...runs.map((r) => r(c, l)))
2587
- };
2631
+ if (host) {
2632
+ const run = (c, l) => {
2633
+ const args = runs.map((r) => r(c, l));
2634
+ try {
2635
+ return host(...args);
2636
+ } catch (err) {
2637
+ throw new Error(`${name}(…) failed: ${err instanceof Error ? err.message : String(err)}`);
2638
+ }
2639
+ };
2640
+ return {
2641
+ type: "any",
2642
+ constant: false,
2643
+ run
2644
+ };
2645
+ }
2588
2646
  const known = [
2589
2647
  ...Object.keys(BUILTINS),
2590
2648
  ...this.names.fits.keys(),
@@ -2672,15 +2730,16 @@ var Compiler$1 = class {
2672
2730
  };
2673
2731
  /**
2674
2732
  * Wraps a formula for the model: it takes the context, and any error it throws carries the formula's place. An error
2675
- * already placed (from a formula this one read) keeps its place: the innermost formula is the one to fix.
2733
+ * already placed (from a formula or a flow this one read) keeps its place: the innermost one is the one to fix.
2676
2734
  */
2677
2735
  function placed(span, run) {
2678
2736
  return (ctx, local) => {
2679
2737
  try {
2680
2738
  return run(ctx, local);
2681
2739
  } catch (err) {
2682
- if (err instanceof FormulaError) throw err;
2683
- throw new FormulaError(span, err instanceof Error ? err.message.replace(/^loadmap: /, "") : String(err));
2740
+ if (err instanceof FormulaError || flowOf(err)) throw err;
2741
+ const { message, hint } = failure(err);
2742
+ throw new FormulaError(span, message, hint);
2684
2743
  }
2685
2744
  };
2686
2745
  }
@@ -3098,7 +3157,13 @@ var Parser = class {
3098
3157
  from: chain[0].from,
3099
3158
  to: chain.at(-1).to
3100
3159
  };
3101
- if (this.eat(":")) {
3160
+ if (this.is(":") && this.is("{", 1)) {
3161
+ const colon = this.next();
3162
+ e.colon = {
3163
+ from: colon.from,
3164
+ to: colon.to
3165
+ };
3166
+ } else if (this.eat(":")) {
3102
3167
  if (this.endsStatement()) this.fail(this.peek(), "expected a value after \":\"");
3103
3168
  e.value = this.expr();
3104
3169
  e.to = e.value.to;
@@ -3403,6 +3468,8 @@ function describe(t) {
3403
3468
  default: return `"${t.text}"`;
3404
3469
  }
3405
3470
  }
3471
+ /** The longest move to a new structure a definition may ask for, in seconds. */
3472
+ const MAX_TRANSITION = 5;
3406
3473
  /** Counts that size the drawing; larger ones would hang the layout, so a shared link cannot ask for them. */
3407
3474
  const LIMITS = {
3408
3475
  members: 64,
@@ -3429,7 +3496,10 @@ function compile(source, o = {}) {
3429
3496
  };
3430
3497
  });
3431
3498
  } catch (err) {
3432
- if (err instanceof FormulaError) report.error(err.span, err.message);
3499
+ if (err instanceof FormulaError || flowOf(err) || err instanceof Error && err.message.startsWith("loadmap: ")) c.runError(err, {
3500
+ from: 0,
3501
+ to: 0
3502
+ });
3433
3503
  else if (!(err instanceof Bail)) report.error({
3434
3504
  from: 0,
3435
3505
  to: 0
@@ -3611,6 +3681,7 @@ var Compiler = class {
3611
3681
  for (const b of flows) {
3612
3682
  const id = b.args.id;
3613
3683
  if (!id) continue;
3684
+ this.flowAt.set(id.name, id);
3614
3685
  const label = this.text(b.args.label);
3615
3686
  const def = {};
3616
3687
  if (label != null) def.label = label;
@@ -3659,7 +3730,7 @@ var Compiler = class {
3659
3730
  }
3660
3731
  for (const [id, s] of stageOf) if (!reached.has(id) && s.block.keyword !== "fanout") this.report.warning(s.block.args.id ?? s.block.at, `no flow passes the stage "${id}"`);
3661
3732
  const used = new Set(Object.values(stageDefs).map((d) => d.component));
3662
- for (const [id, c] of Object.entries(componentDefs)) if (!c.demand && !used.has(id)) this.report.error(componentNames.get(id), `the component "${id}" needs "demand: …": no stage uses it`, COMPONENT_PROPS.demand.doc);
3733
+ for (const [id, c] of Object.entries(componentDefs)) if (!c.demand && !used.has(id) && !this.plainBoxes.has(id)) this.report.error(componentNames.get(id), `the component "${id}" needs "demand: …": no stage uses it`, COMPONENT_PROPS.demand.doc);
3663
3734
  const calibration = {};
3664
3735
  for (const f of fits) {
3665
3736
  const fit = f.args.id && this.fits.get(f.args.id.name);
@@ -3704,10 +3775,15 @@ var Compiler = class {
3704
3775
  const options = { assumptions: [] };
3705
3776
  const speed = this.constProp(root, "speed");
3706
3777
  if (speed != null) options.speed = speed;
3778
+ const transition = this.constProp(root, "transition");
3779
+ if (transition != null && (transition < 0 || transition > MAX_TRANSITION)) this.report.error(this.prop(root, "transition").value, `transition is ${transition} seconds; 0 to ${MAX_TRANSITION}`);
3780
+ else if (transition != null) options.transition = transition;
3707
3781
  const layout = this.prop(root, "layout");
3708
3782
  if (layout) options.direction = this.enumValue(layout, SCHEMA[""].props.layout) === "down" ? "DOWN" : "RIGHT";
3709
3783
  const panels = this.prop(root, "fullScreen");
3710
3784
  if (panels) options.fullScreen = this.words(panels, SCHEMA[""].props.fullScreen);
3785
+ const mode = this.prop(root, "fullScreenMode");
3786
+ if (mode) options.fullScreenMode = this.enumValue(mode, SCHEMA[""].props.fullScreenMode);
3711
3787
  for (const a of root.props.get("assume") ?? []) {
3712
3788
  const v = this.constant(a.value, "string");
3713
3789
  if (typeof v === "string") options.assumptions.push(v);
@@ -3731,7 +3807,7 @@ var Compiler = class {
3731
3807
  model = defineModel(modelDef);
3732
3808
  view = defineView(model, viewDef);
3733
3809
  } catch (err) {
3734
- this.report.error(top, err instanceof Error ? err.message.replace(/^loadmap: /, "") : String(err));
3810
+ this.runError(err, top);
3735
3811
  return;
3736
3812
  }
3737
3813
  const runs = [{
@@ -3754,7 +3830,7 @@ var Compiler = class {
3754
3830
  at: b.at
3755
3831
  });
3756
3832
  }
3757
- for (const { values, at } of runs) try {
3833
+ for (const [i, { values, at }] of runs.entries()) try {
3758
3834
  const ev = model.evaluate(values);
3759
3835
  labelsAt(view, ev);
3760
3836
  for (const k of Object.keys(modelDef.derived ?? {})) ev.ctx.derived[k];
@@ -3768,8 +3844,7 @@ var Compiler = class {
3768
3844
  for (const e of viewDef.edges) e.when?.(ev.ctx);
3769
3845
  for (const f of model.flowIds) if (!Number.isFinite(ev.flows[f])) this.report.warning(this.flowRate.get(f) ?? at, `the rate of "${f}" is ${ev.flows[f]} here`);
3770
3846
  } catch (err) {
3771
- if (err instanceof FormulaError) this.report.error(err.span, err.message);
3772
- else this.report.error(at, err instanceof Error ? err.message.replace(/^loadmap: /, "") : String(err));
3847
+ this.runError(err, at, i === 0);
3773
3848
  }
3774
3849
  if (this.report.failed) return void 0;
3775
3850
  return {
@@ -3778,7 +3853,19 @@ var Compiler = class {
3778
3853
  options
3779
3854
  };
3780
3855
  }
3856
+ /**
3857
+ * What running the model threw, where it came from: its formula, else its flow, else `at`. A preset or a scenario
3858
+ * keeps `at` (`onFlow` false): the flow is fine at the defaults, the preset is what breaks it.
3859
+ */
3860
+ runError(err, at, onFlow = true) {
3861
+ if (err instanceof FormulaError) return this.report.error(err.span, err.message, err.hint);
3862
+ const flow = flowOf(err);
3863
+ const { message, hint } = failure(err, flow ? `the flow "${flow}"` : "the model");
3864
+ this.report.error(onFlow && flow && this.flowAt.get(flow) || at, message, hint);
3865
+ }
3781
3866
  flowRate = /* @__PURE__ */ new Map();
3867
+ flowAt = /* @__PURE__ */ new Map();
3868
+ plainBoxes = /* @__PURE__ */ new Set();
3782
3869
  shape(keyword, spec, stmts, at, decl) {
3783
3870
  const b = {
3784
3871
  keyword,
@@ -3914,6 +4001,15 @@ var Compiler = class {
3914
4001
  component
3915
4002
  };
3916
4003
  }
4004
+ /** A box with only a component's optional lines (`describe`, `name`, …) was meant as a plain box: one error, the fix. */
4005
+ plainBox(b) {
4006
+ const [k, p] = Object.keys(COMPONENT_PROPS).flatMap((k) => b.props.has(k) ? [[k, this.prop(b, k)]] : []).sort((x, y) => x[1].key.from - y[1].key.from)[0];
4007
+ let hint = "give the box capacity and over lines, or drop it";
4008
+ const sub = b.args.sub;
4009
+ const noSub = (!sub || sub.t === "str" && sub.value === "") && !b.props.has("sub");
4010
+ if (k === "describe" && p.value.t === "str" && noSub) hint = b.args.label ? `for a line under a plain box, put it on the first line: ${b.keyword} ${b.args.id.name} ${this.slice(b.args.label)} ${this.slice(p.value)}` : `for a line under a plain box, write sub: ${this.slice(p.value)}`;
4011
+ this.report.error(p.key, `"${k}" belongs to a component, and this box has no capacity`, hint);
4012
+ }
3917
4013
  slice(s) {
3918
4014
  return this.source.slice(s.from, s.to);
3919
4015
  }
@@ -3942,7 +4038,8 @@ var Compiler = class {
3942
4038
  try {
3943
4039
  return f.run(void 0);
3944
4040
  } catch (err) {
3945
- this.report.error(e, err instanceof Error ? err.message : String(err));
4041
+ const { message, hint } = failure(err);
4042
+ this.report.error(e, message, hint);
3946
4043
  return;
3947
4044
  }
3948
4045
  }
@@ -4343,7 +4440,14 @@ var Compiler = class {
4343
4440
  o[k] = this.dyn(prop.value, want, "ctx", check);
4344
4441
  }
4345
4442
  const at = box ? box.args.id ?? box.at : b.at;
4346
- for (const k of ["capacity", "over"]) if (!(k in o)) this.report.error(at, `the component needs "${k}: …"`, COMPONENT_PROPS[k].doc);
4443
+ const missing = ["capacity", "over"].filter((k) => !(k in o));
4444
+ const nested = b.children.some((c) => c.keyword === "component");
4445
+ if (b === box && !nested && missing.length === 2 && !("demand" in o)) {
4446
+ this.plainBoxes.add(b.args.id.name);
4447
+ this.plainBox(b);
4448
+ return o;
4449
+ }
4450
+ for (const k of missing) this.report.error(at, `the component needs "${k}: …"`, COMPONENT_PROPS[k].doc);
4347
4451
  return o;
4348
4452
  }
4349
4453
  stage(b, box, components, resolve, flows, reached) {
@@ -4438,6 +4542,10 @@ var Compiler = class {
4438
4542
  if (!flows.has(f.name)) this.report.error(f, `unknown flow "${f.name}"`, didYouMean(f.name, flows.keys()));
4439
4543
  const stage = resolve(s, "stage");
4440
4544
  if (stage) reached.add(stage);
4545
+ if (e.colon) {
4546
+ this.report.error(e.colon, "a branch takes its copies after \":\", not a block", `${f.name} -> ${s.name}: 1`);
4547
+ continue;
4548
+ }
4441
4549
  if (!e.value) this.report.error(e, "how many copies per unit?", `${f.name} -> ${s.name}: 1`);
4442
4550
  if (e.body) this.report.error(e.bodySpan ?? e, "a branch has no block");
4443
4551
  if (stage && e.value && flows.has(f.name)) branches.push([
@@ -4511,6 +4619,7 @@ var Compiler = class {
4511
4619
  };
4512
4620
  }
4513
4621
  edges(e, boxes) {
4622
+ if (e.colon) this.report.error(e.colon, "drop the \":\" before a { … } block", `${e.chain.map((c) => c.name).join(" -> ")} { ${e.chain.length > 2 ? "" : "label: …; "}when: … }`);
4514
4623
  for (const n of e.chain) if (!boxes.has(n.name)) this.report.error(n, `unknown node "${n.name}"`, didYouMean(n.name, boxes.keys()));
4515
4624
  const props = /* @__PURE__ */ new Map();
4516
4625
  for (const s of e.body ?? []) {
@@ -4898,6 +5007,20 @@ async function readStdin() {
4898
5007
  for await (const c of process.stdin) chunks.push(c);
4899
5008
  return Buffer.concat(chunks).toString("utf8");
4900
5009
  }
5010
+ // src/playwright.ts
5011
+ /**
5012
+ * Playwright, loaded only to record: `check`, `fmt` and `--help` run without it, so a CI step that never draws does
5013
+ * not install a browser driver. Missing, it says how to add it.
5014
+ */
5015
+ async function playwright(load = () => import("playwright")) {
5016
+ try {
5017
+ return await load();
5018
+ } catch (err) {
5019
+ const e = err;
5020
+ if ((e.code === "ERR_MODULE_NOT_FOUND" || e.code === "MODULE_NOT_FOUND") && /['"]playwright['"]/.test(e.message ?? "")) throw new Error("loadmap record needs Playwright: npm i -D playwright, then npx playwright install chromium");
5021
+ throw err;
5022
+ }
5023
+ }
4901
5024
  // src/index.ts
4902
5025
  const USAGE = `loadmap check <file.loadmap>… [--format text|github]
4903
5026
  loadmap fmt <file.loadmap>… [--check] | loadmap fmt -
@@ -4916,7 +5039,8 @@ operators; numbers, texts and formulas stay as written. A file with a syntax err
4916
5039
 
4917
5040
  record opens the page in headless Chromium, finds the loadmap it exposes (expose() from @loadmap/export) and
4918
5041
  records it on a fixed clock, so the same options give the same file. A .loadmap file is drawn by the viewer that
4919
- comes with the command.
5042
+ comes with the command. It needs Playwright next to the command: npm i -D playwright, then npx playwright install
5043
+ chromium (check and fmt run without it).
4920
5044
 
4921
5045
  --out <file> the extension picks the format: gif, webm, mp4, png or svg
4922
5046
  --script <file.json> [{ "at": 0, "set": { "load": 5 } }, { "at": 3, "scenario": "Proposed" }, …]
@@ -5039,7 +5163,8 @@ async function record(a, o = {}) {
5039
5163
  "png",
5040
5164
  "svg"
5041
5165
  ].includes(format)) throw new Error(`--out must end in .gif, .webm, .mp4, .png or .svg`);
5042
- if (!a.file) return recordPage(a, format);
5166
+ const { chromium } = await playwright();
5167
+ if (!a.file) return recordPage(a, format, chromium);
5043
5168
  const source = await readFile(a.file, "utf8");
5044
5169
  const r = compile(source);
5045
5170
  if (!r.ok) throw new Error(r.diagnostics.map((d) => formatDiagnostic(d, source, a.file)).join("\n"));
@@ -5048,7 +5173,7 @@ async function record(a, o = {}) {
5048
5173
  return await recordPage({
5049
5174
  ...a,
5050
5175
  url: server.url
5051
- }, format);
5176
+ }, format, chromium);
5052
5177
  } finally {
5053
5178
  await server.close();
5054
5179
  }
@@ -5085,7 +5210,7 @@ async function serve(source, dir) {
5085
5210
  close: () => new Promise((done) => server.close(() => done()))
5086
5211
  };
5087
5212
  }
5088
- async function recordPage(a, format) {
5213
+ async function recordPage(a, format, chromium) {
5089
5214
  const browser = await chromium.launch();
5090
5215
  try {
5091
5216
  const page = await browser.newPage({