@intentius/behold 0.2.3 → 0.4.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/web/app.js CHANGED
@@ -4,11 +4,15 @@
4
4
  // the inspect panel, and (later) the lanes + delegated actions.
5
5
 
6
6
  // Ghostty colour themes (#62): apply the persisted/default theme's tokens as CSS vars
7
- // before first paint (so the whole graph + chrome recolour from one source), then mount
8
- // the theme picker into the header's #pickers slot.
7
+ // before first paint (so the whole graph + chrome recolour from one source). Then the
8
+ // floating control panel's chrome (panel.js drag/snap/collapse/tabs, persisted
9
+ // position), and the theme picker into the panel's View-tab slot (a stable element
10
+ // renderPanelView never rewrites, so the select mounts once and survives re-renders).
9
11
  import { initTheme, mountThemePicker, readableOn, colorForCategory, onThemeChange, getTokens } from "./theme.js";
12
+ import { initPanel, setPanelTab, togglePanelCollapsed, isPanelCollapsed } from "./panel.js";
10
13
  initTheme();
11
- mountThemePicker(document.getElementById("pickers"));
14
+ initPanel();
15
+ mountThemePicker(document.getElementById("panel-theme"));
12
16
 
13
17
  // Colour node fills by category/kind using the theme's FULL palette (spicypath-style, so the
14
18
  // graph shows the theme's many colours), while pinhole's drift stays on the bar/stroke. Node
@@ -588,6 +592,14 @@ function wire(ir) {
588
592
  // the `changed` SSE re-pull and a palette lens change go through the same path.
589
593
  const view = { env: null, detail: 2, components: true, logical: false, runtime: false, tier: null, target: null, stack: null, radial: false };
590
594
 
595
+ // #182: `components` is the boot default, but a project that declares no
596
+ // components renders it as ZERO nodes — the first screen was a blank graph
597
+ // pane with only a statusbar note explaining. True exactly once, consumed by
598
+ // load(): an empty first components fetch falls back to the resources zoom
599
+ // before anything is painted. An explicit later pick of "components" (panel
600
+ // or ⌘K) still shows the honest empty view + the server's note.
601
+ let autoZoomFallback = true;
602
+
591
603
  // v0.1.0 preview lock (set from /api/project in initActions): hides the git/PR
592
604
  // write ops (Rollback, Sync, Adopt, Run ▾) — the server also 403s them. Local
593
605
  // deploy (Apply all / dial), Reset, Bring up, Approve, and reads stay on.
@@ -631,86 +643,379 @@ function applyZoom(z) {
631
643
  if (z !== "components" && z !== "logical") view.detail = ZOOM_DETAIL[z] ?? 2;
632
644
  }
633
645
 
634
- // #73 "hide controls, never state": zoom/env/tier/radial used to live in
635
- // header <select>s; the pickers moved into the ⌘K palette (paletteCommands()
636
- // below), but the CURRENT value must stay visible without opening it — this
637
- // is the one place that reads. Called after every render() and once before
638
- // the first load (initPickers()). stack (#76) joins the strip the same way —
639
- // only ever truthy on a project that declares `stacks[]` (initPickers seeds
640
- // `view.stack` from `info.stacks`; a project with none leaves it null), so a
641
- // single-stack project's strip is unaffected.
642
- // The zoom picker, back in the header as a control you can see and click.
643
- //
644
- // #73 moved the pickers into ⌘K and left the current value on the strip, on the
645
- // principle "hide controls, never state". That reads well until someone opens
646
- // behold for the first time: the strip says "zoom: components", it looks like a
647
- // dropdown because it is a value next to other values, and clicking it does
648
- // nothing — renderStatusbar() only ever sets textContent. The discoverable path
649
- // to the single most useful control is a keyboard shortcut hinted by a "⌘K"
650
- // glyph at the far end of the header.
651
- //
652
- // So zoom gets a real <select> and the palette keeps its entry. The other axes
653
- // stay where #73 put them: zoom is the one you reach for constantly, and one
654
- // visible control is not the header of selects that issue was about.
655
- function renderZoomPicker() {
656
- // #zoom-slot, top-left after the brand — not #pickers on the far right,
657
- // where it sat beside the theme select and read as chrome rather than as the
658
- // control. Falls back to #pickers so a static export built from older markup
659
- // still gets a picker rather than none.
660
- const slot = document.getElementById("zoom-slot") || document.getElementById("pickers");
661
- if (!slot) return;
662
- let sel = document.getElementById("zoom-picker");
663
- if (!sel) {
664
- sel = document.createElement("select");
665
- sel.id = "zoom-picker";
666
- sel.title = "Zoom components, logical, composites, resources, attributes, runtime (also ⌘K)";
646
+ // --- Floating control panel content ---------------------------------------
647
+ // panel.js owns the chrome (drag, snap to edges/corners, collapse, tabs);
648
+ // these functions own what's IN the tabs, re-rendered from renderStatusbar()
649
+ // which already runs on every load/lens change so the panel always
650
+ // reflects current state. The Substrates and Deploy tabs are populated
651
+ // separately (renderSubstrates / initActions / renderDial): their host
652
+ // elements simply live inside the panel now. Every control here keeps its ⌘K
653
+ // twin (paletteCommands()) the panel is the discoverable surface, the
654
+ // palette the fast one.
655
+ function panelHeading(text) {
656
+ const h = document.createElement("h3");
657
+ h.textContent = text;
658
+ return h;
659
+ }
660
+ function panelMuted(text) {
661
+ const p = document.createElement("p");
662
+ p.className = "panel-muted";
663
+ p.textContent = text;
664
+ return p;
665
+ }
666
+ function panelOpt(label, active, onClick, title) {
667
+ const b = button(label, "opt" + (active ? " active" : ""), onClick);
668
+ if (title) b.title = title;
669
+ return b;
670
+ }
671
+ function actButton(label, onClick, title) {
672
+ const b = button(label, "act", onClick);
673
+ if (title) b.title = title;
674
+ return b;
675
+ }
676
+
677
+ // View tab: the zoom stops (the one granularity axis, coarse → fine), the
678
+ // radial toggle (entity zooms only), and the graph tools. The theme picker
679
+ // mounts once into #panel-theme at boot and is never rewritten here.
680
+ function renderPanelView() {
681
+ const zoom = document.getElementById("panel-zoom");
682
+ if (!zoom) return;
683
+ zoom.innerHTML = "";
684
+ zoom.appendChild(panelHeading("zoom"));
685
+ const current = zoomValue();
686
+ // runtime is only meaningful with an env: it descends below the declaration
687
+ // boundary to owner-referenced children, which exist in a cluster and never
688
+ // in your source.
689
+ for (const [label, v] of ZOOM_OPTS.filter(([, z]) => z !== "runtime" || view.env)) {
690
+ zoom.appendChild(
691
+ panelOpt(label.replace(/^zoom: /, ""), v === current, () => {
692
+ applyZoom(v);
693
+ renderStatusbar();
694
+ load();
695
+ }),
696
+ );
697
+ }
698
+ // Radial toggle — entity zooms only, same gate the ⌘K entry has
699
+ // (components/logical both lay themselves out: waves / nested arch boxes).
700
+ if (!view.components && !view.logical) {
701
+ zoom.appendChild(panelHeading("layout"));
702
+ zoom.appendChild(
703
+ panelOpt("radial", view.radial, () => {
704
+ view.radial = !view.radial;
705
+ load();
706
+ }, "Curl the wide DAG onto concentric rings"),
707
+ );
708
+ }
709
+ const tools = document.getElementById("panel-viewtools");
710
+ tools.innerHTML = "";
711
+ tools.appendChild(panelHeading("graph"));
712
+ const row = document.createElement("div");
713
+ row.className = "prow";
714
+ row.appendChild(actButton("⤢ fit", () => fitGraph(), "Reset zoom/pan to fit. Pinch or ⌘/Ctrl+scroll zooms at the cursor; drag pans."));
715
+ row.appendChild(actButton("↓ SVG", () => exportSvg(), "Export the current graph as a standalone SVG file"));
716
+ const collapsed = document.getElementById("app").classList.contains("inspect-collapsed");
717
+ row.appendChild(
718
+ actButton(collapsed ? "show inspect" : "hide inspect", () => {
719
+ toggleInspect();
720
+ renderPanelView();
721
+ }),
722
+ );
723
+ const lanes = document.createElement("a");
724
+ lanes.href = "/lanes";
725
+ lanes.textContent = "lanes →";
726
+ lanes.title = "The time-lanes view — captured frames of this graph over time";
727
+ lanes.style.cssText = "color:var(--pending);text-decoration:none;font-size:12px";
728
+ row.appendChild(lanes);
729
+ tools.appendChild(row);
730
+ }
731
+
732
+ // Scope tab: which world the graph reads — env (source vs live overlay),
733
+ // stack (#76), tier and target (M2 #54). The same lenses ⌘K offers, but
734
+ // visible: this tab is the answer to "what can I even do in behold?".
735
+ // #195: filesystem basename, for showing a project by name with the full
736
+ // path demoted to a tooltip / muted line.
737
+ function pathBasename(p) {
738
+ return String(p || "").replace(/\/+$/, "").split("/").pop() || String(p || "");
739
+ }
740
+
741
+ // #195: pop the OS file manager (Finder on macOS) at a project directory —
742
+ // the server allowlists to served + recent projects.
743
+ async function revealProject(dir) {
744
+ try {
745
+ const r = await fetch("/api/project/reveal", {
746
+ method: "POST",
747
+ headers: { "content-type": "application/json" },
748
+ body: JSON.stringify(dir ? { dir } : {}),
749
+ });
750
+ const j = await r.json();
751
+ if (j.error) showToast("✗ reveal: " + j.error, false);
752
+ } catch (e) {
753
+ showToast("✗ reveal: " + e.message, false);
754
+ }
755
+ }
756
+
757
+ // #195: switch the served project. On success the page reloads — every
758
+ // client-side list and cache is project-scoped, so a clean boot is the honest
759
+ // way to re-seed all of it.
760
+ async function switchProject(dir) {
761
+ showLoading(`switching to ${pathBasename(dir)}…`);
762
+ try {
763
+ const r = await fetch("/api/project/open", {
764
+ method: "POST",
765
+ headers: { "content-type": "application/json" },
766
+ body: JSON.stringify({ dir }),
767
+ });
768
+ const j = await r.json();
769
+ if (!r.ok || j.error) {
770
+ hideLoading();
771
+ showToast("✗ switch: " + (j.error || r.statusText), false);
772
+ return;
773
+ }
774
+ location.reload();
775
+ } catch (e) {
776
+ hideLoading();
777
+ showToast("✗ switch: " + e.message, false);
778
+ }
779
+ }
780
+
781
+ function renderPanelScope() {
782
+ const host = document.getElementById("tab-scope");
783
+ if (!host) return;
784
+ host.innerHTML = "";
785
+ // #195: which project is loaded — the first thing this tab answers. Name
786
+ // bold, full path as a muted line, every estate member listed when several
787
+ // projects are composed, and a reveal button to pop the folder in the OS
788
+ // file manager.
789
+ host.appendChild(panelHeading("project"));
790
+ const info = projectInfo || {};
791
+ const estateDirs = info.projectDirs && info.projectDirs.length > 1 ? info.projectDirs : info.projectDir ? [info.projectDir] : [];
792
+ for (const dir of estateDirs) {
793
+ const row = document.createElement("div");
794
+ row.className = "prow";
795
+ const name = document.createElement("span");
796
+ name.className = "grow";
797
+ name.style.fontWeight = "600";
798
+ name.textContent = pathBasename(dir) + (estateDirs.length > 1 && dir === info.projectDir ? " · primary" : "");
799
+ name.title = dir;
800
+ row.appendChild(name);
801
+ if (!staticMode) row.appendChild(actButton("⌖ reveal", () => revealProject(dir), "Open this project's folder in your file manager"));
802
+ host.appendChild(row);
803
+ host.appendChild(panelMuted(dir));
804
+ }
805
+ if (!estateDirs.length) host.appendChild(panelMuted("no project loaded yet"));
806
+ // #195: switching — recents first (server-persisted, validated), then a
807
+ // free path input. Locked in preview mode (the demo's contract) and
808
+ // meaningless in a static export.
809
+ if (!staticMode && !previewMode) {
810
+ host.appendChild(panelHeading("switch project"));
811
+ const recents = (info.recents || []).filter((d) => d !== info.projectDir);
812
+ for (const d of recents.slice(0, 8)) {
813
+ host.appendChild(panelOpt(pathBasename(d), false, () => switchProject(d), d));
814
+ }
815
+ if (!recents.length) host.appendChild(panelMuted("projects you open appear here"));
816
+ const row = document.createElement("div");
817
+ row.className = "prow";
818
+ const input = document.createElement("input");
819
+ input.className = "panel-input";
820
+ input.placeholder = "/path/to/chant-project";
821
+ input.title = "Absolute path to a chant project (a directory with a chant.config.ts)";
822
+ const go = actButton("open →", () => {
823
+ if (input.value.trim()) switchProject(input.value.trim());
824
+ });
825
+ input.addEventListener("keydown", (e) => {
826
+ if (e.key === "Enter") go.click();
827
+ });
828
+ row.append(input, go);
829
+ host.appendChild(row);
830
+ }
831
+ host.appendChild(panelHeading("environment"));
832
+ host.appendChild(
833
+ panelOpt("(source)", !view.env, () => {
834
+ view.env = null;
835
+ resetDialCaches();
836
+ renderStatusbar();
837
+ load();
838
+ }, "The declared source graph — no live overlay"),
839
+ );
840
+ for (const e of environments) {
841
+ // #191: a k8s env shows the cluster it's bound to (`home → home-cloud`),
842
+ // so a wrong-cluster pick is visible before the read, not after.
843
+ const ctx = projectInfo && projectInfo.k8sContexts && projectInfo.k8sContexts[e];
844
+ host.appendChild(
845
+ panelOpt(ctx ? `${e} → ${ctx}` : e, view.env === e, () => {
846
+ view.env = e;
847
+ resetDialCaches();
848
+ renderStatusbar();
849
+ load();
850
+ }, ctx ? `Live overlay for ${e} — bound to kubeconfig context ${ctx}` : `Live overlay for ${e}`),
851
+ );
852
+ }
853
+ if (!environments.length) host.appendChild(panelMuted("no environments declared"));
854
+ if (stacks.length) {
855
+ host.appendChild(panelHeading("stack"));
856
+ for (const s of stacks) {
857
+ host.appendChild(
858
+ panelOpt(s, view.stack === s, () => {
859
+ view.stack = s;
860
+ resetDialCaches();
861
+ renderStatusbar();
862
+ load();
863
+ }),
864
+ );
865
+ }
866
+ }
867
+ if (tiers.length) {
868
+ host.appendChild(panelHeading("tier"));
869
+ for (const t of tiers) {
870
+ host.appendChild(
871
+ panelOpt(t, view.tier === t, () => {
872
+ view.tier = t;
873
+ resetDialCaches();
874
+ renderStatusbar();
875
+ load();
876
+ }),
877
+ );
878
+ }
879
+ }
880
+ if (targets.length) {
881
+ host.appendChild(panelHeading("target"));
882
+ const sel = document.createElement("select");
883
+ for (const t of targets) sel.add(new Option(t.endpoint, t.endpoint, false, view.target === t.endpoint));
667
884
  sel.addEventListener("change", () => {
668
- applyZoom(sel.value);
885
+ view.target = sel.value;
886
+ resetDialCaches();
669
887
  renderStatusbar();
670
888
  load();
671
889
  });
672
- // Before the theme picker, which mounts into the same slot at load.
673
- slot.insertBefore(sel, slot.firstChild);
890
+ host.appendChild(sel);
891
+ }
892
+ }
893
+
894
+ // Model tab: how the current graph's nodes stand against reality — the drift
895
+ // overlay's managed/foreign/pending or the component DAG's applied/unapplied
896
+ // (deployed / in progress / rolled back / not deployed). Replaces the two old
897
+ // header legends, with live counts; component/attention rows click through to
898
+ // the node's inspect panel.
899
+ const DRIFT_STATUS_VAR = { good: "var(--managed)", warn: "var(--foreign)", accent: "var(--pending)", neutral: "var(--muted)", runtime: "var(--runtime)" };
900
+ const COMPONENT_STATUS_VAR = { good: "var(--managed)", accent: "var(--pending)", warn: "var(--degraded)", neutral: "var(--muted)" };
901
+
902
+ function panelDotRow(color, main, tag, onClick) {
903
+ const row = document.createElement("div");
904
+ row.className = onClick ? "node-row" : "count-row";
905
+ const dot = document.createElement("span");
906
+ dot.className = "dot";
907
+ dot.style.background = color;
908
+ const name = document.createElement("span");
909
+ name.className = "grow";
910
+ name.textContent = main;
911
+ name.title = main;
912
+ row.append(dot, name);
913
+ if (tag) {
914
+ const t = document.createElement("span");
915
+ t.className = "tag";
916
+ t.textContent = tag;
917
+ row.appendChild(t);
918
+ }
919
+ if (onClick) row.addEventListener("click", onClick);
920
+ return row;
921
+ }
922
+
923
+ // Select a node from a panel row the same way a graph click would: highlight
924
+ // its card (when it's in the current SVG) and open its inspect panel.
925
+ function selectNode(id) {
926
+ const node = lastGraphIr && lastGraphIr.nodes.find((n) => n.id === id);
927
+ if (!node) return;
928
+ const host = document.getElementById("graph");
929
+ host.querySelectorAll(".sel").forEach((n) => n.classList.remove("sel"));
930
+ const g = host.querySelector(`[data-node-id="${CSS.escape(id)}"]`);
931
+ if (g) g.classList.add("sel");
932
+ inspect(node);
933
+ }
934
+
935
+ function renderPanelModel() {
936
+ const host = document.getElementById("tab-model");
937
+ if (!host) return;
938
+ host.innerHTML = "";
939
+ const ir = lastGraphIr;
940
+ const m = lastMeta;
941
+ if (!ir || !m) {
942
+ host.appendChild(panelMuted("no graph loaded yet"));
943
+ return;
674
944
  }
675
- const current = zoomValue();
676
- // Rebuilt each render because runtime is only meaningful with an env: it
677
- // descends below the declaration boundary to owner-referenced children,
678
- // which exist in a cluster and never in your source.
679
- const opts = ZOOM_OPTS.filter(([, v]) => v !== "runtime" || view.env);
680
- const want = opts.map(([label, v]) => `${v}:${label}`).join("|") + "@" + current;
681
- if (sel.dataset.built !== want) {
682
- sel.innerHTML = "";
683
- for (const [label, v] of opts) {
684
- const o = document.createElement("option");
685
- o.value = v;
686
- o.textContent = label;
687
- if (v === current) o.selected = true;
688
- sel.appendChild(o);
945
+ const drift = m.mode === "overlay" || (m.mode === "logical" && !!m.env);
946
+ const componentStatus = m.mode === "component-status";
947
+ const count = (statuses) => {
948
+ const c = Object.fromEntries(Object.keys(statuses).map((k) => [k, 0]));
949
+ for (const n of ir.nodes) {
950
+ const s = n.attrs && n.attrs._status;
951
+ if (s in c) c[s]++;
952
+ }
953
+ return c;
954
+ };
955
+ if (componentStatus) {
956
+ host.appendChild(panelHeading(`live status · ${m.env}`));
957
+ const c = count(COMPONENT_STATUS_LABEL);
958
+ for (const [k, label] of Object.entries(COMPONENT_STATUS_LABEL)) {
959
+ host.appendChild(panelDotRow(COMPONENT_STATUS_VAR[k], label, String(c[k])));
960
+ }
961
+ host.appendChild(panelHeading("components"));
962
+ for (const n of ir.nodes.filter((n) => n.kind === "Component")) {
963
+ const s = n.attrs && n.attrs._status;
964
+ host.appendChild(panelDotRow(COMPONENT_STATUS_VAR[s] || "var(--muted)", n.id, APPLY_STATUS_TAG[s] || "", () => selectNode(n.id)));
965
+ }
966
+ } else if (drift) {
967
+ host.appendChild(panelHeading(`drift · ${m.env}`));
968
+ const c = count(STATUS_LABEL);
969
+ for (const [k, label] of Object.entries(STATUS_LABEL)) {
970
+ // The additive buckets (chant#1168 unobserved, chant#1180 runtime) stay
971
+ // hidden until a chant actually emits them — same as the old legend.
972
+ if ((k === "neutral" || k === "runtime") && !c[k]) continue;
973
+ host.appendChild(panelDotRow(DRIFT_STATUS_VAR[k], label, String(c[k])));
974
+ }
975
+ // The actionable nodes — foreign (adoptable) and pending (not applied yet).
976
+ const attention = ir.nodes.filter((n) => {
977
+ const s = n.attrs && n.attrs._status;
978
+ return s === "warn" || s === "accent";
979
+ });
980
+ if (attention.length) {
981
+ host.appendChild(panelHeading("needs attention"));
982
+ for (const n of attention.slice(0, 40)) {
983
+ const s = n.attrs._status;
984
+ host.appendChild(panelDotRow(DRIFT_STATUS_VAR[s], n.id, STATUS_LABEL[s], () => selectNode(n.id)));
985
+ }
986
+ if (attention.length > 40) host.appendChild(panelMuted(`+ ${attention.length - 40} more — click nodes in the graph`));
987
+ }
988
+ } else {
989
+ host.appendChild(panelHeading("model"));
990
+ host.appendChild(panelMuted(`declared source graph — ${ir.nodes.length} nodes, ${ir.edges.length} edges.`));
991
+ if (environments.length && !staticMode) {
992
+ host.appendChild(panelMuted("pick an environment on the Scope tab to see live status: applied / drift / health."));
993
+ host.appendChild(actButton("→ Scope", () => setPanelTab("scope")));
689
994
  }
690
- sel.dataset.built = want;
691
995
  }
692
- sel.value = current;
996
+ }
997
+
998
+ function renderPanel() {
999
+ renderPanelView();
1000
+ renderPanelScope();
1001
+ renderPanelModel();
693
1002
  }
694
1003
 
695
1004
  function renderStatusbar() {
696
- renderZoomPicker();
1005
+ renderPanel();
697
1006
  const el = document.getElementById("statusbar");
698
1007
  if (!el) return;
699
1008
  // The strip looks clickable whether or not it is, so make it act like it:
700
- // clicking opens the palette rather than doing nothing. env, stack and tier
701
- // live only there, and this is the only affordance pointing at them.
1009
+ // clicking opens the palette rather than doing nothing.
702
1010
  if (!el.dataset.clickable) {
703
1011
  el.dataset.clickable = "1";
704
1012
  el.style.cursor = "pointer";
705
- el.title = "env · stack · tier — click, or ⌘K, to change";
1013
+ el.title = "zoom · env · stack · tier — change on the panel, or ⌘K";
706
1014
  el.addEventListener("click", () => openPalette());
707
1015
  }
708
- // No zoom here any more. #73 put the current zoom on the strip because the
709
- // control had moved into the palette; with a real picker two slots along,
710
- // the same value in both places reads as two pickers, one of which does not
711
- // work — which is exactly how it was reported. The strip keeps the axes that
712
- // still have no on-screen control.
713
- const parts = [view.env ? `env: ${view.env}` : "env: (source)"];
1016
+ // Pure state the strip echoes the axes whose controls live on the floating
1017
+ // panel and in ⌘K, so the current view stays legible with the panel collapsed.
1018
+ const parts = [`zoom: ${zoomValue()}`, view.env ? `env: ${view.env}` : "env: (source)"];
714
1019
  if (view.stack) parts.push(`stack: ${view.stack}`);
715
1020
  if (axes.tier) parts.push(`tier: ${axes.tier}`);
716
1021
  if (view.radial && !view.components && !view.logical) parts.push("radial");
@@ -733,6 +1038,10 @@ function renderStatusbar() {
733
1038
  // itself.
734
1039
  let lastNote = null;
735
1040
 
1041
+ // The `meta` of the last rendered graph — the panel's Model tab reads its
1042
+ // mode/env to pick the status vocabulary (drift vs component live status).
1043
+ let lastMeta = null;
1044
+
736
1045
  // The deploy axes as currently displayed in the header (#59 unify, M2 #54
737
1046
  // lenses) — seeded once from /api/project (server-derived from the process
738
1047
  // env at launch; see deployAxes() in src/server.ts), then kept in sync with
@@ -1259,6 +1568,11 @@ function render(ir, svg, m) {
1259
1568
  // #131: set before anything can early-return, so a level that stopped
1260
1569
  // degrading stops explaining itself on the very next render.
1261
1570
  lastNote = m.note || null;
1571
+ // The panel's Model tab reads both of these via renderStatusbar() →
1572
+ // renderPanel() below — set them first so it renders THIS graph, not the
1573
+ // previous one (recolorNodesByCategory also sets lastGraphIr; harmless).
1574
+ lastMeta = m;
1575
+ lastGraphIr = ir;
1262
1576
  const overlay = m.mode === "overlay";
1263
1577
  // Logical/architecture lens (#63): its own mode, but when an env is picked the
1264
1578
  // projected nodes still carry the drift `_status`, so it reads as a drift view
@@ -1304,22 +1618,27 @@ function render(ir, svg, m) {
1304
1618
  tail = ` · ${c.good} healthy · ${c.accent} in progress · ${c.warn} rollback/failed · ${c.neutral} not deployed`;
1305
1619
  }
1306
1620
  // Multi-estate (#31): note the composed project count; the graph draws one box per project.
1307
- const scope = m.estate ? `estate of ${m.estate} projects` : m.projectDir;
1621
+ // #186: the meta line lives in the panel's 272px footer now — the directory
1622
+ // basename reads better than an absolute path (full path in the tooltip).
1623
+ const scope = m.estate
1624
+ ? `estate of ${m.estate} projects`
1625
+ : String(m.projectDir || "").replace(/\/+$/, "").split("/").pop() || m.projectDir;
1308
1626
  // The deploy axes (#59 unify, M2 #54 lenses) — tier/target, kept in sync with
1309
1627
  // what this response actually observed (falls back to the launch-time value
1310
1628
  // from /api/project when a route doesn't echo them, e.g. /api/overlay).
1311
1629
  if (m.tier !== undefined) axes.tier = m.tier;
1312
1630
  if (m.target !== undefined) axes.target = m.target;
1313
1631
  const axesTail = `${axes.tier ? " · tier " + axes.tier : ""}${axes.target ? " · target " + axes.target : ""}`;
1314
- document.getElementById("meta").textContent =
1632
+ const metaEl = document.getElementById("meta");
1633
+ metaEl.title = m.projectDir || "";
1634
+ // #195: the browser tab names the loaded project — with the header gone
1635
+ // (#186) the title bar is free chrome, and it's what shows in cmd-tab /
1636
+ // tab-hover when several behold instances are up.
1637
+ document.title = `behold — ${scope}`;
1638
+ metaEl.textContent =
1315
1639
  `${scope}${m.env ? " · env " + m.env : ""}${axesTail}${overlay ? " · overlay" : ""}${logical ? " · logical" : ""}${m.components ? " · components" : ""}${componentStatus ? " · live status" : ""} · ${ir.nodes.length} nodes${tail}`;
1316
- document.getElementById("legend").style.display = drift ? "flex" : "none";
1317
- document.getElementById("component-legend").style.display = componentStatus ? "flex" : "none";
1318
- // Keep the persistent state strip in sync — zoom/env/tier/radial are picked
1319
- // via the ⌘K palette now (#73), but stay visible here regardless (radial
1320
- // only applies to the entity zooms; renderStatusbar() drops it for
1321
- // components/logical, both of which lay themselves out: waves / nested
1322
- // architecture boxes).
1640
+ // Keep the persistent state strip + the floating panel in sync (the panel's
1641
+ // Model tab is what replaced the two old header legends).
1323
1642
  renderStatusbar();
1324
1643
  const g = document.getElementById("graph");
1325
1644
  // Ghostty theming (#62): strip pinhole's baked-in `:root{--pin-*}` defaults from the
@@ -1542,6 +1861,9 @@ const PRECONDITION_TITLE = {
1542
1861
  "not-installed": "This project isn't installed",
1543
1862
  tier: "This tier needs credentials",
1544
1863
  eval: "chant couldn't evaluate this project",
1864
+ // #193: behold was pointed at a directory that isn't a chant project at all
1865
+ // — the first screen must say so, not draw a blank graph.
1866
+ "no-project": "This isn't a chant project",
1545
1867
  };
1546
1868
 
1547
1869
  // A precondition failure — the lint gate, a not-installed/no-typegen project,
@@ -1573,8 +1895,6 @@ function renderPreconditionError(body) {
1573
1895
  card.appendChild(remedy);
1574
1896
  }
1575
1897
  host.appendChild(card);
1576
- document.getElementById("legend").style.display = "none";
1577
- document.getElementById("component-legend").style.display = "none";
1578
1898
  }
1579
1899
 
1580
1900
  // Fetch the current view (source graph, or the picked env's live overlay).
@@ -1641,6 +1961,17 @@ async function load(opts = {}) {
1641
1961
  }
1642
1962
  throw new Error(body.error || res.statusText);
1643
1963
  }
1964
+ // #182: an empty first components view → re-load at the resources zoom
1965
+ // instead of painting a blank pane. Checked before render() so the blank
1966
+ // graph never flashes; the loading scrim stays up across the second fetch
1967
+ // (showLoading is ref-counted).
1968
+ if (autoZoomFallback && view.components && !((body.ir && body.ir.nodes) || []).length) {
1969
+ autoZoomFallback = false;
1970
+ applyZoom("resources");
1971
+ renderStatusbar();
1972
+ return load(opts);
1973
+ }
1974
+ autoZoomFallback = false;
1644
1975
  render(body.ir, body.svg, body.meta);
1645
1976
  } catch (err) {
1646
1977
  // A background settle poll must not blow away a good graph on a transient error.
@@ -1688,6 +2019,9 @@ let environments = [];
1688
2019
  let tiers = [];
1689
2020
  let targets = [];
1690
2021
  let stacks = [];
2022
+ // #195: the last /api/project payload — the Scope tab's project section reads
2023
+ // projectDir/projectDirs/recents from it.
2024
+ let projectInfo = null;
1691
2025
 
1692
2026
  // Fetch the project once, seed view/axes + the lens lists above, then do the
1693
2027
  // first load. previously also built the header's env/zoom/radial/tier/target
@@ -1697,6 +2031,7 @@ async function initPickers() {
1697
2031
  const info = await apiFetch("/api/project")
1698
2032
  .then((r) => r.json())
1699
2033
  .catch(() => ({ environments: [], currentEnv: null }));
2034
+ projectInfo = info;
1700
2035
  view.env = info.currentEnv || null;
1701
2036
  view.tier = info.tier || null;
1702
2037
  view.target = (info.targets && info.targets[0] && info.targets[0].endpoint) || null;
@@ -1738,10 +2073,10 @@ events.addEventListener("changed", () => {
1738
2073
  scheduleSettle();
1739
2074
  });
1740
2075
 
1741
- // Substrate readiness strip (M5, #54): is each substrate the project needs
1742
- // actually up? Poll /api/substrates, render status pills pure state (#73:
1743
- // the "Bring up" / "Reset" affordances that used to sit inside each pill moved
1744
- // into the ⌘K palette; see paletteCommands()'s use of lastSubstrates below).
2076
+ // Substrate readiness (M5, #54): is each substrate the project needs actually
2077
+ // up? Poll /api/substrates and render rows on the panel's Substrates tab —
2078
+ // status dot + name + state, with the bring-up/reset/pipeline actions inline
2079
+ // again (#73 moved those into ⌘K; they remain there too, via lastSubstrates).
1745
2080
  async function loadSubstrates() {
1746
2081
  try {
1747
2082
  const { substrates } = await apiFetch("/api/substrates").then((r) => r.json());
@@ -1759,27 +2094,40 @@ let lastSubstrates = [];
1759
2094
  function renderSubstrates(subs) {
1760
2095
  lastSubstrates = subs;
1761
2096
  const host = document.getElementById("substrates");
2097
+ if (!host) return;
2098
+ // Rebuilt only when the data changes — this runs on a 5s poll, and wiping
2099
+ // identical rows would yank a button out from under the cursor.
2100
+ const sig = JSON.stringify(subs) + `|${staticMode}|${previewMode}`;
2101
+ if (host.dataset.sig === sig) return;
2102
+ host.dataset.sig = sig;
2103
+ host.innerHTML = "";
1762
2104
  if (!subs.length) {
1763
- host.style.display = "none";
2105
+ host.appendChild(panelMuted("no substrates detected for this project"));
1764
2106
  return;
1765
2107
  }
1766
- host.style.display = "flex";
1767
- host.innerHTML = "";
1768
- const lbl = document.createElement("span");
1769
- lbl.className = "label";
1770
- lbl.textContent = "substrates:";
1771
- host.appendChild(lbl);
1772
2108
  for (const s of subs) {
1773
- const pill = document.createElement("span");
1774
- pill.className = `sub ${s.status}`;
1775
- pill.title = s.bringUp || s.name === "floci" ? `${s.detail} (⌘K for actions)` : s.detail;
2109
+ const row = document.createElement("div");
2110
+ row.className = `sub ${s.status}`;
2111
+ row.title = s.detail || "";
1776
2112
  const dot = document.createElement("span");
1777
2113
  dot.className = "dot";
1778
- pill.appendChild(dot);
1779
2114
  const name = document.createElement("span");
2115
+ name.className = "grow";
1780
2116
  name.textContent = s.label;
1781
- pill.appendChild(name);
1782
- host.appendChild(pill);
2117
+ const status = document.createElement("span");
2118
+ status.className = "tag";
2119
+ status.textContent = s.status;
2120
+ row.append(dot, name, status);
2121
+ // Writes — none in a static export; the GitHub dispatch also respects the
2122
+ // preview lock, mirroring paletteCommands()'s gating exactly.
2123
+ if (!staticMode) {
2124
+ if (s.bringUp) row.appendChild(actButton("bring up", () => bringUpSubstrate(s)));
2125
+ if (s.name === "floci" && s.status === "up")
2126
+ row.appendChild(actButton("reset", () => resetLocal(), "Reset the local emulator — wipes every stack, reboots, redeploys clean"));
2127
+ if (s.name === "github" && !previewMode)
2128
+ row.appendChild(actButton("run", () => dispatchPipeline(), "Dispatch the GitHub Actions pipeline via your gh login"));
2129
+ }
2130
+ host.appendChild(row);
1783
2131
  }
1784
2132
  }
1785
2133
 
@@ -1811,6 +2159,24 @@ function bringUpSubstrate(s) {
1811
2159
  .catch((e) => nowline("✗ bring up: " + e.message));
1812
2160
  }
1813
2161
 
2162
+ // Dispatch a GitHub Actions run (#164) — through the operator's own `gh`
2163
+ // login. The server refuses honestly (no gh, unauthenticated, no matching
2164
+ // workflow_dispatch workflow) and the reason lands as a toast. Shared by the
2165
+ // Substrates tab's button and the ⌘K entry.
2166
+ function dispatchPipeline() {
2167
+ if (!window.confirm("Dispatch the GitHub Actions pipeline?\nRuns via YOUR gh login (gh workflow run); behold follows the run on the dial.")) return;
2168
+ fetch("/api/ci/dispatch", { method: "POST" })
2169
+ .then((r) => r.json())
2170
+ .then((j) => {
2171
+ if (j.error) {
2172
+ showToast(`✗ dispatch: ${j.error}`, false);
2173
+ nowline("✗ dispatch: " + j.error);
2174
+ } else {
2175
+ showToast(`▶ dispatched ${j.workflow} @ ${j.ref} (${j.jobs} jobs) — following on the dial`, true);
2176
+ }
2177
+ });
2178
+ }
2179
+
1814
2180
  loadSubstrates();
1815
2181
  // Poll readiness so pills update as things come up on their own (Docker
1816
2182
  // starting, a bring-up provisioning) without needing a `changed` event.
@@ -1857,7 +2223,7 @@ function showToast(msg, ok) {
1857
2223
  if (!host) {
1858
2224
  host = document.createElement("div");
1859
2225
  host.id = "toasts";
1860
- host.style.cssText = "position:fixed;top:52px;right:16px;display:flex;flex-direction:column;gap:8px;z-index:60;max-width:420px";
2226
+ host.style.cssText = "position:fixed;top:12px;right:16px;display:flex;flex-direction:column;gap:8px;z-index:60;max-width:420px";
1861
2227
  document.body.appendChild(host);
1862
2228
  }
1863
2229
  const t = document.createElement("div");
@@ -2065,7 +2431,7 @@ async function initActions() {
2065
2431
  applyPicker = true;
2066
2432
  loadComponentChoices().then(renderDial);
2067
2433
  renderDial();
2068
- document.getElementById("dial").scrollIntoView({ block: "nearest" });
2434
+ setPanelTab("deploy"); // the dial lives on the panel's Deploy tab
2069
2435
  });
2070
2436
  deploy.title = `chant run <component|all> --components --env ${view.env || opsInitialEnv} --progress-json — opens the component picker on the dial. behold triggers, chant executes.`;
2071
2437
  bar.appendChild(deploy);
@@ -2180,10 +2546,10 @@ function exportSvg() {
2180
2546
  // list from live state on every open, palRender() filtering + repainting,
2181
2547
  // openPalette()/closePalette() toggling the `.on` class), retargeted at
2182
2548
  // behold's own handlers instead of spicypath's. "Hide controls, never state"
2183
- // (spicypath's own design rule, carried over): every action this moves out of
2184
- // the toolbar is still reachable here; zoom/env/tier/drift/substrates stay
2185
- // visible in the header regardless (renderStatusbar(), #substrates pills,
2186
- // #meta) see index.html's #statusbar comment.
2549
+ // (spicypath's own design rule, carried over): every control here also lives
2550
+ // on the floating panel now, and the current values stay visible in the
2551
+ // header (renderStatusbar(), #meta) — the palette is the fast surface, the
2552
+ // panel the discoverable one.
2187
2553
  const palette = document.getElementById("palette");
2188
2554
  const palInput = document.getElementById("pal-input");
2189
2555
  const palList = document.getElementById("pal-list");
@@ -2201,6 +2567,11 @@ function paletteCommands() {
2201
2567
  c.push(["Export: current graph as SVG", () => exportSvg()]);
2202
2568
  const inspectCollapsed = document.getElementById("app").classList.contains("inspect-collapsed");
2203
2569
  c.push([inspectCollapsed ? "Show inspect panel" : "Hide inspect panel", () => toggleInspect()]);
2570
+ // The floating control panel (panel.js): collapse/expand + jump to a tab.
2571
+ c.push([isPanelCollapsed() ? "Expand control panel" : "Collapse control panel", () => togglePanelCollapsed()]);
2572
+ for (const b of document.querySelectorAll("#panel-tabs button[data-tab]")) {
2573
+ c.push([`Panel: ${b.textContent}`, () => setPanelTab(b.dataset.tab)]);
2574
+ }
2204
2575
 
2205
2576
  // Lens/zoom switches (#56, #63) — replaces the old header zoom picker.
2206
2577
  for (const [label, v] of ZOOM_OPTS) {
@@ -2262,22 +2633,7 @@ function paletteCommands() {
2262
2633
  // (no gh, unauthenticated, no matching workflow_dispatch workflow) and
2263
2634
  // the reason lands as a toast.
2264
2635
  if (s.name === "github" && !previewMode) {
2265
- c.push([
2266
- "Run pipeline: GitHub Actions",
2267
- () => {
2268
- if (!window.confirm("Dispatch the GitHub Actions pipeline?\nRuns via YOUR gh login (gh workflow run); behold follows the run on the dial.")) return;
2269
- fetch("/api/ci/dispatch", { method: "POST" })
2270
- .then((r) => r.json())
2271
- .then((j) => {
2272
- if (j.error) {
2273
- showToast(`✗ dispatch: ${j.error}`, false);
2274
- nowline("✗ dispatch: " + j.error);
2275
- } else {
2276
- showToast(`▶ dispatched ${j.workflow} @ ${j.ref} (${j.jobs} jobs) — following on the dial`, true);
2277
- }
2278
- });
2279
- },
2280
- ]);
2636
+ c.push(["Run pipeline: GitHub Actions", () => dispatchPipeline()]);
2281
2637
  }
2282
2638
  }
2283
2639