@intentius/behold 0.2.3 → 0.3.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,376 @@ 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
+ host.appendChild(
842
+ panelOpt(e, view.env === e, () => {
843
+ view.env = e;
844
+ resetDialCaches();
845
+ renderStatusbar();
846
+ load();
847
+ }, `Live overlay for ${e}`),
848
+ );
849
+ }
850
+ if (!environments.length) host.appendChild(panelMuted("no environments declared"));
851
+ if (stacks.length) {
852
+ host.appendChild(panelHeading("stack"));
853
+ for (const s of stacks) {
854
+ host.appendChild(
855
+ panelOpt(s, view.stack === s, () => {
856
+ view.stack = s;
857
+ resetDialCaches();
858
+ renderStatusbar();
859
+ load();
860
+ }),
861
+ );
862
+ }
863
+ }
864
+ if (tiers.length) {
865
+ host.appendChild(panelHeading("tier"));
866
+ for (const t of tiers) {
867
+ host.appendChild(
868
+ panelOpt(t, view.tier === t, () => {
869
+ view.tier = t;
870
+ resetDialCaches();
871
+ renderStatusbar();
872
+ load();
873
+ }),
874
+ );
875
+ }
876
+ }
877
+ if (targets.length) {
878
+ host.appendChild(panelHeading("target"));
879
+ const sel = document.createElement("select");
880
+ for (const t of targets) sel.add(new Option(t.endpoint, t.endpoint, false, view.target === t.endpoint));
667
881
  sel.addEventListener("change", () => {
668
- applyZoom(sel.value);
882
+ view.target = sel.value;
883
+ resetDialCaches();
669
884
  renderStatusbar();
670
885
  load();
671
886
  });
672
- // Before the theme picker, which mounts into the same slot at load.
673
- slot.insertBefore(sel, slot.firstChild);
887
+ host.appendChild(sel);
888
+ }
889
+ }
890
+
891
+ // Model tab: how the current graph's nodes stand against reality — the drift
892
+ // overlay's managed/foreign/pending or the component DAG's applied/unapplied
893
+ // (deployed / in progress / rolled back / not deployed). Replaces the two old
894
+ // header legends, with live counts; component/attention rows click through to
895
+ // the node's inspect panel.
896
+ const DRIFT_STATUS_VAR = { good: "var(--managed)", warn: "var(--foreign)", accent: "var(--pending)", neutral: "var(--muted)", runtime: "var(--runtime)" };
897
+ const COMPONENT_STATUS_VAR = { good: "var(--managed)", accent: "var(--pending)", warn: "var(--degraded)", neutral: "var(--muted)" };
898
+
899
+ function panelDotRow(color, main, tag, onClick) {
900
+ const row = document.createElement("div");
901
+ row.className = onClick ? "node-row" : "count-row";
902
+ const dot = document.createElement("span");
903
+ dot.className = "dot";
904
+ dot.style.background = color;
905
+ const name = document.createElement("span");
906
+ name.className = "grow";
907
+ name.textContent = main;
908
+ name.title = main;
909
+ row.append(dot, name);
910
+ if (tag) {
911
+ const t = document.createElement("span");
912
+ t.className = "tag";
913
+ t.textContent = tag;
914
+ row.appendChild(t);
915
+ }
916
+ if (onClick) row.addEventListener("click", onClick);
917
+ return row;
918
+ }
919
+
920
+ // Select a node from a panel row the same way a graph click would: highlight
921
+ // its card (when it's in the current SVG) and open its inspect panel.
922
+ function selectNode(id) {
923
+ const node = lastGraphIr && lastGraphIr.nodes.find((n) => n.id === id);
924
+ if (!node) return;
925
+ const host = document.getElementById("graph");
926
+ host.querySelectorAll(".sel").forEach((n) => n.classList.remove("sel"));
927
+ const g = host.querySelector(`[data-node-id="${CSS.escape(id)}"]`);
928
+ if (g) g.classList.add("sel");
929
+ inspect(node);
930
+ }
931
+
932
+ function renderPanelModel() {
933
+ const host = document.getElementById("tab-model");
934
+ if (!host) return;
935
+ host.innerHTML = "";
936
+ const ir = lastGraphIr;
937
+ const m = lastMeta;
938
+ if (!ir || !m) {
939
+ host.appendChild(panelMuted("no graph loaded yet"));
940
+ return;
674
941
  }
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);
942
+ const drift = m.mode === "overlay" || (m.mode === "logical" && !!m.env);
943
+ const componentStatus = m.mode === "component-status";
944
+ const count = (statuses) => {
945
+ const c = Object.fromEntries(Object.keys(statuses).map((k) => [k, 0]));
946
+ for (const n of ir.nodes) {
947
+ const s = n.attrs && n.attrs._status;
948
+ if (s in c) c[s]++;
949
+ }
950
+ return c;
951
+ };
952
+ if (componentStatus) {
953
+ host.appendChild(panelHeading(`live status · ${m.env}`));
954
+ const c = count(COMPONENT_STATUS_LABEL);
955
+ for (const [k, label] of Object.entries(COMPONENT_STATUS_LABEL)) {
956
+ host.appendChild(panelDotRow(COMPONENT_STATUS_VAR[k], label, String(c[k])));
957
+ }
958
+ host.appendChild(panelHeading("components"));
959
+ for (const n of ir.nodes.filter((n) => n.kind === "Component")) {
960
+ const s = n.attrs && n.attrs._status;
961
+ host.appendChild(panelDotRow(COMPONENT_STATUS_VAR[s] || "var(--muted)", n.id, APPLY_STATUS_TAG[s] || "", () => selectNode(n.id)));
962
+ }
963
+ } else if (drift) {
964
+ host.appendChild(panelHeading(`drift · ${m.env}`));
965
+ const c = count(STATUS_LABEL);
966
+ for (const [k, label] of Object.entries(STATUS_LABEL)) {
967
+ // The additive buckets (chant#1168 unobserved, chant#1180 runtime) stay
968
+ // hidden until a chant actually emits them — same as the old legend.
969
+ if ((k === "neutral" || k === "runtime") && !c[k]) continue;
970
+ host.appendChild(panelDotRow(DRIFT_STATUS_VAR[k], label, String(c[k])));
971
+ }
972
+ // The actionable nodes — foreign (adoptable) and pending (not applied yet).
973
+ const attention = ir.nodes.filter((n) => {
974
+ const s = n.attrs && n.attrs._status;
975
+ return s === "warn" || s === "accent";
976
+ });
977
+ if (attention.length) {
978
+ host.appendChild(panelHeading("needs attention"));
979
+ for (const n of attention.slice(0, 40)) {
980
+ const s = n.attrs._status;
981
+ host.appendChild(panelDotRow(DRIFT_STATUS_VAR[s], n.id, STATUS_LABEL[s], () => selectNode(n.id)));
982
+ }
983
+ if (attention.length > 40) host.appendChild(panelMuted(`+ ${attention.length - 40} more — click nodes in the graph`));
984
+ }
985
+ } else {
986
+ host.appendChild(panelHeading("model"));
987
+ host.appendChild(panelMuted(`declared source graph — ${ir.nodes.length} nodes, ${ir.edges.length} edges.`));
988
+ if (environments.length && !staticMode) {
989
+ host.appendChild(panelMuted("pick an environment on the Scope tab to see live status: applied / drift / health."));
990
+ host.appendChild(actButton("→ Scope", () => setPanelTab("scope")));
689
991
  }
690
- sel.dataset.built = want;
691
992
  }
692
- sel.value = current;
993
+ }
994
+
995
+ function renderPanel() {
996
+ renderPanelView();
997
+ renderPanelScope();
998
+ renderPanelModel();
693
999
  }
694
1000
 
695
1001
  function renderStatusbar() {
696
- renderZoomPicker();
1002
+ renderPanel();
697
1003
  const el = document.getElementById("statusbar");
698
1004
  if (!el) return;
699
1005
  // 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.
1006
+ // clicking opens the palette rather than doing nothing.
702
1007
  if (!el.dataset.clickable) {
703
1008
  el.dataset.clickable = "1";
704
1009
  el.style.cursor = "pointer";
705
- el.title = "env · stack · tier — click, or ⌘K, to change";
1010
+ el.title = "zoom · env · stack · tier — change on the panel, or ⌘K";
706
1011
  el.addEventListener("click", () => openPalette());
707
1012
  }
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)"];
1013
+ // Pure state the strip echoes the axes whose controls live on the floating
1014
+ // panel and in ⌘K, so the current view stays legible with the panel collapsed.
1015
+ const parts = [`zoom: ${zoomValue()}`, view.env ? `env: ${view.env}` : "env: (source)"];
714
1016
  if (view.stack) parts.push(`stack: ${view.stack}`);
715
1017
  if (axes.tier) parts.push(`tier: ${axes.tier}`);
716
1018
  if (view.radial && !view.components && !view.logical) parts.push("radial");
@@ -733,6 +1035,10 @@ function renderStatusbar() {
733
1035
  // itself.
734
1036
  let lastNote = null;
735
1037
 
1038
+ // The `meta` of the last rendered graph — the panel's Model tab reads its
1039
+ // mode/env to pick the status vocabulary (drift vs component live status).
1040
+ let lastMeta = null;
1041
+
736
1042
  // The deploy axes as currently displayed in the header (#59 unify, M2 #54
737
1043
  // lenses) — seeded once from /api/project (server-derived from the process
738
1044
  // env at launch; see deployAxes() in src/server.ts), then kept in sync with
@@ -1259,6 +1565,11 @@ function render(ir, svg, m) {
1259
1565
  // #131: set before anything can early-return, so a level that stopped
1260
1566
  // degrading stops explaining itself on the very next render.
1261
1567
  lastNote = m.note || null;
1568
+ // The panel's Model tab reads both of these via renderStatusbar() →
1569
+ // renderPanel() below — set them first so it renders THIS graph, not the
1570
+ // previous one (recolorNodesByCategory also sets lastGraphIr; harmless).
1571
+ lastMeta = m;
1572
+ lastGraphIr = ir;
1262
1573
  const overlay = m.mode === "overlay";
1263
1574
  // Logical/architecture lens (#63): its own mode, but when an env is picked the
1264
1575
  // projected nodes still carry the drift `_status`, so it reads as a drift view
@@ -1304,22 +1615,27 @@ function render(ir, svg, m) {
1304
1615
  tail = ` · ${c.good} healthy · ${c.accent} in progress · ${c.warn} rollback/failed · ${c.neutral} not deployed`;
1305
1616
  }
1306
1617
  // 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;
1618
+ // #186: the meta line lives in the panel's 272px footer now — the directory
1619
+ // basename reads better than an absolute path (full path in the tooltip).
1620
+ const scope = m.estate
1621
+ ? `estate of ${m.estate} projects`
1622
+ : String(m.projectDir || "").replace(/\/+$/, "").split("/").pop() || m.projectDir;
1308
1623
  // The deploy axes (#59 unify, M2 #54 lenses) — tier/target, kept in sync with
1309
1624
  // what this response actually observed (falls back to the launch-time value
1310
1625
  // from /api/project when a route doesn't echo them, e.g. /api/overlay).
1311
1626
  if (m.tier !== undefined) axes.tier = m.tier;
1312
1627
  if (m.target !== undefined) axes.target = m.target;
1313
1628
  const axesTail = `${axes.tier ? " · tier " + axes.tier : ""}${axes.target ? " · target " + axes.target : ""}`;
1314
- document.getElementById("meta").textContent =
1629
+ const metaEl = document.getElementById("meta");
1630
+ metaEl.title = m.projectDir || "";
1631
+ // #195: the browser tab names the loaded project — with the header gone
1632
+ // (#186) the title bar is free chrome, and it's what shows in cmd-tab /
1633
+ // tab-hover when several behold instances are up.
1634
+ document.title = `behold — ${scope}`;
1635
+ metaEl.textContent =
1315
1636
  `${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).
1637
+ // Keep the persistent state strip + the floating panel in sync (the panel's
1638
+ // Model tab is what replaced the two old header legends).
1323
1639
  renderStatusbar();
1324
1640
  const g = document.getElementById("graph");
1325
1641
  // Ghostty theming (#62): strip pinhole's baked-in `:root{--pin-*}` defaults from the
@@ -1542,6 +1858,9 @@ const PRECONDITION_TITLE = {
1542
1858
  "not-installed": "This project isn't installed",
1543
1859
  tier: "This tier needs credentials",
1544
1860
  eval: "chant couldn't evaluate this project",
1861
+ // #193: behold was pointed at a directory that isn't a chant project at all
1862
+ // — the first screen must say so, not draw a blank graph.
1863
+ "no-project": "This isn't a chant project",
1545
1864
  };
1546
1865
 
1547
1866
  // A precondition failure — the lint gate, a not-installed/no-typegen project,
@@ -1573,8 +1892,6 @@ function renderPreconditionError(body) {
1573
1892
  card.appendChild(remedy);
1574
1893
  }
1575
1894
  host.appendChild(card);
1576
- document.getElementById("legend").style.display = "none";
1577
- document.getElementById("component-legend").style.display = "none";
1578
1895
  }
1579
1896
 
1580
1897
  // Fetch the current view (source graph, or the picked env's live overlay).
@@ -1641,6 +1958,17 @@ async function load(opts = {}) {
1641
1958
  }
1642
1959
  throw new Error(body.error || res.statusText);
1643
1960
  }
1961
+ // #182: an empty first components view → re-load at the resources zoom
1962
+ // instead of painting a blank pane. Checked before render() so the blank
1963
+ // graph never flashes; the loading scrim stays up across the second fetch
1964
+ // (showLoading is ref-counted).
1965
+ if (autoZoomFallback && view.components && !((body.ir && body.ir.nodes) || []).length) {
1966
+ autoZoomFallback = false;
1967
+ applyZoom("resources");
1968
+ renderStatusbar();
1969
+ return load(opts);
1970
+ }
1971
+ autoZoomFallback = false;
1644
1972
  render(body.ir, body.svg, body.meta);
1645
1973
  } catch (err) {
1646
1974
  // A background settle poll must not blow away a good graph on a transient error.
@@ -1688,6 +2016,9 @@ let environments = [];
1688
2016
  let tiers = [];
1689
2017
  let targets = [];
1690
2018
  let stacks = [];
2019
+ // #195: the last /api/project payload — the Scope tab's project section reads
2020
+ // projectDir/projectDirs/recents from it.
2021
+ let projectInfo = null;
1691
2022
 
1692
2023
  // Fetch the project once, seed view/axes + the lens lists above, then do the
1693
2024
  // first load. previously also built the header's env/zoom/radial/tier/target
@@ -1697,6 +2028,7 @@ async function initPickers() {
1697
2028
  const info = await apiFetch("/api/project")
1698
2029
  .then((r) => r.json())
1699
2030
  .catch(() => ({ environments: [], currentEnv: null }));
2031
+ projectInfo = info;
1700
2032
  view.env = info.currentEnv || null;
1701
2033
  view.tier = info.tier || null;
1702
2034
  view.target = (info.targets && info.targets[0] && info.targets[0].endpoint) || null;
@@ -1738,10 +2070,10 @@ events.addEventListener("changed", () => {
1738
2070
  scheduleSettle();
1739
2071
  });
1740
2072
 
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).
2073
+ // Substrate readiness (M5, #54): is each substrate the project needs actually
2074
+ // up? Poll /api/substrates and render rows on the panel's Substrates tab —
2075
+ // status dot + name + state, with the bring-up/reset/pipeline actions inline
2076
+ // again (#73 moved those into ⌘K; they remain there too, via lastSubstrates).
1745
2077
  async function loadSubstrates() {
1746
2078
  try {
1747
2079
  const { substrates } = await apiFetch("/api/substrates").then((r) => r.json());
@@ -1759,27 +2091,40 @@ let lastSubstrates = [];
1759
2091
  function renderSubstrates(subs) {
1760
2092
  lastSubstrates = subs;
1761
2093
  const host = document.getElementById("substrates");
2094
+ if (!host) return;
2095
+ // Rebuilt only when the data changes — this runs on a 5s poll, and wiping
2096
+ // identical rows would yank a button out from under the cursor.
2097
+ const sig = JSON.stringify(subs) + `|${staticMode}|${previewMode}`;
2098
+ if (host.dataset.sig === sig) return;
2099
+ host.dataset.sig = sig;
2100
+ host.innerHTML = "";
1762
2101
  if (!subs.length) {
1763
- host.style.display = "none";
2102
+ host.appendChild(panelMuted("no substrates detected for this project"));
1764
2103
  return;
1765
2104
  }
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
2105
  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;
2106
+ const row = document.createElement("div");
2107
+ row.className = `sub ${s.status}`;
2108
+ row.title = s.detail || "";
1776
2109
  const dot = document.createElement("span");
1777
2110
  dot.className = "dot";
1778
- pill.appendChild(dot);
1779
2111
  const name = document.createElement("span");
2112
+ name.className = "grow";
1780
2113
  name.textContent = s.label;
1781
- pill.appendChild(name);
1782
- host.appendChild(pill);
2114
+ const status = document.createElement("span");
2115
+ status.className = "tag";
2116
+ status.textContent = s.status;
2117
+ row.append(dot, name, status);
2118
+ // Writes — none in a static export; the GitHub dispatch also respects the
2119
+ // preview lock, mirroring paletteCommands()'s gating exactly.
2120
+ if (!staticMode) {
2121
+ if (s.bringUp) row.appendChild(actButton("bring up", () => bringUpSubstrate(s)));
2122
+ if (s.name === "floci" && s.status === "up")
2123
+ row.appendChild(actButton("reset", () => resetLocal(), "Reset the local emulator — wipes every stack, reboots, redeploys clean"));
2124
+ if (s.name === "github" && !previewMode)
2125
+ row.appendChild(actButton("run", () => dispatchPipeline(), "Dispatch the GitHub Actions pipeline via your gh login"));
2126
+ }
2127
+ host.appendChild(row);
1783
2128
  }
1784
2129
  }
1785
2130
 
@@ -1811,6 +2156,24 @@ function bringUpSubstrate(s) {
1811
2156
  .catch((e) => nowline("✗ bring up: " + e.message));
1812
2157
  }
1813
2158
 
2159
+ // Dispatch a GitHub Actions run (#164) — through the operator's own `gh`
2160
+ // login. The server refuses honestly (no gh, unauthenticated, no matching
2161
+ // workflow_dispatch workflow) and the reason lands as a toast. Shared by the
2162
+ // Substrates tab's button and the ⌘K entry.
2163
+ function dispatchPipeline() {
2164
+ if (!window.confirm("Dispatch the GitHub Actions pipeline?\nRuns via YOUR gh login (gh workflow run); behold follows the run on the dial.")) return;
2165
+ fetch("/api/ci/dispatch", { method: "POST" })
2166
+ .then((r) => r.json())
2167
+ .then((j) => {
2168
+ if (j.error) {
2169
+ showToast(`✗ dispatch: ${j.error}`, false);
2170
+ nowline("✗ dispatch: " + j.error);
2171
+ } else {
2172
+ showToast(`▶ dispatched ${j.workflow} @ ${j.ref} (${j.jobs} jobs) — following on the dial`, true);
2173
+ }
2174
+ });
2175
+ }
2176
+
1814
2177
  loadSubstrates();
1815
2178
  // Poll readiness so pills update as things come up on their own (Docker
1816
2179
  // starting, a bring-up provisioning) without needing a `changed` event.
@@ -1857,7 +2220,7 @@ function showToast(msg, ok) {
1857
2220
  if (!host) {
1858
2221
  host = document.createElement("div");
1859
2222
  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";
2223
+ host.style.cssText = "position:fixed;top:12px;right:16px;display:flex;flex-direction:column;gap:8px;z-index:60;max-width:420px";
1861
2224
  document.body.appendChild(host);
1862
2225
  }
1863
2226
  const t = document.createElement("div");
@@ -2065,7 +2428,7 @@ async function initActions() {
2065
2428
  applyPicker = true;
2066
2429
  loadComponentChoices().then(renderDial);
2067
2430
  renderDial();
2068
- document.getElementById("dial").scrollIntoView({ block: "nearest" });
2431
+ setPanelTab("deploy"); // the dial lives on the panel's Deploy tab
2069
2432
  });
2070
2433
  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
2434
  bar.appendChild(deploy);
@@ -2180,10 +2543,10 @@ function exportSvg() {
2180
2543
  // list from live state on every open, palRender() filtering + repainting,
2181
2544
  // openPalette()/closePalette() toggling the `.on` class), retargeted at
2182
2545
  // 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.
2546
+ // (spicypath's own design rule, carried over): every control here also lives
2547
+ // on the floating panel now, and the current values stay visible in the
2548
+ // header (renderStatusbar(), #meta) — the palette is the fast surface, the
2549
+ // panel the discoverable one.
2187
2550
  const palette = document.getElementById("palette");
2188
2551
  const palInput = document.getElementById("pal-input");
2189
2552
  const palList = document.getElementById("pal-list");
@@ -2201,6 +2564,11 @@ function paletteCommands() {
2201
2564
  c.push(["Export: current graph as SVG", () => exportSvg()]);
2202
2565
  const inspectCollapsed = document.getElementById("app").classList.contains("inspect-collapsed");
2203
2566
  c.push([inspectCollapsed ? "Show inspect panel" : "Hide inspect panel", () => toggleInspect()]);
2567
+ // The floating control panel (panel.js): collapse/expand + jump to a tab.
2568
+ c.push([isPanelCollapsed() ? "Expand control panel" : "Collapse control panel", () => togglePanelCollapsed()]);
2569
+ for (const b of document.querySelectorAll("#panel-tabs button[data-tab]")) {
2570
+ c.push([`Panel: ${b.textContent}`, () => setPanelTab(b.dataset.tab)]);
2571
+ }
2204
2572
 
2205
2573
  // Lens/zoom switches (#56, #63) — replaces the old header zoom picker.
2206
2574
  for (const [label, v] of ZOOM_OPTS) {
@@ -2262,22 +2630,7 @@ function paletteCommands() {
2262
2630
  // (no gh, unauthenticated, no matching workflow_dispatch workflow) and
2263
2631
  // the reason lands as a toast.
2264
2632
  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
- ]);
2633
+ c.push(["Run pipeline: GitHub Actions", () => dispatchPipeline()]);
2281
2634
  }
2282
2635
  }
2283
2636