@intentius/behold 0.20.0 → 0.22.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/web/index.html CHANGED
@@ -132,6 +132,16 @@
132
132
  either for the full reason (native SVG <title>). */
133
133
  #graph g[data-edge-via="data dependency"] .pin-edge-line { stroke-dasharray: 6 5; }
134
134
  #graph g[data-edge-from][data-edge-via] { cursor: help; }
135
+ /* #477: a declared workspace's waiting gates, over the top of the graph
136
+ pane (same grid cell, so #graph's re-renders never take it with them). */
137
+ #ws-gates { grid-row: 1; grid-column: 1; align-self: start; justify-self: center; z-index: 3; margin-top: 8px;
138
+ max-width: min(560px, 70%); display: flex; flex-direction: column; gap: 6px; }
139
+ #ws-gates[hidden] { display: none; }
140
+ #ws-gates .ws-gate { background: var(--panel); border: 1px solid var(--warn, var(--rule)); border-radius: var(--r-ctl);
141
+ padding: 6px 10px; box-shadow: 0 2px 8px rgba(0,0,0,.12); }
142
+ #ws-gates .ws-gate-title { font-weight: 600; }
143
+ #ws-gates .ws-gate-facts, #ws-gates .ws-gate-note { color: var(--muted); font-size: var(--t-caption); }
144
+ #ws-gates .ws-gate button { margin-top: 4px; }
135
145
  /* Fit reset + a zoom hint, floating top-right of the graph pane. */
136
146
  #zoom-toggle { position: absolute; top: 8px; right: 8px; z-index: 2; background: var(--panel);
137
147
  color: var(--muted); border: 1px solid var(--rule); border-radius: var(--r-ctl); padding: 4px 10px;
@@ -279,6 +289,15 @@
279
289
  color: var(--muted); font: var(--t-caption)/1 var(--font-ui); padding: 6px 0; cursor: pointer; }
280
290
  #panel-tabs button[data-tab]:hover { color: var(--fg); }
281
291
  #panel-tabs button[data-tab].active { color: var(--fg); background: var(--active); font-weight: 600; }
292
+ /* #476: framed in a host pane. Deploying and the theme are the host's
293
+ business; the panel and the inspect pane start folded (app.js). */
294
+ .embed #panel-tabs button[data-tab="deploy"], .embed #panel-body section[data-tab="deploy"],
295
+ .embed #panel-body section[data-tab="view"] > h3:has(+ #panel-theme), .embed #panel-theme { display: none !important; }
296
+ /* Folded in a frame, the panel is a small pill (⌘K and the expand
297
+ chevron) in the corner, so the graph has the pane to itself. */
298
+ .embed #panel.collapsed #panel-tabs button[data-tab] { display: none; }
299
+ .embed #panel.collapsed { width: auto; }
300
+ .embed #panel.collapsed #panel-tabs { gap: 4px; padding: 2px 4px; }
282
301
  #panel-tabs #hintk { flex: none; align-self: center; padding: 2px 6px; margin-left: 2px; }
283
302
  #panel-collapse { flex: none; align-self: center; background: none; border: none; color: var(--muted);
284
303
  font-size: var(--t-body); cursor: pointer; line-height: 1; padding: 2px 4px; }
@@ -585,6 +604,7 @@
585
604
  </div>
586
605
  <div id="app">
587
606
  <div id="graph"></div>
607
+ <div id="ws-gates" hidden></div>
588
608
  <aside id="inspect">
589
609
  <div id="inspect-resize" title="Drag to resize"></div>
590
610
  <button id="inspect-collapse" title="Collapse panel">›</button>
package/web/operator.js CHANGED
@@ -26,6 +26,13 @@
26
26
  */
27
27
  import { waitingFor } from "./run-playhead.js";
28
28
 
29
+ // #477: why a gate card has no approve button.
30
+ const NO_APPROVE = {
31
+ host: "approve in the host that framed this graph",
32
+ preview: "approving is off in preview",
33
+ static: "a snapshot: approve where behold runs",
34
+ };
35
+
29
36
  /** How a lease reads. `expired` is its own tone, not a shade of `free`: a lapsed
30
37
  * lease means the holder stopped renewing and the next round reclaims it, which
31
38
  * is a different statement from "nobody has ever held it". */
@@ -459,7 +466,13 @@ export function renderConvergeGateCard(gate, actions = {}) {
459
466
  if (at) box.appendChild(at);
460
467
 
461
468
  const command = (gate.approve && gate.approve.command) || `chant approve ${gate.op} ${gate.gate}`;
462
- const approve = el("button", "approve", "Record approval");
469
+ // #477: framed in a host, the host approves; the gate is drawn, not offered.
470
+ if (!actions.approve) {
471
+ box.appendChild(el("div", "run-gate-meta", NO_APPROVE[actions.why] || NO_APPROVE.host));
472
+ return box;
473
+ }
474
+ // Standalone, chant records whoever runs behold. Said before the click.
475
+ const approve = el("button", "approve", actions.approver ? `Record approval as ${actions.approver}` : "Record approval");
463
476
  approve.title =
464
477
  `${(gate.approve && gate.approve.method) || "POST"} ${(gate.approve && gate.approve.path) || ""} — ` +
465
478
  `${command}. ${gate.semantics || APPROVE_SEMANTICS} behold never approves on its own.`;
package/web/panel.js CHANGED
@@ -6,7 +6,9 @@
6
6
  // browser JS, no build step, themed entirely by the shared CSS vars — same
7
7
  // rules as app.js.
8
8
 
9
- const STORE_KEY = "behold.panel";
9
+ // #476: a framed behold keeps its own panel state, folded to start with, so a
10
+ // host pane and a full window never fight over one saved position.
11
+ let STORE_KEY = "behold.panel";
10
12
  const MARGIN = 12; // gap kept between a snapped panel and the viewport edge
11
13
  const SNAP = 32; // released within this many px of an edge → dock to it
12
14
 
@@ -168,9 +170,14 @@ export function isPanelCollapsed() {
168
170
  return !!state.collapsed;
169
171
  }
170
172
 
171
- export function initPanel() {
173
+ export function initPanel({ embed = false } = {}) {
172
174
  panel = document.getElementById("panel");
173
175
  if (!panel) return;
176
+ if (embed) {
177
+ STORE_KEY = "behold.panel.embed";
178
+ state.collapsed = true;
179
+ state.y = 12;
180
+ }
174
181
  state = loadState();
175
182
  const bar = document.getElementById("panel-tabs");
176
183
  initDrag(bar);
@@ -182,7 +189,8 @@ export function initPanel() {
182
189
  for (const b of panel.querySelectorAll("#panel-tabs button[data-tab]")) b.addEventListener("click", () => setPanelTab(b.dataset.tab));
183
190
  const known = [...panel.querySelectorAll("#panel-tabs button[data-tab]")].map((b) => b.dataset.tab);
184
191
  setCollapsed(state.collapsed);
185
- setPanelTab(known.includes(state.tab) ? state.tab : known[0], { expand: false });
192
+ const hidden = embed ? ["deploy"] : [];
193
+ setPanelTab(known.includes(state.tab) && !hidden.includes(state.tab) ? state.tab : known[0], { expand: false });
186
194
  window.addEventListener("resize", applyPosition);
187
195
  // Tab-content re-renders change the panel's height — a bottom-snapped panel
188
196
  // must stay flush through them.
@@ -186,9 +186,21 @@ export function renderGateCard(gate, actions = {}) {
186
186
  );
187
187
  if (gate.guards) box.appendChild(el("div", "run-gate-meta", `holds back: ${gate.guards}`));
188
188
 
189
- const approve = el("button", "approve", `Approve ${gate.signalName}`);
189
+ // #477: framed in a host, the host approves; the gate is drawn, not offered.
190
+ if (!actions.approve) {
191
+ box.appendChild(el("div", "run-gate-meta", NO_APPROVE[actions.why] || NO_APPROVE.host));
192
+ return box;
193
+ }
194
+ const approve = el("button", "approve", actions.approver ? `Approve ${gate.signalName} as ${actions.approver}` : `Approve ${gate.signalName}`);
190
195
  approve.title = `${gate.approve.method} ${gate.approve.path} — the same delegated signal as chant run signal ${gate.op} ${gate.signalName}. behold never approves on its own.`;
191
196
  approve.addEventListener("click", () => actions.approve?.(gate.op, gate.signalName));
192
197
  box.appendChild(approve);
193
198
  return box;
194
199
  }
200
+
201
+ // #477: why a gate card has no approve button.
202
+ const NO_APPROVE = {
203
+ host: "approve in the host that framed this graph",
204
+ preview: "approving is off in preview",
205
+ static: "a snapshot: approve where behold runs",
206
+ };
@@ -0,0 +1,151 @@
1
+ // #475: the view in the URL. A host (arugula's workspace block) opens behold
2
+ // at `?member=delivery&zoom=components&env=prod`, a reload keeps the view, and a
3
+ // person can send the link to what they are looking at.
4
+ //
5
+ // Pure logic, no DOM: app.js reads the query once at boot, settles it against
6
+ // what the served estate offers, and writes the view back with
7
+ // history.replaceState as it changes. The static export runs the same SPA, so
8
+ // it takes the same parameters against its captured lens matrix.
9
+
10
+ /** The view's keys, in the order they are written back. Anything else in the
11
+ * query (embed, theme, host, #476) is left where it is. `gates` is the env the
12
+ * gate strip reads when it isn't the graph's (#477); it is the view's because
13
+ * a host can move it with `behold:view`, and a reload has to keep that. */
14
+ export const VIEW_KEYS = ["member", "zoom", "env", "tier", "lens", "node", "radial", "gates"];
15
+
16
+ /** An env name as the server takes one (src/workspace-gates.ts isEnvName). */
17
+ export function isEnvName(env) {
18
+ return typeof env === "string" && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(env);
19
+ }
20
+
21
+ /** The colour lenses (web/behaviour-scale.js COLOUR_MODES). `lens` in the URL
22
+ * is the colour the graph is painted in. */
23
+ export const LENSES = ["drift", "cost", "headroom"];
24
+
25
+ /**
26
+ * The view a query asks for. Only the keys present come back; `radial` is a
27
+ * boolean (`1`/`true` on, `0`/`false` off). An empty value is the same as an
28
+ * absent one, except `env=` which asks for the source graph (null).
29
+ */
30
+ export function readViewQuery(search) {
31
+ const q = new URLSearchParams(search || "");
32
+ const want = {};
33
+ for (const k of VIEW_KEYS) {
34
+ if (!q.has(k)) continue;
35
+ const v = (q.get(k) || "").trim();
36
+ if (k === "radial") {
37
+ if (/^(1|true|on|yes)$/i.test(v)) want.radial = true;
38
+ else if (/^(0|false|off|no)$/i.test(v)) want.radial = false;
39
+ else want.radial = v; // left for settleView to name
40
+ continue;
41
+ }
42
+ if (k === "env") {
43
+ want.env = v || null;
44
+ continue;
45
+ }
46
+ if (v) want[k] = v;
47
+ }
48
+ return want;
49
+ }
50
+
51
+ /**
52
+ * The query string for a view, keeping every parameter that isn't a view key.
53
+ * Defaults are left out so a plain view keeps a plain URL. `defaults` says
54
+ * what a load with no parameter would give: the env behold was started with
55
+ * (so the source graph under `--env` is written `env=`, or a reload would
56
+ * open live), and the lens saved in this browser (so a link carries a lens
57
+ * that differs from it, drift included).
58
+ */
59
+ export function writeViewQuery(search, state, defaults = {}) {
60
+ const q = new URLSearchParams(search || "");
61
+ for (const k of VIEW_KEYS) q.delete(k);
62
+ const s = state || {};
63
+ const envDefault = defaults.env || null;
64
+ const lensDefault = defaults.lens || "drift";
65
+ if (s.member) q.set("member", s.member);
66
+ if (s.zoom) q.set("zoom", s.zoom);
67
+ if (s.env) q.set("env", s.env);
68
+ else if (envDefault) q.set("env", "");
69
+ if (s.tier) q.set("tier", s.tier);
70
+ if (s.lens && s.lens !== lensDefault) q.set("lens", s.lens);
71
+ if (s.node) q.set("node", s.node);
72
+ if (s.radial) q.set("radial", "1");
73
+ if (s.gates) q.set("gates", s.gates);
74
+ const out = q.toString();
75
+ return out ? `?${out}` : "";
76
+ }
77
+
78
+ /**
79
+ * Split what a query asks for into what this estate can honour and what it
80
+ * can't, with the reason. `offer` lists what the served estate has:
81
+ * `{ zooms, envs, tiers, lenses }`. member and node are checked later, against
82
+ * the first graph (they are only known once it is read), so they pass through.
83
+ * A value behold can't honour is named, never dropped without a word.
84
+ */
85
+ export function settleView(want, offer) {
86
+ const apply = {};
87
+ const refused = [];
88
+ const o = offer || {};
89
+ if ("zoom" in want) {
90
+ if ((o.zooms || []).includes(want.zoom)) apply.zoom = want.zoom;
91
+ else refused.push({ key: "zoom", value: want.zoom, reason: `this estate offers ${listOr(o.zooms)}` });
92
+ }
93
+ if ("env" in want) {
94
+ if (want.env === null || (o.envs || []).includes(want.env)) apply.env = want.env;
95
+ else refused.push({ key: "env", value: want.env, reason: (o.envs || []).length ? `this estate declares ${listOr(o.envs)}` : "this estate declares no environments" });
96
+ }
97
+ if ("tier" in want) {
98
+ if ((o.tiers || []).includes(want.tier)) apply.tier = want.tier;
99
+ else refused.push({ key: "tier", value: want.tier, reason: (o.tiers || []).length ? `this project offers ${listOr(o.tiers)}` : "this project offers no tiers" });
100
+ }
101
+ if ("lens" in want) {
102
+ const lenses = o.lenses || LENSES;
103
+ if (lenses.includes(want.lens)) apply.lens = want.lens;
104
+ else refused.push({ key: "lens", value: want.lens, reason: `the lenses are ${listOr(lenses)}` });
105
+ }
106
+ if ("radial" in want) {
107
+ if (typeof want.radial === "boolean") apply.radial = want.radial;
108
+ else refused.push({ key: "radial", value: want.radial, reason: "radial is 1 or 0" });
109
+ }
110
+ if ("gates" in want) {
111
+ // null: the gate strip follows the graph's env again.
112
+ if (want.gates === null || isEnvName(want.gates)) apply.gates = want.gates;
113
+ else refused.push({ key: "gates", value: want.gates, reason: "an env name is letters, digits, '.', '_' and '-'" });
114
+ }
115
+ if (want.member) apply.member = want.member;
116
+ if (want.node) apply.node = want.node;
117
+ return { apply, refused };
118
+ }
119
+
120
+ /**
121
+ * member and node, checked against a graph that came back. `members` is the
122
+ * set of member names in it, `nodes` the set of node ids. A node in another
123
+ * member than the one asked for is refused; so is one the zoom elided.
124
+ */
125
+ export function settlePlace(want, members, nodes) {
126
+ const apply = {};
127
+ const refused = [];
128
+ if (want.member) {
129
+ if (members.has(want.member)) apply.member = want.member;
130
+ else refused.push({ key: "member", value: want.member, reason: members.size ? `the members here are ${listOr([...members])}` : "this estate has no members" });
131
+ }
132
+ if (want.node) {
133
+ if (!nodes.has(want.node)) refused.push({ key: "node", value: want.node, reason: "it isn't on this view; the zoom may elide it" });
134
+ else if (apply.member && !want.node.startsWith(`${apply.member}/`)) refused.push({ key: "node", value: want.node, reason: `it isn't in member ${apply.member}` });
135
+ else apply.node = want.node;
136
+ }
137
+ return { apply, refused };
138
+ }
139
+
140
+ /** One line for the now line: what the URL asked for that behold didn't do. */
141
+ export function refusalLine(refused) {
142
+ if (!refused.length) return "";
143
+ return "from the link: " + refused.map((r) => `${r.key}=${r.value} not opened, ${r.reason}`).join("; ");
144
+ }
145
+
146
+ function listOr(xs) {
147
+ const a = (xs || []).filter(Boolean);
148
+ if (!a.length) return "nothing";
149
+ if (a.length > 8) return a.slice(0, 8).join(", ") + ", …";
150
+ return a.join(", ");
151
+ }
@@ -0,0 +1,109 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { readViewQuery, writeViewQuery, settleView, settlePlace, refusalLine, isEnvName } from "./view-url.js";
3
+
4
+ describe("readViewQuery (#475)", () => {
5
+ it("reads the view keys and nothing else", () => {
6
+ expect(readViewQuery("?member=delivery&zoom=components&env=prod&node=delivery/appService&embed=1")).toEqual({
7
+ member: "delivery",
8
+ zoom: "components",
9
+ env: "prod",
10
+ node: "delivery/appService",
11
+ });
12
+ });
13
+ it("reads radial as a boolean, and an empty env as the source graph", () => {
14
+ expect(readViewQuery("?radial=1&env=")).toEqual({ radial: true, env: null });
15
+ expect(readViewQuery("?radial=0")).toEqual({ radial: false });
16
+ });
17
+ it("drops empty values", () => {
18
+ expect(readViewQuery("?member=&zoom=")).toEqual({});
19
+ });
20
+ });
21
+
22
+ describe("writeViewQuery (#475)", () => {
23
+ it("keeps parameters that are not the view's, and leaves defaults out", () => {
24
+ const q = writeViewQuery("?embed=1&host=http%3A%2F%2Fa&member=old", { member: "delivery", zoom: "resources", env: null, lens: "drift", radial: false, node: null });
25
+ expect(q).toBe("?embed=1&host=http%3A%2F%2Fa&member=delivery&zoom=resources");
26
+ });
27
+ it("round-trips through readViewQuery", () => {
28
+ const state = { member: "delivery", zoom: "components", env: "prod", tier: "local", lens: "cost", node: "delivery/appService", radial: true };
29
+ expect(readViewQuery(writeViewQuery("", state))).toEqual(state);
30
+ });
31
+ it("writes env= for the source graph when behold was started on an env, so a reload doesn't open live", () => {
32
+ expect(writeViewQuery("", { zoom: "resources", env: null }, { env: "prod" })).toBe("?zoom=resources&env=");
33
+ expect(readViewQuery("?zoom=resources&env=")).toEqual({ zoom: "resources", env: null });
34
+ });
35
+ it("writes a lens that differs from the saved one, drift included", () => {
36
+ expect(writeViewQuery("", { lens: "drift" }, { lens: "cost" })).toBe("?lens=drift");
37
+ expect(writeViewQuery("", { lens: "cost" }, { lens: "cost" })).toBe("");
38
+ });
39
+ it("writes nothing for an empty view", () => {
40
+ expect(writeViewQuery("", {})).toBe("");
41
+ });
42
+ });
43
+
44
+ describe("settleView (#475)", () => {
45
+ const offer = { zooms: ["components", "resources"], envs: ["prod"], tiers: [], lenses: ["drift", "cost", "headroom"] };
46
+ it("applies what the estate offers", () => {
47
+ expect(settleView({ zoom: "resources", env: "prod", lens: "cost", radial: true, member: "m" }, offer)).toEqual({
48
+ apply: { zoom: "resources", env: "prod", lens: "cost", radial: true, member: "m" },
49
+ refused: [],
50
+ });
51
+ });
52
+ it("names what it can't honour, with the reason", () => {
53
+ const { apply, refused } = settleView({ zoom: "ops", env: "staging", tier: "aws", radial: "maybe" }, offer);
54
+ expect(apply).toEqual({});
55
+ expect(refused.map((r) => r.key)).toEqual(["zoom", "env", "tier", "radial"]);
56
+ expect(refused[1].reason).toBe("this estate declares prod");
57
+ expect(refused[2].reason).toBe("this project offers no tiers");
58
+ });
59
+ it("takes env= as the source graph", () => {
60
+ expect(settleView({ env: null }, offer).apply).toEqual({ env: null });
61
+ });
62
+ });
63
+
64
+ describe("gates, the gate strip's env (#477)", () => {
65
+ it("reads gates from the query, and an empty one as no override", () => {
66
+ expect(readViewQuery("?gates=prod&env=")).toEqual({ env: null, gates: "prod" });
67
+ expect(readViewQuery("?gates=")).toEqual({});
68
+ });
69
+ it("owns gates on write-back: written when set, dropped when cleared", () => {
70
+ expect(writeViewQuery("?embed=1&gates=old", { zoom: "resources", gates: "prod" })).toBe("?embed=1&zoom=resources&gates=prod");
71
+ expect(writeViewQuery("?embed=1&gates=old", { zoom: "resources", gates: null })).toBe("?embed=1&zoom=resources");
72
+ });
73
+ it("round-trips through readViewQuery", () => {
74
+ expect(readViewQuery(writeViewQuery("", { gates: "staging" }))).toEqual({ gates: "staging" });
75
+ });
76
+ it("takes an env name or null, and refuses anything chant would read as a flag", () => {
77
+ expect(settleView({ gates: "prod" }, {}).apply).toEqual({ gates: "prod" });
78
+ expect(settleView({ gates: null }, {}).apply).toEqual({ gates: null });
79
+ const { apply, refused } = settleView({ gates: "--sign" }, {});
80
+ expect(apply).toEqual({});
81
+ expect(refused).toEqual([{ key: "gates", value: "--sign", reason: "an env name is letters, digits, '.', '_' and '-'" }]);
82
+ });
83
+ it("matches the server's env-name check", () => {
84
+ for (const ok of ["local", "prod-eu", "a.b_c", "0"]) expect(isEnvName(ok)).toBe(true);
85
+ for (const bad of ["", "-x", ".x", "a b", "a/b", null]) expect(isEnvName(bad)).toBe(false);
86
+ });
87
+ });
88
+
89
+ describe("settlePlace (#475)", () => {
90
+ const members = new Set(["delivery", "app"]);
91
+ const nodes = new Set(["delivery/appService", "app/x"]);
92
+ it("opens a member and a node in it", () => {
93
+ expect(settlePlace({ member: "delivery", node: "delivery/appService" }, members, nodes)).toEqual({
94
+ apply: { member: "delivery", node: "delivery/appService" },
95
+ refused: [],
96
+ });
97
+ });
98
+ it("refuses an unknown member, an absent node, and a node in another member", () => {
99
+ expect(settlePlace({ member: "nope" }, members, nodes).refused[0].reason).toBe("the members here are delivery, app");
100
+ expect(settlePlace({ node: "delivery/gone" }, members, nodes).refused[0].key).toBe("node");
101
+ expect(settlePlace({ member: "delivery", node: "app/x" }, members, nodes).refused[0].reason).toBe("it isn't in member delivery");
102
+ });
103
+ it("says it in one line", () => {
104
+ expect(refusalLine([{ key: "zoom", value: "ops", reason: "this estate offers components" }])).toBe(
105
+ "from the link: zoom=ops not opened, this estate offers components",
106
+ );
107
+ expect(refusalLine([])).toBe("");
108
+ });
109
+ });
@@ -0,0 +1,44 @@
1
+ // #477: a declared workspace's waiting gates, as cards. Pure: app.js fetches
2
+ // /api/workspace/gates and paints what gateCards returns.
3
+ //
4
+ // One approve path per gate. Framed in a host (#476), behold draws the gate
5
+ // and no button: the host knows who is looking and records it. Standalone, the
6
+ // button stays, and the card says the approval is recorded as the user running
7
+ // behold, since behold has no other identity to give chant.
8
+
9
+ /** The words a framed card uses instead of a button. */
10
+ export const HOST_APPROVES = "approve in the host that framed this graph";
11
+
12
+ /**
13
+ * Cards for a gates answer. `embed` is whether behold is framed; `member`
14
+ * narrows to one member's gates when a host opened behold on it (the rest are
15
+ * counted, not hidden without a word).
16
+ */
17
+ export function gateCards(answer, { embed = false, member = null } = {}) {
18
+ const all = (answer && answer.gates) || [];
19
+ const shown = member ? all.filter((g) => g.member === member) : all;
20
+ const cards = shown.map((g) => {
21
+ const facts = [`${g.approvals.length} of ${g.needed} approval${g.needed === 1 ? "" : "s"}`];
22
+ if (g.env) facts.push(`env ${g.env}`);
23
+ if (g.planDigest) facts.push(`plan ${shortDigest(g.planDigest)}`);
24
+ if (g.approvals.length) facts.push(`approved by ${g.approvals.map((a) => a.principal).join(", ")}`);
25
+ const card = {
26
+ key: g.key,
27
+ member: g.member,
28
+ title: `${g.member}: ${g.op} waits at gate ${g.gate}`,
29
+ facts: facts.join(" · "),
30
+ command: g.approve,
31
+ };
32
+ if (embed) card.note = HOST_APPROVES;
33
+ else if (g.signed) card.note = `wants a signed approval; run ${g.approve}`;
34
+ else card.button = { label: `Approve as ${answer.approver || "you"}`, title: `${g.approve}, run by behold. chant records the approval as ${answer.approver || "the user running behold"}.` };
35
+ return card;
36
+ });
37
+ const elsewhere = all.length - shown.length;
38
+ return { cards, elsewhere, refusal: (answer && answer.refusal) || null };
39
+ }
40
+
41
+ function shortDigest(d) {
42
+ const s = String(d).replace(/^sha256:/, "");
43
+ return s.length > 12 ? s.slice(0, 12) : s;
44
+ }
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { gateCards, HOST_APPROVES } from "./workspace-gates.js";
3
+
4
+ const answer = {
5
+ workspace: true,
6
+ env: "local",
7
+ approver: "alex",
8
+ gates: [
9
+ { key: "delivery/ship/approve-ship", member: "delivery", op: "ship", gate: "approve-ship", env: null, planDigest: null, approvals: [], needed: 1, signed: false, approve: "chant approve ship approve-ship" },
10
+ { key: "infra/deploy/go", member: "infra", op: "deploy", gate: "go", env: "prod", planDigest: "sha256:0123456789abcdef", approvals: [{ principal: "jake" }], needed: 2, signed: false, approve: "chant approve deploy go --env prod" },
11
+ ],
12
+ };
13
+
14
+ describe("gateCards (#477)", () => {
15
+ it("offers approve standalone, and says who chant records", () => {
16
+ const { cards } = gateCards(answer);
17
+ expect(cards[0]).toMatchObject({ title: "delivery: ship waits at gate approve-ship", facts: "0 of 1 approval", button: { label: "Approve as alex" } });
18
+ expect(cards[1].facts).toBe("1 of 2 approvals · env prod · plan 0123456789ab · approved by jake");
19
+ });
20
+ it("draws the gate and no button when framed", () => {
21
+ const { cards } = gateCards(answer, { embed: true });
22
+ expect(cards.every((c) => !c.button && c.note === HOST_APPROVES)).toBe(true);
23
+ });
24
+ it("narrows to one member and counts the rest", () => {
25
+ const { cards, elsewhere } = gateCards(answer, { embed: true, member: "delivery" });
26
+ expect(cards.map((c) => c.key)).toEqual(["delivery/ship/approve-ship"]);
27
+ expect(elsewhere).toBe(1);
28
+ });
29
+ it("sends a signed gate to the command line", () => {
30
+ const { cards } = gateCards({ ...answer, gates: [{ ...answer.gates[0], signed: true, approve: "chant approve ship approve-ship --sign" }] });
31
+ expect(cards[0].button).toBeUndefined();
32
+ expect(cards[0].note).toBe("wants a signed approval; run chant approve ship approve-ship --sign");
33
+ });
34
+ });