@intentius/behold 0.7.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.
Files changed (104) hide show
  1. package/AGENTS.md +94 -2
  2. package/README.md +133 -2
  3. package/demos.json +25 -1
  4. package/dist/cli.js +3176 -330
  5. package/example-argo-estate/README.md +71 -0
  6. package/example-argo-estate/app-a/chant.config.ts +18 -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 +18 -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 +29 -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-carve/README.md +194 -0
  23. package/example-carve/app/chant.config.ts +6 -0
  24. package/example-carve/app/package-lock.json +1075 -0
  25. package/example-carve/app/package.json +13 -0
  26. package/example-carve/app/src/carved.ts +30 -0
  27. package/example-carve/app/tsconfig.json +1 -0
  28. package/example-carve/carve-report.json +872 -0
  29. package/example-carve/legacy-tf/cdn.tf +23 -0
  30. package/example-carve/legacy-tf/compute.tf +63 -0
  31. package/example-carve/legacy-tf/floci-override.tf.disabled +61 -0
  32. package/example-carve/legacy-tf/modules/cdn/main.tf +72 -0
  33. package/example-carve/legacy-tf/naming.tf +10 -0
  34. package/example-carve/legacy-tf/network.tf +119 -0
  35. package/example-carve/legacy-tf/observability.tf +18 -0
  36. package/example-carve/legacy-tf/outputs.tf +16 -0
  37. package/example-carve/legacy-tf/storage.tf +33 -0
  38. package/example-carve/legacy-tf/terraform.tfstate +602 -0
  39. package/example-carve/legacy-tf/versions.tf +40 -0
  40. package/example-flux-estate/README.md +22 -12
  41. package/example-flux-estate/app-a/package.json +2 -2
  42. package/example-flux-estate/app-a/src/app.ts +2 -1
  43. package/example-flux-estate/app-b/chant.config.ts +4 -3
  44. package/example-flux-estate/app-b/package.json +2 -2
  45. package/example-flux-estate/app-b/src/app.ts +5 -3
  46. package/example-flux-estate/control-plane/package.json +2 -2
  47. package/example-flux-estate/control-plane/src/flux.ts +10 -2
  48. package/example-flux-estate/package-lock.json +17 -17
  49. package/example-k8s/README.md +16 -2
  50. package/example-k8s/base/deployment.yaml +21 -0
  51. package/example-k8s/base/kustomization.yaml +3 -0
  52. package/example-k8s/base/service.yaml +12 -0
  53. package/example-k8s/chant.config.ts +10 -0
  54. package/example-k8s/overlays/dev/kustomization.yaml +6 -0
  55. package/example-k8s/package-lock.json +18 -18
  56. package/example-k8s/package.json +3 -3
  57. package/example-writes/package-lock.json +14 -14
  58. package/example-writes/package.json +3 -3
  59. package/package.json +9 -6
  60. package/web/app.js +1027 -42
  61. package/web/carve-steps.js +610 -0
  62. package/web/carve-steps.test.js +233 -0
  63. package/web/demos.js +71 -0
  64. package/web/demos.test.js +83 -0
  65. package/web/icons/cncf/argo.svg +1 -0
  66. package/web/icons/cncf/flux.svg +1 -0
  67. package/web/icons/cncf/helm.svg +1 -0
  68. package/web/icons/k8s/c-role.svg +1 -0
  69. package/web/icons/k8s/cm.svg +1 -0
  70. package/web/icons/k8s/crb.svg +1 -0
  71. package/web/icons/k8s/crd.svg +1 -0
  72. package/web/icons/k8s/cronjob.svg +1 -0
  73. package/web/icons/k8s/deploy.svg +1 -0
  74. package/web/icons/k8s/ds.svg +1 -0
  75. package/web/icons/k8s/ep.svg +1 -0
  76. package/web/icons/k8s/group.svg +1 -0
  77. package/web/icons/k8s/hpa.svg +1 -0
  78. package/web/icons/k8s/ing.svg +1 -0
  79. package/web/icons/k8s/job.svg +1 -0
  80. package/web/icons/k8s/limits.svg +1 -0
  81. package/web/icons/k8s/netpol.svg +1 -0
  82. package/web/icons/k8s/ns.svg +1 -0
  83. package/web/icons/k8s/pod.svg +1 -0
  84. package/web/icons/k8s/psp.svg +1 -0
  85. package/web/icons/k8s/pv.svg +1 -0
  86. package/web/icons/k8s/pvc.svg +1 -0
  87. package/web/icons/k8s/quota.svg +1 -0
  88. package/web/icons/k8s/rb.svg +1 -0
  89. package/web/icons/k8s/role.svg +1 -0
  90. package/web/icons/k8s/rs.svg +1 -0
  91. package/web/icons/k8s/sa.svg +1 -0
  92. package/web/icons/k8s/sc.svg +1 -0
  93. package/web/icons/k8s/secret.svg +1 -0
  94. package/web/icons/k8s/sts.svg +1 -0
  95. package/web/icons/k8s/svc.svg +1 -0
  96. package/web/icons/k8s/user.svg +1 -0
  97. package/web/icons/k8s/vol.svg +1 -0
  98. package/web/index.html +285 -85
  99. package/web/json-view.js +334 -0
  100. package/web/json-view.test.js +218 -0
  101. package/web/layout-store.js +278 -0
  102. package/web/layout-store.test.js +388 -0
  103. package/web/panel.js +28 -0
  104. package/web/theme.js +114 -6
package/web/panel.js CHANGED
@@ -122,6 +122,34 @@ export function setPanelTab(tab, { expand = true } = {}) {
122
122
  }
123
123
  }
124
124
 
125
+ /**
126
+ * #254: add a tab the markup doesn't ship — the Carve walkthrough, which only
127
+ * exists when the server is serving one. Mounted at runtime rather than hidden
128
+ * in index.html so no other project grows a dead tab (and so a persisted
129
+ * `tab: "carve"` can never strand someone on an empty section: the tab isn't
130
+ * there to be restored). Returns the section element to render into; calling it
131
+ * twice is a no-op that returns the same one.
132
+ */
133
+ export function addPanelTab(id, label, title) {
134
+ if (!panel) return null;
135
+ const existing = panel.querySelector(`#panel-body section[data-tab="${id}"]`);
136
+ if (existing) return existing;
137
+ const b = document.createElement("button");
138
+ b.dataset.tab = id;
139
+ b.textContent = label;
140
+ if (title) b.title = title;
141
+ b.addEventListener("click", () => setPanelTab(id));
142
+ // Before the ⌘K pill + collapse chevron, which are not tabs.
143
+ document.getElementById("panel-tabs").insertBefore(b, document.getElementById("hintk"));
144
+ const section = document.createElement("section");
145
+ section.dataset.tab = id;
146
+ document.getElementById("panel-body").appendChild(section);
147
+ // The new button starts inactive; the section starts hidden. Re-applying the
148
+ // current tab keeps the two in step whatever it happens to be.
149
+ setPanelTab(state.tab, { expand: false });
150
+ return section;
151
+ }
152
+
125
153
  function setCollapsed(on) {
126
154
  state.collapsed = !!on;
127
155
  panel.classList.toggle("collapsed", state.collapsed);
package/web/theme.js CHANGED
@@ -32,6 +32,18 @@ export function readableOn(bg) { return isDark(bg) ? "#ffffff" : "#000000"; }
32
32
  // --- OKLCH (perceptual hue rotation for category counts > palette slots) --
33
33
  const s2l = (c) => ((c /= 255) <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
34
34
  const l2s = (c) => { const v = c <= 0.0031308 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055; return clamp(v, 0, 1) * 255; };
35
+ // A real WCAG contrast ratio (#240) — sRGB→linear via s2l (the same EOTF step
36
+ // hexToOklch already does below), unlike the quick luminance() above, which
37
+ // skips gamma correction and is only good enough for a light/dark coin-flip
38
+ // (isDark/readableOn). The floor --muted has to clear is a real accessibility
39
+ // number, and luminance()'s shortcut proved too far off it on the exact
40
+ // palette that opened this issue (Darkermatrix: quick metric said 3.03,
41
+ // actual WCAG was 2.13) to be worth the savings.
42
+ function relLuminance(hex) { const [r, g, b] = hexToRgb(hex).map(s2l); return 0.2126 * r + 0.7152 * g + 0.0722 * b; }
43
+ export function contrast(a, b) {
44
+ const A = relLuminance(a), B = relLuminance(b);
45
+ return (Math.max(A, B) + 0.05) / (Math.min(A, B) + 0.05);
46
+ }
35
47
  export function hexToOklch(hex) {
36
48
  const [R, G, B] = hexToRgb(hex).map(s2l);
37
49
  const l = Math.cbrt(0.4122214708 * R + 0.5363325363 * G + 0.0514459929 * B);
@@ -53,20 +65,113 @@ export function oklchToHex({ L, C, H }) {
53
65
  l2s(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s));
54
66
  }
55
67
 
68
+ // Drop a colour's chroma without touching its lightness — the chrome's one
69
+ // "quieter" step (#229). Used on the tokens that SURROUND the drift hues, never
70
+ // on the drift hues themselves.
71
+ export function desaturate(hex, k) {
72
+ const { L, C, H } = hexToOklch(hex);
73
+ return oklchToHex({ L, C: C * k, H });
74
+ }
75
+
76
+ // #240: the same guaranteed-visible trick #229 used for --active (push OKLCH
77
+ // L away from a reference until it clears a bar), generalised from a fixed L
78
+ // delta to an actual contrast() floor — because unlike --active's "just don't
79
+ // land on the same fill", secondary text has a real legibility target.
80
+ //
81
+ // Tries `dark`'s intended direction first (lighter text on a dark theme,
82
+ // darker on a light one — the direction a person would actually pick, and
83
+ // the one #229's --active push uses). luminance() isn't gamma-corrected, so
84
+ // its contrast() ratio is asymmetric in a way real perceived contrast isn't
85
+ // — it overstates how much headroom the near-black end has. Chasing that
86
+ // headroom blindly is how a dark theme's muted text ends up pushed to
87
+ // near-black-on-dark-panel, technically clearing the ratio while reading as
88
+ // invisible. So the "chase more headroom" fallback only fires if the
89
+ // intended direction's own gamut-limited walk can't reach the floor at all
90
+ // (a saturated, nominally-dark bg like Hot Dog Stand's red, whose panel ends
91
+ // up mid-bright once mixed toward fg) — never as a first choice. Chroma
92
+ // eases toward 0 as L walks away so a saturated start doesn't clip out of
93
+ // gamut and stall short of the target.
94
+ function pushForContrast(hex, against, floor, dark) {
95
+ if (contrast(hex, against) >= floor) return hex;
96
+ const tok = hexToOklch(hex);
97
+ const walk = (dir) => {
98
+ let best = hex, bestRatio = contrast(hex, against);
99
+ for (let step = 1; step <= 100; step++) {
100
+ const t = step / 100;
101
+ const L = clamp(tok.L + dir * step * 0.01, 0, 1);
102
+ const out = oklchToHex({ L, C: tok.C * (1 - t * 0.6), H: tok.H });
103
+ const ratio = contrast(out, against);
104
+ if (ratio > bestRatio) { bestRatio = ratio; best = out; }
105
+ if (ratio >= floor) return { hex: out, ratio, reached: true };
106
+ if (L === 0 || L === 1) break;
107
+ }
108
+ return { hex: best, ratio: bestRatio, reached: false };
109
+ };
110
+ const primary = walk(dark ? 1 : -1);
111
+ if (primary.reached) return primary.hex;
112
+ const fallback = walk(dark ? -1 : 1);
113
+ return fallback.ratio > primary.ratio ? fallback.hex : primary.hex;
114
+ }
115
+
56
116
  // --- behold's semantic tokens derived from a theme -----------------------
57
117
  // Drift states are ANCHORED to palette slots so "coloured by drift" stays meaningful across
58
118
  // every theme (green=managed … red=degraded); only the shades change. Chrome is derived from
59
119
  // bg/fg mixes. `cat` are the chromatic slots that colorForCategory cycles for substrates.
120
+ //
121
+ // #229: the chrome tokens (line/muted/rule/focus/active/well/shadow) are one
122
+ // step quieter than the palette that feeds them — the status hues are behold's
123
+ // actual message, so everything around them gives up chroma to let them land.
124
+ // Every one of them is still DERIVED: no chrome constant is a fixed colour, and
125
+ // all 552 palettes have to come out of here legible.
60
126
  export function tokensFor(th) {
61
127
  const fgMix = (t) => mix(th.bg, th.fg, t);
128
+ const P = th.palette;
129
+ const panel = fgMix(0.09);
130
+ // The active-control fill (tab strip, the picked option) — the panel pulled
131
+ // toward the accent, then pushed AWAY from the panel's own lightness so the
132
+ // fill is visible even on a palette whose blue slot happens to sit exactly at
133
+ // the panel's lightness (Red Sands, CGA). It stays within one step of the
134
+ // panel, so `--fg` reads on the fill exactly as it reads on the panel — no
135
+ // second ink token to keep legible across the corpus.
136
+ const panelL = hexToOklch(panel).L;
137
+ const rawActive = hexToOklch(mix(panel, P[4], 0.17));
138
+ const active = oklchToHex({
139
+ ...rawActive,
140
+ L: clamp(panelL + (th.dark ? 1 : -1) * clamp(Math.abs(rawActive.L - panelL), 0.055, 0.1), 0.05, 0.95),
141
+ });
62
142
  return {
63
143
  dark: th.dark,
64
144
  bg: th.bg,
65
- panel: fgMix(0.09),
66
- line: fgMix(0.16),
145
+ panel,
146
+ line: desaturate(fgMix(0.16), 0.75),
67
147
  fg: th.fg,
68
- muted: fgMix(0.5),
148
+ // #240: secondary text needs an actual floor, not just "quieter than
149
+ // fg" — a handful of low-range palettes (Darkermatrix and friends) mix
150
+ // fg/panel close enough that the quieted 0.5 mix reads at ~1.25:1.
151
+ // Push its OKLCH L away from --panel's until it clears 3:1.
152
+ muted: pushForContrast(desaturate(fgMix(0.5), 0.75), panel, 3, th.dark),
69
153
  edge: fgMix(0.32),
154
+ // The structural border — the panel shell, the pane edge, the footer rule.
155
+ // Tinted by the palette's OWN bright-black slot instead of being one more
156
+ // fg/bg mix, but anchored to a guaranteed-visible mix so a theme whose
157
+ // slot 8 sits on the panel's lightness still draws an edge.
158
+ rule: mix(fgMix(0.22), P[8], 0.45),
159
+ // Interaction chrome (hover/focus borders, the load spinner): the accent
160
+ // with most of its chroma taken off, so hover doesn't compete with a
161
+ // pending node for the same blue.
162
+ focus: desaturate(P[4], 0.4),
163
+ active,
164
+ // The recessed ground — the nowline log, the ⌘K input. One perceptual step
165
+ // off the background (down, or UP on the near-black palettes where there is
166
+ // no "darker" left), keeping the bg's own hue so it never reads as a patch
167
+ // of a different theme.
168
+ well: (() => {
169
+ const b = hexToOklch(th.bg);
170
+ return oklchToHex({ ...b, L: clamp(b.L + (b.L < 0.16 ? 0.06 : -0.06), 0, 1) });
171
+ })(),
172
+ // Shadow/scrim base, used through color-mix() at the call site — a light
173
+ // theme's drop shadow must not be the same slab of black a dark one wants.
174
+ shadow: mix(th.bg, "#000000", th.dark ? 0.85 : 0.6),
70
175
  managed: th.palette[2], // green — good / managed
71
176
  foreign: th.palette[3], // yellow — warn / foreign
72
177
  pending: th.palette[4], // blue — accent / pending
@@ -84,8 +189,10 @@ export function tokensFor(th) {
84
189
  };
85
190
  }
86
191
 
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"];
192
+ // CSS custom properties behold's chrome (:root) reads. The pre-paint fallback
193
+ // block in index.html mirrors these for the DEFAULT theme smoke/ui-smoke.mjs
194
+ // recomputes it from here and fails if the two ever drift apart.
195
+ const CSS_VARS = ["bg", "panel", "line", "fg", "muted", "edge", "managed", "foreign", "pending", "degraded", "neutral", "runtime", "rule", "focus", "active", "well", "shadow"];
89
196
 
90
197
  // pinhole (the graph painter) reads its OWN vars, `--pin-<token>` (emitted as
91
198
  // `var(--pin-token, <baked default>)`). Derive them from the active Ghostty theme so the graph
@@ -171,7 +278,8 @@ export function mountThemePicker(container) {
171
278
  const sel = document.createElement("select");
172
279
  sel.setAttribute("aria-label", "Color theme");
173
280
  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";
281
+ sel.style.cssText =
282
+ "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
283
  const groups = { Dark: document.createElement("optgroup"), Light: document.createElement("optgroup") };
176
284
  groups.Dark.label = "Dark"; groups.Light.label = "Light";
177
285
  for (const name of listThemes()) {