@intentius/behold 0.7.0 → 0.8.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.
Files changed (76) hide show
  1. package/AGENTS.md +9 -2
  2. package/README.md +12 -2
  3. package/demos.json +8 -0
  4. package/dist/cli.js +750 -132
  5. package/example-argo-estate/README.md +46 -0
  6. package/example-argo-estate/app-a/chant.config.ts +10 -0
  7. package/example-argo-estate/app-a/manifests/app.yaml +54 -0
  8. package/example-argo-estate/app-a/package.json +13 -0
  9. package/example-argo-estate/app-a/src/app.ts +35 -0
  10. package/example-argo-estate/app-a/tsconfig.json +1 -0
  11. package/example-argo-estate/app-b/chant.config.ts +9 -0
  12. package/example-argo-estate/app-b/manifests/app.yaml +54 -0
  13. package/example-argo-estate/app-b/package.json +13 -0
  14. package/example-argo-estate/app-b/src/app.ts +34 -0
  15. package/example-argo-estate/app-b/tsconfig.json +1 -0
  16. package/example-argo-estate/control-plane/chant.config.ts +14 -0
  17. package/example-argo-estate/control-plane/package.json +13 -0
  18. package/example-argo-estate/control-plane/src/argo.ts +69 -0
  19. package/example-argo-estate/control-plane/tsconfig.json +1 -0
  20. package/example-argo-estate/package-lock.json +1949 -0
  21. package/example-argo-estate/package.json +10 -0
  22. package/example-flux-estate/README.md +13 -8
  23. package/example-flux-estate/app-a/package.json +2 -2
  24. package/example-flux-estate/app-b/package.json +2 -2
  25. package/example-flux-estate/control-plane/package.json +2 -2
  26. package/example-flux-estate/control-plane/src/flux.ts +6 -0
  27. package/example-flux-estate/package-lock.json +17 -17
  28. package/example-k8s/README.md +16 -2
  29. package/example-k8s/base/deployment.yaml +21 -0
  30. package/example-k8s/base/kustomization.yaml +3 -0
  31. package/example-k8s/base/service.yaml +12 -0
  32. package/example-k8s/chant.config.ts +10 -0
  33. package/example-k8s/overlays/dev/kustomization.yaml +6 -0
  34. package/example-k8s/package-lock.json +18 -18
  35. package/example-k8s/package.json +3 -3
  36. package/example-writes/package-lock.json +14 -14
  37. package/example-writes/package.json +3 -3
  38. package/package.json +3 -2
  39. package/web/app.js +350 -22
  40. package/web/icons/cncf/argo.svg +1 -0
  41. package/web/icons/cncf/flux.svg +1 -0
  42. package/web/icons/cncf/helm.svg +1 -0
  43. package/web/icons/k8s/c-role.svg +1 -0
  44. package/web/icons/k8s/cm.svg +1 -0
  45. package/web/icons/k8s/crb.svg +1 -0
  46. package/web/icons/k8s/crd.svg +1 -0
  47. package/web/icons/k8s/cronjob.svg +1 -0
  48. package/web/icons/k8s/deploy.svg +1 -0
  49. package/web/icons/k8s/ds.svg +1 -0
  50. package/web/icons/k8s/ep.svg +1 -0
  51. package/web/icons/k8s/group.svg +1 -0
  52. package/web/icons/k8s/hpa.svg +1 -0
  53. package/web/icons/k8s/ing.svg +1 -0
  54. package/web/icons/k8s/job.svg +1 -0
  55. package/web/icons/k8s/limits.svg +1 -0
  56. package/web/icons/k8s/netpol.svg +1 -0
  57. package/web/icons/k8s/ns.svg +1 -0
  58. package/web/icons/k8s/pod.svg +1 -0
  59. package/web/icons/k8s/psp.svg +1 -0
  60. package/web/icons/k8s/pv.svg +1 -0
  61. package/web/icons/k8s/pvc.svg +1 -0
  62. package/web/icons/k8s/quota.svg +1 -0
  63. package/web/icons/k8s/rb.svg +1 -0
  64. package/web/icons/k8s/role.svg +1 -0
  65. package/web/icons/k8s/rs.svg +1 -0
  66. package/web/icons/k8s/sa.svg +1 -0
  67. package/web/icons/k8s/sc.svg +1 -0
  68. package/web/icons/k8s/secret.svg +1 -0
  69. package/web/icons/k8s/sts.svg +1 -0
  70. package/web/icons/k8s/svc.svg +1 -0
  71. package/web/icons/k8s/user.svg +1 -0
  72. package/web/icons/k8s/vol.svg +1 -0
  73. package/web/index.html +192 -84
  74. package/web/layout-store.js +118 -0
  75. package/web/layout-store.test.js +163 -0
  76. package/web/theme.js +58 -6
@@ -0,0 +1,163 @@
1
+ // #228: the hand-layout delta store, checked without a browser. The pointer
2
+ // work and the SVG live in app.js and are covered by smoke/ui-smoke.mjs; every
3
+ // rule that decides WHAT gets stored and under which key lives here.
4
+ import { describe, expect, it } from "vitest";
5
+ import {
6
+ applicable,
7
+ clearLayout,
8
+ isEmpty,
9
+ layoutKey,
10
+ lensKeyOf,
11
+ normalize,
12
+ projectKeyOf,
13
+ readLayout,
14
+ setDelta,
15
+ slug,
16
+ writeLayout,
17
+ } from "./layout-store.js";
18
+
19
+ /** A localStorage stand-in; `fail` makes every operation throw (private mode). */
20
+ function fakeStorage(seed = {}, fail = false) {
21
+ const map = new Map(Object.entries(seed));
22
+ return {
23
+ map,
24
+ getItem(k) {
25
+ if (fail) throw new Error("nope");
26
+ return map.has(k) ? map.get(k) : null;
27
+ },
28
+ setItem(k, v) {
29
+ if (fail) throw new Error("nope");
30
+ map.set(k, v);
31
+ },
32
+ removeItem(k) {
33
+ if (fail) throw new Error("nope");
34
+ map.delete(k);
35
+ },
36
+ };
37
+ }
38
+
39
+ describe("keys", () => {
40
+ it("slugs a project dir and a lens into one readable key", () => {
41
+ expect(layoutKey("/estates/stub-estate", "components")).toBe("behold.layout.estates-stub-estate.components");
42
+ });
43
+
44
+ it("takes the project half from /api/project's projectDir", () => {
45
+ expect(projectKeyOf({ projectDir: "/Users/me/work/My Estate" })).toBe("users-me-work-my-estate");
46
+ expect(projectKeyOf(null)).toBe("unknown");
47
+ expect(projectKeyOf({})).toBe("unknown");
48
+ });
49
+
50
+ it("gives the logical lens and the plain graph independent keys (#228 acceptance)", () => {
51
+ const p = "/estates/x";
52
+ expect(layoutKey(p, lensKeyOf({ zoom: "logical" }))).not.toBe(layoutKey(p, lensKeyOf({ zoom: "resources" })));
53
+ });
54
+
55
+ it("separates radial from straight, and one stack from another", () => {
56
+ expect(lensKeyOf({ zoom: "resources", radial: true })).toBe("resources+radial");
57
+ expect(lensKeyOf({ zoom: "resources", stack: "edge" })).toBe("resources+stack-edge");
58
+ expect(lensKeyOf({ zoom: "resources" })).toBe("resources");
59
+ expect(layoutKey("/e", lensKeyOf({ zoom: "resources", radial: true }))).not.toBe(layoutKey("/e", lensKeyOf({ zoom: "resources" })));
60
+ });
61
+
62
+ it("keeps the env OUT of the key — an overlay recolours, it does not re-place", () => {
63
+ expect(lensKeyOf({ zoom: "components", env: "prod" })).toBe(lensKeyOf({ zoom: "components" }));
64
+ });
65
+
66
+ it("never produces an empty segment", () => {
67
+ expect(slug("///")).toBe("unknown");
68
+ expect(layoutKey("", "")).toBe("behold.layout.unknown.unknown");
69
+ });
70
+ });
71
+
72
+ describe("normalize", () => {
73
+ it("keeps finite non-zero deltas only", () => {
74
+ expect(normalize({ a: { dx: 10, dy: 0, dw: 4 } })).toEqual({ a: { dx: 10, dw: 4 } });
75
+ });
76
+
77
+ it("drops junk without throwing", () => {
78
+ expect(normalize(null)).toEqual({});
79
+ expect(normalize("nope")).toEqual({});
80
+ expect(normalize({ a: 5, b: null, c: { dx: NaN }, d: { dx: "x" }, e: { nope: 1 } })).toEqual({});
81
+ });
82
+
83
+ it("parses numeric strings (JSON round-trips are not always clean)", () => {
84
+ expect(normalize({ a: { dx: "12.5" } })).toEqual({ a: { dx: 12.5 } });
85
+ });
86
+
87
+ it("isEmpty ignores entries that normalize away", () => {
88
+ expect(isEmpty({ a: { dx: 0, dy: 0 } })).toBe(true);
89
+ expect(isEmpty({ a: { dx: 1 } })).toBe(false);
90
+ });
91
+ });
92
+
93
+ describe("setDelta", () => {
94
+ it("replaces one id and leaves the rest alone", () => {
95
+ const next = setDelta({ a: { dx: 1 }, b: { dy: 2 } }, "a", { dx: 9, dy: 9 });
96
+ expect(next).toEqual({ a: { dx: 9, dy: 9 }, b: { dy: 2 } });
97
+ });
98
+
99
+ it("prunes an id dragged back to where dagre put it", () => {
100
+ expect(setDelta({ a: { dx: 1 } }, "a", { dx: 0, dy: 0 })).toEqual({});
101
+ });
102
+
103
+ it("does not mutate its input", () => {
104
+ const before = { a: { dx: 1 } };
105
+ setDelta(before, "a", { dx: 4 });
106
+ expect(before).toEqual({ a: { dx: 1 } });
107
+ });
108
+
109
+ it("carries a box's size delta alongside its position delta", () => {
110
+ expect(setDelta({}, "box:vpc", { dx: 3, dy: 0, dw: 40, dh: -20 })).toEqual({ "box:vpc": { dx: 3, dw: 40, dh: -20 } });
111
+ });
112
+ });
113
+
114
+ describe("applicable", () => {
115
+ it("silently drops a delta for a node that left the estate", () => {
116
+ expect(applicable({ gone: { dx: 5 }, here: { dy: 3 } }, ["here"])).toEqual({ here: { dy: 3 } });
117
+ });
118
+
119
+ it("takes a Set as well as an array", () => {
120
+ expect(applicable({ a: { dx: 1 } }, new Set(["a"]))).toEqual({ a: { dx: 1 } });
121
+ });
122
+
123
+ it("is a no-op filter when nothing is live", () => {
124
+ expect(applicable({ a: { dx: 1 } }, [])).toEqual({});
125
+ });
126
+ });
127
+
128
+ describe("storage", () => {
129
+ it("round-trips through a storage", () => {
130
+ const s = fakeStorage();
131
+ const key = layoutKey("/estates/x", "components");
132
+ writeLayout(s, key, { api: { dx: 12, dy: -4 } });
133
+ expect(readLayout(s, key)).toEqual({ api: { dx: 12, dy: -4 } });
134
+ });
135
+
136
+ it("removes the key rather than storing an empty layout", () => {
137
+ const s = fakeStorage();
138
+ writeLayout(s, "k", { api: { dx: 1 } });
139
+ writeLayout(s, "k", {});
140
+ expect(s.map.has("k")).toBe(false);
141
+ expect(readLayout(s, "k")).toEqual({});
142
+ });
143
+
144
+ it("survives a corrupt value", () => {
145
+ expect(readLayout(fakeStorage({ k: "{not json" }), "k")).toEqual({});
146
+ });
147
+
148
+ it("survives a storage that throws (private mode, quota, file://)", () => {
149
+ const s = fakeStorage({}, true);
150
+ expect(() => writeLayout(s, "k", { a: { dx: 1 } })).not.toThrow();
151
+ expect(readLayout(s, "k")).toEqual({});
152
+ expect(() => clearLayout(s, "k")).not.toThrow();
153
+ });
154
+
155
+ it("clearLayout drops just this lens's key", () => {
156
+ const s = fakeStorage();
157
+ writeLayout(s, layoutKey("/e", "components"), { a: { dx: 1 } });
158
+ writeLayout(s, layoutKey("/e", "logical"), { a: { dy: 2 } });
159
+ clearLayout(s, layoutKey("/e", "components"));
160
+ expect(readLayout(s, layoutKey("/e", "components"))).toEqual({});
161
+ expect(readLayout(s, layoutKey("/e", "logical"))).toEqual({ a: { dy: 2 } });
162
+ });
163
+ });
package/web/theme.js CHANGED
@@ -53,20 +53,69 @@ export function oklchToHex({ L, C, H }) {
53
53
  l2s(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s));
54
54
  }
55
55
 
56
+ // Drop a colour's chroma without touching its lightness — the chrome's one
57
+ // "quieter" step (#229). Used on the tokens that SURROUND the drift hues, never
58
+ // on the drift hues themselves.
59
+ export function desaturate(hex, k) {
60
+ const { L, C, H } = hexToOklch(hex);
61
+ return oklchToHex({ L, C: C * k, H });
62
+ }
63
+
56
64
  // --- behold's semantic tokens derived from a theme -----------------------
57
65
  // Drift states are ANCHORED to palette slots so "coloured by drift" stays meaningful across
58
66
  // every theme (green=managed … red=degraded); only the shades change. Chrome is derived from
59
67
  // bg/fg mixes. `cat` are the chromatic slots that colorForCategory cycles for substrates.
68
+ //
69
+ // #229: the chrome tokens (line/muted/rule/focus/active/well/shadow) are one
70
+ // step quieter than the palette that feeds them — the status hues are behold's
71
+ // actual message, so everything around them gives up chroma to let them land.
72
+ // Every one of them is still DERIVED: no chrome constant is a fixed colour, and
73
+ // all 552 palettes have to come out of here legible.
60
74
  export function tokensFor(th) {
61
75
  const fgMix = (t) => mix(th.bg, th.fg, t);
76
+ const P = th.palette;
77
+ const panel = fgMix(0.09);
78
+ // The active-control fill (tab strip, the picked option) — the panel pulled
79
+ // toward the accent, then pushed AWAY from the panel's own lightness so the
80
+ // fill is visible even on a palette whose blue slot happens to sit exactly at
81
+ // the panel's lightness (Red Sands, CGA). It stays within one step of the
82
+ // panel, so `--fg` reads on the fill exactly as it reads on the panel — no
83
+ // second ink token to keep legible across the corpus.
84
+ const panelL = hexToOklch(panel).L;
85
+ const rawActive = hexToOklch(mix(panel, P[4], 0.17));
86
+ const active = oklchToHex({
87
+ ...rawActive,
88
+ L: clamp(panelL + (th.dark ? 1 : -1) * clamp(Math.abs(rawActive.L - panelL), 0.055, 0.1), 0.05, 0.95),
89
+ });
62
90
  return {
63
91
  dark: th.dark,
64
92
  bg: th.bg,
65
- panel: fgMix(0.09),
66
- line: fgMix(0.16),
93
+ panel,
94
+ line: desaturate(fgMix(0.16), 0.75),
67
95
  fg: th.fg,
68
- muted: fgMix(0.5),
96
+ muted: desaturate(fgMix(0.5), 0.75),
69
97
  edge: fgMix(0.32),
98
+ // The structural border — the panel shell, the pane edge, the footer rule.
99
+ // Tinted by the palette's OWN bright-black slot instead of being one more
100
+ // fg/bg mix, but anchored to a guaranteed-visible mix so a theme whose
101
+ // slot 8 sits on the panel's lightness still draws an edge.
102
+ rule: mix(fgMix(0.22), P[8], 0.45),
103
+ // Interaction chrome (hover/focus borders, the load spinner): the accent
104
+ // with most of its chroma taken off, so hover doesn't compete with a
105
+ // pending node for the same blue.
106
+ focus: desaturate(P[4], 0.4),
107
+ active,
108
+ // The recessed ground — the nowline log, the ⌘K input. One perceptual step
109
+ // off the background (down, or UP on the near-black palettes where there is
110
+ // no "darker" left), keeping the bg's own hue so it never reads as a patch
111
+ // of a different theme.
112
+ well: (() => {
113
+ const b = hexToOklch(th.bg);
114
+ return oklchToHex({ ...b, L: clamp(b.L + (b.L < 0.16 ? 0.06 : -0.06), 0, 1) });
115
+ })(),
116
+ // Shadow/scrim base, used through color-mix() at the call site — a light
117
+ // theme's drop shadow must not be the same slab of black a dark one wants.
118
+ shadow: mix(th.bg, "#000000", th.dark ? 0.85 : 0.6),
70
119
  managed: th.palette[2], // green — good / managed
71
120
  foreign: th.palette[3], // yellow — warn / foreign
72
121
  pending: th.palette[4], // blue — accent / pending
@@ -84,8 +133,10 @@ export function tokensFor(th) {
84
133
  };
85
134
  }
86
135
 
87
- // CSS custom properties behold's chrome (:root) reads.
88
- const CSS_VARS = ["bg", "panel", "line", "fg", "muted", "edge", "managed", "foreign", "pending", "degraded", "neutral", "runtime"];
136
+ // CSS custom properties behold's chrome (:root) reads. The pre-paint fallback
137
+ // block in index.html mirrors these for the DEFAULT theme smoke/ui-smoke.mjs
138
+ // recomputes it from here and fails if the two ever drift apart.
139
+ const CSS_VARS = ["bg", "panel", "line", "fg", "muted", "edge", "managed", "foreign", "pending", "degraded", "neutral", "runtime", "rule", "focus", "active", "well", "shadow"];
89
140
 
90
141
  // pinhole (the graph painter) reads its OWN vars, `--pin-<token>` (emitted as
91
142
  // `var(--pin-token, <baked default>)`). Derive them from the active Ghostty theme so the graph
@@ -171,7 +222,8 @@ export function mountThemePicker(container) {
171
222
  const sel = document.createElement("select");
172
223
  sel.setAttribute("aria-label", "Color theme");
173
224
  sel.title = "Color theme";
174
- sel.style.cssText = "background:var(--panel);color:var(--fg);border:1px solid var(--line);border-radius:6px;padding:3px 8px;font-size:12px;max-width:220px";
225
+ sel.style.cssText =
226
+ "background:var(--well);color:var(--fg);border:1px solid var(--line);border-radius:var(--r-ctl);padding:4px 8px;font:var(--t-body)/1.3 var(--font-ui);max-width:220px";
175
227
  const groups = { Dark: document.createElement("optgroup"), Light: document.createElement("optgroup") };
176
228
  groups.Dark.label = "Dark"; groups.Light.label = "Light";
177
229
  for (const name of listThemes()) {