@intentius/behold 0.17.0 → 0.18.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.
@@ -0,0 +1,58 @@
1
+ // #234's last remainder — the cross-environment half of "is prod running what
2
+ // staging tested", the pure half (tested the way web/release.js and
3
+ // web/run-playhead.js are — no DOM). The payload is /api/components/compare's
4
+ // `{env, to, comparisons}`, each comparison chant's own CrossEnvComparison:
5
+ // `same` decided on the COMPARABLE identity (inputDigest ?? digest per side —
6
+ // a pinned helm deploy's bytes legitimately differ per cluster), `comparedOn`
7
+ // present when the input side spoke, digests always visible.
8
+
9
+ /** Tone vocabulary matches the component palette: good / warn / neutral. */
10
+ export function comparisonRow(c) {
11
+ const missingA = !c.digestA;
12
+ const missingB = !c.digestB;
13
+ if (missingA && missingB) {
14
+ return { component: c.component, tone: "neutral", verdict: "unrecorded", detail: `no release recorded in ${c.envA} or ${c.envB}` };
15
+ }
16
+ if (missingA || missingB) {
17
+ const where = missingA ? c.envA : c.envB;
18
+ return { component: c.component, tone: "neutral", verdict: "unrecorded", detail: `no release recorded in ${where}` };
19
+ }
20
+ if (c.same) {
21
+ // The honest wording: matching inputs with differing bytes is still
22
+ // "same" — that is chant#1243's contract, and saying "same bytes" here
23
+ // would claim more than the ledger does.
24
+ const inputDecided = !!c.comparedOn && c.comparedOn.a === c.comparedOn.b && c.digestA !== c.digestB;
25
+ return {
26
+ component: c.component,
27
+ tone: "good",
28
+ verdict: inputDecided ? "same inputs" : "same",
29
+ detail: inputDecided
30
+ ? `built from the same inputs (${short(c.comparedOn.a)}); rendered bytes differ per cluster, as pinned deploys do`
31
+ : `same digest in both (${short(c.digestA)})`,
32
+ };
33
+ }
34
+ return {
35
+ component: c.component,
36
+ tone: "warn",
37
+ verdict: "differs",
38
+ detail: `${c.envA}: ${short(c.digestA)} · ${c.envB}: ${short(c.digestB)}`,
39
+ };
40
+ }
41
+
42
+ /** One line for the section header: the counts, worst-tone-first. */
43
+ export function comparisonSummary(payload) {
44
+ const rows = (payload.comparisons || []).map(comparisonRow);
45
+ if (!rows.length) return { text: `no components recorded in ${payload.env} or ${payload.to}`, tone: "neutral" };
46
+ const differs = rows.filter((r) => r.tone === "warn").length;
47
+ const unrecorded = rows.filter((r) => r.tone === "neutral").length;
48
+ const same = rows.length - differs - unrecorded;
49
+ if (differs) return { text: `${differs} of ${rows.length} differ from ${payload.to}`, tone: "warn" };
50
+ if (same === rows.length) return { text: `all ${rows.length} running what ${payload.to} tested`, tone: "good" };
51
+ return { text: `${same} same as ${payload.to}, ${unrecorded} unrecorded`, tone: "neutral" };
52
+ }
53
+
54
+ function short(digest) {
55
+ if (!digest) return "—";
56
+ const hex = digest.startsWith("sha256:") ? digest.slice(7) : digest;
57
+ return hex.slice(0, 12);
58
+ }
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { comparisonRow, comparisonSummary } from "./cross-env.js";
3
+
4
+ const cmp = (over) => ({ component: "web", envA: "prod", envB: "staging", digestA: "sha256:aaaa1111bbbb2222", digestB: "sha256:aaaa1111bbbb2222", same: true, ...over });
5
+
6
+ describe("comparisonRow (#234 — is prod running what staging tested)", () => {
7
+ it("same digest in both reads good, with the digest named", () => {
8
+ const r = comparisonRow(cmp({}));
9
+ expect(r).toMatchObject({ tone: "good", verdict: "same" });
10
+ expect(r.detail).toContain("aaaa1111bbbb");
11
+ });
12
+
13
+ it("matching inputs with differing bytes says SAME INPUTS — never claims same bytes (pinned helm, chant#1243)", () => {
14
+ const r = comparisonRow(cmp({
15
+ digestA: "sha256:prod-bytes",
16
+ digestB: "sha256:staging-bytes",
17
+ same: true,
18
+ comparedOn: { a: "sha256:inputs", b: "sha256:inputs" },
19
+ }));
20
+ expect(r.tone).toBe("good");
21
+ expect(r.verdict).toBe("same inputs");
22
+ expect(r.detail).toContain("rendered bytes differ per cluster");
23
+ });
24
+
25
+ it("differing identities read warn, both digests visible", () => {
26
+ const r = comparisonRow(cmp({ digestB: "sha256:other", same: false }));
27
+ expect(r).toMatchObject({ tone: "warn", verdict: "differs" });
28
+ expect(r.detail).toContain("prod");
29
+ expect(r.detail).toContain("staging");
30
+ });
31
+
32
+ it("a missing side is UNRECORDED (neutral) and names where — never painted as drift", () => {
33
+ const r = comparisonRow(cmp({ digestB: undefined, same: false }));
34
+ expect(r).toMatchObject({ tone: "neutral", verdict: "unrecorded" });
35
+ expect(r.detail).toContain("staging");
36
+ expect(comparisonRow(cmp({ digestA: undefined, digestB: undefined, same: false })).detail).toContain("prod or staging");
37
+ });
38
+ });
39
+
40
+ describe("comparisonSummary", () => {
41
+ const payload = (comparisons) => ({ env: "prod", to: "staging", comparisons });
42
+
43
+ it("worst tone first: any differ makes the line warn with the count", () => {
44
+ const s = comparisonSummary(payload([cmp({}), cmp({ component: "api", digestB: "sha256:x", same: false })]));
45
+ expect(s).toEqual({ text: "1 of 2 differ from staging", tone: "warn" });
46
+ });
47
+
48
+ it("all same is the good line the question wants answered", () => {
49
+ const s = comparisonSummary(payload([cmp({}), cmp({ component: "api" })]));
50
+ expect(s).toEqual({ text: "all 2 running what staging tested", tone: "good" });
51
+ });
52
+
53
+ it("unrecorded components temper the claim instead of hiding in either count", () => {
54
+ const s = comparisonSummary(payload([cmp({}), cmp({ component: "api", digestB: undefined, same: false })]));
55
+ expect(s).toEqual({ text: "1 same as staging, 1 unrecorded", tone: "neutral" });
56
+ });
57
+
58
+ it("an empty ledger pair says so", () => {
59
+ expect(comparisonSummary(payload([]))).toEqual({ text: "no components recorded in prod or staging", tone: "neutral" });
60
+ });
61
+ });
package/web/index.html CHANGED
@@ -4,6 +4,14 @@
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>behold</title>
7
+ <!-- #393: "nothing in the browser console" includes the 404 every page
8
+ load has always fetched for itself. behold ships no icon file and its
9
+ static export copies only web/, so the mark is a data URI rather than a
10
+ route or an asset: no request, and the exported page keeps it. -->
11
+ <link
12
+ rel="icon"
13
+ href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='6' fill='none' stroke='%23a6adc8' stroke-width='1.6'/%3E%3Ccircle cx='8' cy='8' r='2.2' fill='%23a6adc8'/%3E%3C/svg%3E"
14
+ />
7
15
  <style>
8
16
  /* Pre-paint fallback (#229). These are the DEFAULT theme's tokens —
9
17
  Catppuccin Mocha run through theme.js's tokensFor() — not a palette of
@@ -313,6 +321,25 @@
313
321
  #panel .grow { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
314
322
  #panel .tag { color: var(--muted); font: var(--t-caption)/1.5 var(--font-mono); white-space: nowrap; }
315
323
 
324
+ /* The provenance badge (#401, M4 of #397): who stated this figure, to
325
+ what tolerance, and whether it is modeled from list prices or
326
+ validated against a bill. Boxed rather than plain muted text, because
327
+ it rides BESIDE a number and has to read as a label on that number
328
+ rather than as more of it — and it sits on every figure the behaviour
329
+ overlay shows, in the panel and in the inspect pane alike, so it is
330
+ styled for both. `help` cursor: the hover carries "modeled, not
331
+ billed", which is the sentence #397 turns on. */
332
+ .behaviour-badge { color: var(--muted); font: var(--t-caption)/1.4 var(--font-mono);
333
+ border: 1px solid var(--line); border-radius: var(--r-ctl); padding: 0 5px;
334
+ white-space: nowrap; cursor: help; flex: none; }
335
+ #panel .behaviour-total { display: flex; flex-direction: column; gap: 1px; padding: 3px 0;
336
+ font: var(--t-caption)/1.5 var(--font-mono); }
337
+ #panel .behaviour-ramp { margin: 4px 0 2px; }
338
+ /* A card nothing priced keeps the drift overlay's neutral fill (#399) —
339
+ and says so, faintly, so the grey is legible as "not looked at" rather
340
+ than as the low end of the scale. */
341
+ #graph [data-unpriced="1"] { opacity: 0.82; }
342
+
316
343
  /* Substrate readiness (M5, #54) — one row per substrate on the panel's
317
344
  Substrates tab: status dot + name + state, with its actions (bring up /
318
345
  reset / run pipeline) visible inline again. #73 moved those into ⌘K;
@@ -424,6 +451,13 @@
424
451
  here", not "something is wrong". */
425
452
  #graph [data-node-id].operator-home rect:first-of-type { stroke: var(--pending); stroke-width: 2; }
426
453
 
454
+ /* #404: a bound choudoufu card the plan would change in place. A DASHED
455
+ --degraded edge, deliberately not a fill: the fill is the ownership
456
+ verdict and attribute drift never moves it, so the two signals have to
457
+ live in different channels. Dashed rather than solid so it reads apart
458
+ from .carved's and .operator-home's solid strokes at a glance. */
459
+ #graph [data-node-id].plan-drifted rect:first-of-type { stroke: var(--degraded); stroke-width: 2; stroke-dasharray: 4 3; }
460
+
427
461
  /* The step the playhead sits on, on the graph itself — the ops lens marks
428
462
  it `_playhead` and the card wears a dashed edge, so "here" reads
429
463
  differently from "this one is done". */
@@ -515,6 +549,10 @@
515
549
  font: var(--t-body)/1.5 var(--font-mono);
516
550
  cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
517
551
  #pal-list .row.sel { background: var(--pending); color: var(--pal-sel-fg, var(--fg)); }
552
+ /* A node row's second line (#393): the member and the kind, which is what
553
+ tells two same-named addresses in two members apart. Inherits the row's
554
+ colour so the selected row stays readable on --pending. */
555
+ #pal-list .row .sub { font-size: var(--t-caption); opacity: 0.66; margin-top: 1px; }
518
556
  #pal-list .empty { padding: 8px 11px; color: var(--muted); font-size: var(--t-body); }
519
557
  </style>
520
558
  </head>
@@ -577,7 +615,7 @@
577
615
  <!-- Command palette (⌘K / Ctrl+K, #73) — the spicypath FG-036 shape
578
616
  (../spicypath/src/index.html): input + filtered list, driven entirely
579
617
  by app.js's paletteCommands()/palRender()/openPalette()/closePalette(). -->
580
- <div id="palette"><div class="box"><input id="pal-input" placeholder="type a command… (Esc to close)" autocomplete="off"><div id="pal-list"></div></div></div>
618
+ <div id="palette"><div class="box"><input id="pal-input" placeholder="type a command, or an address… (Esc to close)" autocomplete="off"><div id="pal-list"></div></div></div>
581
619
  <script type="module" src="./app.js"></script>
582
620
  </body>
583
621
  </html>