@intentius/behold 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +85 -0
- package/README.md +123 -2
- package/demos.json +17 -1
- package/dist/cli.js +2522 -294
- package/example-argo-estate/README.md +43 -18
- package/example-argo-estate/app-a/chant.config.ts +10 -2
- package/example-argo-estate/app-a/package.json +2 -2
- package/example-argo-estate/app-b/chant.config.ts +11 -2
- package/example-argo-estate/app-b/package.json +2 -2
- package/example-argo-estate/control-plane/chant.config.ts +20 -5
- package/example-argo-estate/control-plane/package.json +2 -2
- package/example-argo-estate/package-lock.json +17 -17
- package/example-carve/README.md +194 -0
- package/example-carve/app/chant.config.ts +6 -0
- package/example-carve/app/package-lock.json +1075 -0
- package/example-carve/app/package.json +13 -0
- package/example-carve/app/src/carved.ts +30 -0
- package/example-carve/app/tsconfig.json +1 -0
- package/example-carve/carve-report.json +872 -0
- package/example-carve/legacy-tf/cdn.tf +23 -0
- package/example-carve/legacy-tf/compute.tf +63 -0
- package/example-carve/legacy-tf/floci-override.tf.disabled +61 -0
- package/example-carve/legacy-tf/modules/cdn/main.tf +72 -0
- package/example-carve/legacy-tf/naming.tf +10 -0
- package/example-carve/legacy-tf/network.tf +119 -0
- package/example-carve/legacy-tf/observability.tf +18 -0
- package/example-carve/legacy-tf/outputs.tf +16 -0
- package/example-carve/legacy-tf/storage.tf +33 -0
- package/example-carve/legacy-tf/terraform.tfstate +602 -0
- package/example-carve/legacy-tf/versions.tf +40 -0
- package/example-flux-estate/README.md +9 -4
- package/example-flux-estate/app-a/package.json +2 -2
- package/example-flux-estate/app-a/src/app.ts +2 -1
- package/example-flux-estate/app-b/chant.config.ts +4 -3
- package/example-flux-estate/app-b/package.json +2 -2
- package/example-flux-estate/app-b/src/app.ts +5 -3
- package/example-flux-estate/control-plane/package.json +2 -2
- package/example-flux-estate/control-plane/src/flux.ts +4 -2
- package/example-flux-estate/package-lock.json +17 -17
- package/example-k8s/package-lock.json +18 -18
- package/example-k8s/package.json +3 -3
- package/example-writes/package-lock.json +14 -14
- package/example-writes/package.json +3 -3
- package/package.json +8 -6
- package/web/app.js +714 -57
- package/web/carve-steps.js +610 -0
- package/web/carve-steps.test.js +233 -0
- package/web/demos.js +71 -0
- package/web/demos.test.js +83 -0
- package/web/index.html +93 -1
- package/web/json-view.js +334 -0
- package/web/json-view.test.js +218 -0
- package/web/layout-store.js +164 -4
- package/web/layout-store.test.js +226 -1
- package/web/panel.js +28 -0
- package/web/theme.js +57 -1
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// #254: the pure half of web/carve-steps.js, tested the way web/json-view.js
|
|
2
|
+
// (#259) and web/layout-store.js (#245) are — no DOM, no jsdom, no browser.
|
|
3
|
+
// Everything the stepper DECIDES (which step is reachable, what the Pick step
|
|
4
|
+
// can honestly claim about the cut, which runbook lines are commands, what
|
|
5
|
+
// `chant lint` reads as) is a function from data to data, so it is checkable
|
|
6
|
+
// here; the DOM half — the buttons, the copy controls, the six-step walk — is
|
|
7
|
+
// smoke/ui-smoke.mjs's job.
|
|
8
|
+
import { describe, it, expect } from "vitest";
|
|
9
|
+
import {
|
|
10
|
+
CARVE_STEPS,
|
|
11
|
+
blockedReason,
|
|
12
|
+
carveable,
|
|
13
|
+
completed,
|
|
14
|
+
cutSummary,
|
|
15
|
+
edgeLine,
|
|
16
|
+
initialCarveState,
|
|
17
|
+
lintVerdict,
|
|
18
|
+
pickFacts,
|
|
19
|
+
runbookCommands,
|
|
20
|
+
stepStatus,
|
|
21
|
+
} from "./carve-steps.js";
|
|
22
|
+
|
|
23
|
+
const BUCKET = {
|
|
24
|
+
address: "aws_s3_bucket.assets",
|
|
25
|
+
score: 88,
|
|
26
|
+
band: "clean leaf",
|
|
27
|
+
mapsTo: "AWS::S3::Bucket",
|
|
28
|
+
breakdown: { inbound: 1, outbound: 0, tier: 1 },
|
|
29
|
+
};
|
|
30
|
+
const NODE = { id: "aws_s3_bucket.assets", attrs: { arithmetic: "100 - 12x1 inbound = 88", score: 88, band: "clean leaf", tier: 1 } };
|
|
31
|
+
|
|
32
|
+
const picked = () => ({ ...initialCarveState(), step: 1, pick: { node: NODE, resource: BUCKET } });
|
|
33
|
+
|
|
34
|
+
describe("the six steps", () => {
|
|
35
|
+
it("are the six #254 names, in order", () => {
|
|
36
|
+
expect(CARVE_STEPS.map((s) => s.id)).toEqual(["advise", "pick", "emit", "bridge", "handoff", "done"]);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("blockedReason — the gates are real data dependencies", () => {
|
|
41
|
+
it("blocks emit until something is picked", () => {
|
|
42
|
+
expect(blockedReason(initialCarveState(), "emit")).toContain("Pick a resource first");
|
|
43
|
+
expect(blockedReason(picked(), "emit")).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("blocks bridge until emit ran, and says why: the manifest emit leaves behind", () => {
|
|
47
|
+
expect(blockedReason(picked(), "bridge")).toContain("carve manifest");
|
|
48
|
+
expect(blockedReason({ ...picked(), emit: { ok: true } }, "bridge")).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("blocks handoff until bridge wrote the runbook", () => {
|
|
52
|
+
const s = { ...picked(), emit: { ok: true } };
|
|
53
|
+
expect(blockedReason(s, "handoff")).toContain("Run Bridge first");
|
|
54
|
+
expect(blockedReason({ ...s, bridge: { ok: true } }, "handoff")).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("never blocks advise or pick — the walkthrough always has a first frame", () => {
|
|
58
|
+
expect(blockedReason(initialCarveState(), "advise")).toBeNull();
|
|
59
|
+
expect(blockedReason(initialCarveState(), "pick")).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("completed / stepStatus", () => {
|
|
64
|
+
it("a fresh walkthrough is on advise, with everything past pick blocked", () => {
|
|
65
|
+
const s = initialCarveState();
|
|
66
|
+
expect(stepStatus(s, 0)).toBe("current");
|
|
67
|
+
expect(stepStatus(s, 1)).toBe("todo");
|
|
68
|
+
expect(stepStatus(s, 2)).toBe("blocked");
|
|
69
|
+
expect(stepStatus(s, 5)).toBe("blocked");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("a step reads done from its RESULT, not from having walked past it", () => {
|
|
73
|
+
const s = { ...picked(), step: 3 };
|
|
74
|
+
expect(completed(s, "emit")).toBe(false);
|
|
75
|
+
expect(stepStatus(s, 2)).toBe("todo"); // stepped past emit without running it
|
|
76
|
+
expect(completed({ ...s, emit: { ok: true } }, "emit")).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("done needs the handoff acknowledged AND the bridge run", () => {
|
|
80
|
+
const s = { ...picked(), emit: { ok: true }, bridge: { ok: true } };
|
|
81
|
+
expect(completed(s, "done")).toBe(false);
|
|
82
|
+
expect(completed({ ...s, handoff: true }, "done")).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("cutSummary — counts and edge lists are different claims", () => {
|
|
87
|
+
it("names the survivors when the report carries the edge lists (chant#1636)", () => {
|
|
88
|
+
const withEdges = {
|
|
89
|
+
...BUCKET,
|
|
90
|
+
boundary: {
|
|
91
|
+
inbound: [
|
|
92
|
+
{
|
|
93
|
+
direction: "inbound",
|
|
94
|
+
survivor: "aws_lambda_function.api",
|
|
95
|
+
carved: "aws_s3_bucket.assets",
|
|
96
|
+
attrs: ["bucket"],
|
|
97
|
+
via: ["environment"],
|
|
98
|
+
bridge: "tf-data-source",
|
|
99
|
+
required: "immediately",
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
outbound: [],
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
const cut = cutSummary(withEdges);
|
|
106
|
+
expect(cut.known).toBe(true);
|
|
107
|
+
expect(cut.items[0]).toContain("aws_lambda_function.api");
|
|
108
|
+
expect(cut.items[0]).toContain("data source");
|
|
109
|
+
expect(cut.note).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("falls back to the counts, and SAYS the survivors aren't in this report", () => {
|
|
113
|
+
const cut = cutSummary(BUCKET);
|
|
114
|
+
expect(cut.known).toBe(false);
|
|
115
|
+
expect(cut.items[0]).toContain("1 inbound");
|
|
116
|
+
expect(cut.note).toContain("chant#1636");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("distinguishes 'no edges' from 'not reported'", () => {
|
|
120
|
+
const clean = cutSummary({ ...BUCKET, breakdown: { inbound: 0, outbound: 0, tier: 1 } });
|
|
121
|
+
expect(clean.items[0]).toContain("No boundary edges at all");
|
|
122
|
+
expect(clean.note).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("reads either shape of the boundary field", () => {
|
|
126
|
+
const flat = cutSummary({ ...BUCKET, boundary: [{ survivor: "a", carved: "b", direction: "outbound" }] });
|
|
127
|
+
expect(flat.known).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("falls back to the IR node's own counts when the raw report isn't in hand", () => {
|
|
131
|
+
// The lens puts inbound/outbound on every card, so a pick made before the
|
|
132
|
+
// extra /api/carve fetch lands still says something true — rather than
|
|
133
|
+
// claiming "no boundary edges at all", which would be a different (and
|
|
134
|
+
// wrong) statement about the same resource.
|
|
135
|
+
const cut = cutSummary(null, { id: "aws_s3_bucket.assets", attrs: { inbound: 1, outbound: 0 } });
|
|
136
|
+
expect(cut.items[0]).toContain("1 inbound");
|
|
137
|
+
expect(cut.note).toContain("chant#1636");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("still reads a genuine zero as zero, not as missing", () => {
|
|
141
|
+
const cut = cutSummary(null, { id: "x", attrs: { inbound: 0, outbound: 0 } });
|
|
142
|
+
expect(cut.items[0]).toContain("No boundary edges at all");
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("edgeLine", () => {
|
|
147
|
+
it("reads an inbound edge as the survivor's problem and an outbound as ours", () => {
|
|
148
|
+
expect(edgeLine({ direction: "inbound", survivor: "lambda", carved: "bucket", attrs: ["bucket"] })).toContain("lambda reads bucket");
|
|
149
|
+
expect(edgeLine({ direction: "outbound", survivor: "vpc", carved: "sg", attrs: ["id"] })).toContain("from vpc");
|
|
150
|
+
expect(edgeLine({ direction: "outbound", survivor: "vpc", carved: "sg", bridge: "deferred-input" })).toContain("deploy-time input");
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("pickFacts / carveable", () => {
|
|
155
|
+
it("shows the arithmetic the lens already spelled out", () => {
|
|
156
|
+
const facts = pickFacts(NODE, BUCKET);
|
|
157
|
+
expect(facts.map((f) => f.label)).toEqual(["address", "score", "arithmetic", "maps to", "tier"]);
|
|
158
|
+
expect(facts[2].value).toBe("100 - 12x1 inbound = 88");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("says up front when a resource has no native mapping to carve into", () => {
|
|
162
|
+
expect(carveable(BUCKET, NODE)).toBeNull();
|
|
163
|
+
const unmappable = { address: "random_pet.suffix", score: 0, band: "leave in Terraform", breakdown: { tier: null } };
|
|
164
|
+
expect(carveable(unmappable, { id: "random_pet.suffix", attrs: { tier: "none" } })).toContain("no known native mapping");
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("runbookCommands", () => {
|
|
169
|
+
// chant's own runbook shape (carve bridge, 0.44.4), trimmed.
|
|
170
|
+
const RUNBOOK = `# Carve-out: aws_s3_bucket.assets → chant [observe-first, reversible]
|
|
171
|
+
|
|
172
|
+
## 1. Review the emitted chant source
|
|
173
|
+
(produced by \`chant carve emit\` — confirm it builds to a spec-true template)
|
|
174
|
+
|
|
175
|
+
## 2. Stop Terraform managing the resource (does NOT destroy it)
|
|
176
|
+
terraform state rm aws_s3_bucket.assets aws_s3_bucket_versioning.assets
|
|
177
|
+
|
|
178
|
+
## 3. Confirm no destroy, then patch the survivors
|
|
179
|
+
terraform plan # expect 0 to destroy
|
|
180
|
+
# the generated bridge patch removes the carved block(s)
|
|
181
|
+
terraform plan # expect: in-place updates to the survivors only
|
|
182
|
+
terraform apply
|
|
183
|
+
|
|
184
|
+
## Rollback (any time before apply-graduation)
|
|
185
|
+
terraform import aws_s3_bucket.assets <physical-id>
|
|
186
|
+
`;
|
|
187
|
+
|
|
188
|
+
it("takes the indented commands, in order, under their own headings", () => {
|
|
189
|
+
const cmds = runbookCommands(RUNBOOK);
|
|
190
|
+
expect(cmds.map((c) => c.command)).toEqual([
|
|
191
|
+
"terraform state rm aws_s3_bucket.assets aws_s3_bucket_versioning.assets",
|
|
192
|
+
"terraform plan",
|
|
193
|
+
"terraform apply",
|
|
194
|
+
"terraform import aws_s3_bucket.assets <physical-id>",
|
|
195
|
+
]);
|
|
196
|
+
expect(cmds[0].section).toContain("Stop Terraform managing");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("drops annotations, keeps a trailing comment as the row's note, and dedupes", () => {
|
|
200
|
+
const cmds = runbookCommands(RUNBOOK);
|
|
201
|
+
expect(cmds.some((c) => c.command.startsWith("("))).toBe(false);
|
|
202
|
+
expect(cmds.some((c) => c.command.startsWith("#"))).toBe(false);
|
|
203
|
+
expect(cmds.filter((c) => c.command === "terraform plan")).toHaveLength(1);
|
|
204
|
+
expect(cmds[1].note).toBe("expect 0 to destroy");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("survives an empty or missing runbook", () => {
|
|
208
|
+
expect(runbookCommands("")).toEqual([]);
|
|
209
|
+
expect(runbookCommands(null)).toEqual([]);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("lintVerdict", () => {
|
|
214
|
+
it("counts warnings off chant's SUMMARY line, not the column numbers above it", () => {
|
|
215
|
+
const output =
|
|
216
|
+
" 5:1 warning Exported declarable 'assets' is never referenced. COR004\n" +
|
|
217
|
+
" 5:23 warning S3 Bucket created without encryption configuration. WAW006\n" +
|
|
218
|
+
" 7:9 warning Inline object in Declarable constructor. COR001\n\n" +
|
|
219
|
+
"⚠ 3 warnings";
|
|
220
|
+
expect(lintVerdict({ ok: true, code: 0, output }).text).toBe("chant lint: passes, 3 warning(s)");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("reads a clean run and a failing one", () => {
|
|
224
|
+
expect(lintVerdict({ ok: true, code: 0, output: "" }).text).toBe("chant lint: passes");
|
|
225
|
+
const bad = lintVerdict({ ok: false, code: 1, output: "✖ 2 errors" });
|
|
226
|
+
expect(bad.tone).toBe("bad");
|
|
227
|
+
expect(bad.text).toContain("exited 1");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("is null with no lint at all — nothing to claim", () => {
|
|
231
|
+
expect(lintVerdict(null)).toBeNull();
|
|
232
|
+
});
|
|
233
|
+
});
|
package/web/demos.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// The demo catalog in the switcher (#268) — the panel's half of `behold demo
|
|
2
|
+
// --list`. The server (GET /api/demos) answers with the install's own bundled
|
|
3
|
+
// catalog, each entry already carrying whether it can run on this machine
|
|
4
|
+
// (doctor's PATH probes) and whether starting it reaches the network. This
|
|
5
|
+
// module turns one of those rows into what a button says about it; app.js
|
|
6
|
+
// builds the DOM with the panel's own helpers.
|
|
7
|
+
//
|
|
8
|
+
// A demo that can't run here is rendered disabled with its reason on its face,
|
|
9
|
+
// never hidden: "k8s needs k3d" is a thing worth knowing, and a catalog that
|
|
10
|
+
// silently shrinks to what happens to be installed teaches nothing.
|
|
11
|
+
|
|
12
|
+
/** GET the catalog. Never throws and never rejects — a server too old to know
|
|
13
|
+
* the route (or an export with no server at all) is an empty catalog, and the
|
|
14
|
+
* demos group simply doesn't render. */
|
|
15
|
+
export async function fetchDemos(fetchImpl) {
|
|
16
|
+
const get = fetchImpl || ((u) => fetch(u));
|
|
17
|
+
try {
|
|
18
|
+
const res = await get("/api/demos");
|
|
19
|
+
if (!res.ok) return [];
|
|
20
|
+
const body = await res.json();
|
|
21
|
+
return Array.isArray(body.demos) ? body.demos : [];
|
|
22
|
+
} catch {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** `https://github.com/INTENTIUS/fountain-ops` → `github.com/INTENTIUS/fountain-ops`
|
|
28
|
+
* — a label wants the host and the path, not the scheme. */
|
|
29
|
+
export function shortRepo(repo) {
|
|
30
|
+
return String(repo || "").replace(/^[a-z+]+:\/\//i, "").replace(/\.git$/, "");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The one thing worth saying about this demo beyond its name: why it can't
|
|
34
|
+
* run, that starting it clones from the network, or that it's already on disk. */
|
|
35
|
+
export function demoNote(demo) {
|
|
36
|
+
if (!demo.satisfiable) return demo.reason || "unavailable here";
|
|
37
|
+
// #254: runnable, but not as an in-place switch — the carve walkthrough
|
|
38
|
+
// serves a report rather than a project, and a running server can't change
|
|
39
|
+
// into carve mode. Listed, disabled, with the command that does work.
|
|
40
|
+
if (demo.switchable === false) return demo.reason || "run it from a terminal";
|
|
41
|
+
if (demo.fetches) return demo.repo ? `clones ${shortRepo(demo.repo)}` : "clones from the network";
|
|
42
|
+
if (demo.loaded) return "loaded";
|
|
43
|
+
return "";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function demoLabel(demo) {
|
|
47
|
+
const note = demoNote(demo);
|
|
48
|
+
return note ? `${demo.name} · ${note}` : demo.name;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The tooltip: what the demo is, then exactly what clicking it will do —
|
|
52
|
+
* including the fetch, before it happens. */
|
|
53
|
+
export function demoTitle(demo) {
|
|
54
|
+
if (!demo.satisfiable) {
|
|
55
|
+
return `${demo.description}\n\nCan't run here — ${demo.reason || "a prerequisite is missing"}. Install it and reopen behold.`;
|
|
56
|
+
}
|
|
57
|
+
if (demo.switchable === false) {
|
|
58
|
+
return `${demo.description}\n\nNot a project switch — ${demo.reason || "run it from a terminal"}.`;
|
|
59
|
+
}
|
|
60
|
+
const source = demo.fetches
|
|
61
|
+
? `Clones ${demo.repo || "a public repo"} into ${demo.target}`
|
|
62
|
+
: `${demo.loaded ? "Reuses" : "Copies the bundled example to"} ${demo.target}`;
|
|
63
|
+
const setup = ", installs its dependencies";
|
|
64
|
+
return `${demo.description}\n\n${source}${setup}, then serves it here. It's yours — edit it and the graph follows.`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The loading line, said while it runs. Distinguishes the fetch from the copy
|
|
68
|
+
* for the same reason the button does. */
|
|
69
|
+
export function demoProgress(demo) {
|
|
70
|
+
return `loading the ${demo.name} demo — ${demo.fetches ? "cloning" : "copying"}, installing…`;
|
|
71
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// #268: what the switcher's demo buttons say. Pure functions over one /api/demos
|
|
2
|
+
// row, so they get unit tests next to them; the smoke covers the DOM they end up
|
|
3
|
+
// in. The two rules worth pinning: a demo that can't run here says WHY (it is
|
|
4
|
+
// rendered disabled, not dropped), and one that would clone from the network
|
|
5
|
+
// says so on the button, before anyone clicks it.
|
|
6
|
+
import { describe, it, expect } from "vitest";
|
|
7
|
+
import { fetchDemos, shortRepo, demoNote, demoLabel, demoTitle, demoProgress } from "./demos.js";
|
|
8
|
+
|
|
9
|
+
const row = (over = {}) => ({
|
|
10
|
+
name: "k8s",
|
|
11
|
+
description: "nginx on a throwaway k3d cluster.",
|
|
12
|
+
requires: ["docker", "k3d"],
|
|
13
|
+
source: "bundled",
|
|
14
|
+
fetches: false,
|
|
15
|
+
target: "/w/behold-demos/k8s",
|
|
16
|
+
loaded: false,
|
|
17
|
+
satisfiable: true,
|
|
18
|
+
...over,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("the demo button's face", () => {
|
|
22
|
+
it("a runnable, unloaded demo is just its name", () => {
|
|
23
|
+
expect(demoLabel(row())).toBe("k8s");
|
|
24
|
+
expect(demoNote(row())).toBe("");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("an unsatisfiable demo carries its reason — the disabled row still teaches", () => {
|
|
28
|
+
const blocked = row({ satisfiable: false, reason: "needs k3d on PATH" });
|
|
29
|
+
expect(demoLabel(blocked)).toBe("k8s · needs k3d on PATH");
|
|
30
|
+
expect(demoTitle(blocked)).toContain("Can't run here — needs k3d on PATH");
|
|
31
|
+
// A server that forgot the reason still says something honest.
|
|
32
|
+
expect(demoNote(row({ satisfiable: false }))).toBe("unavailable here");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("a network-fetching demo names the repo it would clone", () => {
|
|
36
|
+
const remote = row({ name: "fountain", fetches: true, repo: "https://github.com/INTENTIUS/fountain-ops" });
|
|
37
|
+
expect(demoLabel(remote)).toBe("fountain · clones github.com/INTENTIUS/fountain-ops");
|
|
38
|
+
expect(demoTitle(remote)).toContain("Clones https://github.com/INTENTIUS/fountain-ops");
|
|
39
|
+
expect(demoProgress(remote)).toContain("cloning");
|
|
40
|
+
expect(demoProgress(row())).toContain("copying");
|
|
41
|
+
// The reason wins over the fetch note: it can't run at all.
|
|
42
|
+
expect(demoNote(row({ fetches: true, satisfiable: false, reason: "needs git on PATH" }))).toBe("needs git on PATH");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("an already-copied demo says so, and its tooltip promises a reuse", () => {
|
|
46
|
+
expect(demoLabel(row({ loaded: true }))).toBe("k8s · loaded");
|
|
47
|
+
expect(demoTitle(row({ loaded: true }))).toContain("Reuses /w/behold-demos/k8s");
|
|
48
|
+
expect(demoTitle(row())).toContain("Copies the bundled example to /w/behold-demos/k8s");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("a runnable demo that isn't a project switch says which command IS (#254)", () => {
|
|
52
|
+
const carve = row({ name: "carve", switchable: false, reason: "serves a carve report, not a project — run `behold demo carve`" });
|
|
53
|
+
expect(demoLabel(carve)).toBe("carve · serves a carve report, not a project — run `behold demo carve`");
|
|
54
|
+
expect(demoTitle(carve)).toContain("Not a project switch");
|
|
55
|
+
// Still satisfiable — the row is disabled for a different reason, and
|
|
56
|
+
// conflating the two would tell someone to install something they have.
|
|
57
|
+
expect(demoTitle(carve)).not.toContain("Can't run here");
|
|
58
|
+
expect(demoNote(row({ switchable: false }))).toBe("run it from a terminal");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("shortRepo drops the scheme and the .git suffix", () => {
|
|
62
|
+
expect(shortRepo("https://github.com/INTENTIUS/fountain-ops.git")).toBe("github.com/INTENTIUS/fountain-ops");
|
|
63
|
+
expect(shortRepo(undefined)).toBe("");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("fetchDemos degrades to an empty catalog", () => {
|
|
68
|
+
const res = (body, ok = true) => ({ ok, json: async () => body });
|
|
69
|
+
|
|
70
|
+
it("returns the rows a server answers with", async () => {
|
|
71
|
+
expect(await fetchDemos(async () => res({ demos: [row()] }))).toHaveLength(1);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("a 404 (an older server), a junk body, or a dead fetch is no group at all", async () => {
|
|
75
|
+
expect(await fetchDemos(async () => res({}, false))).toEqual([]);
|
|
76
|
+
expect(await fetchDemos(async () => res({ demos: "nope" }))).toEqual([]);
|
|
77
|
+
expect(
|
|
78
|
+
await fetchDemos(async () => {
|
|
79
|
+
throw new Error("offline");
|
|
80
|
+
}),
|
|
81
|
+
).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
});
|
package/web/index.html
CHANGED
|
@@ -174,12 +174,61 @@
|
|
|
174
174
|
.precondition-error-message { color: var(--muted); white-space: pre-wrap; font: var(--t-body)/1.55 var(--font-mono); }
|
|
175
175
|
.precondition-error-remedy { color: var(--pending); margin-top: 14px; font: var(--t-body)/1.5 var(--font-mono); }
|
|
176
176
|
.precondition-error-remedy::before { content: "→ "; }
|
|
177
|
+
/* #259: the raw /api payload behind the card, collapsed. */
|
|
178
|
+
.precondition-error-raw { margin-top: 18px; border-top: 1px solid var(--line); padding-top: 10px; }
|
|
179
|
+
.precondition-error-rawlabel { color: var(--muted); font-size: var(--t-caption); text-transform: uppercase;
|
|
180
|
+
letter-spacing: var(--t-caps); margin-bottom: 5px; }
|
|
177
181
|
#actions button, #inspect button { background: var(--panel); color: var(--fg); border: 1px solid var(--rule);
|
|
178
182
|
border-radius: var(--r-ctl); padding: 4px 12px; font-size: var(--t-body); cursor: pointer; }
|
|
179
183
|
#actions button:hover, #inspect button:hover { border-color: var(--focus); }
|
|
180
184
|
#actions button.approve { border-color: var(--managed); color: var(--managed); }
|
|
181
185
|
#inspect button { border-color: var(--foreign); color: var(--foreign); }
|
|
182
186
|
|
|
187
|
+
/* ---- Collapsible JSON (#259) -----------------------------------------
|
|
188
|
+
web/json-view.js builds this; nothing else styles it. Two colours and
|
|
189
|
+
one font: keys and scalars are --fg (the data), every bracket, comma,
|
|
190
|
+
chevron, count and affordance is --muted (the punctuation around it).
|
|
191
|
+
The status hues are deliberately NOT used — they carry meaning in this
|
|
192
|
+
pane, and unlike --fg/--muted they clear no contrast floor across the
|
|
193
|
+
552 palettes (#240), so a "strings are green" scheme would be
|
|
194
|
+
unreadable on some of them.
|
|
195
|
+
Indent is 2ch — the same two spaces the copied text carries — with a
|
|
196
|
+
--line rule down it so a deep tree still tracks by eye. */
|
|
197
|
+
.jsonv { font: var(--t-body)/1.5 var(--font-mono); color: var(--fg); overflow-wrap: anywhere; }
|
|
198
|
+
.jsonv-children { padding-left: 2ch; border-left: 1px solid var(--line); }
|
|
199
|
+
.jsonv-node[data-open="0"] > .jsonv-children,
|
|
200
|
+
.jsonv-node[data-open="0"] > .jsonv-tail { display: none; }
|
|
201
|
+
.jsonv-node[data-open="1"] > .jsonv-line .jsonv-summary,
|
|
202
|
+
.jsonv-node[data-open="1"] > .jsonv-line .jsonv-close-inline { display: none; }
|
|
203
|
+
.jsonv-key { color: var(--fg); }
|
|
204
|
+
.jsonv-punct, .jsonv-summary, .jsonv-toggle, .jsonv-null { color: var(--muted); }
|
|
205
|
+
.jsonv-head { cursor: pointer; border-radius: var(--r-ctl); }
|
|
206
|
+
.jsonv-head:hover .jsonv-toggle { color: var(--fg); }
|
|
207
|
+
.jsonv-toggle { display: inline-block; width: 1.2ch; user-select: none; }
|
|
208
|
+
/* The two affordances stay quiet until the row is under the pointer or a
|
|
209
|
+
keyboard focus is inside it — a tree of visible "copy" links reads as
|
|
210
|
+
chrome, not as data. */
|
|
211
|
+
.jsonv-copy, .jsonv-more { color: var(--muted); font-size: var(--t-caption); cursor: pointer;
|
|
212
|
+
margin-left: 8px; user-select: none; }
|
|
213
|
+
.jsonv-copy { opacity: 0; }
|
|
214
|
+
.jsonv-line:hover > .jsonv-copy, .jsonv-copy:focus-visible, .jsonv-copy[data-copied] { opacity: 1; }
|
|
215
|
+
.jsonv-copy:hover, .jsonv-more:hover { color: var(--fg); }
|
|
216
|
+
.jsonv-copy[data-copied="1"] { color: var(--managed); }
|
|
217
|
+
.jsonv-copy[data-copied="0"] { color: var(--degraded); }
|
|
218
|
+
.jsonv-head:focus-visible, .jsonv-copy:focus-visible, .jsonv-more:focus-visible {
|
|
219
|
+
outline: 1px solid var(--focus); outline-offset: 1px; }
|
|
220
|
+
/* In the inspect pane the tree replaces a <dd>'s text, so it inherits the
|
|
221
|
+
pane's own mono scale and needs no extra gap. */
|
|
222
|
+
#inspect dd > .jsonv { margin-top: 1px; }
|
|
223
|
+
/* A value PAIR (was/now, declared/live, baseline/live) whose sides are
|
|
224
|
+
trees rather than scalars — see pairCell() in app.js. */
|
|
225
|
+
#inspect .pair-row { display: flex; align-items: baseline; gap: 6px; margin-top: 3px; }
|
|
226
|
+
#inspect .pair-row > .jsonv { flex: 1; min-width: 0; }
|
|
227
|
+
#inspect .pair-label { color: var(--muted); font-size: var(--t-caption); flex: none; }
|
|
228
|
+
/* The op log's JSON lines (#259): a report chant emitted as an object
|
|
229
|
+
reads as a tree in the now-line, not as one 900-column line. */
|
|
230
|
+
#nowline .jsonv { white-space: normal; margin: 2px 0; }
|
|
231
|
+
|
|
183
232
|
/* ---- Floating control panel ------------------------------------------
|
|
184
233
|
An Adobe-style palette holding the controls that used to line the
|
|
185
234
|
header and its strips: zoom, scope lenses, substrate pills, the model
|
|
@@ -238,7 +287,10 @@
|
|
|
238
287
|
#panel .opt { display: block; width: 100%; text-align: left; background: none;
|
|
239
288
|
border: 1px solid transparent; border-radius: var(--r-ctl); color: var(--fg);
|
|
240
289
|
font-size: var(--t-body); padding: 4px 8px; cursor: pointer; }
|
|
241
|
-
#panel .opt:hover { border-color: var(--line); }
|
|
290
|
+
#panel .opt:hover:not(:disabled) { border-color: var(--line); }
|
|
291
|
+
/* #268: a demo whose prerequisites are missing stays visible and says why
|
|
292
|
+
— dimmed and unclickable, not removed from the catalog. */
|
|
293
|
+
#panel .opt:disabled { opacity: .5; cursor: not-allowed; }
|
|
242
294
|
#panel .opt.active { background: var(--active); border-color: var(--rule); color: var(--fg);
|
|
243
295
|
box-shadow: inset 2px 0 0 var(--pending); }
|
|
244
296
|
#panel select { width: 100%; background: var(--well); color: var(--fg); border: 1px solid var(--line);
|
|
@@ -295,6 +347,46 @@
|
|
|
295
347
|
.dial-detail { display: flex; flex-wrap: wrap; gap: 4px 12px; color: var(--muted);
|
|
296
348
|
font: var(--t-caption)/1.6 var(--font-mono); }
|
|
297
349
|
|
|
350
|
+
/* The carve walkthrough (#254) — the panel's Carve tab. Six steps on the
|
|
351
|
+
same track the deploy dial uses, so "where am I in this" reads the same
|
|
352
|
+
way in both places; below it, the step's own panel. Every colour here is
|
|
353
|
+
a shared token, so all 552 palettes get it for free. */
|
|
354
|
+
#tab-carve { display: flex; flex-direction: column; gap: 10px; }
|
|
355
|
+
#tab-carve .dial-track { gap: 4px; }
|
|
356
|
+
.carve-step { padding: 3px 8px; font-size: var(--t-caption); }
|
|
357
|
+
.carve-step.done { color: var(--managed); border-color: color-mix(in srgb, var(--managed) 45%, var(--rule)); }
|
|
358
|
+
.carve-step.blocked { opacity: .55; }
|
|
359
|
+
.carve-body { display: flex; flex-direction: column; gap: 6px; }
|
|
360
|
+
/* Data — chant's output, a file, a command — is mono and boxed; prose
|
|
361
|
+
about it stays in the panel's own voice. */
|
|
362
|
+
.carve-pre { margin: 0; background: var(--well); border: 1px solid var(--line);
|
|
363
|
+
border-radius: var(--r-ctl); padding: 6px 8px; max-height: 240px; overflow: auto;
|
|
364
|
+
font: var(--t-caption)/1.5 var(--font-mono); color: var(--fg); white-space: pre-wrap;
|
|
365
|
+
overflow-wrap: anywhere; }
|
|
366
|
+
.carve-pre.carve-cmd { color: var(--muted); }
|
|
367
|
+
.carve-artifact { display: flex; flex-direction: column; gap: 3px; }
|
|
368
|
+
.carve-path { font: var(--t-caption)/1.5 var(--font-mono); overflow-wrap: anywhere; }
|
|
369
|
+
.carve-cut, .carve-honesty { margin: 2px 0; }
|
|
370
|
+
/* The one thing the walkthrough will not do for you, and the refusals it
|
|
371
|
+
hands back — same card, because they are the same kind of statement. */
|
|
372
|
+
.carve-refusal { border: 1px solid var(--degraded); border-left: 3px solid var(--degraded);
|
|
373
|
+
border-radius: var(--r-ctl); padding: 6px 8px; background: var(--well); }
|
|
374
|
+
.carve-refusal.carve-human { border-color: var(--pending); border-left-color: var(--pending); }
|
|
375
|
+
.carve-refusal-title { font-size: var(--t-body); margin-bottom: 2px; }
|
|
376
|
+
.carve-cmd-row { align-items: center; gap: 6px; }
|
|
377
|
+
.carve-cmd-text { font: var(--t-caption)/1.6 var(--font-mono); background: var(--well);
|
|
378
|
+
border: 1px solid var(--line); border-radius: var(--r-ctl); padding: 3px 6px;
|
|
379
|
+
overflow-wrap: anywhere; }
|
|
380
|
+
.carve-cmd-note, .carve-section { margin: 2px 0; }
|
|
381
|
+
.carve-endcard { border: 1px solid var(--managed); border-left: 3px solid var(--managed);
|
|
382
|
+
border-radius: var(--r-ctl); padding: 6px 8px; background: var(--well); }
|
|
383
|
+
.carve-endcard-title { font-size: var(--t-body); margin-bottom: 2px; }
|
|
384
|
+
.carve-caveat > summary { cursor: pointer; color: var(--muted);
|
|
385
|
+
font: var(--t-caption)/1.6 var(--font-mono); }
|
|
386
|
+
/* The still frame of the morph the follow-up animates: a carved card
|
|
387
|
+
wears the managed edge its new owner paints everything with. */
|
|
388
|
+
#graph [data-node-id].carved rect:first-of-type { stroke: var(--managed); stroke-width: 2; }
|
|
389
|
+
|
|
298
390
|
#nowline { grid-column: 1 / 3; grid-row: 2; margin: 0; max-height: 160px; overflow: auto; display: none;
|
|
299
391
|
background: var(--well); border-top: 1px solid var(--rule); padding: 8px 12px;
|
|
300
392
|
font: var(--t-caption)/1.6 var(--font-mono); color: var(--fg); white-space: pre-wrap; }
|