generated from coulomb/repo-seed
feat: render graph explorer zone boundaries
This commit is contained in:
@@ -154,6 +154,23 @@ The graph explorer should support zone-oriented modes for Fabric payloads:
|
|||||||
Zone modes are diagnostic views. They answer "where does this run or who can
|
Zone modes are diagnostic views. They answer "where does this run or who can
|
||||||
reach it here?" without mutating the underlying Fabric responsibility boundary.
|
reach it here?" without mutating the underlying Fabric responsibility boundary.
|
||||||
|
|
||||||
|
Zone boundary overlays are a visual layer over the graph canvas. They should be
|
||||||
|
computed from visible node positions after layout/filtering rather than modeled
|
||||||
|
as graph parent nodes. The default boundary grouping is `deploymentEnvironment`:
|
||||||
|
|
||||||
|
| Overlay label | Matching nodes |
|
||||||
|
|---------------|----------------|
|
||||||
|
| `dev-tegwick` | visible nodes with `deploymentEnvironment: dev` in the private local development overlay |
|
||||||
|
| `test` | visible nodes with `deploymentEnvironment: test` or legacy `staging` |
|
||||||
|
| `prod` | visible nodes with `deploymentEnvironment: prod` |
|
||||||
|
|
||||||
|
Nodes without deployment overlay data are not enclosed. Edge-only deployment
|
||||||
|
overlay evidence may contribute to zone summaries and warnings, but it should
|
||||||
|
not create a boundary unless at least one visible node belongs to the same zone.
|
||||||
|
When access-zone grouping is selected, boundaries use `accessZone` values such
|
||||||
|
as `private-dev`, `collaborator-test`, `production-public`, or
|
||||||
|
`production-admin`.
|
||||||
|
|
||||||
Useful warnings for the graph explorer include:
|
Useful warnings for the graph explorer include:
|
||||||
|
|
||||||
- control surfaces in user-facing access zones;
|
- control surfaces in user-facing access zones;
|
||||||
|
|||||||
@@ -36,7 +36,50 @@ def graph_explorer_page() -> str:
|
|||||||
}
|
}
|
||||||
.main { display: grid; grid-template-columns: minmax(0, 1fr) 340px; min-height: 0; }
|
.main { display: grid; grid-template-columns: minmax(0, 1fr) 340px; min-height: 0; }
|
||||||
.canvas-wrap { position: relative; min-width: 0; min-height: 0; }
|
.canvas-wrap { position: relative; min-width: 0; min-height: 0; }
|
||||||
#graph-canvas { position: absolute; inset: 0; }
|
#graph-canvas { position: absolute; inset: 0; z-index: 0; }
|
||||||
|
.zone-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.zone-boundary {
|
||||||
|
position: absolute;
|
||||||
|
border: 2px solid var(--zone-color, #2563eb);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--zone-fill, rgba(37, 99, 235, .06));
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .74), 0 10px 32px rgba(23, 32, 51, .08);
|
||||||
|
min-height: 56px;
|
||||||
|
min-width: 90px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.zone-boundary.selected {
|
||||||
|
border-width: 3px;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .82), 0 14px 36px rgba(23, 32, 51, .14);
|
||||||
|
}
|
||||||
|
.zone-label {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
left: 10px;
|
||||||
|
z-index: 1;
|
||||||
|
min-height: 26px;
|
||||||
|
max-width: calc(100% - 20px);
|
||||||
|
border-color: var(--zone-color, #2563eb);
|
||||||
|
color: var(--zone-color, #2563eb);
|
||||||
|
background: rgba(255, 255, 255, .96);
|
||||||
|
box-shadow: 0 8px 18px rgba(23, 32, 51, .1);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 3px 8px;
|
||||||
|
pointer-events: auto;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.zone-label:focus-visible {
|
||||||
|
outline: 2px solid var(--zone-color, #2563eb);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
.side {
|
.side {
|
||||||
border-left: 1px solid var(--line);
|
border-left: 1px solid var(--line);
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
@@ -181,6 +224,14 @@ def graph_explorer_page() -> str:
|
|||||||
min-width: 84px;
|
min-width: 84px;
|
||||||
padding: 4px 6px;
|
padding: 4px 6px;
|
||||||
}
|
}
|
||||||
|
.map-control-field input[type="checkbox"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
min-height: 0;
|
||||||
|
margin: 5px 0;
|
||||||
|
padding: 0;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
.selection-anchor {
|
.selection-anchor {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
@@ -390,7 +441,17 @@ def graph_explorer_page() -> str:
|
|||||||
<main class="main">
|
<main class="main">
|
||||||
<div class="canvas-wrap">
|
<div class="canvas-wrap">
|
||||||
<div id="graph-canvas" role="img" aria-label="Interactive Fabric graph" aria-live="polite"><p class="canvas-notice">Loading graph...</p></div>
|
<div id="graph-canvas" role="img" aria-label="Interactive Fabric graph" aria-live="polite"><p class="canvas-notice">Loading graph...</p></div>
|
||||||
|
<div id="zone-overlay" class="zone-overlay" aria-label="Deployment zone boundaries"></div>
|
||||||
<div class="map-controls" aria-label="Map navigation controls">
|
<div class="map-controls" aria-label="Map navigation controls">
|
||||||
|
<label class="map-control-field"><span class="field-label">Zones <button type="button" class="help-tip" aria-label="Zone boundary help" data-help-title="Zones" data-help="Zone boundaries draw labeled rectangles around visible nodes that share deployment overlay fields. They are visual annotations only.">?</button></span>
|
||||||
|
<input id="zone-boundary-toggle" type="checkbox" aria-label="Show zone boundaries" checked>
|
||||||
|
</label>
|
||||||
|
<label class="map-control-field"><span class="field-label">Zone By <button type="button" class="help-tip" aria-label="Zone grouping help" data-help-title="Zone By" data-help="Environment groups nodes as dev-tegwick, test, or prod. Access Zone groups by intended reachability such as private-dev or production-public.">?</button></span>
|
||||||
|
<select id="zone-group-select" title="Choose zone boundary grouping">
|
||||||
|
<option value="deploymentEnvironment">Environment</option>
|
||||||
|
<option value="accessZone">Access Zone</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label class="map-control-field"><span class="field-label">Labels <button type="button" class="help-tip" aria-label="Labels help" data-help-title="Labels" data-help="Label density changes only text visibility. Auto hides low-priority labels when the map is dense; Key keeps repositories, services, deployments, servers, and issue markers visible.">?</button></span>
|
<label class="map-control-field"><span class="field-label">Labels <button type="button" class="help-tip" aria-label="Labels help" data-help-title="Labels" data-help="Label density changes only text visibility. Auto hides low-priority labels when the map is dense; Key keeps repositories, services, deployments, servers, and issue markers visible.">?</button></span>
|
||||||
<select id="label-select" title="Control node label density">
|
<select id="label-select" title="Control node label density">
|
||||||
<option value="auto">Auto</option>
|
<option value="auto">Auto</option>
|
||||||
@@ -495,6 +556,7 @@ def graph_explorer_page() -> str:
|
|||||||
const manifestUrl = "/exports/graph-explorer/manifest";
|
const manifestUrl = "/exports/graph-explorer/manifest";
|
||||||
const graphUrl = "/exports/graph-explorer";
|
const graphUrl = "/exports/graph-explorer";
|
||||||
const canvas = document.getElementById("graph-canvas");
|
const canvas = document.getElementById("graph-canvas");
|
||||||
|
const zoneOverlay = document.getElementById("zone-overlay");
|
||||||
const popup = document.getElementById("popup");
|
const popup = document.getElementById("popup");
|
||||||
const helpPopup = document.getElementById("help-popup");
|
const helpPopup = document.getElementById("help-popup");
|
||||||
const selectionAnchor = document.getElementById("selection-anchor");
|
const selectionAnchor = document.getElementById("selection-anchor");
|
||||||
@@ -503,6 +565,8 @@ def graph_explorer_page() -> str:
|
|||||||
const modeSelect = document.getElementById("mode-select");
|
const modeSelect = document.getElementById("mode-select");
|
||||||
const layoutSelect = document.getElementById("layout-select");
|
const layoutSelect = document.getElementById("layout-select");
|
||||||
const labelSelect = document.getElementById("label-select");
|
const labelSelect = document.getElementById("label-select");
|
||||||
|
const zoneBoundaryToggle = document.getElementById("zone-boundary-toggle");
|
||||||
|
const zoneGroupSelect = document.getElementById("zone-group-select");
|
||||||
const nodeTypeFilter = document.getElementById("node-type-filter");
|
const nodeTypeFilter = document.getElementById("node-type-filter");
|
||||||
const nodeTypeSummary = document.getElementById("node-type-summary");
|
const nodeTypeSummary = document.getElementById("node-type-summary");
|
||||||
const edgeTypeFilter = document.getElementById("edge-type-filter");
|
const edgeTypeFilter = document.getElementById("edge-type-filter");
|
||||||
@@ -541,12 +605,16 @@ def graph_explorer_page() -> str:
|
|||||||
let allEdgeTypes = [];
|
let allEdgeTypes = [];
|
||||||
let activeMode = "full";
|
let activeMode = "full";
|
||||||
let activeLabelMode = "auto";
|
let activeLabelMode = "auto";
|
||||||
|
let activeZoneGrouping = "deploymentEnvironment";
|
||||||
let profilePersistence = "none";
|
let profilePersistence = "none";
|
||||||
let profiles = [];
|
let profiles = [];
|
||||||
let currentProfileId = "";
|
let currentProfileId = "";
|
||||||
let filterRules = [];
|
let filterRules = [];
|
||||||
let editingRuleId = "";
|
let editingRuleId = "";
|
||||||
let orientationContext = null;
|
let orientationContext = null;
|
||||||
|
let selectedZoneId = "";
|
||||||
|
let zoneOverlayFrame = 0;
|
||||||
|
let zoneSummaries = new Map();
|
||||||
|
|
||||||
const escapeHtml = (value) => String(value ?? "")
|
const escapeHtml = (value) => String(value ?? "")
|
||||||
.replaceAll("&", "&").replaceAll("<", "<")
|
.replaceAll("&", "&").replaceAll("<", "<")
|
||||||
@@ -903,6 +971,255 @@ def graph_explorer_page() -> str:
|
|||||||
return warnings;
|
return warnings;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const zonePalette = {
|
||||||
|
"deploymentEnvironment:dev-tegwick": {color: "#0f766e", fill: "rgba(15, 118, 110, .07)"},
|
||||||
|
"deploymentEnvironment:test": {color: "#2563eb", fill: "rgba(37, 99, 235, .07)"},
|
||||||
|
"deploymentEnvironment:prod": {color: "#be123c", fill: "rgba(190, 18, 60, .07)"},
|
||||||
|
};
|
||||||
|
|
||||||
|
const fallbackZonePalette = [
|
||||||
|
{color: "#7c3aed", fill: "rgba(124, 58, 237, .07)"},
|
||||||
|
{color: "#b45309", fill: "rgba(180, 83, 9, .08)"},
|
||||||
|
{color: "#0891b2", fill: "rgba(8, 145, 178, .08)"},
|
||||||
|
{color: "#4f46e5", fill: "rgba(79, 70, 229, .07)"},
|
||||||
|
];
|
||||||
|
|
||||||
|
const hashString = (value) => String(value || "")
|
||||||
|
.split("")
|
||||||
|
.reduce((total, character) => (total * 31 + character.charCodeAt(0)) >>> 0, 0);
|
||||||
|
|
||||||
|
const zoneStyle = (zone) =>
|
||||||
|
zonePalette[zone.id] || fallbackZonePalette[hashString(zone.id) % fallbackZonePalette.length];
|
||||||
|
|
||||||
|
const zoneRank = (zone) => {
|
||||||
|
if (zone.id === "deploymentEnvironment:dev-tegwick") return 0;
|
||||||
|
if (zone.id === "deploymentEnvironment:test") return 1;
|
||||||
|
if (zone.id === "deploymentEnvironment:prod") return 2;
|
||||||
|
return 10 + hashString(zone.id) % 20;
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoneLabelRank = (zone, index) => {
|
||||||
|
if (zone.id === "deploymentEnvironment:test") return 0;
|
||||||
|
if (zone.id === "deploymentEnvironment:prod") return 1;
|
||||||
|
if (zone.id === "deploymentEnvironment:dev-tegwick") return 2;
|
||||||
|
return index % 4;
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoneLabelTop = (zone, bounds, index) => {
|
||||||
|
const rank = zone.field === "deploymentEnvironment" ? zoneLabelRank(zone, index) : index % 4;
|
||||||
|
return Math.min(8 + rank * 28, Math.max(8, Math.round(bounds.height - 32)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoneForData = (data, grouping = activeZoneGrouping) => {
|
||||||
|
if (grouping === "deploymentEnvironment") {
|
||||||
|
const value = String(data.deploymentEnvironment || "").trim();
|
||||||
|
if (!value) return null;
|
||||||
|
const normalized = value.toLowerCase();
|
||||||
|
if (normalized === "all") return null;
|
||||||
|
const label = normalized === "dev"
|
||||||
|
? "dev-tegwick"
|
||||||
|
: normalized === "test" || normalized === "staging"
|
||||||
|
? "test"
|
||||||
|
: normalized === "prod"
|
||||||
|
? normalized
|
||||||
|
: value;
|
||||||
|
return {
|
||||||
|
field: "deploymentEnvironment",
|
||||||
|
id: `deploymentEnvironment:${label}`,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (grouping === "accessZone") {
|
||||||
|
const value = String(data.accessZone || "").trim();
|
||||||
|
if (!value) return null;
|
||||||
|
return {
|
||||||
|
field: "accessZone",
|
||||||
|
id: `accessZone:${value}`,
|
||||||
|
label: value,
|
||||||
|
value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderedNodeBox = (node) => {
|
||||||
|
try {
|
||||||
|
const box = node.renderedBoundingBox({includeLabels: true, includeOverlays: false});
|
||||||
|
if ([box.x1, box.y1, box.x2, box.y2].every(Number.isFinite)) return box;
|
||||||
|
} catch {
|
||||||
|
// Fall back to the rendered center point below.
|
||||||
|
}
|
||||||
|
const position = node.renderedPosition();
|
||||||
|
const radius = (Number(node.data("visualSize")) || 44) / 2;
|
||||||
|
return {
|
||||||
|
x1: position.x - radius,
|
||||||
|
y1: position.y - radius,
|
||||||
|
x2: position.x + radius,
|
||||||
|
y2: position.y + radius,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoneBoundsForNodes = (nodes) => {
|
||||||
|
if (!nodes.length) return null;
|
||||||
|
const bounds = nodes.reduce((box, node) => {
|
||||||
|
const nodeBox = renderedNodeBox(node);
|
||||||
|
return {
|
||||||
|
x1: Math.min(box.x1, nodeBox.x1),
|
||||||
|
y1: Math.min(box.y1, nodeBox.y1),
|
||||||
|
x2: Math.max(box.x2, nodeBox.x2),
|
||||||
|
y2: Math.max(box.y2, nodeBox.y2),
|
||||||
|
};
|
||||||
|
}, {x1: Infinity, y1: Infinity, x2: -Infinity, y2: -Infinity});
|
||||||
|
if (![bounds.x1, bounds.y1, bounds.x2, bounds.y2].every(Number.isFinite)) return null;
|
||||||
|
const padding = 34;
|
||||||
|
let left = bounds.x1 - padding;
|
||||||
|
let top = bounds.y1 - padding;
|
||||||
|
let width = bounds.x2 - bounds.x1 + padding * 2;
|
||||||
|
let height = bounds.y2 - bounds.y1 + padding * 2;
|
||||||
|
const minWidth = 132;
|
||||||
|
const minHeight = 84;
|
||||||
|
if (width < minWidth) {
|
||||||
|
left -= (minWidth - width) / 2;
|
||||||
|
width = minWidth;
|
||||||
|
}
|
||||||
|
if (height < minHeight) {
|
||||||
|
top -= (minHeight - height) / 2;
|
||||||
|
height = minHeight;
|
||||||
|
}
|
||||||
|
return {left, top, width, height};
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectZoneSummaries = () => {
|
||||||
|
const groups = new Map();
|
||||||
|
if (!cy) return groups;
|
||||||
|
cy.elements().filter((element) => element.style("display") !== "none").forEach((element) => {
|
||||||
|
const zone = zoneForData(element.data());
|
||||||
|
if (!zone) return;
|
||||||
|
if (!groups.has(zone.id)) {
|
||||||
|
groups.set(zone.id, {...zone, nodes: [], elements: []});
|
||||||
|
}
|
||||||
|
const group = groups.get(zone.id);
|
||||||
|
group.elements.push(element);
|
||||||
|
if (element.isNode()) group.nodes.push(element);
|
||||||
|
});
|
||||||
|
return groups;
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoneCountsForField = (elements, field) => {
|
||||||
|
if (field !== "deploymentEnvironment" && field !== "accessZone") return groupCounts(elements, field);
|
||||||
|
const groups = new Map();
|
||||||
|
elements.forEach((element) => {
|
||||||
|
const zone = zoneForData(element.data(), field);
|
||||||
|
if (!zone) return;
|
||||||
|
groups.set(zone.label, (groups.get(zone.label) || 0) + 1);
|
||||||
|
});
|
||||||
|
return Array.from(groups.entries())
|
||||||
|
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]));
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderZoneDetails = (zone) => {
|
||||||
|
selected = null;
|
||||||
|
selectedZoneId = zone.id;
|
||||||
|
const visibleElements = zone.elements || [];
|
||||||
|
const visibleNodes = zone.nodes || [];
|
||||||
|
detailTitle.textContent = zone.label;
|
||||||
|
detailSummary.textContent = `${visibleNodes.length} visible node${visibleNodes.length === 1 ? "" : "s"} in ${ruleAttributeLabels[zone.field] || humanize(zone.field)}`;
|
||||||
|
detailPills.innerHTML = [zone.field, zone.value, activeZoneGrouping]
|
||||||
|
.map((value) => value ? `<span class="pill">${escapeHtml(value)}</span>` : "")
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const valuesFor = (field) => groupCounts(visibleElements, field)
|
||||||
|
.map(([value, count]) => `${value} (${count})`)
|
||||||
|
.join(", ");
|
||||||
|
const warnings = visibleElements
|
||||||
|
.flatMap((element) => zoneWarningsForData(element.data()).map((warning) =>
|
||||||
|
`${elementLabel(element)}: ${warning}`
|
||||||
|
));
|
||||||
|
const rows = [
|
||||||
|
["visible nodes", String(visibleNodes.length)],
|
||||||
|
["deployment environments", valuesFor("deploymentEnvironment")],
|
||||||
|
["deployment scenarios", valuesFor("deploymentScenario")],
|
||||||
|
["access zones", valuesFor("accessZone")],
|
||||||
|
["routing authorities", valuesFor("routingAuthority")],
|
||||||
|
["policy authorities", valuesFor("policyAuthority")],
|
||||||
|
...warnings.slice(0, 8).map((warning) => ["warning", warning]),
|
||||||
|
];
|
||||||
|
if (warnings.length > 8) rows.push(["warning", `${warnings.length - 8} additional route warnings`]);
|
||||||
|
detailList.innerHTML = rows
|
||||||
|
.filter(([, value]) => value)
|
||||||
|
.map(([key, value]) => `<li class="${key === "warning" ? "orientation-warning" : ""}"><strong>${escapeHtml(key)}</strong> ${escapeHtml(value)}</li>`)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const contextIds = new Set(visibleNodes.map((node) => node.id()));
|
||||||
|
visibleElements.filter((element) => element.isEdge()).forEach((edge) => addContextEdge(contextIds, edge));
|
||||||
|
orientationContext = {
|
||||||
|
ids: Array.from(contextIds),
|
||||||
|
profileName: `Zone: ${zone.label}`,
|
||||||
|
};
|
||||||
|
orientationTitle.textContent = `Zone context (${orientationContext.ids.length} items)`;
|
||||||
|
orientationList.innerHTML = [
|
||||||
|
{label: "grouping", value: ruleAttributeLabels[zone.field] || humanize(zone.field), state: "good"},
|
||||||
|
{label: "zone", value: zone.label},
|
||||||
|
{label: "warnings", value: warnings.length ? String(warnings.length) : "none", state: warnings.length ? "warning" : "good"},
|
||||||
|
].map((row) => `
|
||||||
|
<li class="orientation-item ${orientationStateClass(row.state)}">
|
||||||
|
<span class="orientation-label">${escapeHtml(row.label)}</span>
|
||||||
|
<span class="orientation-value">${escapeHtml(row.value)}</span>
|
||||||
|
</li>
|
||||||
|
`).join("");
|
||||||
|
orientationActions.innerHTML = `
|
||||||
|
<button type="button" data-orientation-action="focus">Focus Context</button>
|
||||||
|
<button type="button" data-orientation-action="highlight">Highlight Context</button>
|
||||||
|
<button type="button" data-orientation-action="hide-other">Hide Other</button>
|
||||||
|
<button type="button" data-orientation-action="remove-other">Remove Other</button>
|
||||||
|
<button type="button" data-orientation-action="name-view">Name View</button>
|
||||||
|
`;
|
||||||
|
updateLabelVisibility();
|
||||||
|
updateSelectionAnchor();
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderZoneOverlay = () => {
|
||||||
|
if (!zoneOverlay || !cy) return;
|
||||||
|
const enabled = zoneBoundaryToggle ? zoneBoundaryToggle.checked : true;
|
||||||
|
if (!enabled) {
|
||||||
|
zoneSummaries = new Map();
|
||||||
|
selectedZoneId = "";
|
||||||
|
zoneOverlay.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
zoneSummaries = collectZoneSummaries();
|
||||||
|
const boundaries = Array.from(zoneSummaries.values())
|
||||||
|
.filter((zone) => zone.nodes.length > 0)
|
||||||
|
.sort((left, right) => zoneRank(left) - zoneRank(right) || left.label.localeCompare(right.label))
|
||||||
|
.map((zone, index) => {
|
||||||
|
const bounds = zoneBoundsForNodes(zone.nodes);
|
||||||
|
if (!bounds) return "";
|
||||||
|
const style = zoneStyle(zone);
|
||||||
|
const selectedClass = selectedZoneId === zone.id ? " selected" : "";
|
||||||
|
const labelTop = zoneLabelTop(zone, bounds, index);
|
||||||
|
return `
|
||||||
|
<div class="zone-boundary${selectedClass}" data-zone-id="${escapeHtml(zone.id)}" style="--zone-color:${style.color};--zone-fill:${style.fill};left:${Math.round(bounds.left)}px;top:${Math.round(bounds.top)}px;width:${Math.round(bounds.width)}px;height:${Math.round(bounds.height)}px">
|
||||||
|
<button type="button" class="zone-label" data-zone-id="${escapeHtml(zone.id)}" style="--zone-color:${style.color};top:${labelTop}px" title="${escapeHtml(zone.label)}">${escapeHtml(zone.label)}</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
zoneOverlay.innerHTML = boundaries.join("");
|
||||||
|
if (selectedZoneId && zoneSummaries.has(selectedZoneId)) {
|
||||||
|
renderZoneDetails(zoneSummaries.get(selectedZoneId));
|
||||||
|
} else if (selectedZoneId) {
|
||||||
|
selectedZoneId = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleZoneOverlayUpdate = () => {
|
||||||
|
if (!zoneOverlay || zoneOverlayFrame) return;
|
||||||
|
zoneOverlayFrame = window.requestAnimationFrame(() => {
|
||||||
|
zoneOverlayFrame = 0;
|
||||||
|
renderZoneOverlay();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const groupCounts = (elements, field) => {
|
const groupCounts = (elements, field) => {
|
||||||
const counts = new Map();
|
const counts = new Map();
|
||||||
elements.forEach((element) => {
|
elements.forEach((element) => {
|
||||||
@@ -927,7 +1244,7 @@ def graph_explorer_page() -> str:
|
|||||||
|
|
||||||
const rows = [{label: "visible", value: `${visibleNodes} nodes / ${visibleEdges} edges`}];
|
const rows = [{label: "visible", value: `${visibleNodes} nodes / ${visibleEdges} edges`}];
|
||||||
fields.forEach((field) => {
|
fields.forEach((field) => {
|
||||||
const counts = groupCounts(visibleElements, field);
|
const counts = zoneCountsForField(visibleElements, field);
|
||||||
rows.push({
|
rows.push({
|
||||||
label: ruleAttributeLabels[field] || humanize(field),
|
label: ruleAttributeLabels[field] || humanize(field),
|
||||||
value: counts.length
|
value: counts.length
|
||||||
@@ -1032,6 +1349,8 @@ def graph_explorer_page() -> str:
|
|||||||
edgeTypes: Array.from(selectedEdgeTypes()),
|
edgeTypes: Array.from(selectedEdgeTypes()),
|
||||||
review: reviewFilter.value,
|
review: reviewFilter.value,
|
||||||
unresolved: unresolvedFilter.value,
|
unresolved: unresolvedFilter.value,
|
||||||
|
zoneBoundaries: zoneBoundaryToggle ? zoneBoundaryToggle.checked : true,
|
||||||
|
zoneGrouping: zoneGroupSelect ? zoneGroupSelect.value || "deploymentEnvironment" : "deploymentEnvironment",
|
||||||
rules: filterRules.map((rule) => ({...rule})),
|
rules: filterRules.map((rule) => ({...rule})),
|
||||||
manualOverrides: {...manualOverrides},
|
manualOverrides: {...manualOverrides},
|
||||||
});
|
});
|
||||||
@@ -1060,6 +1379,8 @@ def graph_explorer_page() -> str:
|
|||||||
if (params.has("layer")) state.nodeTypes = parseListParam(params.get("layer"));
|
if (params.has("layer")) state.nodeTypes = parseListParam(params.get("layer"));
|
||||||
if (params.has("review")) state.review = params.get("review") || "";
|
if (params.has("review")) state.review = params.get("review") || "";
|
||||||
if (params.has("unresolved")) state.unresolved = params.get("unresolved") || "";
|
if (params.has("unresolved")) state.unresolved = params.get("unresolved") || "";
|
||||||
|
if (params.has("zoneBoundaries")) state.zoneBoundaries = params.get("zoneBoundaries") !== "0";
|
||||||
|
if (params.has("zoneGrouping")) state.zoneGrouping = params.get("zoneGrouping") || "";
|
||||||
if (params.has("profile")) state.profile = params.get("profile") || "";
|
if (params.has("profile")) state.profile = params.get("profile") || "";
|
||||||
if (params.has("state")) {
|
if (params.has("state")) {
|
||||||
try {
|
try {
|
||||||
@@ -1082,6 +1403,8 @@ def graph_explorer_page() -> str:
|
|||||||
if (state.edgeTypes.length !== allEdgeTypes.length) params.set("edgeTypes", state.edgeTypes.join(","));
|
if (state.edgeTypes.length !== allEdgeTypes.length) params.set("edgeTypes", state.edgeTypes.join(","));
|
||||||
if (state.review) params.set("review", state.review);
|
if (state.review) params.set("review", state.review);
|
||||||
if (state.unresolved) params.set("unresolved", state.unresolved);
|
if (state.unresolved) params.set("unresolved", state.unresolved);
|
||||||
|
if (state.zoneBoundaries === false) params.set("zoneBoundaries", "0");
|
||||||
|
if (state.zoneGrouping && state.zoneGrouping !== "deploymentEnvironment") params.set("zoneGrouping", state.zoneGrouping);
|
||||||
if (currentProfileId) params.set("profile", currentProfileId);
|
if (currentProfileId) params.set("profile", currentProfileId);
|
||||||
if (includeStateBlob || hasManualOverrides() || filterRules.length > 0) {
|
if (includeStateBlob || hasManualOverrides() || filterRules.length > 0) {
|
||||||
params.set("state", encodeStateBlob(state));
|
params.set("state", encodeStateBlob(state));
|
||||||
@@ -1108,6 +1431,10 @@ def graph_explorer_page() -> str:
|
|||||||
if ("edgeTypes" in state) setCheckedValues(edgeTypeFilter, (state.edgeTypes || []).filter((value) => allEdgeTypes.includes(value)));
|
if ("edgeTypes" in state) setCheckedValues(edgeTypeFilter, (state.edgeTypes || []).filter((value) => allEdgeTypes.includes(value)));
|
||||||
if ("review" in state) reviewFilter.value = optionExists(reviewFilter, state.review) ? state.review : "";
|
if ("review" in state) reviewFilter.value = optionExists(reviewFilter, state.review) ? state.review : "";
|
||||||
if ("unresolved" in state) unresolvedFilter.value = optionExists(unresolvedFilter, state.unresolved) ? state.unresolved : "";
|
if ("unresolved" in state) unresolvedFilter.value = optionExists(unresolvedFilter, state.unresolved) ? state.unresolved : "";
|
||||||
|
if ("zoneBoundaries" in state && zoneBoundaryToggle) zoneBoundaryToggle.checked = state.zoneBoundaries !== false;
|
||||||
|
if ("zoneGrouping" in state && zoneGroupSelect) {
|
||||||
|
zoneGroupSelect.value = optionExists(zoneGroupSelect, state.zoneGrouping) ? state.zoneGrouping : "deploymentEnvironment";
|
||||||
|
}
|
||||||
if ("manualOverrides" in state && state.manualOverrides && typeof state.manualOverrides === "object") {
|
if ("manualOverrides" in state && state.manualOverrides && typeof state.manualOverrides === "object") {
|
||||||
manualOverrides = {...state.manualOverrides};
|
manualOverrides = {...state.manualOverrides};
|
||||||
}
|
}
|
||||||
@@ -1119,6 +1446,8 @@ def graph_explorer_page() -> str:
|
|||||||
}
|
}
|
||||||
activeMode = modeSelect.value || "full";
|
activeMode = modeSelect.value || "full";
|
||||||
activeLabelMode = labelSelect.value || "auto";
|
activeLabelMode = labelSelect.value || "auto";
|
||||||
|
activeZoneGrouping = zoneGroupSelect ? zoneGroupSelect.value || "deploymentEnvironment" : "deploymentEnvironment";
|
||||||
|
selectedZoneId = "";
|
||||||
focusSet = null;
|
focusSet = null;
|
||||||
syncFilterSummaries();
|
syncFilterSummaries();
|
||||||
applyFilters();
|
applyFilters();
|
||||||
@@ -1154,6 +1483,10 @@ def graph_explorer_page() -> str:
|
|||||||
if (selectedEdgeTypes().size !== allEdgeTypes.length) parts.push("edge filter");
|
if (selectedEdgeTypes().size !== allEdgeTypes.length) parts.push("edge filter");
|
||||||
if (reviewFilter.value) parts.push(`${reviewFilter.value} review`);
|
if (reviewFilter.value) parts.push(`${reviewFilter.value} review`);
|
||||||
if (unresolvedFilter.value) parts.push("unresolved only");
|
if (unresolvedFilter.value) parts.push("unresolved only");
|
||||||
|
if (zoneBoundaryToggle && !zoneBoundaryToggle.checked) parts.push("zones off");
|
||||||
|
if (zoneGroupSelect && (zoneGroupSelect.value || "deploymentEnvironment") !== "deploymentEnvironment") {
|
||||||
|
parts.push("access zone boundaries");
|
||||||
|
}
|
||||||
if (filterRules.length) parts.push(`${filterRules.length} rule${filterRules.length === 1 ? "" : "s"}`);
|
if (filterRules.length) parts.push(`${filterRules.length} rule${filterRules.length === 1 ? "" : "s"}`);
|
||||||
const overrideCount = Object.keys(manualOverrides).length;
|
const overrideCount = Object.keys(manualOverrides).length;
|
||||||
if (overrideCount) parts.push(`${overrideCount} override${overrideCount === 1 ? "" : "s"}`);
|
if (overrideCount) parts.push(`${overrideCount} override${overrideCount === 1 ? "" : "s"}`);
|
||||||
@@ -1327,12 +1660,15 @@ def graph_explorer_page() -> str:
|
|||||||
hiddenSummary.textContent = removed ? `Hidden ${hidden} / Removed ${removed}` : `Hidden ${hidden}`;
|
hiddenSummary.textContent = removed ? `Hidden ${hidden} / Removed ${removed}` : `Hidden ${hidden}`;
|
||||||
updateLabelVisibility();
|
updateLabelVisibility();
|
||||||
updateSelectionAnchor();
|
updateSelectionAnchor();
|
||||||
|
scheduleZoneOverlayUpdate();
|
||||||
if (!selected) renderMapOverview();
|
if (!selected) renderMapOverview();
|
||||||
if (options.redrawOnRemove && previousRemoved !== ruleRemovalSignature()) runLayout();
|
if (options.redrawOnRemove && previousRemoved !== ruleRemovalSignature()) runLayout();
|
||||||
};
|
};
|
||||||
|
|
||||||
const showDetails = (element) => {
|
const showDetails = (element) => {
|
||||||
selected = element || null;
|
selected = element || null;
|
||||||
|
selectedZoneId = "";
|
||||||
|
scheduleZoneOverlayUpdate();
|
||||||
if (!element) {
|
if (!element) {
|
||||||
orientationContext = null;
|
orientationContext = null;
|
||||||
renderMapOverview();
|
renderMapOverview();
|
||||||
@@ -1623,6 +1959,7 @@ def graph_explorer_page() -> str:
|
|||||||
: {name, padding: 48, animate: false};
|
: {name, padding: 48, animate: false};
|
||||||
layoutElements.layout(options).run();
|
layoutElements.layout(options).run();
|
||||||
updateSelectionAnchor();
|
updateSelectionAnchor();
|
||||||
|
scheduleZoneOverlayUpdate();
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderLegend = (layers) => {
|
const renderLegend = (layers) => {
|
||||||
@@ -1742,8 +2079,14 @@ def graph_explorer_page() -> str:
|
|||||||
renderRules();
|
renderRules();
|
||||||
cy.on("tap", "node, edge", (event) => showDetails(event.target));
|
cy.on("tap", "node, edge", (event) => showDetails(event.target));
|
||||||
cy.on("tap", (event) => { if (event.target === cy) showDetails(null); });
|
cy.on("tap", (event) => { if (event.target === cy) showDetails(null); });
|
||||||
cy.on("pan zoom resize render layoutstop", updateSelectionAnchor);
|
cy.on("pan zoom resize render layoutstop", () => {
|
||||||
cy.on("position", "node", updateSelectionAnchor);
|
updateSelectionAnchor();
|
||||||
|
scheduleZoneOverlayUpdate();
|
||||||
|
});
|
||||||
|
cy.on("position", "node", () => {
|
||||||
|
updateSelectionAnchor();
|
||||||
|
scheduleZoneOverlayUpdate();
|
||||||
|
});
|
||||||
cy.on("mouseover", "node, edge", (event) => {
|
cy.on("mouseover", "node, edge", (event) => {
|
||||||
event.target.addClass("hover");
|
event.target.addClass("hover");
|
||||||
const data = event.target.data();
|
const data = event.target.data();
|
||||||
@@ -1811,6 +2154,36 @@ def graph_explorer_page() -> str:
|
|||||||
updateProfileSummary();
|
updateProfileSummary();
|
||||||
updateUrlState();
|
updateUrlState();
|
||||||
});
|
});
|
||||||
|
zoneBoundaryToggle.addEventListener("input", () => {
|
||||||
|
selectedZoneId = "";
|
||||||
|
currentProfileId = "";
|
||||||
|
profileSelect.value = "";
|
||||||
|
renderZoneOverlay();
|
||||||
|
if (!selected) renderMapOverview();
|
||||||
|
updateProfileControls();
|
||||||
|
updateProfileSummary();
|
||||||
|
updateUrlState();
|
||||||
|
});
|
||||||
|
zoneGroupSelect.addEventListener("input", () => {
|
||||||
|
activeZoneGrouping = zoneGroupSelect.value || "deploymentEnvironment";
|
||||||
|
selectedZoneId = "";
|
||||||
|
currentProfileId = "";
|
||||||
|
profileSelect.value = "";
|
||||||
|
renderZoneOverlay();
|
||||||
|
if (!selected) renderMapOverview();
|
||||||
|
updateProfileControls();
|
||||||
|
updateProfileSummary();
|
||||||
|
updateUrlState();
|
||||||
|
});
|
||||||
|
zoneOverlay.addEventListener("click", (event) => {
|
||||||
|
const button = event.target.closest("[data-zone-id]");
|
||||||
|
if (!button) return;
|
||||||
|
const zone = zoneSummaries.get(button.dataset.zoneId);
|
||||||
|
if (!zone) return;
|
||||||
|
event.stopPropagation();
|
||||||
|
selectedZoneId = zone.id;
|
||||||
|
renderZoneOverlay();
|
||||||
|
});
|
||||||
ruleTarget.addEventListener("input", () => refreshRuleBuilder({target: ruleTarget.value}));
|
ruleTarget.addEventListener("input", () => refreshRuleBuilder({target: ruleTarget.value}));
|
||||||
ruleType.addEventListener("input", () => refreshRuleBuilder({target: ruleTarget.value, type: ruleType.value}));
|
ruleType.addEventListener("input", () => refreshRuleBuilder({target: ruleTarget.value, type: ruleType.value}));
|
||||||
ruleAttribute.addEventListener("input", () => refreshRuleBuilder({
|
ruleAttribute.addEventListener("input", () => refreshRuleBuilder({
|
||||||
@@ -1899,6 +2272,10 @@ def graph_explorer_page() -> str:
|
|||||||
activeMode = "full";
|
activeMode = "full";
|
||||||
labelSelect.value = "auto";
|
labelSelect.value = "auto";
|
||||||
activeLabelMode = "auto";
|
activeLabelMode = "auto";
|
||||||
|
zoneBoundaryToggle.checked = true;
|
||||||
|
zoneGroupSelect.value = "deploymentEnvironment";
|
||||||
|
activeZoneGrouping = "deploymentEnvironment";
|
||||||
|
selectedZoneId = "";
|
||||||
setCheckedValues(nodeTypeFilter);
|
setCheckedValues(nodeTypeFilter);
|
||||||
setCheckedValues(edgeTypeFilter);
|
setCheckedValues(edgeTypeFilter);
|
||||||
reviewFilter.value = "";
|
reviewFilter.value = "";
|
||||||
|
|||||||
@@ -399,6 +399,9 @@ def test_registry_serves_graph_explorer_exports(tmp_path: Path) -> None:
|
|||||||
assert 'id="mode-select"' in page
|
assert 'id="mode-select"' in page
|
||||||
assert 'id="layout-select"' in page
|
assert 'id="layout-select"' in page
|
||||||
assert 'id="label-select"' in page
|
assert 'id="label-select"' in page
|
||||||
|
assert 'id="zone-overlay"' in page
|
||||||
|
assert 'id="zone-boundary-toggle"' in page
|
||||||
|
assert 'id="zone-group-select"' in page
|
||||||
assert 'id="node-type-filter"' in page
|
assert 'id="node-type-filter"' in page
|
||||||
assert 'id="edge-type-filter"' in page
|
assert 'id="edge-type-filter"' in page
|
||||||
assert 'id="rule-panel"' in page
|
assert 'id="rule-panel"' in page
|
||||||
@@ -411,6 +414,14 @@ def test_registry_serves_graph_explorer_exports(tmp_path: Path) -> None:
|
|||||||
assert "ruleActionFor" in page
|
assert "ruleActionFor" in page
|
||||||
assert "ruleRemovalSignature" in page
|
assert "ruleRemovalSignature" in page
|
||||||
assert "zoneModeFields" in page
|
assert "zoneModeFields" in page
|
||||||
|
assert "zoneForData" in page
|
||||||
|
assert "zoneBoundsForNodes" in page
|
||||||
|
assert "renderZoneOverlay" in page
|
||||||
|
assert "renderZoneDetails" in page
|
||||||
|
assert "dev-tegwick" in page
|
||||||
|
assert 'normalized === "staging"' in page
|
||||||
|
assert "zoneBoundaries" in page
|
||||||
|
assert "zoneGrouping" in page
|
||||||
assert "renderMapOverview" in page
|
assert "renderMapOverview" in page
|
||||||
assert "route without policy authority" in page
|
assert "route without policy authority" in page
|
||||||
assert "deploymentEnvironment" in page
|
assert "deploymentEnvironment" in page
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ type: workplan
|
|||||||
title: "Zone Boundary Overlays"
|
title: "Zone Boundary Overlays"
|
||||||
domain: railiance
|
domain: railiance
|
||||||
repo: railiance-fabric
|
repo: railiance-fabric
|
||||||
status: ready
|
status: finished
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: railiance
|
topic_slug: railiance
|
||||||
created: "2026-05-24"
|
created: "2026-05-24"
|
||||||
@@ -58,7 +58,7 @@ parent nodes unless testing shows the overlay layer is too fragile.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: RAIL-FAB-WP-0021-T01
|
id: RAIL-FAB-WP-0021-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "91777c22-663c-443a-b682-38bfb7a864bf"
|
state_hub_task_id: "91777c22-663c-443a-b682-38bfb7a864bf"
|
||||||
```
|
```
|
||||||
@@ -78,11 +78,16 @@ Expected grouping rules:
|
|||||||
Done when the grouping and label rules are documented in the graph explorer
|
Done when the grouping and label rules are documented in the graph explorer
|
||||||
contract or operations docs and have focused tests.
|
contract or operations docs and have focused tests.
|
||||||
|
|
||||||
|
Result: documented environment/access-zone boundary rules in
|
||||||
|
`docs/graph-explorer-contract.md`; graph explorer maps `dev` to `dev-tegwick`,
|
||||||
|
legacy `staging` to `test`, `prod` to `prod`, and ignores catch-all `all`
|
||||||
|
environment values for rectangle boundaries.
|
||||||
|
|
||||||
## T02 - Render Rectangle Boundary Overlays
|
## T02 - Render Rectangle Boundary Overlays
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RAIL-FAB-WP-0021-T02
|
id: RAIL-FAB-WP-0021-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "98ef5ecc-fa4d-443c-a4fe-fa896984d6c4"
|
state_hub_task_id: "98ef5ecc-fa4d-443c-a4fe-fa896984d6c4"
|
||||||
```
|
```
|
||||||
@@ -103,11 +108,15 @@ Implementation expectations:
|
|||||||
Done when the UI shows stable labeled rectangles for `dev-tegwick`, `test`, and
|
Done when the UI shows stable labeled rectangles for `dev-tegwick`, `test`, and
|
||||||
`prod` where matching nodes are visible.
|
`prod` where matching nodes are visible.
|
||||||
|
|
||||||
|
Result: added a canvas overlay layer that computes padded rendered-node bounds,
|
||||||
|
draws labeled rectangles, updates after layout/pan/zoom/filter changes, and
|
||||||
|
hides empty zones.
|
||||||
|
|
||||||
## T03 - Add Zone Overlay Controls
|
## T03 - Add Zone Overlay Controls
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RAIL-FAB-WP-0021-T03
|
id: RAIL-FAB-WP-0021-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "30d48d5c-bd53-434b-9b8b-ed1a495cee4e"
|
state_hub_task_id: "30d48d5c-bd53-434b-9b8b-ed1a495cee4e"
|
||||||
```
|
```
|
||||||
@@ -125,11 +134,14 @@ Expected controls:
|
|||||||
Done when operators can turn the overlay off for dense graph inspection and
|
Done when operators can turn the overlay off for dense graph inspection and
|
||||||
restore it through shared/saved view state.
|
restore it through shared/saved view state.
|
||||||
|
|
||||||
|
Result: added map controls for zone visibility and grouping by deployment
|
||||||
|
environment or access zone, with URL/profile state preservation.
|
||||||
|
|
||||||
## T04 - Surface Zone Boundary Details
|
## T04 - Surface Zone Boundary Details
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RAIL-FAB-WP-0021-T04
|
id: RAIL-FAB-WP-0021-T04
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "c002aa54-a243-4912-81ae-0d282910ebc6"
|
state_hub_task_id: "c002aa54-a243-4912-81ae-0d282910ebc6"
|
||||||
```
|
```
|
||||||
@@ -148,11 +160,16 @@ Expected behavior:
|
|||||||
Done when a zone label provides the same core answers as the WP-20 map overview,
|
Done when a zone label provides the same core answers as the WP-20 map overview,
|
||||||
but scoped to that zone.
|
but scoped to that zone.
|
||||||
|
|
||||||
|
Result: zone labels are clickable and show scoped counts for visible nodes,
|
||||||
|
environments, scenarios, access zones, routing authorities, policy authorities,
|
||||||
|
and route-without-policy-authority warnings; existing focus/highlight/hide
|
||||||
|
orientation actions work for zone contexts.
|
||||||
|
|
||||||
## T05 - Verify Responsive And Dense Graph Behavior
|
## T05 - Verify Responsive And Dense Graph Behavior
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RAIL-FAB-WP-0021-T05
|
id: RAIL-FAB-WP-0021-T05
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "03f265ad-f335-4caf-a882-35d8d30c75fc"
|
state_hub_task_id: "03f265ad-f335-4caf-a882-35d8d30c75fc"
|
||||||
```
|
```
|
||||||
@@ -171,3 +188,8 @@ Checks should cover:
|
|||||||
|
|
||||||
Done when automated tests cover the computed grouping behavior and a browser
|
Done when automated tests cover the computed grouping behavior and a browser
|
||||||
smoke check confirms the canvas overlay is visible and aligned.
|
smoke check confirms the canvas overlay is visible and aligned.
|
||||||
|
|
||||||
|
Result: static UI tests cover the zone controls/helpers; JavaScript syntax was
|
||||||
|
checked with `node --check`; the full test suite and validator pass; a headless
|
||||||
|
Edge smoke screenshot confirmed visible labeled boundaries in deployment
|
||||||
|
environment mode.
|
||||||
|
|||||||
Reference in New Issue
Block a user