// screens-orgchart.jsx — THE ORG CHART. BUILD_40.
//
// Three views from one control: Tree, Directory, Group Lens.
//
// THIS SCREEN IS NOW THE ONLY WAY TO ADD, EDIT OR RETIRE A PERSON.
//
// It used to read only, and this header used to warn that screens-org.jsx
// (Employee Records) was the sole writer of `people` and must not be replaced.
// That stopped being true on 2026-08-27 when the modals were extracted to
// screens-org-people.jsx, which this screen renders. Employee Records was
// removed on 2026-08-28 once the write path, the retire control and the
// directory were all confirmed here.
//
// So: createPerson and updatePerson are called from screens-org-people.jsx and
// nowhere else, and this file is what puts those modals on screen. It also
// writes org_groups / org_group_members, which are its own tables.
//
// WHAT THE DESIGN ACTUALLY SPECIFIES, corrected 2026-08-26. BUILD_40 §1 said
// the Tree was an SVG canvas. It is not -- read from `Vault Org Chart.dc.html`,
// the Tree is a root card, a connector, and a grid of branch cards with
// indented rows. THE SVG IS THE GROUP LENS: a dot-grid stage with positioned
// node cards, curved reporting edges, and dashed links between the members of
// the selected group. I wrote that spec from the binding names without reading
// which view they sat in.
//
// ROOT IS DERIVED, NEVER LITERAL (acceptance #2). The design fixture hardcodes
// rootId "dharveyinc", which is not a Vault handle and does not exist in
// `people`. Here the root is whoever the scope contains with no in-scope
// supervisor. Under a module-scoped roster that is the module lead, not Dave
// Harvey, and the tree still renders.
//
// COLOUR IS A TOKEN, ENFORCED IN THE DATABASE. org_groups.color_token has a
// CHECK constraint pinning it to ^--cat-[1-7]$, so a hex cannot be stored by
// any client. Seven is the whole categorical ramp and an eighth group is
// REFUSED rather than wrapped -- two groups sharing a colour on a screen whose
// job is telling them apart is worse than a blocked action. Brian has not ruled
// on what an eighth should do; refusing surfaces the question when it matters.

(function () {
  "use strict";

  var useState = React.useState, useMemo = React.useMemo, useEffect = React.useEffect;
  var useCallback = React.useCallback, useRef = React.useRef;

  var CAT_TOKENS = ["--cat-1", "--cat-2", "--cat-3", "--cat-4",
                    "--cat-5", "--cat-6", "--cat-7"];

  function firm() { return window.VAULT_FIRM || {}; }
  function byId() { return firm().PEOPLE_BY_ID || {}; }

  // THE ORDER OF THE ROOT'S BRANCHES IS A RULING, NOT A DERIVATION.
  //
  // Brian, 2026-08-27: the admin and non-deal branches sit on the RIGHT, in a
  // fixed order, and everything else runs alphabetically to their left. There
  // is no field in `people` that expresses this -- it is a reading order, so it
  // lives here as a named list rather than being invented from headcount or
  // department. TO CHANGE THE LAYOUT, EDIT THIS ARRAY. Nothing else needs to
  // move, and a handle that no longer exists is simply skipped.
  //
  // Reading LEFT to RIGHT: everyone else alphabetically, then these, in order.
  var BRANCH_TAIL = ["pbollman", "jhoulihan", "tleigh", "tpblock", "amadrid"];

  // Only the ROOT's children use this. Every other level stays alphabetical --
  // a fixed order inside a module would be a second ruling nobody has made.
  function orderBranches(ids) {
    return (ids || []).slice().sort(function (a, b) {
      var ia = BRANCH_TAIL.indexOf(a), ib = BRANCH_TAIL.indexOf(b);
      if (ia !== -1 || ib !== -1) {
        if (ia === -1) return -1;   // untailed sorts left of any tailed branch
        if (ib === -1) return 1;
        return ia - ib;
      }
      return bySurname(a, b);
    });
  }

  // SURNAME ORDER, not first name. The design lists branches Dutra, Friedman,
  // Hartley, Kaneko, Moya, Mulholland, Perrin, Scott, Sullivan -- alphabetical
  // by LAST name. Sorting on `name` gave Alle, Bradley, Brian, Brian, Clifford,
  // which is a different order and reads as unsorted to anyone looking for a
  // person. Same comparator drives the tree columns and the map, so the two
  // views agree on where a branch sits.
  function surnameOf(p) {
    var parts = String((p && p.name) || "").trim().split(/\s+/);
    return (parts.length > 1 ? parts[parts.length - 1] : parts[0] || "").toLowerCase();
  }
  function bySurname(aId, bId) {
    var P = byId(), a = P[aId] || {}, b = P[bId] || {};
    return surnameOf(a).localeCompare(surnameOf(b))
        || String(a.name || aId).localeCompare(String(b.name || bId));
  }

  function initialsOf(p) {
    if (!p) return "?";
    if (p.initials) return p.initials;
    var parts = String(p.name || p.id || "").trim().split(/\s+/);
    if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
    return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
  }

  // ------------------------------------------------------------------ shape
  // supId -> [personId], confined to the ids the roster returned. The
  // confinement is the ITERATION: nobody the roster excluded can appear as a
  // child, including the cross-module case where an out-of-scope analyst
  // reports to an in-scope lead.
  function buildChildren(ids) {
    var P = byId();
    var kids = Object.create(null);
    ids.forEach(function (id) {
      var p = P[id];
      var s = p && p.supId;
      if (!s || s === id) return;
      (kids[s] = kids[s] || []).push(id);
    });
    Object.keys(kids).forEach(function (k) { kids[k].sort(bySurname); });
    return kids;
  }

  // THE `seen` SET IS A TERMINATION GUARANTEE, AND IT CANNOT CURRENTLY FIRE.
  // Said plainly because the harness proved it: the mutant that removes it does
  // not die. `kids` is built from supId, which gives every person exactly ONE
  // parent, so a cycle is always a closed component with no edge into it from
  // anywhere else -- and descendantCount is only ever called on a root or a
  // branch lead, neither of which can be inside one. Two people naming each
  // other still exist in the data (probe territory), they just show up as
  // unassigned rather than in anyone's subtree.
  // It stays because the moment `kids` gains a second edge source -- a dotted
  // line, a group link, a matrix report -- one parent stops being true and this
  // becomes the difference between a number and a frozen tab. Cheap insurance,
  // honestly labelled, rather than a guard advertised as load-bearing when the
  // test for it cannot fail.
  function descendantCount(rootId, kids) {
    var seen = Object.create(null);
    seen[rootId] = true;              // or a cycle counts a person as their own
    var stack = (kids[rootId] || []).slice();
    var n = 0;
    while (stack.length) {
      var id = stack.pop();
      if (seen[id]) continue;
      seen[id] = true;
      n++;
      var next = kids[id] || [];
      for (var i = 0; i < next.length; i++) if (!seen[next[i]]) stack.push(next[i]);
    }
    return n;
  }

  // kids + root together, so the branch order is applied in ONE place and the
  // Tree and the Map cannot disagree about it. Deriving the root needs kids,
  // and ordering the branches needs the root, so the sequence is fixed.
  function buildTree(ids) {
    var kids = buildChildren(ids);
    var rootId = deriveRoot(ids, kids);
    if (rootId && kids[rootId]) kids[rootId] = orderBranches(kids[rootId]);
    return { kids: kids, rootId: rootId };
  }

  // Whoever has no supervisor INSIDE the scope. Derived, never literal.
  // Ties break on descendant count so a stray unparented row cannot displace
  // the real head of the tree.
  function deriveRoot(ids, kids) {
    var P = byId();
    var allow = Object.create(null);
    ids.forEach(function (id) { allow[id] = true; });
    var roots = ids.filter(function (id) {
      var s = P[id] && P[id].supId;
      return !s || !allow[s] || s === id;
    });
    if (!roots.length) return null;
    roots.sort(function (a, b) { return descendantCount(b, kids) - descendantCount(a, kids); });
    return roots[0];
  }

  // Depth-first walk under a person, honouring collapsed nodes.
  function flatten(rootId, kids, collapsed, depth, out, seen) {
    out = out || []; depth = depth || 0; seen = seen || Object.create(null);
    if (seen[rootId]) return out;
    seen[rootId] = true;
    if (collapsed[rootId]) return out;
    (kids[rootId] || []).forEach(function (id) {
      out.push({ id: id, depth: depth });
      flatten(id, kids, collapsed, depth + 1, out, seen);
    });
    return out;
  }

  // ------------------------------------------------------------------ group
  // COLOURED AVATARS. window.Avatar reads people.tone and renders the same
  // face this person has on every other screen -- a flat grey circle here made
  // the chart look like a different product. It returns null for an id it does
  // not know, so the initials block stays as the fallback rather than leaving
  // a hole.
  function Face(props) {
    if (typeof window.Avatar === "function") {
      var el = <window.Avatar id={props.id} px={props.px}/>;
      if (el) return el;
    }
    return (
      <span style={{
        width: props.px, height: props.px, flex: "none", borderRadius: "var(--r-pill)",
        background: "var(--surface-3)", color: "var(--ink-3)",
        display: "grid", placeItems: "center",
        fontSize: Math.round(props.px * 0.4), fontWeight: "var(--w-bold)",
      }}>{initialsOf(byId()[props.id])}</span>
    );
  }

  function GroupDots(props) {
    var list = props.groups || [];
    if (!list.length) return null;
    return (
      <span style={{ display: "inline-flex", gap: 3, alignItems: "center", flex: "none" }}>
        {list.map(function (g) {
          return (
            <span key={g.id} title={g.name} style={{
              width: 7, height: 7, borderRadius: "var(--r-pill)",
              background: "var(" + g.colorToken + ")",
            }}/>
          );
        })}
      </span>
    );
  }

  // ------------------------------------------------------------------- rows
  function BranchRow(props) {
    var p = props.person;
    var hasKids = props.hasKids;
    // A row kept only because somebody below it is in the group. Dimmed rather
    // than hidden -- remove it and the member underneath has nothing to hang
    // from; leave it at full strength and it reads as a member.
    var dim = !!props.dim;
    return (
      <div
        data-org-row={p.id}
        onClick={props.onClick}
        style={{
          display: "flex", alignItems: "center", gap: 7,
          padding: "5px 12px 5px " + (10 + props.depth * 14) + "px",
          borderTop: "1px solid var(--line-2)",
          cursor: "pointer",
          background: props.selected ? "var(--accent-soft)" : "transparent",
          opacity: dim ? 0.42 : 1,
        }}
      >
        <button
          onClick={function (e) { e.stopPropagation(); props.onToggle(); }}
          aria-label={hasKids ? (props.collapsed ? "Expand" : "Collapse") : "No reports"}
          style={{
            width: 14, height: 14, flex: "none", border: "none", background: "none",
            color: hasKids ? "var(--muted)" : "transparent",
            fontSize: 9, lineHeight: 1, padding: 0,
            cursor: hasKids ? "pointer" : "default",
          }}
        >{hasKids ? (props.collapsed ? "\u25B8" : "\u25BE") : "\u00B7"}</button>
<Face id={p.id} px={20}/>
        <span style={{
          fontSize: "var(--t-body)", fontWeight: "var(--w-medium)",
          color: "var(--ink)", whiteSpace: "nowrap",
        }}>{p.name}</span>
        <span style={{
          fontSize: "var(--t-micro)", color: "var(--muted)", flex: 1, minWidth: 0,
          whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
        }}>{p.title || p.role || ""}</span>
        <GroupDots groups={props.groups}/>
        {hasKids ? (
          <span style={{
            fontSize: "var(--t-micro)", fontWeight: "var(--w-medium)",
            color: "var(--muted)", background: "var(--surface-2)",
            borderRadius: "var(--r-pill)", padding: "1px 7px", flex: "none",
          }}>{props.reports}</span>
        ) : null}
      </div>
    );
  }

  // ------------------------------------------------------------------- tree
  function TreeView(props) {
    var P = byId();
    var kids = props.kids;
    var rootId = props.rootId;
    var root = rootId ? P[rootId] : null;

    if (!root) {
      if (!props.unassigned.length) {
        // An EMPTY GROUP is not a failed search, and saying "nobody matches"
        // when you have just made a group and not added anyone yet reads as a
        // broken screen rather than an obvious next step.
        if (props.lensGroup) {
          return <window.EmptyState
                   title={"Nobody is in " + props.lensGroup.name + " yet."}
                   hint="Turn on Edit Members, then click people in the chart to add them."/>;
        }
        return <window.EmptyState title="Nobody matches."
                 hint="No one in this scope matches the current search."/>;
      }
      return (
        <div style={{ padding: "20px 22px 40px" }}>
          <div className="v-empty" style={{ marginBottom: 12 }}>
            Nobody here has a supervisor outside this set, so there is no top of
            the chart to draw.
          </div>
          <div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
            <window.VaultKicker>Unassigned</window.VaultKicker>
            {props.unassigned.map(function (id) {
              var q = P[id];
              if (!q) return null;
              return (
                <button key={id} data-org-row={id}
                  onClick={function () { props.onPick(id); }}
                  style={{
                    display: "inline-flex", alignItems: "center", gap: 7,
                    padding: "6px 12px", background: "var(--surface)",
                    border: "1px dashed var(--line-strong)",
                    borderRadius: "var(--r-ctl)", cursor: "pointer",
                    fontFamily: "inherit", fontSize: "var(--t-body)",
                    color: "var(--ink)",
                  }}>{q.name}</button>
              );
            })}
          </div>
        </div>
      );
    }

    var branches = (kids[rootId] || []);

    return (
      <div style={{ padding: "20px 22px 40px" }}>
        <div style={{ display: "flex", justifyContent: "center" }}>
          <div
            data-org-row={root.id}
            onClick={function () { props.onPick(root.id); }}
            className="v-card"
            style={{
              display: "flex", alignItems: "center", gap: 12,
              padding: "12px 18px", cursor: "pointer",
              background: props.selected === root.id ? "var(--accent-soft)" : "var(--surface)",
            }}>
<Face id={root.id} px={38}/>
            <div>
              <div style={{ fontSize: "var(--t-card)", fontWeight: "var(--w-bold)",
                            color: "var(--ink)" }}>{root.name}</div>
              <div style={{ fontSize: "var(--t-small)", color: "var(--muted)" }}>
                {(root.title || root.role || "") + " \u00B7 " + props.headcount + " Reporting"}
              </div>
            </div>
          </div>
        </div>

        <div style={{ display: "flex", justifyContent: "center", height: 20 }}>
          <div style={{ width: 2, background: "var(--line-strong)" }}/>
        </div>

        {/* THREE COLUMNS, FIXED. auto-fill at minmax(21rem, 1fr) laid out five
            across on a wide monitor and the branch cards read as a wall. The
            design is three. Deterministic beats responsive here -- the point of
            the layout is that a branch is legible, not that it fills the width. */}
        <div data-branch-grid="1" style={{
          display: "grid",
          gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
          gap: 14, alignItems: "start",
        }}>
          {branches.map(function (bid) {
            var lead = P[bid];
            if (!lead) return null;
            var rows = flatten(bid, kids, props.collapsed, 0);
            var collapsedHere = !!props.collapsed[bid];
            return (
              <div key={bid} className="v-card" style={{ overflow: "hidden" }}>
                <div
                  data-org-row={bid}
                  onClick={function () { props.onPick(bid); }}
                  style={{
                    display: "flex", alignItems: "center", gap: 9,
                    padding: "10px 12px", cursor: "pointer",
                    background: props.selected === bid ? "var(--accent-soft)" : "var(--surface-2)",
                  }}>
<Face id={lead.id} px={30}/>
                  <div style={{ minWidth: 0, flex: 1 }}>
                    <div style={{
                      fontSize: "var(--t-body-lg)", fontWeight: "var(--w-medium)",
                      color: "var(--ink)", whiteSpace: "nowrap",
                      overflow: "hidden", textOverflow: "ellipsis",
                    }}>{lead.name}</div>
                    <div style={{
                      fontSize: "var(--t-micro)", color: "var(--muted)",
                      whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
                    }}>{lead.title || lead.role || ""}</div>
                  </div>
                  <GroupDots groups={props.groupsOf(bid)}/>
                  {(props.memberSet && !props.memberSet[bid]) ? (
                    <span style={{ fontSize: "var(--t-kicker)", color: "var(--muted-2)" }}>
                      not in group
                    </span>
                  ) : null}
                  <span style={{
                    fontSize: "var(--t-micro)", fontWeight: "var(--w-medium)",
                    color: "var(--muted)", background: "var(--surface-3)",
                    borderRadius: "var(--r-pill)", padding: "2px 8px", flex: "none",
                  }}>{descendantCount(bid, kids)}</span>
                  <button
                    onClick={function (e) { e.stopPropagation(); props.onToggle(bid); }}
                    aria-label={collapsedHere ? "Expand Branch" : "Collapse Branch"}
                    style={{
                      width: 22, height: 22, border: "none", background: "transparent",
                      color: "var(--muted)", fontSize: 10,
                      display: "grid", placeItems: "center", cursor: "pointer",
                    }}>{collapsedHere ? "\u25B8" : "\u25BE"}</button>
                </div>
                {rows.map(function (r) {
                  var p = P[r.id];
                  if (!p) return null;
                  return (
                    <BranchRow
                      key={r.id}
                      person={p}
                      depth={r.depth}
                      hasKids={(kids[r.id] || []).length > 0}
                      reports={(kids[r.id] || []).length}
                      collapsed={!!props.collapsed[r.id]}
                      groups={props.groupsOf(r.id)}
                      dim={!!props.memberSet && !props.memberSet[r.id]}
                      selected={props.selected === r.id}
                      onToggle={function () { props.onToggle(r.id); }}
                      onClick={function () { props.onPick(r.id); }}
                    />
                  );
                })}
              </div>
            );
          })}
        </div>

        {props.unassigned.length ? (
          <div style={{ marginTop: 16, display: "flex", alignItems: "center",
                        gap: 10, flexWrap: "wrap" }}>
            <window.VaultKicker>Unassigned</window.VaultKicker>
            {props.unassigned.map(function (id) {
              var p = P[id];
              if (!p) return null;
              return (
                <button key={id} data-org-row={id}
                  onClick={function () { props.onPick(id); }}
                  style={{
                    display: "inline-flex", alignItems: "center", gap: 7,
                    padding: "6px 12px", background: "var(--surface)",
                    border: "1px dashed var(--line-strong)",
                    borderRadius: "var(--r-ctl)", cursor: "pointer",
                    fontFamily: "inherit", fontSize: "var(--t-body)",
                    color: "var(--ink)",
                  }}>
                  {p.name}
                  <span style={{ fontSize: "var(--t-micro)", color: "var(--muted)" }}>
                    {p.title || p.role || ""}
                  </span>
                </button>
              );
            })}
          </div>
        ) : null}
      </div>
    );
  }

  // ---------------------------------------------------------------- the map
  //
  // WHAT WAS WRONG WITH THE FIRST VERSION. It placed every person at their
  // depth and centred each row in the stage. With 137 people that put ~120 of
  // them on one row about 26,000px wide, centred the root at x≈13,000, and
  // opened the view scrolled to the far left showing a strip of analysts and
  // nothing above them. It was not a tree, it was four very long lines.
  //
  // WHAT IT DOES NOW. A tidy tree: leaves take the next slot, a parent sits
  // centred over its children. That is what makes a branch READ as a branch --
  // locality, not just correct depth. And the map opens COLLAPSED to the root's
  // children, each carrying a +N badge, exactly as the design draws it. An org
  // chart of 137 people fully expanded is not navigable at any zoom.
  function layout(rootId, kids, ids, collapsed) {
    var NODE_W = 152, NODE_H = 44, GAP_X = 16, GAP_Y = 54;
    var pos = Object.create(null);
    var cursor = 0;
    var seen = Object.create(null);
    var maxDepth = 0;

    function place(id, depth) {
      if (seen[id]) return null;
      seen[id] = true;
      if (depth > maxDepth) maxDepth = depth;
      var children = collapsed[id] ? [] : (kids[id] || []);
      var xs = [];
      children.forEach(function (c) {
        var cx = place(c, depth + 1);
        if (cx !== null) xs.push(cx);
      });
      var x;
      if (!xs.length) { x = cursor; cursor += NODE_W + GAP_X; }
      else { x = (xs[0] + xs[xs.length - 1]) / 2; }
      pos[id] = { x: x, y: 20 + depth * (NODE_H + GAP_Y) };
      return x;
    }

    if (rootId) place(rootId, 0);

    // TRULY UNREACHABLE, NOT MERELY FOLDED. `seen` records what this pass
    // PLACED, and a collapsed branch is deliberately not placed -- so filtering
    // on `seen` alone dumped every hidden descendant onto the orphan row, which
    // is how a map that was supposed to open showing six branch leads opened
    // showing everybody. Reachability is a separate walk that ignores collapse.
    var reach = Object.create(null);
    if (rootId) {
      var stack = [rootId]; reach[rootId] = true;
      while (stack.length) {
        var cur = stack.pop();
        (kids[cur] || []).forEach(function (k) {
          if (!reach[k]) { reach[k] = true; stack.push(k); }
        });
      }
    }
    var orphans = ids.filter(function (id) { return !reach[id]; });
    if (orphans.length) {
      cursor += GAP_X;
      var oy = 20 + (maxDepth + 1) * (NODE_H + GAP_Y);
      orphans.forEach(function (id) {
        pos[id] = { x: cursor, y: oy };
        cursor += NODE_W + GAP_X;
      });
      maxDepth += 1;
    }

    return {
      pos: pos, w: Math.max(cursor, NODE_W) + GAP_X,
      h: 20 + (maxDepth + 1) * (NODE_H + GAP_Y) + 20,
      nodeW: NODE_W, nodeH: NODE_H,
    };
  }

  function edgePath(a, b, w, h) {
    var x1 = a.x + w / 2, y1 = a.y + h;
    var x2 = b.x + w / 2, y2 = b.y;
    var mid = (y1 + y2) / 2;
    return "M" + x1 + " " + y1 + " C" + x1 + " " + mid + " " + x2 + " " + mid + " " + x2 + " " + y2;
  }

  function LensView(props) {
    var P = byId();
    var L = props.layoutResult;
    var kids = props.kids;
    var scrollRef = useRef(null);
    var drag = useRef(null);

    // DRAG TO PAN. A canvas you can only reach with the scrollbars is a canvas
    // nobody pans. Pointer events rather than mouse events so a trackpad and a
    // touch screen behave the same, and setPointerCapture so a fast drag that
    // leaves the element does not stick in the dragging state.
    function panDown(e) {
      var el = scrollRef.current;
      if (!el) return;
      // Let a click on a node be a click. Only empty canvas starts a drag.
      if (e.target.closest && e.target.closest("[data-org-node]")) return;
      drag.current = { x: e.clientX, y: e.clientY, l: el.scrollLeft, t: el.scrollTop };
      el.style.cursor = "grabbing";
      if (el.setPointerCapture && e.pointerId != null) {
        try { el.setPointerCapture(e.pointerId); } catch (err) {}
      }
    }
    function panMove(e) {
      var el = scrollRef.current;
      if (!el || !drag.current) return;
      el.scrollLeft = drag.current.l - (e.clientX - drag.current.x);
      el.scrollTop  = drag.current.t - (e.clientY - drag.current.y);
    }
    function panUp(e) {
      var el = scrollRef.current;
      drag.current = null;
      if (el) el.style.cursor = "grab";
    }

    // OPEN CENTRED ON THE ROOT. The stage is far wider than the viewport and
    // scrollLeft starts at 0, which is the left edge of the widest row -- the
    // reason the first version appeared to show only a strip of analysts.
    var rootId = props.rootId;
    useEffect(function () {
      var el = scrollRef.current;
      if (!el || !rootId || !L.pos[rootId]) return;
      var x = (L.pos[rootId].x + L.nodeW / 2) * props.zoom;
      el.scrollLeft = Math.max(0, x - el.clientWidth / 2);
      el.scrollTop = 0;
    }, [rootId, L.w, props.zoom]);

    var edges = [];
    Object.keys(kids).forEach(function (pid) {
      var a = L.pos[pid];
      if (!a || props.collapsed[pid]) return;
      kids[pid].forEach(function (cid) {
        var b = L.pos[cid];
        if (!b) return;
        edges.push({ key: pid + ">" + cid, d: edgePath(a, b, L.nodeW, L.nodeH) });
      });
    });

    // Dashed links chaining the members of the selected group. A group is a
    // SET, not a sequence -- the chain is a way to see the set at a glance,
    // not a claim about order.
    var links = [];
    if (props.lensGroup) {
      var members = props.lensMembers.slice().filter(function (id) { return L.pos[id]; });
      members.sort(function (a, b) {
        return (L.pos[a].y - L.pos[b].y) || (L.pos[a].x - L.pos[b].x);
      });
      for (var i = 0; i < members.length - 1; i++) {
        var a2 = L.pos[members[i]], b2 = L.pos[members[i + 1]];
        links.push({
          key: members[i] + "~" + members[i + 1],
          d: "M" + (a2.x + L.nodeW / 2) + " " + (a2.y + L.nodeH / 2) +
             " L" + (b2.x + L.nodeW / 2) + " " + (b2.y + L.nodeH / 2),
        });
      }
    }

    return (
      <div
        ref={scrollRef}
        data-org-canvas="1"
        onPointerDown={panDown}
        onPointerMove={panMove}
        onPointerUp={panUp}
        onPointerCancel={panUp}
        style={{
          // HEIGHT, NOT max-height. The map is a PAN-AND-ZOOM CANVAS: its inner
          // surface is L.w x L.h scaled by zoom, and with only a max the card
          // shrank to whatever the diagram happened to measure, leaving dead
          // grey page beneath it and a smaller area to drag within. A tree view
          // sized to its content is fine; a canvas you navigate is not.
          position: "relative", overflow: "auto", height: "calc(100vh - 17rem)", minHeight: "28rem",
          backgroundImage: "radial-gradient(var(--line-2) 1px, transparent 1px)",
          backgroundSize: "22px 22px",
          border: "1px solid var(--line)", borderRadius: "var(--r-card)",
          cursor: "grab", userSelect: "none", touchAction: "none",
        }}>
        <div style={{ width: L.w * props.zoom, height: L.h * props.zoom, position: "relative" }}>
          <div style={{
            width: L.w, height: L.h, position: "absolute", left: 0, top: 0,
            transform: "scale(" + props.zoom + ")", transformOrigin: "0 0",
          }}>
            <svg width={L.w} height={L.h}
                 style={{ position: "absolute", left: 0, top: 0, pointerEvents: "none" }}>
              {edges.map(function (e) {
                return <path key={e.key} d={e.d} fill="none"
                             stroke="var(--line-strong)" strokeWidth="1.5" opacity="0.8"/>;
              })}
              {links.map(function (l) {
                return <path key={l.key} d={l.d} fill="none"
                             stroke={"var(" + props.lensGroup.colorToken + ")"}
                             strokeWidth="2" strokeDasharray="5 5" opacity="0.65"/>;
              })}
            </svg>
            {Object.keys(L.pos).map(function (id) {
              var p = P[id];
              if (!p) return null;
              var xy = L.pos[id];
              var inLens = props.lensGroup && props.lensMemberSet[id];
              var hidden = props.collapsed[id] ? descendantCount(id, kids) : 0;
              return (
                <div key={id} data-org-node={id}
                  onClick={function () { props.onPick(id); }}
                  style={{
                    position: "absolute", left: xy.x, top: xy.y,
                    width: L.nodeW, height: L.nodeH, boxSizing: "border-box",
                    display: "flex", alignItems: "center", gap: 7,
                    padding: "0 9px", cursor: "pointer",
                    background: "var(--surface)",
                    border: "1px solid " + (inLens
                      ? "var(" + props.lensGroup.colorToken + ")"
                      : (props.selected === id ? "var(--accent)" : "var(--line)")),
                    borderRadius: "var(--r-ctl)",
                    opacity: (props.lensGroup && !inLens) ? 0.45 : 1,
                  }}>
<Face id={p.id} px={22}/>
                  <div style={{ minWidth: 0, flex: 1 }}>
                    <div style={{
                      fontSize: "var(--t-micro)", fontWeight: "var(--w-bold)",
                      color: "var(--ink)", whiteSpace: "nowrap",
                      overflow: "hidden", textOverflow: "ellipsis",
                    }}>{p.name}</div>
                    <div style={{
                      fontSize: "var(--t-kicker)", color: "var(--muted)",
                      whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
                    }}>{p.title || p.role || ""}</div>
                  </div>
                  <GroupDots groups={props.groupsOf(id)}/>
                  {(kids[id] || []).length ? (
                    <button
                      data-org-expand={id}
                      onClick={function (e) { e.stopPropagation(); props.onToggle(id); }}
                      aria-label={hidden ? "Expand" : "Collapse"}
                      style={{
                        position: "absolute", left: "50%", bottom: -9,
                        transform: "translateX(-50%)",
                        padding: "1px 7px", border: "1px solid var(--line-strong)",
                        borderRadius: "var(--r-pill)", background: "var(--surface)",
                        color: "var(--muted)", fontSize: "var(--t-kicker)",
                        fontWeight: "var(--w-bold)", fontFamily: "inherit",
                        cursor: "pointer", lineHeight: 1.6,
                      }}>{hidden ? "+" + hidden : "\u2013"}</button>
                  ) : null}
                </div>
              );
            })}
          </div>
        </div>
      </div>
    );
  }

  // ---------------------------------------------------------------- drawer
  // The person panel is a DRAWER OVER THE CANVAS, available in every view --
  // that is how the design draws it. It was a split pane inside Directory only,
  // which meant clicking somebody on the Tree or the Map did nothing visible.
  //
  // PERSONNEL FILE ROWS ARE HONEST OR ABSENT. probe_90d: people_history holds
  // 49 rows, all from the arrivals import and the re-key, none of them anyone's
  // career. Vault has no start date, no fee attribution per person. So Start
  // Date, Deal Fees YTD and Deal Fees LTM read "not on file" and Tenure reads
  // an em dash. Last Promotion is the ONE that can be real: people.effective_date,
  // typed in by hand on Employee Records. A number invented from an audit row
  // would look like data and be a guess.
  //
  // UPDATED 2026-09-01. "Vault has no start date" was true when this was
  // written and is not any more: people_positions holds a dated row per
  // position, so the EARLIEST effective_from IS the start date and tenure
  // follows from it. Both are still absent rather than guessed when nobody has
  // entered a date -- the principle above is unchanged, only the availability
  // of the fact. Deal fees per person remain genuinely unavailable.
  function FileRow(props) {
    return (
      <div data-file-row={props.label} style={{
        display: "flex", justifyContent: "space-between", alignItems: "center",
        gap: 10, fontSize: "var(--t-body)", padding: "6px 0",
        borderBottom: "1px solid var(--line-2)",
      }}>
        <span style={{ color: "var(--ink-3)" }}>{props.label}</span>
        {props.value ? (
          <span style={{ color: "var(--ink)", fontWeight: "var(--w-medium)" }}>{props.value}</span>
        ) : (
          <span style={{ color: "var(--muted-2)", fontStyle: "italic" }}>not on file</span>
        )}
      </div>
    );
  }

  function DrawerStat(props) {
    return (
      <div>
        <div className="v-kicker" style={{ marginBottom: 3 }}>{props.label}</div>
        <div style={{ fontSize: 19, fontWeight: "var(--w-medium)", color: "var(--ink)",
                      lineHeight: 1.1 }}>{props.value}</div>
      </div>
    );
  }

  function PersonDrawer(props) {
    var P = byId();
    var p = P[props.id];
    if (!p) return null;

    var kids = props.kids;
    var reports = (kids[p.id] || []).map(function (id) { return P[id]; }).filter(Boolean);
    var team = descendantCount(p.id, kids);
    var mgr = p.supId && P[p.supId] && props.allow[p.supId] ? P[p.supId] : null;
    var since = p.effectiveDate && typeof window.VaultDate === "function"
      ? window.VaultDate(p.effectiveDate, { month: "short", day: "numeric", year: "numeric" })
      : null;

    // Start date and tenure come from the position history, which is the only
    // place either of them exists. Loaded per person; absent until somebody has
    // entered a dated row, which is most of the firm.
    var posState = React.useState(null);
    var positions = posState[0], setPositions = posState[1];
    React.useEffect(function () {
      setPositions(null);
      if (!p || !p.id || !window.VaultAPI || !window.VaultAPI.listPersonPositions) return;
      var cancelled = false;
      window.VaultAPI.listPersonPositions(p.id)
        .then(function (rows) { if (!cancelled) setPositions(rows || []); })
        .catch(function () { if (!cancelled) setPositions([]); });
      // Refetch when the edit modal writes a position. Without this a promotion
      // recorded in the modal only appeared after a full page reload: this
      // effect is keyed on the person id, which does not change when their
      // history does.
      var onWrote = function (e) {
        if (!e || !e.detail || e.detail.personId === p.id) {
          window.VaultAPI.listPersonPositions(p.id)
            .then(function (rows) { if (!cancelled) setPositions(rows || []); })
            .catch(function () {});
        }
      };
      window.addEventListener("vault:positions-updated", onWrote);
      return function () { cancelled = true; window.removeEventListener("vault:positions-updated", onWrote); };
    }, [p && p.id]);

    var earliest = null;
    (positions || []).forEach(function (h) {
      if (h.effectiveFrom && (!earliest || h.effectiveFrom < earliest)) earliest = h.effectiveFrom;
    });
    var startDate = earliest && typeof window.VaultDate === "function"
      ? window.VaultDate(earliest, { month: "short", day: "numeric", year: "numeric" })
      : null;
    // Whole years, floored. "3 yrs" is defensible from a start date; a figure to
    // one decimal implies a precision the entered date does not have.
    var tenure = null;
    if (earliest) {
      var yrs = Math.floor((Date.now() - new Date(earliest + "T00:00:00").getTime()) / 31557600000);
      tenure = yrs < 1 ? "<1 yr" : yrs + (yrs === 1 ? " yr" : " yrs");
    }
    var chips = props.groupsOf(p.id);
    // ---------------------------------------------------------------- drawer
    // REBUILT TO THE DESIGN, 2026-09-01 (Personnel Drawer.dc.html).
    //
    // Sectioned tinted cards rather than one flat column of rows. Start Date and
    // Last Promotion are GONE from Record on purpose -- Position History now
    // carries both, and printing a "Last Promotion" line beside a timeline that
    // shows the promotions is the same fact twice.
    //
    // Tokens, not the hexes in the .dc.html: a design file is standalone and has
    // to spell #F4F6F9, but shipping it forks the theme and breaks dark mode.
    var CARD = { background: "var(--surface-2)", border: "1px solid var(--line-2)",
                 borderRadius: "var(--r-card, 8px)", padding: "10px 12px" };
    var KICK = { fontSize: "var(--t-kicker)", fontWeight: "var(--w-bold, 700)",
                 letterSpacing: "1.2px", textTransform: "uppercase", color: "var(--muted)" };
    var ROW = { display: "flex", justifyContent: "space-between", alignItems: "center",
                gap: 10, fontSize: "var(--t-body-lg)", padding: "6px 0" };
    var NOTON = { color: "var(--muted-2)", fontStyle: "italic" };

    return (
      <div style={{
        position: "absolute", top: 0, right: 0, bottom: 0, width: "23rem",
        background: "var(--surface)", borderLeft: "1px solid var(--line)",
        boxShadow: "var(--shadow-drawer)", overflow: "auto", zIndex: 5,
        padding: "16px 14px", boxSizing: "border-box",
        display: "flex", flexDirection: "column", gap: 10,
      }}>
        {/* ---- header ---- */}
        <div style={{ display: "flex", alignItems: "flex-start", gap: 11, padding: "0 4px" }}>
          <Face id={p.id} px={40}/>
          <div style={{ flex: 1, minWidth: 0 }}>
            <div style={KICK}>Personnel File</div>
            <div style={{ fontSize: "var(--t-section)", fontWeight: "var(--w-medium)",
                          color: "var(--ink)", letterSpacing: "-.015em", marginTop: 1 }}>{p.name}</div>
            <div style={{ fontSize: "var(--t-body)", color: "var(--ink-3)" }}>
              {p.title || p.role || "\u2014"}
            </div>
          </div>
          {props.isAdmin && props.onEdit ? (
            <button className="btn sm" data-drawer-edit={p.id}
              onClick={function () { props.onEdit(p.id); }}>Edit</button>
          ) : null}
          <button className="btn ghost sm" aria-label="Close" onClick={props.onClose}
                  style={{ padding: 5 }}>
            <svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M3 3l8 8M11 3l-8 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/></svg>
          </button>
        </div>

        {/* ---- three tiles ---- */}
        <div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: 8 }}>
          {[["Directs", reports.length], ["Team", team], ["Tenure", tenure || "\u2014"]].map(function (t) {
            return (
              <div key={t[0]} style={Object.assign({}, CARD, { padding: "8px 10px" })}>
                <div style={Object.assign({}, KICK, { marginBottom: 2 })}>{t[0]}</div>
                <div style={{ fontSize: "var(--t-section)", fontWeight: "var(--w-medium)",
                              letterSpacing: "-.015em", lineHeight: 1.1 }}>{t[1]}</div>
              </div>
            );
          })}
        </div>

        {/* ---- record ---- */}
        <div style={CARD}>
          <div style={Object.assign({}, KICK, { marginBottom: 2 })}>Record</div>
          <div style={Object.assign({}, ROW, { borderBottom: "1px solid var(--line)" })}>
            <span style={{ color: "var(--ink-3)" }}>Reports To</span>
            {mgr ? (
              <a href="#" onClick={function (e) { e.preventDefault(); props.onPick(mgr.id); }}
                 style={{ fontWeight: "var(--w-medium)", textDecoration: "none" }}>{mgr.name}</a>
            ) : <span style={NOTON}>not on file</span>}
          </div>
          {/* Deal fees per person genuinely do not exist in Vault. "not on file"
              is the honest answer; a number assembled from closed_deals would
              look like data and be an attribution guess. */}
          <div style={Object.assign({}, ROW, { borderBottom: "1px solid var(--line)" })}>
            <span style={{ color: "var(--ink-3)" }}>Deal Fees (YTD)</span>
            <span style={NOTON}>not on file</span>
          </div>
          <div style={ROW}>
            <span style={{ color: "var(--ink-3)" }}>Deal Fees (LTM)</span>
            <span style={NOTON}>not on file</span>
          </div>
        </div>

        {/* ---- position history ---- */}
        <div style={CARD}>
          <div style={Object.assign({}, KICK, { marginBottom: 8 })}>Position History</div>
          {positions === null ? (
            <div style={{ fontSize: "var(--t-small)", color: "var(--muted)" }}>Loading\u2026</div>
          ) : positions.length === 0 ? (
            <div style={{ fontSize: "var(--t-small)", color: "var(--muted-2)", fontStyle: "italic" }}>
              Nothing recorded yet.
            </div>
          ) : positions.map(function (h, i) {
            var open = !h.effectiveTo;
            return (
              <div key={h.id} style={{ display: "flex", gap: 10,
                marginBottom: i === positions.length - 1 ? 0 : 2 }}>
                <div style={{ display: "flex", flexDirection: "column", alignItems: "center", paddingTop: 5 }}>
                  <span style={{ width: 8, height: 8, borderRadius: "50%", flexShrink: 0,
                    background: open ? "var(--accent)" : "var(--line-strong)" }}/>
                  {i === positions.length - 1 ? null : (
                    <span style={{ width: 1, flex: 1, background: "var(--line-strong)", marginTop: 4 }}/>
                  )}
                </div>
                <div style={{ flex: 1, minWidth: 0, background: "var(--surface)",
                  border: "1px solid " + (open ? "var(--line)" : "var(--line-2)"),
                  borderRadius: "var(--r-ctl)", padding: "8px 10px",
                  marginBottom: i === positions.length - 1 ? 0 : 8,
                  opacity: open ? 1 : 0.8 }}>
                  <div style={{ fontFamily: "var(--f-mono)", fontSize: "var(--t-micro)", color: "var(--muted)" }}>
                    {(h.effectiveFrom || "no date") + " \u2192 " + (h.effectiveTo || "Now")}
                    {h.isSeed ? " \u00b7 not verified" : ""}
                  </div>
                  <div style={{ fontSize: "var(--t-body)", fontWeight: "var(--w-medium)",
                    color: open ? "var(--ink)" : "var(--ink-3)", marginTop: 2 }}>{h.title || "\u2014"}</div>
                  {h.subTeamLeadId ? (
                    <div style={{ fontSize: "var(--t-small)", color: "var(--muted)", marginTop: 1 }}>
                      {"Supervisor: " + ((P[h.subTeamLeadId] || {}).name || h.subTeamLeadId)}
                    </div>
                  ) : null}
                </div>
              </div>
            );
          })}
        </div>

        {/* ---- groups ---- */}
        {chips && chips.length ? (
          <div style={CARD}>
            <div style={Object.assign({}, KICK, { marginBottom: 6 })}>Groups</div>
            <div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
              {chips.map(function (g) {
                return (
                  <span key={g.id} style={{ display: "inline-flex", alignItems: "center", gap: 6,
                    padding: "3px 10px", fontSize: "var(--t-micro)", fontWeight: "var(--w-medium)",
                    letterSpacing: ".02em", color: "var(--ink-3)", background: "var(--surface)",
                    border: "1px solid var(--line-strong)", borderRadius: "var(--r-pill)" }}>
                    <span style={{ width: 7, height: 7, borderRadius: "var(--r-pill)",
                      // colorToken already carries its "--" prefix; line 217 in
                      // this file is the existing convention. Stripping and
                      // re-adding it worked but invented a second spelling.
                      background: "var(" + (g.colorToken || "--cat-1") + ")" }}/>
                    {g.name}
                  </span>
                );
              })}
            </div>
          </div>
        ) : null}

        {/* ---- supervised ---- */}
        <div style={CARD}>
          <div style={Object.assign({}, KICK, { marginBottom: 4 })}>Supervised Individuals</div>
          {reports.length === 0 ? (
            <div style={{ fontSize: "var(--t-small)", color: "var(--muted-2)", fontStyle: "italic" }}>
              Nobody reports to this person.
            </div>
          ) : reports.map(function (r, i) {
            return (
              <div key={r.id} onClick={function () { props.onPick(r.id); }}
                style={{ display: "flex", alignItems: "center", gap: 8, padding: "6px 0",
                  cursor: "pointer",
                  borderBottom: i === reports.length - 1 ? "none" : "1px solid var(--line)" }}>
                <Face id={r.id} px={24}/>
                <span style={{ fontSize: "var(--t-body-lg)", fontWeight: "var(--w-medium)" }}>{r.name}</span>
                <span style={{ fontSize: "var(--t-small)", color: "var(--muted)", flex: 1, minWidth: 0,
                  whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
                  {r.title || r.role || ""}
                </span>
              </div>
            );
          })}
        </div>
      </div>
    );
  }

  // ------------------------------------------------------------------ screen
  function OrgBoardScreen(props) {
    var isAdmin = !!props.isAdmin;

    var [view, setView] = useState("tree");
    var [filter, setFilter] = useState("");
    // ONE COLLAPSE MAP PER VIEW, deliberately. The Tree opens fully expanded --
    // that is what it is for, a readable list of every branch. The Map opens
    // collapsed to the root's children with +N badges, because 137 nodes fully
    // expanded is a wall at any zoom. Sharing one map would force one of those
    // defaults onto the other view. The toolbar buttons act on whichever view
    // is showing.
    var [treeCollapsed, setTreeCollapsed] = useState({});
    var [mapCollapsed, setMapCollapsed] = useState(null);   // null = not seeded
    var [selected, setSelected] = useState(null);
    var [zoom, setZoom] = useState(1);

    var [groups, setGroups] = useState([]);
    var [members, setMembers] = useState([]);       // {groupId, personId}
    var [lensId, setLensId] = useState(null);
    var [editingMembers, setEditingMembers] = useState(false);
    var [draftName, setDraftName] = useState(null);   // non-null = creating
    var [renameTo, setRenameTo] = useState(null);     // non-null = renaming lensId
    var [confirmKill, setConfirmKill] = useState(null); // group id awaiting confirm
    // THIS SCREEN NOW WRITES `people`, via the extracted modals. Employee
    // Records held the only two callers of createPerson/updatePerson; they live
    // in screens-org-people.jsx so both screens can use them while the old one
    // is retired. Nothing else in Vault writes this table.
    var [editingPerson, setEditingPerson] = useState(null);
    var [addingPerson, setAddingPerson] = useState(false);
    var [err, setErr] = useState("");
    var [busy, setBusy] = useState(false);

    var module = props.module || null;

    // ---- HOLD FOR THE LIVE ORG CHART --------------------------------------
    //
    // THIS IS WHY THE TREE WENT BLANK AND THE COUNT READ 123 INSTEAD OF 137.
    // data-firm.js ships a STATIC fallback roster that exists from the first
    // paint; the real one arrives from Supabase a moment later and replaces it.
    // The fallback carries no `team` and a different set of ids, so a screen
    // that renders against it builds a tree whose root resolves to nobody --
    // an empty page with a plausible-looking headcount on top of it. A zero
    // that looks legitimate, again.
    //
    // lib-org-scope.ready() exists exactly for this and gates on a person
    // actually carrying a team. app.jsx:220 already holds its scope seeding on
    // it; this screen did not, which is the whole bug. Whether it bit you
    // depended on network timing, which is why it rendered fine yesterday.
    var [ready, setReady] = useState(function () {
      return !(window.VaultOrg && typeof window.VaultOrg.ready === "function")
        || window.VaultOrg.ready();
    });
    useEffect(function () {
      if (ready) return undefined;
      function check() {
        if (window.VaultOrg && typeof window.VaultOrg.ready === "function"
            && window.VaultOrg.ready()) setReady(true);
      }
      // Both, on purpose: the event is the fast path, the poll is the one that
      // still works if the event fired before this screen mounted.
      window.addEventListener("vault:org-updated", check);
      var t = setInterval(check, 250);
      check();
      return function () {
        window.removeEventListener("vault:org-updated", check);
        clearInterval(t);
      };
    }, [ready]);

    // ---- roster -----------------------------------------------------------
    var ids = useMemo(function () {
      if (!ready) return [];
      if (!window.VaultOrg || typeof window.VaultOrg.roster !== "function") return [];
      return window.VaultOrg.roster(module).map(function (r) { return r.id; });
    }, [module, props.orgTick, ready]);

    var tree = useMemo(function () { return buildTree(ids); }, [ids]);
    var kids = tree.kids;
    var allow = useMemo(function () {
      var m = Object.create(null);
      ids.forEach(function (id) { m[id] = true; });
      return m;
    }, [ids]);

    // ---- groups -----------------------------------------------------------
    var reload = useCallback(function () {
      if (!window.VaultAPI || !window.VaultAPI.listOrgGroups) return;
      Promise.all([
        window.VaultAPI.listOrgGroups(module),
        window.VaultAPI.listOrgGroupMembers(),
      ]).then(function (r) {
        setGroups(r[0] || []);
        setMembers(r[1] || []);
      }).catch(function (e) {
        // A FAILED GROUP LOAD MUST NOT LOOK LIKE "NO GROUPS YET". An RLS denial
        // or a missing grant returns an error, and swallowing it into an empty
        // array is the zero-that-looks-legitimate failure this project keeps
        // paying for.
        setErr("Groups did not load: " + (e && e.message ? e.message : e));
      });
    }, [module]);

    useEffect(function () { reload(); }, [reload]);

    var byPerson = useMemo(function () {
      var g = Object.create(null);
      var gi = Object.create(null);
      groups.forEach(function (x) { gi[x.id] = x; });
      members.forEach(function (m) {
        var grp = gi[m.groupId];
        if (!grp) return;
        (g[m.personId] = g[m.personId] || []).push(grp);
      });
      return g;
    }, [groups, members]);

    var groupsOf = useCallback(function (pid) { return byPerson[pid] || []; }, [byPerson]);

    var lensGroup = useMemo(function () {
      return groups.find(function (g) { return g.id === lensId; }) || null;
    }, [groups, lensId]);

    var lensMembers = useMemo(function () {
      return members.filter(function (m) { return m.groupId === lensId; })
                    .map(function (m) { return m.personId; });
    }, [members, lensId]);

    var lensMemberSet = useMemo(function () {
      var s = Object.create(null);
      lensMembers.forEach(function (id) { s[id] = true; });
      return s;
    }, [lensMembers]);

    // ---- filter -----------------------------------------------------------
    //
    // ONE KEEP-SET, TWO INPUTS: the search box and the selected group. They
    // INTERSECT -- searching inside a group narrows within it rather than
    // replacing it, which is what a filter and a lens each being on at once
    // has to mean.
    //
    // Filtering the TREE means keeping a match AND its whole chain of
    // ancestors, or the match has nothing to hang from and disappears.
    //
    // EXCEPT WHILE EDITING MEMBERS. Hiding non-members is exactly wrong when
    // the job in hand is adding one -- you would be able to remove people and
    // never add them, a one-way door hidden behind a filter. So the group
    // constraint lifts while Edit Members is on, and the search box keeps
    // working so you can still find the person you are adding.
    var groupFiltering = !!lensGroup && !editingMembers;

    var visible = useMemo(function () {
      var q = filter.trim().toLowerCase();
      if (!q && !groupFiltering) return null;
      var P = byId();
      var keep = Object.create(null);
      ids.forEach(function (id) {
        var p = P[id];
        if (!p) return;
        if (groupFiltering && !lensMemberSet[id]) return;
        if (q) {
          var hay = (String(p.name || "") + " " + String(p.title || p.role || "")).toLowerCase();
          if (hay.indexOf(q) === -1) return;
        }
        var cur = id, guard = Object.create(null);
        while (cur && !guard[cur]) { guard[cur] = true; keep[cur] = true; cur = P[cur] && P[cur].supId; }
      });
      return keep;
    }, [filter, ids, groupFiltering, lensMemberSet]);

    var shownIds = useMemo(function () {
      return visible ? ids.filter(function (id) { return visible[id]; }) : ids;
    }, [ids, visible]);

    var shownTree = useMemo(function () { return buildTree(shownIds); }, [shownIds]);
    var shownKids = shownTree.kids;
    var shownRoot = shownTree.rootId;

    var unassigned = useMemo(function () {
      // NO ROOT IS NOT NO PEOPLE. A filter that matches only a closed cycle
      // leaves a set where every person's supervisor is also in the set, so
      // nothing qualifies as a root -- and the screen used to render "Nothing
      // to draw" over two people who were right there. A cycle genuinely has
      // no head, so nobody is promoted to one; they all show as unassigned,
      // which is the true answer.
      if (!shownRoot) return shownIds.slice();
      var reach = Object.create(null);
      var stack = [shownRoot]; reach[shownRoot] = true;
      while (stack.length) {
        var cur = stack.pop();
        (shownKids[cur] || []).forEach(function (k) {
          if (!reach[k]) { reach[k] = true; stack.push(k); }
        });
      }
      return shownIds.filter(function (id) { return !reach[id]; });
    }, [shownIds, shownKids, shownRoot]);

    // Seed the Map once the roster has resolved: everything with reports is
    // folded except the root, so the first frame is the root plus its branch
    // leads, each showing how many people it hides.
    useEffect(function () {
      if (mapCollapsed !== null || !shownRoot) return;
      var c = {};
      shownIds.forEach(function (id) {
        if (id !== shownRoot && (shownKids[id] || []).length) c[id] = true;
      });
      setMapCollapsed(c);
    }, [mapCollapsed, shownRoot, shownIds, shownKids]);

    var isMap = view === "map";
    var collapsed = isMap ? (mapCollapsed || {}) : treeCollapsed;
    var setCollapsed = isMap ? setMapCollapsed : setTreeCollapsed;

    var lay = useMemo(function () {
      return layout(shownRoot, shownKids, shownIds, collapsed);
    }, [shownRoot, shownKids, shownIds, collapsed]);

    // ---- toolbar actions --------------------------------------------------
    function expandAll() { setCollapsed({}); }
    function collapseAll() {
      // THE ROOT STAYS OPEN (Brian, 2026-09-01: "collapse all should just show
      // Dave's direct reports instead of just Dave"). Collapsing it left one
      // card on the Map and nothing to act on -- "collapse all" means fold
      // everything BELOW the root, not fold the org away.
      //
      // This is deliberately the same state the Map seeds itself with on first
      // load, so the button returns you to where you started rather than
      // somewhere you have to climb back out of.
      var c = {};
      shownIds.forEach(function (id) {
        if (id === shownRoot) return;
        if ((shownKids[id] || []).length) c[id] = true;
      });
      setCollapsed(c);
    }
    // THREE LEVELS, AND THEY MUST DIFFER. The design lists Collapse Groups and
    // Collapse All as separate controls, so they cannot do the same thing.
    //   Expand All      everything open
    //   Collapse Groups branch leads stay open, their DIRECT reports show,
    //                   anything deeper folds -- "collapse to the operational
    //                   group level, not to the root"
    //   Collapse All    the branches themselves fold; lead headers only
    // Written the obvious way -- collapsing the branches -- these two produced
    // identical screens, because a collapsed branch hides its descendants
    // anyway. The harness caught it: the mutant that made them the same could
    // not be killed.
    function collapseGroups() {
      var c = {};
      var topLevel = Object.create(null);
      (shownKids[shownRoot] || []).forEach(function (id) { topLevel[id] = true; });
      shownIds.forEach(function (id) {
        // THE ROOT MUST BE EXEMPT TOO. topLevel holds the root's CHILDREN, so
        // the root itself fell through and got collapsed -- and a collapsed
        // root places nothing below it. On the Tree that is invisible, because
        // the Tree renders each branch as its own card and never draws from the
        // root; on the Map everything hangs off the root, so Collapse Groups
        // and Collapse All both left a single CEO node and looked identical.
        // The harness that proved these two differ only ever mounted the Tree.
        if (id === shownRoot || topLevel[id]) return;   // root and branch leads stay open
        if ((shownKids[id] || []).length) c[id] = true; // everything deeper folds
      });
      setCollapsed(c);
    }
    function toggle(id) {
      setCollapsed(function (c) {
        var n = Object.assign({}, c);
        if (n[id]) delete n[id]; else n[id] = true;
        return n;
      });
    }

    function nextToken() {
      var used = Object.create(null);
      groups.forEach(function (g) { used[g.colorToken] = true; });
      return CAT_TOKENS.find(function (t) { return !used[t]; }) || null;
    }

    function startGroup() {
      setErr("");
      if (!nextToken()) {
        setErr("All seven group colours are in use. Archive a group before creating another.");
        return;
      }
      setDraftName("");
      setEditingMembers(false);
    }

    function saveGroup() {
      var name = String(draftName || "").trim();
      if (!name) { setErr("A group needs a name."); return; }
      var token = nextToken();
      if (!token) { setErr("All seven group colours are in use."); return; }
      setBusy(true); setErr("");
      window.VaultAPI.createOrgGroup({ name: name, color_token: token, module: module })
        .then(function (g) {
          setDraftName(null);
          setLensId(g && g.id ? g.id : null);
          setEditingMembers(true);
          reload();
        })
        .catch(function (e) { setErr(String(e && e.message ? e.message : e)); })
        .then(function () { setBusy(false); });
    }

    function saveRename() {
      var name = String(renameTo || "").trim();
      if (!name) { setErr("A group needs a name."); return; }
      setBusy(true); setErr("");
      window.VaultAPI.renameOrgGroup(lensId, name)
        .then(function () { setRenameTo(null); reload(); })
        .catch(function (e) { setErr(String(e && e.message ? e.message : e)); })
        .then(function () { setBusy(false); });
    }

    // ARCHIVE, NOT DELETE. DELETE cascades every membership row away and is
    // admin-only by policy; archiving keeps the history and frees both the
    // colour and the name for reuse. Two-step rather than window.confirm --
    // a native dialog is unstyleable, untestable, and blocks the tab.
    function archiveGroup(id) {
      setBusy(true); setErr("");
      window.VaultAPI.archiveOrgGroup(id)
        .then(function () {
          setConfirmKill(null);
          if (lensId === id) { setLensId(null); setEditingMembers(false); }
          reload();
        })
        .catch(function (e) { setErr(String(e && e.message ? e.message : e)); })
        .then(function () { setBusy(false); });
    }

    // Clicking a person while editing members toggles them in the selected
    // group. Writes immediately -- a staged list that has to be "saved" is one
    // more place for the screen and the database to disagree.
    function onPick(id) {
      if (editingMembers && lensId) {
        var already = lensMemberSet[id];
        setErr("");
        var call = already
          ? window.VaultAPI.removeOrgGroupMember(lensId, id)
          : window.VaultAPI.addOrgGroupMember(lensId, id);
        call.then(reload).catch(function (e) {
          setErr(String(e && e.message ? e.message : e));
        });
        return;
      }
      setSelected(id);
    }

    var headcount = ids.length;

    // ---- render -----------------------------------------------------------
    // Every hook above this line runs on every render. The gate sits here, at
    // the end, so holding for the roster cannot change the hook order.
    if (!ready) {
      return (
        <div style={{ padding: "18px 22px", background: "var(--bg)", minHeight: "100%" }}>
          <window.PageToolbar title="Organizational Chart" subtitle="Harvey & Company"/>
          <window.VaultLoader/>
        </div>
      );
    }

    return (
      <div style={{ padding: "18px 22px", background: "var(--bg)", minHeight: "100%" }}>

        <window.PageToolbar
          title="Organizational Chart"
          subtitle={"Harvey & Company \u00B7 " + headcount + " People"}/>

        <div className="v-card" style={{ padding: "9px 12px", marginBottom: 12 }}>
          <div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
            <window.VaultSearch value={filter} onChange={setFilter} size="sm"
              placeholder={"Search name or title\u2026"} label="Search the org chart"/>
            <button className="btn sm" onClick={expandAll} disabled={view === "directory"}>Expand All</button>
            <button className="btn sm" onClick={collapseGroups} disabled={view === "directory"}>Collapse Groups</button>
            <button className="btn sm" onClick={collapseAll} disabled={view === "directory"}>Collapse All</button>
            <span style={{ width: 1, height: 22, background: "var(--line-strong)", margin: "0 4px" }}/>
            {isAdmin ? (
              <button className="btn primary sm" onClick={function () { setAddingPerson(true); }}>
                + Add Employee
              </button>
            ) : null}
            {isAdmin ? (
              <button className="btn sm" onClick={startGroup} disabled={busy}>+ New Group</button>
            ) : null}
            {isAdmin ? (
              <button
                className={editingMembers ? "btn primary sm" : "btn sm"}
                title={lensId ? "Click people to add or remove them" : "Select a group first"}
                onClick={function () {
                  if (!lensId) { setErr("Select an operational group first, then add members."); return; }
                  setErr(""); setEditingMembers(!editingMembers);
                }}>
                {editingMembers ? "Done" : "Edit Members"}
              </button>
            ) : null}
            {editingMembers ? (
              <span style={{ fontSize: "var(--t-micro)", color: "var(--accent)",
                             fontWeight: "var(--w-medium)" }}>
                Click people in the chart to add or remove
              </span>
            ) : null}
            {(isAdmin && lensGroup && draftName === null) ? (
              renameTo !== null ? (
                <span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
                  <input type="text" value={renameTo} autoFocus
                    onChange={function (e) { setRenameTo(e.target.value); }}
                    onKeyDown={function (e) {
                      if (e.key === "Enter") saveRename();
                      if (e.key === "Escape") setRenameTo(null);
                    }}
                    aria-label="Rename group"
                    style={{ padding: "3px 8px", fontFamily: "inherit",
                             fontSize: "var(--t-body)", color: "var(--ink)",
                             background: "var(--surface)",
                             border: "1px solid var(--line-strong)",
                             borderRadius: "var(--r-ctl)", width: 140, outline: "none" }}/>
                  <button className="btn primary sm" onClick={saveRename} disabled={busy}>Save Name</button>
                  <button className="btn ghost sm" onClick={function () { setRenameTo(null); }}>Cancel</button>
                </span>
              ) : confirmKill === lensGroup.id ? (
                <span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
                  <span style={{ fontSize: "var(--t-micro)", color: "var(--gold-ink)",
                                 background: "var(--gold-soft)", border: "1px solid var(--gold-line)",
                                 borderRadius: "var(--r-chip)", padding: "3px 8px",
                                 fontWeight: "var(--w-medium)" }}>
                    {"Archive “" + lensGroup.name + "” and its members?"}
                  </span>
                  <button className="btn primary sm" disabled={busy}
                    onClick={function () { archiveGroup(lensGroup.id); }}>Archive</button>
                  <button className="btn ghost sm" onClick={function () { setConfirmKill(null); }}>Keep</button>
                </span>
              ) : (
                <span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
                  <button className="btn sm"
                    onClick={function () { setErr(""); setRenameTo(lensGroup.name); }}>Rename</button>
                  <button className="btn sm"
                    onClick={function () { setErr(""); setConfirmKill(lensGroup.id); }}>Archive</button>
                </span>
              )
            ) : null}
            {draftName !== null ? (
              <span style={{
                display: "inline-flex", alignItems: "center", gap: 8,
                background: "var(--accent-soft)", border: "1px solid var(--line-strong)",
                borderRadius: "var(--r-ctl)", padding: "4px 10px",
              }}>
                <input type="text" value={draftName}
                  onChange={function (e) { setDraftName(e.target.value); }}
                  onKeyDown={function (e) { if (e.key === "Enter") saveGroup(); }}
                  autoFocus
                  placeholder={"Group name\u2026"}
                  aria-label="New group name"
                  style={{ border: "none", background: "transparent",
                           fontSize: "var(--t-body)", fontWeight: "var(--w-medium)",
                           color: "var(--ink)", outline: "none", width: 130,
                           fontFamily: "inherit" }}/>
                <button className="btn primary sm" onClick={saveGroup} disabled={busy}>Save</button>
                <button className="btn ghost sm" onClick={function () { setDraftName(null); setErr(""); }}>Cancel</button>
              </span>
            ) : null}
            <span style={{ flex: 1 }}/>
            {view === "map" ? (
              <span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
                <button className="btn sm" aria-label="Zoom Out"
                  onClick={function () { setZoom(Math.max(0.5, Math.round((zoom - 0.1) * 10) / 10)); }}>{"\u2212"}</button>
                <span style={{ fontSize: "var(--t-micro)", color: "var(--muted)",
                               minWidth: "2.5rem", textAlign: "center" }}>
                  {Math.round(zoom * 100) + "%"}
                </span>
                <button className="btn sm" aria-label="Zoom In"
                  onClick={function () { setZoom(Math.min(1.6, Math.round((zoom + 0.1) * 10) / 10)); }}>+</button>
              </span>
            ) : null}
            {/* THE VIEW SWITCH LIVES ON THIS BAR, not in the PageToolbar.
                It sat top-right of the page title, away from the controls it
                governs; the design puts it on the same white strip as Expand
                All and the group chips.

                OPTIONS ARE [value, label]. vault-ui.jsx:449 reads
                `{ value: o[0], label: o[1] }`. BRANDING_STANDARDS §4 states the
                opposite -- and describes, exactly, the bug that following it
                produces: "renders the values as labels and sends the labels as
                values". That is what shipped: the tabs read "tree directory
                lens" in lower case, and clicking one set view to "Directory",
                which matched no branch, so two of the three views rendered
                nothing. One wrong pair, two symptoms.
                screens-pipeline.jsx:38 has carried the correct order in a
                call-site comment the whole time. The .md has been corrected. */}
            <window.VaultSeg
              options={[["tree", "Tree"], ["directory", "Directory"], ["map", "Map"]]}
              value={view}
              onChange={function (v) { setView(v); }}
            />
          </div>

          <div style={{ display: "flex", alignItems: "center", gap: 8,
                        flexWrap: "wrap", marginTop: 8 }}>
            <window.VaultKicker style={{ marginRight: 2 }}>Operational Groups</window.VaultKicker>
            {groups.length === 0 ? (
              <span style={{ fontSize: "var(--t-micro)", color: "var(--muted-2)" }}>
                Nothing yet.
              </span>
            ) : groups.map(function (g) {
              var on = g.id === lensId;
              return (
                <button key={g.id} data-org-group={g.id}
                  onClick={function () { setLensId(on ? null : g.id); if (on) setEditingMembers(false); }}
                  style={{
                    display: "inline-flex", alignItems: "center", gap: 6,
                    padding: "3px 10px", cursor: "pointer", fontFamily: "inherit",
                    fontSize: "var(--t-micro)", fontWeight: "var(--w-medium)",
                    color: on ? "var(--accent-ink)" : "var(--ink-3)",
                    background: on ? "var(--accent)" : "var(--surface)",
                    border: "1px solid var(--line-strong)",
                    borderRadius: "var(--r-pill)",
                  }}>
                  <span style={{ width: 7, height: 7, borderRadius: "var(--r-pill)",
                                 background: "var(" + g.colorToken + ")" }}/>
                  {g.name}
                  <span style={{ opacity: .55 }}>
                    {members.filter(function (m) { return m.groupId === g.id; }).length}
                  </span>
                </button>
              );
            })}
          </div>

          {err ? (
            <div style={{ marginTop: 8, fontSize: "var(--t-micro)", color: "var(--risk)" }}>
              {err}
            </div>
          ) : null}
        </div>

        <div style={{ position: "relative" }}>
        {view === "tree" ? (
          /* HEIGHT FOLLOWS THE WINDOW, NOT A FIXED 44rem (Brian, 2026-09-01:
              "the org doesn't take up the rest of the screen below").
              44rem is ~704px, so on any tall monitor the tree stopped
              mid-branch with empty page beneath it -- Hartley's card cut off at
              Kyle Kosmos. Present since Org v3; nothing in the recent pushes
              caused it, it just reads as broken now the branches are longer.
              The subtraction is the chrome above: header, toolbar and the
              operational-group strip. minHeight keeps it usable on a laptop. */
          /* HEIGHT, NOT max-height -- the same correction the Map needed. A
              maximum only caps: when the branch cards are shorter than the
              window the card shrinks to them and the page has grey beneath it
              again, which is the "back to 1/3 of the page" symptom. A fixed
              height fills the space and scrolls inside when there is more. */
          <div className="v-card" style={{ overflow: "auto",
            height: "calc(100vh - 17rem)", minHeight: "28rem" }}>
            <TreeView
              kids={shownKids}
              memberSet={groupFiltering ? lensMemberSet : null}
              lensGroup={lensGroup}
              rootId={shownRoot}
              collapsed={collapsed}
              selected={selected}
              headcount={headcount}
              unassigned={unassigned}
              groupsOf={groupsOf}
              onToggle={toggle}
              onPick={onPick}
            />
          </div>
        ) : null}

        {view === "directory" ? (
          <window.OrgDirectoryView
            isAdmin={isAdmin}
            module={module}
            orgTick={props.orgTick}
            onEditPerson={function (id) { setEditingPerson(id); }}
            groupsOf={groupsOf}
            memberSet={groupFiltering ? lensMemberSet : null}
            groupLabel={groupFiltering ? lensGroup.name : null}
          />
        ) : null}

        {view === "map" ? (
          <LensView
            layoutResult={lay}
            kids={shownKids}
            rootId={shownRoot}
            collapsed={collapsed}
            onToggle={toggle}
            zoom={zoom}
            selected={selected}
            lensGroup={lensGroup}
            lensMembers={lensMembers}
            lensMemberSet={lensMemberSet}
            groupsOf={groupsOf}
            onPick={onPick}
          />
        ) : null}

        {/* ONE DRAWER FOR ALL THREE VIEWS. Clicking a person on the Tree or the
            Map used to do nothing visible, because the panel lived inside the
            Directory. */}
        {isAdmin && addingPerson && window.OrgAddPersonModal ? (
          <window.OrgAddPersonModal
            onClose={function () { setAddingPerson(false); }}
            onSaved={function () { setAddingPerson(false); }}
          />
        ) : null}

        {isAdmin && editingPerson && window.OrgEditPersonModal ? (
          <window.OrgEditPersonModal
            personId={editingPerson}
            onClose={function () { setEditingPerson(null); }}
            onSaved={function () { setEditingPerson(null); }}
          />
        ) : null}

        {selected && view !== "directory" ? (
          <PersonDrawer
            id={selected}
            kids={shownKids}
            allow={allow}
            groupsOf={groupsOf}
            isAdmin={isAdmin}
            onEdit={function (id) { setEditingPerson(id); }}
            onPick={setSelected}
            onClose={function () { setSelected(null); }}
          />
        ) : null}
        </div>
      </div>
    );
  }

  window.OrgBoardScreen = OrgBoardScreen;
})();
