@intentius/behold 0.6.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.
- package/AGENTS.md +9 -2
- package/README.md +12 -2
- package/demos.json +8 -0
- package/dist/cli.js +769 -139
- package/example-argo-estate/README.md +46 -0
- package/example-argo-estate/app-a/chant.config.ts +10 -0
- package/example-argo-estate/app-a/manifests/app.yaml +54 -0
- package/example-argo-estate/app-a/package.json +13 -0
- package/example-argo-estate/app-a/src/app.ts +35 -0
- package/example-argo-estate/app-a/tsconfig.json +1 -0
- package/example-argo-estate/app-b/chant.config.ts +9 -0
- package/example-argo-estate/app-b/manifests/app.yaml +54 -0
- package/example-argo-estate/app-b/package.json +13 -0
- package/example-argo-estate/app-b/src/app.ts +34 -0
- package/example-argo-estate/app-b/tsconfig.json +1 -0
- package/example-argo-estate/control-plane/chant.config.ts +14 -0
- package/example-argo-estate/control-plane/package.json +13 -0
- package/example-argo-estate/control-plane/src/argo.ts +69 -0
- package/example-argo-estate/control-plane/tsconfig.json +1 -0
- package/example-argo-estate/package-lock.json +1949 -0
- package/example-argo-estate/package.json +10 -0
- package/example-flux-estate/README.md +13 -8
- package/example-flux-estate/app-a/package.json +3 -3
- package/example-flux-estate/app-b/package.json +3 -3
- package/example-flux-estate/control-plane/package.json +2 -2
- package/example-flux-estate/control-plane/src/flux.ts +6 -0
- package/example-flux-estate/package-lock.json +17 -17
- package/example-k8s/README.md +16 -2
- package/example-k8s/base/deployment.yaml +21 -0
- package/example-k8s/base/kustomization.yaml +3 -0
- package/example-k8s/base/service.yaml +12 -0
- package/example-k8s/chant.config.ts +10 -0
- package/example-k8s/overlays/dev/kustomization.yaml +6 -0
- package/example-k8s/package-lock.json +43 -40
- package/example-k8s/package.json +4 -4
- package/example-writes/package-lock.json +18 -16
- package/example-writes/package.json +3 -3
- package/package.json +3 -2
- package/web/app.js +359 -21
- package/web/icons/cncf/argo.svg +1 -0
- package/web/icons/cncf/flux.svg +1 -0
- package/web/icons/cncf/helm.svg +1 -0
- package/web/icons/k8s/c-role.svg +1 -0
- package/web/icons/k8s/cm.svg +1 -0
- package/web/icons/k8s/crb.svg +1 -0
- package/web/icons/k8s/crd.svg +1 -0
- package/web/icons/k8s/cronjob.svg +1 -0
- package/web/icons/k8s/deploy.svg +1 -0
- package/web/icons/k8s/ds.svg +1 -0
- package/web/icons/k8s/ep.svg +1 -0
- package/web/icons/k8s/group.svg +1 -0
- package/web/icons/k8s/hpa.svg +1 -0
- package/web/icons/k8s/ing.svg +1 -0
- package/web/icons/k8s/job.svg +1 -0
- package/web/icons/k8s/limits.svg +1 -0
- package/web/icons/k8s/netpol.svg +1 -0
- package/web/icons/k8s/ns.svg +1 -0
- package/web/icons/k8s/pod.svg +1 -0
- package/web/icons/k8s/psp.svg +1 -0
- package/web/icons/k8s/pv.svg +1 -0
- package/web/icons/k8s/pvc.svg +1 -0
- package/web/icons/k8s/quota.svg +1 -0
- package/web/icons/k8s/rb.svg +1 -0
- package/web/icons/k8s/role.svg +1 -0
- package/web/icons/k8s/rs.svg +1 -0
- package/web/icons/k8s/sa.svg +1 -0
- package/web/icons/k8s/sc.svg +1 -0
- package/web/icons/k8s/secret.svg +1 -0
- package/web/icons/k8s/sts.svg +1 -0
- package/web/icons/k8s/svc.svg +1 -0
- package/web/icons/k8s/user.svg +1 -0
- package/web/icons/k8s/vol.svg +1 -0
- package/web/index.html +192 -84
- package/web/layout-store.js +118 -0
- package/web/layout-store.test.js +163 -0
- 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
|
|
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
|
-
|
|
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 =
|
|
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()) {
|