const COMPARE_STORAGE_KEY = "nido-groundwork-compare";
function loadCompare() {
try {
const raw = localStorage.getItem(COMPARE_STORAGE_KEY);
if (!raw) return new Set();
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return new Set();
return new Set(parsed.filter((role) => typeof role === "string"));
} catch {
localStorage.removeItem(COMPARE_STORAGE_KEY);
return new Set();
}
}
const state = {
capabilities: [],
query: "",
layer: "all",
gate: "all",
adoption: "all",
sort: "role",
selectedRole: "",
compare: loadCompare(),
};
const els = {
search: document.querySelector("#search"),
layerFilters: document.querySelector("#layerFilters"),
gateButtons: [...document.querySelectorAll("[data-gate]")],
adoptionButtons: [...document.querySelectorAll("[data-adoption]")],
sort: document.querySelector("#sort"),
clear: document.querySelector("#clearFilters"),
catalog: document.querySelector("#catalogList"),
details: document.querySelector("#details"),
compare: document.querySelector("#compareList"),
total: document.querySelector("[data-total-count]"),
gated: document.querySelector("[data-gated-count]"),
layers: document.querySelector("[data-layer-count]"),
visible: document.querySelector("[data-visible-count]"),
standard: document.querySelector("[data-standard-count]"),
review: document.querySelector("[data-review-count]"),
};
function text(value) {
return document.createTextNode(value);
}
function element(tag, attrs = {}, children = []) {
const node = document.createElement(tag);
for (const [name, value] of Object.entries(attrs)) {
if (value === false || value === null || value === undefined) continue;
if (name === "class") node.className = value;
else if (name === "dataset") {
for (const [key, dataValue] of Object.entries(value)) node.dataset[key] = dataValue;
} else if (name.startsWith("on") && typeof value === "function") {
node.addEventListener(name.slice(2).toLowerCase(), value);
} else if (value === true) {
node.setAttribute(name, "");
} else {
node.setAttribute(name, value);
}
}
for (const child of children) {
node.append(child instanceof Node ? child : text(String(child)));
}
return node;
}
function titleize(value) {
return value
.replaceAll("_", " ")
.replaceAll("-", " ")
.replace(/\b[a-z]/g, (letter) => letter.toUpperCase());
}
function matches(capability) {
const haystack = `${capability.role} ${capability.layer} ${capability.summary}`.toLowerCase();
return (
(!state.query || haystack.includes(state.query.toLowerCase())) &&
(state.layer === "all" || capability.layer === state.layer) &&
(state.gate === "all" || capability.release_gate === state.gate) &&
(state.adoption === "all" || capability.adoption_status === state.adoption)
);
}
function sorted(items) {
const collator = new Intl.Collator("en");
return [...items].sort((a, b) => {
if (state.sort === "layer") {
return collator.compare(a.layer, b.layer) || collator.compare(a.role, b.role);
}
if (state.sort === "gate") {
return collator.compare(a.release_gate, b.release_gate) || collator.compare(a.role, b.role);
}
return collator.compare(a.role, b.role);
});
}
function filtered() {
return sorted(state.capabilities.filter(matches));
}
function setPressed(buttons, attr, value) {
for (const button of buttons) {
button.setAttribute("aria-pressed", button.dataset[attr] === value ? "true" : "false");
}
}
function syncUrl() {
const params = new URLSearchParams();
if (state.query) params.set("q", state.query);
if (state.layer !== "all") params.set("layer", state.layer);
if (state.gate !== "all") params.set("gate", state.gate);
if (state.adoption !== "all") params.set("adoption", state.adoption);
if (state.selectedRole) params.set("role", state.selectedRole);
const query = params.toString();
history.replaceState(null, "", query ? `?${query}` : location.pathname);
}
function renderSummary(items) {
const total = state.capabilities.length;
const gated = state.capabilities.filter((item) => item.release_gate === "license-review").length;
const layers = new Set(state.capabilities.map((item) => item.layer)).size;
els.total.textContent = total;
els.gated.textContent = gated;
els.layers.textContent = layers;
els.visible.textContent = items.length;
els.standard.textContent = items.filter((item) => item.release_gate === "standard").length;
els.review.textContent = items.filter((item) => item.release_gate === "license-review").length;
}
function renderLayerFilters() {
const layers = ["all", ...new Set(state.capabilities.map((item) => item.layer))];
els.layerFilters.replaceChildren(
...layers.map((layer) =>
element(
"button",
{
type: "button",
"data-layer": layer,
"aria-pressed": state.layer === layer ? "true" : "false",
onclick: () => {
state.layer = layer;
render();
},
},
[layer === "all" ? "All" : titleize(layer)],
),
),
);
}
function badge(className, value, data = {}) {
return element("span", { class: className, dataset: data }, [titleize(value)]);
}
function renderCatalog(items) {
if (!items.length) {
els.catalog.replaceChildren(element("div", { class: "empty-state" }, ["No matching capabilities."]));
return;
}
els.catalog.replaceChildren(
...items.map((capability) =>
element(
"article",
{
role: "listitem",
class: "capability-item",
},
[
element(
"button",
{
type: "button",
class: "capability-card",
"aria-current": state.selectedRole === capability.role ? "true" : "false",
"data-role": capability.role,
onclick: () => {
state.selectedRole = capability.role;
render();
},
},
[
element("div", { class: "card-topline" }, [
element("strong", { class: "role-name" }, [capability.role]),
]),
element("div", { class: "badge-row" }, [
badge("layer-badge", capability.layer),
badge("gate-badge", capability.release_gate, { gate: capability.release_gate }),
badge("adoption-badge", capability.adoption_status),
]),
element("div", { class: "card-summary" }, [capability.summary]),
],
),
],
),
),
);
}
function renderCompare() {
const selected = [...state.compare]
.map((role) => state.capabilities.find((item) => item.role === role))
.filter(Boolean);
if (!selected.length) {
els.compare.replaceChildren(element("div", { class: "compare-empty" }, ["No roles pinned."]));
return;
}
els.compare.replaceChildren(
element("table", { class: "compare-table" }, [
element("thead", {}, [
element("tr", {}, [
element("th", { scope: "col" }, ["Role"]),
element("th", { scope: "col" }, ["Layer"]),
element("th", { scope: "col" }, ["Adoption"]),
element("th", { scope: "col" }, ["Gate"]),
element("th", { scope: "col" }, [""]),
]),
]),
element(
"tbody",
{},
selected.map((capability) =>
element("tr", { class: "compare-row" }, [
element("th", { scope: "row" }, [capability.role]),
element("td", {}, [titleize(capability.layer)]),
element("td", {}, [titleize(capability.adoption_status)]),
element("td", {}, [titleize(capability.release_gate)]),
element("td", {}, [
element(
"button",
{
type: "button",
class: "ghost-button",
"aria-label": `Remove ${capability.role}`,
onclick: () => {
state.compare.delete(capability.role);
persistCompare();
render();
},
},
["Remove"],
),
]),
]),
),
),
]),
);
}
async function copyCommand(command, button) {
try {
await navigator.clipboard.writeText(command);
button.textContent = "Copied";
setTimeout(() => (button.textContent = "Copy"), 1300);
} catch {
button.textContent = "Select";
}
}
function persistCompare() {
localStorage.setItem(COMPARE_STORAGE_KEY, JSON.stringify([...state.compare]));
}
function renderDetails(items) {
const visibleSelection = items.find((item) => item.role === state.selectedRole);
const capability =
visibleSelection ||
items[0] ||
state.capabilities.find((item) => item.role === state.selectedRole) ||
state.capabilities[0];
if (!capability) {
els.details.replaceChildren();
return;
}
state.selectedRole = capability.role;
const command = `nido ansible show-groundwork ${capability.role} --json`;
const pinned = state.compare.has(capability.role);
const copyButton = element(
"button",
{
type: "button",
class: "detail-action",
onclick: (event) => copyCommand(command, event.currentTarget),
},
["Copy"],
);
const pinButton = element(
"button",
{
type: "button",
class: "detail-action",
onclick: () => {
if (state.compare.has(capability.role)) state.compare.delete(capability.role);
else state.compare.add(capability.role);
persistCompare();
render();
},
},
[pinned ? "Unpin" : "Pin"],
);
els.details.replaceChildren(
element("div", { class: "detail-kicker" }, ["Selected capability"]),
element("h1", { class: "detail-title" }, [capability.role]),
element("p", { class: "detail-summary" }, [capability.summary]),
element("div", { class: "detail-meta" }, [
element("div", {}, [element("span", {}, ["Layer"]), element("span", {}, [titleize(capability.layer)])]),
element("div", {}, [
element("span", {}, ["Adoption"]),
element("span", {}, [titleize(capability.adoption_status)]),
]),
element("div", {}, [
element("span", {}, ["Release gate"]),
element("span", {}, [titleize(capability.release_gate)]),
]),
]),
element("div", { class: "detail-actions" }, [pinButton, copyButton]),
element("div", { class: "command-box" }, [element("code", {}, [command])]),
);
}
function render() {
const items = filtered();
renderLayerFilters();
renderSummary(items);
renderCatalog(items);
renderDetails(items);
renderCompare();
setPressed(els.gateButtons, "gate", state.gate);
setPressed(els.adoptionButtons, "adoption", state.adoption);
els.search.value = state.query;
els.sort.value = state.sort;
syncUrl();
}
function hydrateFromUrl() {
const params = new URLSearchParams(location.search);
state.query = params.get("q") || "";
state.layer = params.get("layer") || "all";
state.gate = params.get("gate") || "all";
state.adoption = params.get("adoption") || "all";
state.selectedRole = params.get("role") || "";
}
async function boot() {
hydrateFromUrl();
const response = await fetch("./groundwork.json", { cache: "no-store" });
if (!response.ok) throw new Error(`catalog fetch failed: ${response.status}`);
const payload = await response.json();
state.capabilities = payload.capabilities;
render();
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("./sw.js").catch(() => {});
}
}
els.search.addEventListener("input", (event) => {
state.query = event.currentTarget.value;
render();
});
for (const button of els.gateButtons) {
button.addEventListener("click", () => {
state.gate = button.dataset.gate;
render();
});
}
for (const button of els.adoptionButtons) {
button.addEventListener("click", () => {
state.adoption = button.dataset.adoption;
render();
});
}
els.sort.addEventListener("change", (event) => {
state.sort = event.currentTarget.value;
render();
});
els.clear.addEventListener("click", () => {
state.query = "";
state.layer = "all";
state.gate = "all";
state.adoption = "all";
render();
});
boot().catch((error) => {
els.catalog.replaceChildren(
element("div", { class: "empty-state" }, [`Catalog unavailable: ${error.message}`]),
);
});