mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
feat(ui): L-shell step 3a — Admin pane + rail Manage groups
Admin becomes a singleton pane and the rail's Manage section becomes its
navigation; the in-pane sidebar is retired.
- shell.js registers an `admin` pane type that adopts #view-admin (the 18
tabpanels) on first open; the dashboard pane keeps #main.
- admin.js: new ADMIN_IA seam (window.TS_ADMIN) — the group→tab map, a shared
adminTabAllowed() gate (mirrors the legacy showAdmin permission gate, incl.
the ungated node list), an active-tab subscription, and openTab. showAdmin is
now a thin delegator (openPane('admin') + switchAdminTab); the in-#main view
toggle, breadcrumb write, history push, and mobile-hamburger injection go.
- rail.js: mountManage() builds the six collapsible .grp groups from the seam,
permission-filtered, routing a row click through openTab — never touching
admin DOM.
- app.js: home/drill re-focus the Dashboard pane instead of blanking the moved
#view-admin.
- shell.css: the .grp vocabulary + admin-pane layout (in-pane sidebar hidden,
#view-admin fills the pane).
The legacy #admin-sidebar is hidden via CSS pending its deletion in 3b; this is
the additive, independently-runnable half. Verified with a headless-Chrome
harness driving the real shell.js + rail.js over a stubbed seam, plus the
test_shell_js.py guards (19 passing).
This commit is contained in:
@@ -25,6 +25,7 @@ _SHELL_JS = _SHARED / "shell.js"
|
||||
_PANE_JS = _SHARED / "pane.js"
|
||||
_CONSOLE_INDEX = _ROOT / "turnstone/console/static/index.html"
|
||||
_CONSOLE_APP = _ROOT / "turnstone/console/static/app.js"
|
||||
_CONSOLE_ADMIN = _ROOT / "turnstone/console/static/admin.js"
|
||||
|
||||
_RAIL_JS = _SHARED / "rail.js"
|
||||
_ESM_BUNDLES = [_SHELL_JS, _PANE_JS, _RAIL_JS]
|
||||
@@ -178,3 +179,43 @@ def test_console_launcher_routes_by_persona() -> None:
|
||||
"the active-coordinators table must be removed (the rail covers it)"
|
||||
)
|
||||
assert 'id="launcher-personas"' in index, "the persona toggle must be in the launcher panel"
|
||||
|
||||
|
||||
def test_step3_admin_pane_registered_and_manage_mounted() -> None:
|
||||
"""Step 3: the shell registers the singleton Admin pane (which adopts
|
||||
#view-admin) and mounts the rail's Manage groups from the admin IA seam."""
|
||||
body = _SHELL_JS.read_text(encoding="utf-8")
|
||||
assert 'registerType("admin"' in body, "shell must register the admin pane type"
|
||||
assert 'getElementById("view-admin")' in body, "the admin pane must adopt #view-admin"
|
||||
assert "mountManage(" in body, "shell must mount the rail Manage groups"
|
||||
assert "mountManage" in body and 'from "./rail.js"' in body, (
|
||||
"shell must import mountManage from rail.js"
|
||||
)
|
||||
|
||||
|
||||
def test_step3_rail_manage_builds_from_admin_seam() -> None:
|
||||
"""rail.js builds the Manage groups from the TS_ADMIN seam — perm-filtered,
|
||||
collapsible .grp vocabulary, programmatic DOM — and routes a row click
|
||||
through the seam's openTab rather than reaching into admin DOM."""
|
||||
body = _RAIL_JS.read_text(encoding="utf-8")
|
||||
assert "export function mountManage" in body, "rail must export mountManage"
|
||||
assert "window.TS_ADMIN" in body, "Manage must read the admin IA seam"
|
||||
assert "isTabAllowed" in body, "Manage must permission-filter its tabs"
|
||||
assert "openTab" in body, "a Manage row click must route through the seam's openTab"
|
||||
assert '"grp"' in body and '"grp-items"' in body, "Manage reuses the .grp vocabulary"
|
||||
# collapse + active state ride a class + aria, never colour alone
|
||||
assert "aria-expanded" in body, "collapsible group heads must expose aria-expanded"
|
||||
|
||||
|
||||
def test_step3_admin_seam_and_thin_show_admin() -> None:
|
||||
"""admin.js exposes the TS_ADMIN seam (IA + shared perm gate + active-tab +
|
||||
openTab) and showAdmin is now a thin delegator that opens the singleton
|
||||
Admin pane — the legacy in-#main view toggle + history push are gone."""
|
||||
body = _CONSOLE_ADMIN.read_text(encoding="utf-8")
|
||||
assert "const ADMIN_IA = [" in body, "admin must define the IA data"
|
||||
assert "window.TS_ADMIN.ia = ADMIN_IA" in body, "admin must expose the IA seam"
|
||||
assert "window.TS_ADMIN.isTabAllowed" in body and "window.TS_ADMIN.openTab" in body
|
||||
assert "function adminTabAllowed(tab)" in body, "the shared permission gate must exist"
|
||||
assert 'pm.openPane("admin")' in body, "showAdmin must open the singleton Admin pane"
|
||||
for gone in ('currentView = "admin"', 'history.pushState({ view: "admin" }'):
|
||||
assert gone not in body, f"legacy admin view-model bit {gone!r} must be gone"
|
||||
|
||||
+150
-159
@@ -32,152 +32,156 @@ const ALIAS_SETTING_KEYS = [
|
||||
const INHERIT_EMPTY_LABEL_KEYS = ["model.task_effort"];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// View switching (called from app.js showHome/drillDown pattern)
|
||||
// Admin information architecture — the single source of truth for the rail's
|
||||
// Manage groups (rail.js builds from this) AND in-pane tab activation. Each
|
||||
// tab carries the permission scope that gates it, so the rail can filter
|
||||
// without reaching into admin internals. `perm: null` = ungated (always
|
||||
// shown — mirrors the legacy gate, which left node-metadata uncovered).
|
||||
// ---------------------------------------------------------------------------
|
||||
const ADMIN_IA = [
|
||||
{
|
||||
group: "Identity",
|
||||
tabs: [
|
||||
{ tab: "users", label: "Users", perm: "admin.users" },
|
||||
{ tab: "tokens", label: "API Tokens", perm: "admin.users" },
|
||||
{ tab: "channels", label: "Channels", perm: "admin.users" },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Automation",
|
||||
tabs: [
|
||||
{ tab: "schedules", label: "Schedules", perm: "admin.schedules" },
|
||||
{ tab: "watches", label: "Watches", perm: "admin.watches" },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Governance",
|
||||
tabs: [
|
||||
{ tab: "roles", label: "Roles", perm: "admin.roles" },
|
||||
{ tab: "policies", label: "Policies", perm: "admin.policies" },
|
||||
{
|
||||
tab: "prompt-policies",
|
||||
label: "Prompts",
|
||||
perm: "admin.prompt_policies",
|
||||
},
|
||||
{ tab: "judge", label: "Judge", perm: "admin.judge" },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Extensions",
|
||||
tabs: [
|
||||
{ tab: "skills", label: "Skills", perm: "admin.skills" },
|
||||
{ tab: "mcp", label: "MCP Servers", perm: "admin.mcp" },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Observe",
|
||||
tabs: [
|
||||
{ tab: "usage", label: "Usage", perm: "admin.usage" },
|
||||
{ tab: "audit", label: "Audit", perm: "admin.audit" },
|
||||
{ tab: "memories", label: "Memories", perm: "admin.memories" },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "System",
|
||||
tabs: [
|
||||
{ tab: "models", label: "Models", perm: "admin.models" },
|
||||
{ tab: "node-metadata", label: "Nodes", perm: null },
|
||||
{ tab: "settings", label: "Settings", perm: "admin.settings" },
|
||||
{ tab: "tls", label: "TLS", perm: "admin.settings" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function _adminTabMeta(tab) {
|
||||
for (const grp of ADMIN_IA) {
|
||||
for (const t of grp.tabs) if (t.tab === tab) return t;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Mirror the legacy showAdmin gate exactly: only gate when a permission string
|
||||
// is present (unknown perms → show everything); ungated tabs (perm: null) and
|
||||
// tabs whose scope the user holds are allowed.
|
||||
function adminTabAllowed(tab) {
|
||||
const raw = sessionStorage.getItem("turnstone_permissions");
|
||||
if (!raw) return true;
|
||||
const meta = _adminTabMeta(tab);
|
||||
const needed = meta && meta.perm;
|
||||
if (!needed) return true;
|
||||
return raw.split(",").indexOf(needed) >= 0;
|
||||
}
|
||||
|
||||
function _firstAllowedAdminTab() {
|
||||
for (const grp of ADMIN_IA) {
|
||||
for (const t of grp.tabs) if (adminTabAllowed(t.tab)) return t.tab;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// No-permissions empty state in the admin content host (ported from the legacy
|
||||
// sidebar gate — still reachable when a user holds no admin scope at all).
|
||||
function _showAdminNoPermissions() {
|
||||
const panels = document.querySelectorAll(".admin-panel");
|
||||
for (let j = 0; j < panels.length; j++) panels[j].style.display = "none";
|
||||
let empty = document.getElementById("admin-no-permissions");
|
||||
if (!empty) {
|
||||
empty = document.createElement("div");
|
||||
empty.id = "admin-no-permissions";
|
||||
empty.className = "dashboard-empty";
|
||||
empty.textContent = "You do not have permissions to view any admin tabs.";
|
||||
const content = document.getElementById("admin-content");
|
||||
if (content) content.appendChild(empty);
|
||||
}
|
||||
empty.style.display = "";
|
||||
}
|
||||
|
||||
// The rail's Manage groups subscribe here to mirror the active admin tab.
|
||||
const _adminTabSubs = [];
|
||||
function _notifyAdminTab(tab) {
|
||||
for (const cb of _adminTabSubs) {
|
||||
try {
|
||||
cb(tab);
|
||||
} catch (e) {
|
||||
/* a faulty subscriber must not break tab switching */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Seam consumed by rail.js (the Manage section): the admin IA, its permission
|
||||
// gate, the active-tab subscription, the current tab, and the open-on-tab entry
|
||||
// point (a row click opens/focuses the Admin pane on that tab).
|
||||
window.TS_ADMIN = window.TS_ADMIN || {};
|
||||
window.TS_ADMIN.ia = ADMIN_IA;
|
||||
window.TS_ADMIN.isTabAllowed = adminTabAllowed;
|
||||
window.TS_ADMIN.onTabChange = function (cb) {
|
||||
if (typeof cb === "function") _adminTabSubs.push(cb);
|
||||
};
|
||||
window.TS_ADMIN.getActiveTab = function () {
|
||||
return _adminTab;
|
||||
};
|
||||
window.TS_ADMIN.openTab = function (tab) {
|
||||
showAdmin(tab);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// View switching (called from app.js showHome/drillDown pattern + the rail)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function showAdmin() {
|
||||
/* global currentView, showHome */
|
||||
// Toggle: if already in admin view, go back to the home landing
|
||||
if (currentView === "admin") {
|
||||
const adminBtn = document.getElementById("admin-btn");
|
||||
if (adminBtn) {
|
||||
adminBtn.classList.remove("active");
|
||||
adminBtn.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
showHome();
|
||||
return;
|
||||
}
|
||||
function showAdmin(tab) {
|
||||
// The admin surface is a singleton pane now — the L-shell PaneManager owns
|
||||
// show/hide and the rail's Manage groups are its navigation. Open/focus the
|
||||
// pane (its onMount adopts #view-admin), then activate a tab the user may see.
|
||||
const pm = window.TS_SHELL && window.TS_SHELL.panes;
|
||||
if (pm && pm.hasType("admin")) pm.openPane("admin");
|
||||
|
||||
currentView = "admin";
|
||||
const homeView = document.getElementById("view-home");
|
||||
if (homeView) homeView.style.display = "none";
|
||||
document.getElementById("view-filtered").style.display = "none";
|
||||
document.getElementById("view-admin").style.display = "";
|
||||
document.getElementById("breadcrumb").style.display = "";
|
||||
document.getElementById("breadcrumb-label").textContent = "Admin";
|
||||
document.getElementById("main").scrollTop = 0;
|
||||
|
||||
// Highlight admin button as active
|
||||
const adminBtn = document.getElementById("admin-btn");
|
||||
if (adminBtn) {
|
||||
adminBtn.classList.add("active");
|
||||
adminBtn.setAttribute("aria-expanded", "true");
|
||||
}
|
||||
history.pushState({ view: "admin" }, "");
|
||||
|
||||
// Permission gating: hide nav items the user cannot access
|
||||
const perms = sessionStorage.getItem("turnstone_permissions") || "";
|
||||
const tabPerms = {
|
||||
users: "admin.users",
|
||||
tokens: "admin.users",
|
||||
channels: "admin.users",
|
||||
schedules: "admin.schedules",
|
||||
watches: "admin.watches",
|
||||
roles: "admin.roles",
|
||||
policies: "admin.policies",
|
||||
"prompt-policies": "admin.prompt_policies",
|
||||
judge: "admin.judge",
|
||||
skills: "admin.skills",
|
||||
usage: "admin.usage",
|
||||
audit: "admin.audit",
|
||||
memories: "admin.memories",
|
||||
settings: "admin.settings",
|
||||
tls: "admin.settings",
|
||||
mcp: "admin.mcp",
|
||||
models: "admin.models",
|
||||
};
|
||||
if (perms) {
|
||||
const permSet = perms.split(",");
|
||||
const navItems = document.querySelectorAll(".admin-nav");
|
||||
for (let i = 0; i < navItems.length; i++) {
|
||||
const tabName = navItems[i].getAttribute("data-tab");
|
||||
const needed = tabPerms[tabName];
|
||||
if (needed && permSet.indexOf(needed) < 0) {
|
||||
navItems[i].style.display = "none";
|
||||
} else {
|
||||
navItems[i].style.display = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hide groups where all children are permission-hidden
|
||||
const groups = document.querySelectorAll(".admin-sidebar-group");
|
||||
for (let g = 0; g < groups.length; g++) {
|
||||
const visibleInGroup = groups[g].querySelectorAll(
|
||||
'.admin-nav:not([style*="display: none"])',
|
||||
);
|
||||
groups[g].style.display = visibleInGroup.length > 0 ? "" : "none";
|
||||
}
|
||||
|
||||
// Mobile: ensure sidebar starts hidden + inert; desktop: ensure it's accessible
|
||||
const sidebar = document.getElementById("admin-sidebar");
|
||||
|
||||
// Inject close header for mobile drawer (once)
|
||||
if (!document.getElementById("admin-sidebar-close")) {
|
||||
const closeHeader = document.createElement("div");
|
||||
closeHeader.id = "admin-sidebar-close";
|
||||
closeHeader.className = "admin-sidebar-close";
|
||||
const label = document.createElement("span");
|
||||
label.textContent = "Navigation";
|
||||
const closeBtn = document.createElement("button");
|
||||
closeBtn.setAttribute("aria-label", "Close navigation");
|
||||
closeBtn.textContent = "\u00d7";
|
||||
closeBtn.addEventListener("click", function () {
|
||||
if (_mobileSidebarOpen) {
|
||||
_toggleMobileSidebar();
|
||||
const mt = document.getElementById("admin-mobile-toggle");
|
||||
if (mt) mt.focus();
|
||||
}
|
||||
});
|
||||
closeHeader.appendChild(label);
|
||||
closeHeader.appendChild(closeBtn);
|
||||
sidebar.insertBefore(closeHeader, sidebar.firstChild);
|
||||
}
|
||||
|
||||
if (window.innerWidth <= 700) {
|
||||
_mobileSidebarOpen = false;
|
||||
sidebar.classList.add("collapsed");
|
||||
sidebar.classList.remove("open");
|
||||
sidebar.setAttribute("aria-hidden", "true");
|
||||
sidebar.setAttribute("inert", "");
|
||||
} else {
|
||||
sidebar.removeAttribute("aria-hidden");
|
||||
sidebar.removeAttribute("inert");
|
||||
}
|
||||
|
||||
// Mobile backdrop listener (idempotent)
|
||||
const backdrop = document.getElementById("admin-sidebar-backdrop");
|
||||
if (backdrop && !backdrop._listenerAttached) {
|
||||
backdrop.addEventListener("click", function () {
|
||||
if (_mobileSidebarOpen) {
|
||||
_toggleMobileSidebar();
|
||||
const mt = document.getElementById("admin-mobile-toggle");
|
||||
if (mt) mt.focus();
|
||||
}
|
||||
});
|
||||
backdrop._listenerAttached = true;
|
||||
}
|
||||
|
||||
// Switch to the first visible nav item
|
||||
const visibleNavs = document.querySelectorAll(
|
||||
'.admin-nav:not([style*="display: none"])',
|
||||
);
|
||||
if (visibleNavs.length > 0) {
|
||||
switchAdminTab(visibleNavs[0].getAttribute("data-tab"));
|
||||
} else {
|
||||
// No tabs visible — show empty state
|
||||
const panels = document.querySelectorAll(".admin-panel");
|
||||
for (let j = 0; j < panels.length; j++) panels[j].style.display = "none";
|
||||
let empty = document.getElementById("admin-no-permissions");
|
||||
if (!empty) {
|
||||
empty = document.createElement("div");
|
||||
empty.id = "admin-no-permissions";
|
||||
empty.className = "dashboard-empty";
|
||||
empty.textContent = "You do not have permissions to view any admin tabs.";
|
||||
document.getElementById("admin-content").appendChild(empty);
|
||||
}
|
||||
empty.style.display = "";
|
||||
}
|
||||
const target =
|
||||
tab ||
|
||||
(_adminTab && adminTabAllowed(_adminTab)
|
||||
? _adminTab
|
||||
: _firstAllowedAdminTab());
|
||||
if (target) switchAdminTab(target);
|
||||
else _showAdminNoPermissions();
|
||||
}
|
||||
|
||||
function _injectMobileToggle(tab) {
|
||||
@@ -297,22 +301,9 @@ function switchAdminTab(tab) {
|
||||
const bcLabel = document.getElementById("breadcrumb-label");
|
||||
if (bcLabel) bcLabel.textContent = "Admin / " + label;
|
||||
|
||||
// Inject mobile hamburger toggle into active panel's toolbar
|
||||
_injectMobileToggle(tab);
|
||||
|
||||
// On mobile, auto-close sidebar after tab selection
|
||||
if (window.innerWidth <= 700 && _mobileSidebarOpen) {
|
||||
_toggleMobileSidebar();
|
||||
// Move focus to the newly active panel instead of leaving it in the inert sidebar
|
||||
const panel = document.getElementById("admin-" + tab);
|
||||
const focusTarget =
|
||||
panel &&
|
||||
panel.querySelector("h2, .section-header, button:not([disabled])");
|
||||
if (focusTarget) {
|
||||
focusTarget.setAttribute("tabindex", "-1");
|
||||
focusTarget.focus();
|
||||
}
|
||||
}
|
||||
// Mirror the active tab into the rail's Manage groups (the in-pane sidebar
|
||||
// that used to carry the active state is retired — the rail navigates now).
|
||||
_notifyAdminTab(tab);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -427,8 +427,10 @@ function showHome() {
|
||||
currentView = "home";
|
||||
currentFilter = { state: null, node: null, page: 1, per_page: 50 };
|
||||
_setLandingView("home");
|
||||
const adminView = document.getElementById("view-admin");
|
||||
if (adminView) adminView.style.display = "none";
|
||||
// #view-admin is its own pane now; re-focus the Dashboard pane (home/filtered
|
||||
// sub-views live inside it) instead of toggling the old in-#main admin view.
|
||||
const pm = window.TS_SHELL && window.TS_SHELL.panes;
|
||||
if (pm) pm.openPane("dashboard");
|
||||
const adminBtn = document.getElementById("admin-btn");
|
||||
if (adminBtn) {
|
||||
adminBtn.classList.remove("active");
|
||||
@@ -472,8 +474,10 @@ function drillDownByState(state) {
|
||||
currentView = "filtered";
|
||||
currentFilter = { state: state, node: null, page: 1, per_page: 50 };
|
||||
_setLandingView("filtered");
|
||||
const adminView = document.getElementById("view-admin");
|
||||
if (adminView) adminView.style.display = "none";
|
||||
// #view-admin is its own pane now; re-focus the Dashboard pane (home/filtered
|
||||
// sub-views live inside it) instead of toggling the old in-#main admin view.
|
||||
const pm = window.TS_SHELL && window.TS_SHELL.panes;
|
||||
if (pm) pm.openPane("dashboard");
|
||||
const adminBtn = document.getElementById("admin-btn");
|
||||
if (adminBtn) {
|
||||
adminBtn.classList.remove("active");
|
||||
@@ -497,8 +501,10 @@ function drillDownByNode(nodeId) {
|
||||
currentView = "filtered";
|
||||
currentFilter = { state: null, node: nodeId, page: 1, per_page: 50 };
|
||||
_setLandingView("filtered");
|
||||
const adminView = document.getElementById("view-admin");
|
||||
if (adminView) adminView.style.display = "none";
|
||||
// #view-admin is its own pane now; re-focus the Dashboard pane (home/filtered
|
||||
// sub-views live inside it) instead of toggling the old in-#main admin view.
|
||||
const pm = window.TS_SHELL && window.TS_SHELL.panes;
|
||||
if (pm) pm.openPane("dashboard");
|
||||
const adminBtn = document.getElementById("admin-btn");
|
||||
if (adminBtn) {
|
||||
adminBtn.classList.remove("active");
|
||||
|
||||
@@ -267,3 +267,89 @@ export function mountRail(sections, caps) {
|
||||
if (TS.onRender) TS.onRender(render);
|
||||
render(); // initial paint (empty until the first snapshot arrives)
|
||||
}
|
||||
|
||||
// ---- Manage section (admin IA → collapsible discovery groups) --------------
|
||||
|
||||
/**
|
||||
* Build the rail's Manage groups from the admin IA seam (admin.js exposes
|
||||
* `window.TS_ADMIN`). Each group is a collapsible `.grp` whose head toggles
|
||||
* its `.grp-items`; items are the admin tabs the user is permitted to see. A
|
||||
* row click opens/focuses the singleton Admin pane on that tab via the seam's
|
||||
* `openTab`. The IA is static, so this builds once; only the active-row marker
|
||||
* re-renders, driven by the admin tab-change subscription (single writer).
|
||||
*
|
||||
* House style: programmatic DOM, NO innerHTML; reuses the mock's
|
||||
* `.grp`/`.grp-head`/`.grp-items` vocabulary (shell.css).
|
||||
*/
|
||||
export function mountManage(root) {
|
||||
if (!root) return;
|
||||
const TS = window.TS_ADMIN || {};
|
||||
const ia = TS.ia || [];
|
||||
const allowed = TS.isTabAllowed || (() => true);
|
||||
root.replaceChildren();
|
||||
|
||||
const rowByTab = new Map(); // tab -> its row <button>, for active-state sync
|
||||
|
||||
ia.forEach((group, gi) => {
|
||||
const tabs = group.tabs.filter((t) => allowed(t.tab));
|
||||
if (!tabs.length) return; // every tab in the group is gated away → drop it
|
||||
|
||||
const grp = document.createElement("div");
|
||||
grp.className = "grp";
|
||||
if (gi === 0) grp.classList.add("open"); // first group expanded (mock)
|
||||
|
||||
const itemsId = "manage-grp-" + group.group.toLowerCase();
|
||||
const head = document.createElement("button");
|
||||
head.type = "button";
|
||||
head.className = "grp-head";
|
||||
head.setAttribute("aria-expanded", gi === 0 ? "true" : "false");
|
||||
head.setAttribute("aria-controls", itemsId);
|
||||
const chev = document.createElement("span");
|
||||
chev.className = "chev";
|
||||
chev.setAttribute("aria-hidden", "true");
|
||||
chev.textContent = gi === 0 ? "▾" : "▸";
|
||||
const name = document.createElement("span");
|
||||
name.className = "gname";
|
||||
name.textContent = group.group;
|
||||
const count = document.createElement("span");
|
||||
count.className = "gcount";
|
||||
count.textContent = String(tabs.length);
|
||||
head.append(chev, name, count);
|
||||
|
||||
const items = document.createElement("div");
|
||||
items.className = "grp-items";
|
||||
items.id = itemsId;
|
||||
for (const t of tabs) {
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
row.className = "row";
|
||||
row.dataset.tab = t.tab;
|
||||
const nm = document.createElement("span");
|
||||
nm.className = "nm";
|
||||
nm.textContent = t.label;
|
||||
row.append(nm);
|
||||
row.addEventListener("click", () => {
|
||||
if (TS.openTab) TS.openTab(t.tab);
|
||||
});
|
||||
rowByTab.set(t.tab, row);
|
||||
items.append(row);
|
||||
}
|
||||
|
||||
head.addEventListener("click", () => {
|
||||
const open = grp.classList.toggle("open");
|
||||
head.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
chev.textContent = open ? "▾" : "▸";
|
||||
});
|
||||
|
||||
grp.append(head, items);
|
||||
root.append(grp);
|
||||
});
|
||||
|
||||
// Single writer for the Manage active-row: the row for the current admin tab
|
||||
// carries `.active`. admin.js notifies on every switchAdminTab; nothing is
|
||||
// marked until the user actually navigates the admin pane.
|
||||
function markActive(tab) {
|
||||
for (const [t, row] of rowByTab) row.classList.toggle("active", t === tab);
|
||||
}
|
||||
if (TS.onTabChange) TS.onTabChange(markActive);
|
||||
}
|
||||
|
||||
@@ -560,8 +560,83 @@
|
||||
.cpill:focus-visible,
|
||||
.node-row:focus-visible,
|
||||
.row:focus-visible,
|
||||
.grp-head:focus-visible,
|
||||
.persona-btn:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
|
||||
/* ===== Rail Manage section — the admin IA as collapsible discovery groups
|
||||
(rail.js mountManage). Reuses the mock's .grp / .grp-head / .grp-items
|
||||
vocabulary; heads + rows are <button>s so they're keyboard-operable, and
|
||||
collapse state rides the chevron + aria-expanded (no colour-only cue). ===== */
|
||||
.grp {
|
||||
margin-top: 1px;
|
||||
}
|
||||
.grp-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 9px;
|
||||
border-radius: var(--r-sm);
|
||||
cursor: pointer;
|
||||
color: var(--ink-3);
|
||||
font-size: 13px;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: 0;
|
||||
text-align: left;
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
.grp-head:hover {
|
||||
background: var(--panel-2);
|
||||
}
|
||||
.grp-head .chev {
|
||||
width: 12px;
|
||||
color: var(--ink-4);
|
||||
font-size: 10px;
|
||||
flex: none;
|
||||
}
|
||||
.grp-head .gname {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.grp-head .gcount {
|
||||
color: var(--ink-4);
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.grp:not(.open) .grp-items {
|
||||
display: none;
|
||||
}
|
||||
.grp-items {
|
||||
padding-left: 20px;
|
||||
}
|
||||
.grp-items .row {
|
||||
color: var(--ink-3);
|
||||
font-size: 12.5px;
|
||||
padding: 5px 9px;
|
||||
}
|
||||
/* the current admin tab — a subtle single marker (the rail mirrors which tab
|
||||
the Admin pane is showing; distinct from the amber `.open` of live sessions) */
|
||||
.grp-items .row.active {
|
||||
color: var(--ink);
|
||||
background: color-mix(in srgb, var(--accent) 8%, var(--panel-2));
|
||||
}
|
||||
|
||||
/* ===== Admin pane — adopts #view-admin (the 18 tabpanels). The in-pane
|
||||
sidebar + its mobile backdrop are retired (the rail's Manage groups are the
|
||||
navigation now), so #view-admin fills the pane body and content is full-width. */
|
||||
.pane-body > #view-admin {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.pane-body .admin-sidebar,
|
||||
.pane-body .admin-sidebar-backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
========================================================================== */
|
||||
|
||||
import { PaneManager, ShellPane } from "./pane.js";
|
||||
import { mountRail } from "./rail.js";
|
||||
import { mountRail, mountManage } from "./rail.js";
|
||||
|
||||
function make(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
@@ -56,7 +56,7 @@ function buildShell(caps) {
|
||||
scroll.append(connSlot);
|
||||
// IA sections — each a label + a render target. Cluster is capability-gated
|
||||
// (hidden on a single-node standalone deployment); rail.js fills Cluster +
|
||||
// Workspaces from Tier-1. Manage stays a stub label until step 3.
|
||||
// Workspaces from Tier-1 and the Manage groups from the admin IA (step 3).
|
||||
function section(title) {
|
||||
scroll.append(make("div", "sec-label", title));
|
||||
const body = make("div", "rail-section");
|
||||
@@ -65,7 +65,7 @@ function buildShell(caps) {
|
||||
}
|
||||
const clusterSec = caps.cluster ? section("Cluster") : null;
|
||||
const workspacesSec = section("Workspaces");
|
||||
scroll.append(make("div", "sec-label", "Manage"));
|
||||
const manageSec = section("Manage");
|
||||
rail.append(scroll);
|
||||
|
||||
const foot = make("div", "rail-foot");
|
||||
@@ -91,6 +91,7 @@ function buildShell(caps) {
|
||||
panes,
|
||||
clusterSec,
|
||||
workspacesSec,
|
||||
manageSec,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -102,6 +103,7 @@ function mountShell() {
|
||||
const statusBarEl = document.getElementById("status-bar");
|
||||
const breadcrumbEl = document.getElementById("breadcrumb");
|
||||
const mainEl = document.getElementById("main");
|
||||
const viewAdminEl = document.getElementById("view-admin");
|
||||
|
||||
const shell = buildShell(caps);
|
||||
// Insert the shell as the first body child so it owns the viewport; portals
|
||||
@@ -153,6 +155,21 @@ function mountShell() {
|
||||
return pane;
|
||||
});
|
||||
|
||||
// Admin pane (step 3): a singleton that ADOPTS #view-admin (the 18 admin
|
||||
// tabpanels). The rail's Manage groups are its navigation — the in-pane
|
||||
// sidebar is retired. Lazy-mounts on first openPane('admin'); by then the
|
||||
// dashboard pane already hosts #main, so #view-admin moves out of it here.
|
||||
pm.registerType("admin", () => {
|
||||
const pane = new ShellPane({ type: "admin", title: "Admin", glyph: "⚙" });
|
||||
pane.onMount = function () {
|
||||
if (viewAdminEl) {
|
||||
viewAdminEl.style.display = ""; // clear the inline display:none guard
|
||||
this.bodyEl.append(viewAdminEl);
|
||||
}
|
||||
};
|
||||
return pane;
|
||||
});
|
||||
|
||||
// Restore the persisted working set, else open the default Dashboard pane.
|
||||
if (!pm.rehydrate()) pm.openPane("dashboard");
|
||||
|
||||
@@ -169,6 +186,10 @@ function mountShell() {
|
||||
caps,
|
||||
);
|
||||
|
||||
// Manage section — the admin IA as collapsible discovery groups; a row click
|
||||
// routes through the TS_ADMIN seam (opens/focuses the singleton Admin pane).
|
||||
mountManage(shell.manageSec);
|
||||
|
||||
// Hand off to the legacy boot (login + Tier-1 stream) now that the shell and
|
||||
// its status DOM exist.
|
||||
if (window.TS_APP && typeof window.TS_APP.boot === "function") {
|
||||
|
||||
Reference in New Issue
Block a user