diff --git a/assets/css/interactive-diagram.css b/assets/css/interactive-diagram.css new file mode 100644 index 000000000000..41483ef1b8e2 --- /dev/null +++ b/assets/css/interactive-diagram.css @@ -0,0 +1,587 @@ +.interactive-diagram { + --diagram-accent: var(--color-blue-500); + --diagram-accent-soft: var(--color-blue-50); + --diagram-border: var(--color-gray-200); + --diagram-structure: var(--color-gray-500); + --diagram-ink: var(--color-gray-900); + --diagram-muted: var(--color-gray-600); + --diagram-shared: var(--color-green-600); + --diagram-brokered: var(--color-violet-600); + --diagram-token-bg: var(--color-gray-900); + --diagram-token-fg: white; + overflow: hidden; + border: 1px solid var(--diagram-border); + border-radius: 0.5rem; + background: white; + color: var(--diagram-ink); +} + +.dark .interactive-diagram { + --diagram-accent: var(--color-blue-300); + --diagram-accent-soft: var(--color-blue-950); + --diagram-border: var(--color-gray-700); + --diagram-structure: var(--color-gray-600); + --diagram-ink: var(--color-gray-100); + --diagram-muted: var(--color-gray-400); + --diagram-shared: var(--color-green-400); + --diagram-brokered: var(--color-violet-300); + --diagram-token-bg: var(--color-blue-300); + --diagram-token-fg: var(--color-gray-950); + background: var(--color-gray-950); +} + +.interactive-diagram__header { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 1rem; + padding: 1.25rem 1.5rem 1rem; + border-bottom: 1px solid var(--diagram-border); +} + +.interactive-diagram__header-copy { + min-width: 0; +} + +.interactive-diagram--topology .interactive-diagram__header { + grid-template-columns: minmax(0, 1fr); +} + +.interactive-diagram__title, +.interactive-diagram__description, +.interactive-diagram__step-title, +.interactive-diagram__step-body, +.interactive-diagram__state { + margin: 0; +} + +.interactive-diagram__title { + color: var(--diagram-ink); + font-size: 1rem; + font-weight: 650; +} + +.interactive-diagram__description { + margin-top: 0.25rem; + color: var(--diagram-muted); + font-size: 0.875rem; + line-height: 1.45; +} + +.interactive-diagram__stage { + overflow-x: auto; + background-color: var(--diagram-accent-soft); + background-image: + linear-gradient( + to right, + color-mix(in srgb, var(--diagram-border) 28%, transparent) 1px, + transparent 1px + ), + linear-gradient( + to bottom, + color-mix(in srgb, var(--diagram-border) 28%, transparent) 1px, + transparent 1px + ); + background-size: 24px 24px; +} + +.interactive-diagram__svg { + display: block; + width: 100%; + min-width: 680px; + height: auto; + font-family: "Roboto Flex", sans-serif; +} + +.interactive-diagram__boundary rect { + fill: color-mix(in srgb, white 72%, transparent); + stroke: var(--diagram-structure); + stroke-width: 1.5; + stroke-dasharray: 5 5; +} + +.dark .interactive-diagram__boundary rect { + fill: color-mix(in srgb, var(--color-gray-900) 82%, transparent); +} + +.interactive-diagram__boundary--sandbox rect { + stroke: color-mix(in srgb, var(--diagram-accent) 65%, var(--diagram-border)); + stroke-width: 2; +} + +.interactive-diagram__boundary-label { + fill: var(--diagram-muted); + font-family: "Roboto Mono", monospace; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.045em; + text-transform: uppercase; +} + +.interactive-diagram__edge { + stroke: var(--diagram-structure); + stroke-width: 2; + stroke-dasharray: 5 7; + opacity: 0.5; + transition: + opacity 180ms ease, + stroke 180ms ease, + stroke-width 180ms ease; +} + +.interactive-diagram__svg marker path { + fill: context-stroke; +} + +.interactive-diagram__edge.is-active { + stroke: var(--diagram-accent); + stroke-width: 3; + stroke-dasharray: none; + opacity: 1; +} + +.interactive-diagram__edge--interactive { + cursor: pointer; + opacity: 0.82; +} + +.interactive-diagram__edge--shared { + stroke: var(--diagram-shared); +} + +.interactive-diagram__edge--brokered { + stroke: var(--diagram-brokered); +} + +.interactive-diagram__edge-hit { + stroke: transparent; + stroke-width: 22; + stroke-dasharray: none; + pointer-events: stroke; +} + +.interactive-diagram__edge-label rect { + fill: white; + stroke: var(--diagram-border); + stroke-width: 1; + stroke-dasharray: none; +} + +.dark .interactive-diagram__edge-label rect { + fill: var(--color-gray-900); +} + +.interactive-diagram__edge-label-text { + fill: var(--diagram-ink); + stroke: none; + font-family: "Roboto Mono", monospace; + font-size: 10.5px; + font-weight: 600; + text-anchor: middle; +} + +.interactive-diagram__edge--interactive.is-active + .interactive-diagram__edge-label + rect { + stroke: var(--diagram-accent); + stroke-width: 2; +} + +.interactive-diagram__edge--interactive:focus-visible { + outline: none; +} + +.interactive-diagram__edge--interactive:focus-visible + .interactive-diagram__edge-label + rect { + stroke: var(--color-blue-focusvisible); + stroke-width: 3; +} + +.interactive-diagram__node rect { + fill: white; + stroke: var(--diagram-structure); + stroke-width: 1.5; + transition: + fill 180ms ease, + stroke 180ms ease, + stroke-width 180ms ease; +} + +.interactive-diagram--topology .interactive-diagram__node { + cursor: pointer; + transition: opacity 180ms ease; +} + +.interactive-diagram--topology .interactive-diagram__node:focus-visible { + outline: none; +} + +.interactive-diagram--topology .interactive-diagram__node:focus-visible rect { + stroke: var(--color-blue-focusvisible); + stroke-width: 3; +} + +.dark .interactive-diagram__node rect { + fill: var(--color-gray-900); +} + +.interactive-diagram__node--context { + color: var(--diagram-muted); +} + +.interactive-diagram__node--context rect { + fill: var(--color-gray-50); + stroke-dasharray: 3 3; +} + +.dark .interactive-diagram__node--context rect { + fill: var(--color-gray-900); +} + +.interactive-diagram__node--context .interactive-diagram__node-label { + fill: var(--diagram-muted); +} + +.interactive-diagram__node--secret rect { + fill: var(--color-green-50); + stroke: var(--color-green-400); +} + +.interactive-diagram__node--shared rect { + fill: var(--color-green-50); + stroke: var(--diagram-shared); +} + +.dark .interactive-diagram__node--shared rect { + fill: var(--color-green-950); +} + +.interactive-diagram__node--restricted rect { + fill: var(--color-gray-50); + stroke-dasharray: 3 3; +} + +.dark .interactive-diagram__node--restricted rect { + fill: var(--color-gray-900); +} + +.dark .interactive-diagram__node--secret rect { + fill: var(--color-green-950); + stroke: var(--color-green-700); +} + +.interactive-diagram__node.is-active rect { + fill: var(--diagram-accent-soft); + stroke: var(--diagram-accent); + stroke-width: 3; +} + +.interactive-diagram__node.is-related rect { + stroke: var(--diagram-accent); + stroke-width: 2; +} + +.interactive-diagram--topology.has-topology-focus + .interactive-diagram__node:not(.is-active, .is-related), +.interactive-diagram--topology.has-topology-focus + .interactive-diagram__edge:not(.is-active) { + opacity: 0.22; +} + +.interactive-diagram__node-label { + fill: var(--diagram-ink); + font-size: 14px; + font-weight: 650; +} + +.interactive-diagram__node-description { + fill: var(--diagram-muted); + font-size: 11px; +} + +.interactive-diagram__token rect { + fill: var(--diagram-token-bg); + stroke: var(--diagram-token-fg); + stroke-width: 2; + filter: drop-shadow(0 2px 3px rgb(18 21 31 / 0.18)); +} + +.interactive-diagram__token-label { + fill: var(--diagram-token-fg); + font-family: "Roboto Mono", monospace; + font-size: 11px; + font-weight: 600; + text-anchor: middle; +} + +.interactive-diagram__status { + min-height: 7.5rem; + padding: 1rem 1.5rem; + border-top: 1px solid var(--diagram-border); + border-bottom: 1px solid var(--diagram-border); + background: white; +} + +.dark .interactive-diagram__status { + background: var(--color-gray-950); +} + +.interactive-diagram__step-copy { + display: grid; + grid-template-columns: 3.5rem 1fr; + gap: 0.75rem; +} + +.interactive-diagram__step-number { + color: var(--diagram-accent); + font-family: "Roboto Mono", monospace; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.04em; +} + +.interactive-diagram__step-title { + color: var(--diagram-ink); + font-size: 0.95rem; + font-weight: 650; +} + +.interactive-diagram__step-body { + margin-top: 0.25rem; + color: var(--diagram-muted); + font-size: 0.85rem; + line-height: 1.45; +} + +.interactive-diagram__state { + margin-top: 0.75rem; + margin-left: 4.25rem; + padding-left: 0.65rem; + border-left: 2px solid var(--diagram-accent); + color: var(--diagram-ink); + font-size: 0.78rem; + line-height: 1.45; +} + +.interactive-diagram__topology-detail { + display: grid; + min-height: 7rem; + grid-template-columns: 8.5rem minmax(0, 1fr); + gap: 1rem; + padding: 1rem 1.5rem 1.15rem; + border-top: 1px solid var(--diagram-border); + background: white; +} + +.dark .interactive-diagram__topology-detail { + background: var(--color-gray-950); +} + +.interactive-diagram__topology-category, +.interactive-diagram__topology-title, +.interactive-diagram__topology-body { + margin: 0; +} + +.interactive-diagram__topology-category { + padding-top: 0.15rem; + color: var(--diagram-accent); + font-family: "Roboto Mono", monospace; + font-size: 0.7rem; + font-weight: 650; + letter-spacing: 0.045em; + text-transform: uppercase; +} + +.interactive-diagram__topology-title { + color: var(--diagram-ink); + font-size: 0.95rem; + font-weight: 650; +} + +.interactive-diagram__topology-body { + margin-top: 0.3rem; + color: var(--diagram-muted); + font-size: 0.85rem; + line-height: 1.5; +} + +.interactive-diagram__autoplay-progress { + height: 3px; + overflow: hidden; + background: color-mix(in srgb, var(--diagram-structure) 28%, transparent); +} + +.interactive-diagram__autoplay-progress-bar { + display: block; + width: 100%; + height: 100%; + background: var(--diagram-accent); + transform: scaleX(0); + transform-origin: left center; +} + +.interactive-diagram__controls { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + align-items: center; + gap: 0.75rem; + padding: 0.8rem 1rem; +} + +.interactive-diagram__controls > .interactive-diagram__button:first-child { + justify-self: end; +} + +.interactive-diagram__controls > .interactive-diagram__button:last-child { + justify-self: start; +} + +.interactive-diagram__button { + min-height: 2.25rem; + padding: 0.4rem 0.7rem; + border: 1px solid var(--diagram-structure); + border-radius: 0.25rem; + background: transparent; + color: var(--diagram-ink); + font-size: 0.78rem; + font-weight: 600; +} + +.interactive-diagram__button:hover:not(:disabled) { + border-color: var(--diagram-accent); + background: var(--diagram-accent-soft); +} + +.interactive-diagram__button:focus-visible, +.interactive-diagram__progress-step:focus-visible { + outline: 3px solid var(--color-blue-focusvisible); + outline-offset: 2px; +} + +.interactive-diagram__button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.interactive-diagram__button--play { + min-width: 3.8rem; + border-color: var(--diagram-accent); + color: var(--diagram-accent); +} + +.interactive-diagram__progress { + display: flex; + min-width: 0; + justify-content: center; + gap: 0.45rem; +} + +.interactive-diagram__progress-step { + position: relative; + width: 1.75rem; + height: 1.75rem; + padding: 0; + border: 0; + background: transparent; +} + +.interactive-diagram__progress-step::before { + position: absolute; + top: 50%; + left: 50%; + width: 0.6rem; + height: 0.6rem; + border: 1px solid var(--diagram-structure); + border-radius: 50%; + background: transparent; + content: ""; + transform: translate(-50%, -50%); +} + +.interactive-diagram__progress-step.is-active::before { + border-color: var(--diagram-accent); + background: var(--diagram-accent); + box-shadow: 0 0 0 3px var(--color-blue-focus); +} + +.interactive-diagram__fallback { + padding: 1rem 1.5rem; +} + +.interactive-diagram.is-enhanced .interactive-diagram__fallback { + display: none; +} + +.interactive-diagram:not(.is-enhanced) .interactive-diagram__stage, +.interactive-diagram:not(.is-enhanced) .interactive-diagram__status, +.interactive-diagram:not(.is-enhanced) .interactive-diagram__topology-detail, +.interactive-diagram:not(.is-enhanced) .interactive-diagram__autoplay-progress, +.interactive-diagram:not(.is-enhanced) .interactive-diagram__controls { + display: none; +} + +.interactive-diagram:not(.is-enhanced) .interactive-diagram__button--play { + display: none; +} + +@media (prefers-reduced-motion: no-preference) { + .interactive-diagram__token.is-entering { + animation: interactive-diagram-token 280ms ease-out; + } + + @keyframes interactive-diagram-token { + from { + opacity: 0; + } + } +} + +@media (prefers-reduced-motion: reduce) { + .interactive-diagram__edge, + .interactive-diagram__node, + .interactive-diagram__node rect { + transition: none; + } +} + +@media (max-width: 640px) { + .interactive-diagram__header, + .interactive-diagram__status, + .interactive-diagram__topology-detail { + padding-inline: 1rem; + } + + .interactive-diagram__step-copy { + grid-template-columns: 1fr; + gap: 0.35rem; + } + + .interactive-diagram__topology-detail { + min-height: 8.5rem; + grid-template-columns: 1fr; + gap: 0.35rem; + } + + .interactive-diagram__state { + margin-left: 0; + } + + .interactive-diagram__controls { + gap: 0.25rem; + padding-inline: 0.75rem; + } + + .interactive-diagram__progress { + gap: 0.05rem; + } + + .interactive-diagram__progress-step { + width: 1.5rem; + } + + .interactive-diagram__button { + padding-inline: 0.55rem; + } +} diff --git a/assets/css/style.css b/assets/css/style.css index f63fc6f5aec2..d1a2620a8fef 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -42,6 +42,7 @@ @import "syntax-dark.css"; @import "syntax-light.css"; @import "components.css"; +@import "interactive-diagram.css"; @import "highlight-github-dark.css"; @variant dark (&:where(.dark, .dark *)); diff --git a/assets/js/interactive-diagram.js b/assets/js/interactive-diagram.js new file mode 100644 index 000000000000..59535a59f3e3 --- /dev/null +++ b/assets/js/interactive-diagram.js @@ -0,0 +1,678 @@ +const svgNamespace = "http://www.w3.org/2000/svg"; + +document.querySelectorAll("[data-interactive-diagram]").forEach((root) => { + const configElement = root.querySelector("[data-interactive-diagram-config]"); + if (!configElement) return; + + let config; + try { + config = JSON.parse(configElement.textContent); + } catch { + return; + } + + const stage = root.querySelector("[data-diagram-stage]"); + if (!stage || !config.canvas || !config.nodes?.length) return; + + const state = buildDiagram(stage, config); + if (config.type === "topology") { + initializeTopology(root, stage, state, config); + } else if (config.steps?.length) { + initializeSequence(root, state, config); + } +}); + +function initializeSequence(root, state, config) { + const previousButton = root.querySelector("[data-step-previous]"); + const nextButton = root.querySelector("[data-step-next]"); + const playButton = root.querySelector("[data-step-play]"); + const playLabel = root.querySelector("[data-step-play-label]"); + const progress = root.querySelector("[data-step-progress]"); + const autoplayProgress = root.querySelector("[data-step-autoplay-progress]"); + const autoplayProgressBar = root.querySelector( + "[data-step-autoplay-progress-bar]", + ); + const autoplayDuration = Math.max( + 100, + Number(config.autoplayDuration) || 4000, + ); + let currentStep = 0; + let playTimer; + let progressFrame; + let stepStartedAt; + let elapsedInStep = 0; + let isPlaying = false; + + const stepButtons = config.steps.map((step, index) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "interactive-diagram__progress-step"; + button.setAttribute("aria-label", `Show step ${index + 1}: ${step.label}`); + button.addEventListener("click", () => { + resetPlayback(); + showStep(index); + }); + progress?.append(button); + return button; + }); + + function showStep(index) { + currentStep = Math.max(0, Math.min(index, config.steps.length - 1)); + const step = config.steps[currentStep]; + const activeNodes = new Set(step.activeNodes ?? []); + const activeEdges = new Set(step.activeEdges ?? []); + + state.nodes.forEach((element, id) => { + element.classList.toggle("is-active", activeNodes.has(id)); + }); + state.edges.forEach((element, id) => { + element.classList.toggle("is-active", activeEdges.has(id)); + }); + positionToken(state, config, step.token); + + setText( + root, + "[data-step-number]", + `${String(currentStep + 1).padStart(2, "0")} / ${String(config.steps.length).padStart(2, "0")}`, + ); + setText(root, "[data-step-title]", step.label); + setText(root, "[data-step-body]", step.body); + setText(root, "[data-step-state]", step.state); + + elapsedInStep = 0; + setAutoplayProgress(0); + + if (previousButton) previousButton.disabled = currentStep === 0; + if (nextButton) + nextButton.disabled = currentStep === config.steps.length - 1; + stepButtons.forEach((button, buttonIndex) => { + const selected = buttonIndex === currentStep; + button.classList.toggle("is-active", selected); + button.setAttribute("aria-current", selected ? "step" : "false"); + }); + updatePlayButton(); + } + + function clearPlaybackTimers() { + window.clearTimeout(playTimer); + window.cancelAnimationFrame(progressFrame); + playTimer = undefined; + progressFrame = undefined; + } + + function resetPlayback() { + clearPlaybackTimers(); + isPlaying = false; + elapsedInStep = 0; + setAutoplayProgress(0); + updatePlayButton(); + } + + function pausePlaying() { + if (!isPlaying) return; + elapsedInStep = Math.min( + autoplayDuration, + elapsedInStep + now() - stepStartedAt, + ); + clearPlaybackTimers(); + isPlaying = false; + setAutoplayProgress(elapsedInStep / autoplayDuration); + updatePlayButton(); + } + + function startPlaying() { + if ( + currentStep === config.steps.length - 1 && + elapsedInStep >= autoplayDuration + ) { + showStep(0); + } + isPlaying = true; + stepStartedAt = now(); + updatePlayButton(); + scheduleCurrentStep(); + } + + function scheduleCurrentStep() { + clearPlaybackTimers(); + const remaining = Math.max(0, autoplayDuration - elapsedInStep); + playTimer = window.setTimeout(completeCurrentStep, remaining); + updateAutoplayProgress(); + } + + function completeCurrentStep() { + elapsedInStep = autoplayDuration; + setAutoplayProgress(1); + if (currentStep === config.steps.length - 1) { + clearPlaybackTimers(); + isPlaying = false; + updatePlayButton(); + return; + } + showStep(currentStep + 1); + stepStartedAt = now(); + scheduleCurrentStep(); + } + + function updateAutoplayProgress() { + if (!isPlaying) return; + const elapsed = Math.min( + autoplayDuration, + elapsedInStep + now() - stepStartedAt, + ); + setAutoplayProgress(elapsed / autoplayDuration); + if (elapsed < autoplayDuration) { + progressFrame = window.requestAnimationFrame(updateAutoplayProgress); + } + } + + function setAutoplayProgress(value) { + const progressValue = Math.max(0, Math.min(1, value)); + if (autoplayProgressBar) { + autoplayProgressBar.style.transform = `scaleX(${progressValue})`; + } + autoplayProgress?.setAttribute( + "aria-valuenow", + String(Math.round(progressValue * 100)), + ); + } + + function updatePlayButton() { + playButton?.setAttribute("aria-pressed", String(isPlaying)); + if (!playLabel) return; + if (isPlaying) { + playLabel.textContent = "Pause"; + } else if ( + currentStep === config.steps.length - 1 && + elapsedInStep >= autoplayDuration + ) { + playLabel.textContent = "Restart"; + } else { + playLabel.textContent = "Play"; + } + } + + previousButton?.addEventListener("click", () => { + resetPlayback(); + showStep(currentStep - 1); + }); + nextButton?.addEventListener("click", () => { + resetPlayback(); + showStep(currentStep + 1); + }); + playButton?.addEventListener("click", () => { + if (isPlaying) pausePlaying(); + else startPlaying(); + }); + + root.classList.add("is-enhanced"); + showStep(0); +} + +function initializeTopology(root, stage, state, config) { + const overview = config.overview ?? { + category: "Overview", + label: config.title, + body: config.description, + }; + let pinnedItem; + + const items = [ + ...config.nodes.map((node) => ({ + ...node, + itemType: "node", + element: state.nodes.get(node.id), + })), + ...config.edges.map((edge) => ({ + ...edge, + itemType: "edge", + element: state.edges.get(edge.id), + })), + ].filter((item) => item.element); + + items.forEach((item) => { + const { element } = item; + element.setAttribute("role", "button"); + element.setAttribute("tabindex", "0"); + element.setAttribute("aria-label", `${item.label}. ${item.details}`); + element.setAttribute("aria-pressed", "false"); + + element.addEventListener("pointerenter", () => { + if (!pinnedItem) showItem(item); + }); + element.addEventListener("pointerleave", () => { + if (!pinnedItem && element !== document.activeElement) showOverview(); + }); + element.addEventListener("focus", () => { + if (!pinnedItem) showItem(item); + }); + element.addEventListener("blur", () => { + if (!pinnedItem) showOverview(); + }); + element.addEventListener("click", (event) => { + event.stopPropagation(); + if (pinnedItem?.itemType === item.itemType && pinnedItem.id === item.id) { + pinnedItem = undefined; + showOverview(); + } else { + pinnedItem = item; + showItem(item); + } + updatePressedState(); + }); + element.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + element.dispatchEvent(new MouseEvent("click", { bubbles: true })); + } + }); + }); + + stage.addEventListener("click", () => { + pinnedItem = undefined; + updatePressedState(); + showOverview(); + }); + root.addEventListener("keydown", (event) => { + if (event.key !== "Escape") return; + pinnedItem = undefined; + updatePressedState(); + showOverview(); + }); + + function showItem(item) { + const activeNodes = new Set(); + const relatedNodes = new Set(); + const activeEdges = new Set(); + + if (item.itemType === "node") { + activeNodes.add(item.id); + config.edges.forEach((edge) => { + if (edge.from !== item.id && edge.to !== item.id) return; + activeEdges.add(edge.id); + relatedNodes.add(edge.from === item.id ? edge.to : edge.from); + }); + } else { + activeEdges.add(item.id); + activeNodes.add(item.from); + activeNodes.add(item.to); + } + + root.classList.add("has-topology-focus"); + state.nodes.forEach((element, id) => { + element.classList.toggle("is-active", activeNodes.has(id)); + element.classList.toggle("is-related", relatedNodes.has(id)); + }); + state.edges.forEach((element, id) => { + element.classList.toggle("is-active", activeEdges.has(id)); + }); + setTopologyDetail(root, item.category, item.label, item.details); + } + + function showOverview() { + root.classList.remove("has-topology-focus"); + state.nodes.forEach((element) => { + element.classList.remove("is-active", "is-related"); + }); + state.edges.forEach((element) => element.classList.remove("is-active")); + setTopologyDetail(root, overview.category, overview.label, overview.body); + } + + function updatePressedState() { + items.forEach((item) => { + const pressed = + pinnedItem?.itemType === item.itemType && pinnedItem.id === item.id; + item.element.setAttribute("aria-pressed", String(pressed)); + item.element.classList.toggle("is-pinned", pressed); + }); + } + + root.classList.add("is-enhanced"); + showOverview(); +} + +function setTopologyDetail(root, category, label, body) { + setText(root, "[data-topology-category]", category); + setText(root, "[data-topology-title]", label); + setText(root, "[data-topology-body]", body); +} + +function buildDiagram(stage, config) { + const isTopology = config.type === "topology"; + const svg = createSvgElement("svg", { + viewBox: `0 0 ${config.canvas.width} ${config.canvas.height}`, + role: isTopology ? "group" : "img", + "aria-label": config.description, + }); + svg.classList.add("interactive-diagram__svg"); + + const definitions = createSvgElement("defs"); + const marker = createSvgElement("marker", { + id: `arrow-${Math.random().toString(36).slice(2)}`, + viewBox: "0 0 10 10", + refX: "8", + refY: "5", + markerWidth: "7", + markerHeight: "7", + orient: "auto-start-reverse", + }); + marker.append(createSvgElement("path", { d: "M 0 0 L 10 5 L 0 10 z" })); + definitions.append(marker); + svg.append(definitions); + + config.boundaries?.forEach((boundary) => { + const group = createSvgElement("g"); + group.classList.add( + "interactive-diagram__boundary", + `interactive-diagram__boundary--${boundary.kind}`, + ); + group.append( + createSvgElement("rect", { + x: boundary.x, + y: boundary.y, + width: boundary.width, + height: boundary.height, + rx: "8", + }), + createSvgText( + boundary.label, + boundary.x + 16, + boundary.y + 25, + "interactive-diagram__boundary-label", + ), + ); + svg.append(group); + }); + + const nodesById = new Map(config.nodes.map((node) => [node.id, node])); + const edges = new Map(); + config.edges.forEach((edge) => { + const from = nodesById.get(edge.from); + const to = nodesById.get(edge.to); + if (!from || !to) return; + const points = edgePoints(from, to, edge.offset); + const lineAttributes = { + x1: points.x1, + y1: points.y1, + x2: points.x2, + y2: points.y2, + "marker-end": `url(#${marker.id})`, + }; + if (edge.bidirectional) { + lineAttributes["marker-start"] = `url(#${marker.id})`; + } + + if (isTopology) { + const group = createSvgElement("g"); + group.classList.add( + "interactive-diagram__edge", + "interactive-diagram__edge--interactive", + `interactive-diagram__edge--${edge.kind}`, + ); + const line = createSvgElement("line", lineAttributes); + line.classList.add("interactive-diagram__edge-line"); + const hitTarget = createSvgElement("line", { + x1: points.x1, + y1: points.y1, + x2: points.x2, + y2: points.y2, + }); + hitTarget.classList.add("interactive-diagram__edge-hit"); + group.append(line, hitTarget); + + if (edge.label) { + const labelPoint = edgeTokenPoint( + points, + edge.labelProgress ?? 0.5, + edge.labelOffset ?? 0, + ); + const labelWidth = Math.max(68, edge.label.length * 6.6 + 20); + const labelGroup = createSvgElement("g"); + labelGroup.classList.add("interactive-diagram__edge-label"); + labelGroup.append( + createSvgElement("rect", { + x: labelPoint.x - labelWidth / 2, + y: labelPoint.y - 13, + width: labelWidth, + height: 26, + rx: "13", + }), + createSvgText( + edge.label, + labelPoint.x, + labelPoint.y + 4, + "interactive-diagram__edge-label-text", + ), + ); + group.append(labelGroup); + } + svg.append(group); + edges.set(edge.id, group); + } else { + const line = createSvgElement("line", lineAttributes); + line.classList.add("interactive-diagram__edge"); + svg.append(line); + edges.set(edge.id, line); + } + }); + + const nodes = new Map(); + config.nodes.forEach((node) => { + const group = createSvgElement("g"); + group.classList.add( + "interactive-diagram__node", + `interactive-diagram__node--${node.kind}`, + ); + group.append( + createSvgElement("rect", { + x: node.x, + y: node.y, + width: node.width, + height: node.height, + rx: "7", + }), + createSvgText( + node.label, + node.x + 14, + node.y + 29, + "interactive-diagram__node-label", + ), + createSvgText( + node.description, + node.x + 14, + node.y + 51, + "interactive-diagram__node-description", + ), + ); + svg.append(group); + nodes.set(node.id, group); + }); + + let token; + let tokenRect; + let tokenText; + if (!isTopology) { + token = createSvgElement("g"); + token.classList.add("interactive-diagram__token"); + tokenRect = createSvgElement("rect", { height: "28", rx: "14" }); + tokenText = createSvgText("", 0, 0, "interactive-diagram__token-label"); + token.append(tokenRect, tokenText); + svg.append(token); + } + stage.append(svg); + + return { + nodes, + edges, + nodesById, + token, + tokenRect, + tokenText, + tokenAnimation: undefined, + }; +} + +function positionToken(state, config, tokenConfig) { + state.tokenAnimation?.remove(); + state.tokenAnimation = undefined; + + if (!tokenConfig) { + state.token.hidden = true; + return; + } + + const width = Math.max(70, tokenConfig.label.length * 7.2 + 24); + let point; + let travelStart; + if (tokenConfig.node) { + const node = state.nodesById.get(tokenConfig.node); + if (node) { + point = nodeTokenPoint(node, width, tokenConfig.placement); + } + } else if (tokenConfig.edge) { + const edge = config.edges.find( + (candidate) => candidate.id === tokenConfig.edge, + ); + const from = state.nodesById.get(edge?.from); + const to = state.nodesById.get(edge?.to); + if (from && to) { + const points = edgePoints(from, to, edge.offset); + const progress = tokenConfig.progress ?? 0.5; + const labelOffset = tokenConfig.labelOffset ?? 0; + point = edgeTokenPoint(points, progress, labelOffset); + travelStart = edgeTokenPoint(points, 0, labelOffset); + } + } + if (!point) return; + + state.token.hidden = false; + state.token.setAttribute( + "transform", + `translate(${point.x - width / 2} ${point.y - 14})`, + ); + state.tokenRect.setAttribute("width", width); + state.tokenText.setAttribute("x", width / 2); + state.tokenText.setAttribute("y", "18"); + state.tokenText.textContent = tokenConfig.label; + state.token.classList.remove("is-entering"); + if (travelStart && !prefersReducedMotion()) { + animateTokenTravel(state, travelStart, point, width); + } else { + window.requestAnimationFrame(() => + state.token.classList.add("is-entering"), + ); + } +} + +function nodeTokenPoint(node, width, placement = "bottom") { + const gap = 8; + const halfHeight = 14; + const positions = { + top: { + x: node.x + node.width / 2, + y: node.y - gap - halfHeight, + }, + right: { + x: node.x + node.width + gap + width / 2, + y: node.y + node.height / 2, + }, + bottom: { + x: node.x + node.width / 2, + y: node.y + node.height + gap + halfHeight, + }, + left: { + x: node.x - gap - width / 2, + y: node.y + node.height / 2, + }, + }; + return positions[placement] ?? positions.bottom; +} + +function edgeTokenPoint(points, progress, labelOffset) { + const dx = points.x2 - points.x1; + const dy = points.y2 - points.y1; + const length = Math.hypot(dx, dy) || 1; + return { + x: points.x1 + dx * progress - (dy / length) * labelOffset, + y: points.y1 + dy * progress + (dx / length) * labelOffset, + }; +} + +function animateTokenTravel(state, from, to, width) { + const animation = createSvgElement("animateTransform", { + attributeName: "transform", + type: "translate", + from: `${from.x - width / 2} ${from.y - 14}`, + to: `${to.x - width / 2} ${to.y - 14}`, + dur: "650ms", + calcMode: "spline", + keyTimes: "0;1", + keySplines: "0.2 0 0 1", + }); + state.token.prepend(animation); + state.tokenAnimation = animation; + if (typeof animation.beginElement === "function") { + animation.beginElement(); + window.setTimeout(() => { + animation.remove(); + if (state.tokenAnimation === animation) state.tokenAnimation = undefined; + }, 700); + } else { + animation.remove(); + state.tokenAnimation = undefined; + } +} + +function prefersReducedMotion() { + return ( + window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false + ); +} + +function now() { + return window.performance?.now?.() ?? Date.now(); +} + +function edgePoints(from, to, offset = 0) { + const fromCenter = { + x: from.x + from.width / 2, + y: from.y + from.height / 2, + }; + const toCenter = { x: to.x + to.width / 2, y: to.y + to.height / 2 }; + const dx = toCenter.x - fromCenter.x; + const dy = toCenter.y - fromCenter.y; + if (Math.abs(dx) >= Math.abs(dy)) { + return { + x1: dx >= 0 ? from.x + from.width : from.x, + y1: fromCenter.y + offset, + x2: dx >= 0 ? to.x : to.x + to.width, + y2: toCenter.y + offset, + }; + } + return { + x1: fromCenter.x + offset, + y1: dy >= 0 ? from.y + from.height : from.y, + x2: toCenter.x + offset, + y2: dy >= 0 ? to.y : to.y + to.height, + }; +} + +function createSvgElement(name, attributes = {}) { + const element = document.createElementNS(svgNamespace, name); + Object.entries(attributes).forEach(([key, value]) => + element.setAttribute(key, value), + ); + return element; +} + +function createSvgText(value, x, y, className) { + const text = createSvgElement("text", { x, y }); + text.classList.add(className); + text.textContent = value; + return text; +} + +function setText(root, selector, value) { + const element = root.querySelector(selector); + if (element) element.textContent = value; +} diff --git a/content/manuals/ai/sandboxes/architecture.md b/content/manuals/ai/sandboxes/architecture.md index 88c8da4bdebe..fa7462e4b74e 100644 --- a/content/manuals/ai/sandboxes/architecture.md +++ b/content/manuals/ai/sandboxes/architecture.md @@ -61,6 +61,14 @@ proxy also handles [credential injection](configuration/credentials.md). See works and [Default security posture](security/defaults.md) for what is allowed out of the box. +### Follow an authenticated request + +The following diagram shows where Docker Sandboxes checks network policy and +replaces a sentinel credential with the real value. The real credential stays +outside the sandbox throughout the request. + +{{< interactive-diagram src="diagrams/credential-injection.yaml" >}} + ### Upstream proxy The host-side proxy makes its outbound connections using your host's network diff --git a/content/manuals/ai/sandboxes/diagrams/credential-injection.yaml b/content/manuals/ai/sandboxes/diagrams/credential-injection.yaml new file mode 100644 index 000000000000..f6b6adff99a4 --- /dev/null +++ b/content/manuals/ai/sandboxes/diagrams/credential-injection.yaml @@ -0,0 +1,146 @@ +title: Follow an authenticated request +description: Step through a request as Docker Sandboxes applies network policy and injects a credential outside the microVM. +autoplayDuration: 4000 +canvas: + width: 820 + height: 430 +boundaries: + - id: host + label: Host machine + x: 16 + y: 24 + width: 532 + height: 382 + kind: host + - id: sandbox + label: Sandbox microVM + x: 36 + y: 68 + width: 300 + height: 310 + kind: sandbox +nodes: + - id: agent + label: AI agent + description: Sends the API request + x: 65 + y: 122 + width: 168 + height: 76 + kind: process + - id: docker-engine + label: Docker Engine + description: Private to this sandbox + x: 65 + y: 267 + width: 126 + height: 64 + kind: context + - id: workspace + label: Workspace + description: Shared with the host + x: 200 + y: 267 + width: 126 + height: 64 + kind: context + - id: policy + label: Network policy + description: Checks the destination + x: 360 + y: 106 + width: 168 + height: 70 + kind: control + - id: proxy + label: Host-side proxy + description: Rewrites the auth header + x: 360 + y: 218 + width: 168 + height: 70 + kind: control + - id: credentials + label: Credential store + description: Real token stays here + x: 360 + y: 326 + width: 168 + height: 56 + kind: secret + - id: provider + label: Model provider + description: Receives the request + x: 648 + y: 192 + width: 156 + height: 84 + kind: external +edges: + - id: agent-policy + from: agent + to: policy + - id: policy-proxy + from: policy + to: proxy + - id: credentials-proxy + from: credentials + to: proxy + - id: proxy-provider + from: proxy + to: provider + offset: -7 + - id: provider-proxy + from: provider + to: proxy + offset: 7 +steps: + - label: The agent prepares the request + body: The agent sees a sentinel value instead of the real API credential. + activeNodes: [agent] + token: + node: agent + label: proxy-managed + placement: bottom + state: The sandbox sees only the proxy-managed sentinel. + - label: The request leaves the microVM + body: Outbound HTTP and HTTPS traffic crosses the sandbox boundary through the host network path. + activeNodes: [agent, policy] + activeEdges: [agent-policy] + token: + edge: agent-policy + label: proxy-managed + state: The real credential remains outside the microVM. + - label: Network policy checks the destination + body: The request continues only when an active policy permits the provider domain. + activeNodes: [policy] + activeEdges: [agent-policy] + token: + node: policy + label: allowed + placement: top + state: Network policy allows the provider destination. + - label: The proxy retrieves the credential + body: The host-side proxy resolves the matching credential without copying it into the sandbox. + activeNodes: [proxy, credentials] + activeEdges: [policy-proxy, credentials-proxy] + token: + edge: credentials-proxy + label: host lookup + state: The credential stays in the host credential store. + - label: The proxy rewrites the header + body: The proxy replaces the sentinel with the real credential after the request has left the microVM. + activeNodes: [proxy, provider] + activeEdges: [proxy-provider] + token: + edge: proxy-provider + label: Bearer •••••• + state: The provider receives the rewritten authorization header. + - label: The response returns + body: The provider response returns through the host proxy to the agent. The credential remains on the host. + activeNodes: [provider, proxy, agent] + activeEdges: [provider-proxy] + token: + edge: provider-proxy + label: 200 OK + state: The credential remains on the host after the response. diff --git a/content/manuals/ai/sandboxes/diagrams/trust-boundary-topology.yaml b/content/manuals/ai/sandboxes/diagrams/trust-boundary-topology.yaml new file mode 100644 index 000000000000..b129b23c9a22 --- /dev/null +++ b/content/manuals/ai/sandboxes/diagrams/trust-boundary-topology.yaml @@ -0,0 +1,123 @@ +type: topology +title: Explore the microVM isolation boundary +description: Select a component or labeled connection to inspect what is private, mounted, or unreachable. +canvas: + width: 720 + height: 520 +overview: + category: Isolation overview + label: The agent is contained inside the microVM + body: The agent controls the private VM filesystem and Docker Engine. The workspace, optional skills store, and forwarded SSH agent socket are explicit host connections; other host resources have no direct access path. +boundaries: + - id: host + label: Host machine + x: 12 + y: 18 + width: 696 + height: 484 + kind: host + - id: sandbox + label: Sandbox microVM + x: 34 + y: 56 + width: 350 + height: 426 + kind: sandbox +nodes: + - id: agent + label: AI agent + description: Full control in the microVM + details: The agent has sudo access inside the microVM. It can install packages, run code, and modify resources that are inside the sandbox boundary. + category: Inside the microVM + x: 90 + y: 165 + width: 180 + height: 72 + kind: process + - id: vm-filesystem + label: VM filesystem + description: Private to this sandbox + details: The microVM has its own persistent filesystem. The agent cannot use it to browse files elsewhere on the host. + category: Inside the microVM + x: 50 + y: 370 + width: 158 + height: 72 + kind: context + - id: sandbox-engine + label: Docker Engine + description: No host connection + details: Containers created by the agent run on the Docker Engine inside the microVM. This engine has no connection to the Docker daemon on the host. + category: Inside the microVM + x: 218 + y: 370 + width: 145 + height: 72 + kind: control + - id: workspace + label: Workspace + description: Mounted from the host + details: Direct mode mounts the host working tree read-write, so edits are visible on both sides. Clone mode mounts the host repository read-only and gives the agent a private clone. + category: Explicitly shared + x: 445 + y: 68 + width: 205 + height: 72 + kind: shared + - id: skills + label: Shared skills store + description: Optional shared mount + details: Supported agents mount the host-side skills store read-write unless you opt out. Changes can become visible to agents in other participating sandboxes. + category: Explicitly shared + x: 445 + y: 170 + width: 205 + height: 72 + kind: shared + - id: ssh-agent + label: SSH agent socket + description: Forwarded by default + details: When SSH_AUTH_SOCK is set on the host, Docker Sandboxes forwards the agent socket into the microVM. Sandbox processes can request signatures, but the private keys stay on the host. + category: Explicitly shared + x: 445 + y: 272 + width: 205 + height: 72 + kind: shared + - id: host-resources + label: Other host resources + description: No direct access + details: The agent has no direct path to the rest of the host filesystem, host processes, or the host Docker daemon. + category: Outside the microVM + x: 445 + y: 390 + width: 205 + height: 72 + kind: restricted +edges: + - id: workspace-mount + from: agent + to: workspace + label: Workspace mount + details: This explicit mount is the agent's path to project files on the host. Its permissions depend on whether the sandbox uses direct mode or clone mode. + category: Shared relationship + kind: shared + bidirectional: true + labelOffset: -4 + - id: skills-mount + from: agent + to: skills + label: Skills mount + details: This optional read-write mount connects supported agents to a persistent skills store shared through the host. + category: Shared relationship + kind: shared + bidirectional: true + - id: ssh-agent-mount + from: agent + to: ssh-agent + label: SSH agent socket + details: The forwarded socket gives sandbox processes access to signing operations through the host SSH agent without exposing the private keys. + category: Shared relationship + kind: shared + bidirectional: true + labelOffset: 4 diff --git a/content/manuals/ai/sandboxes/images/sbx-security.png b/content/manuals/ai/sandboxes/images/sbx-security.png deleted file mode 100644 index 0f86c436c507..000000000000 Binary files a/content/manuals/ai/sandboxes/images/sbx-security.png and /dev/null differ diff --git a/content/manuals/ai/sandboxes/security/_index.md b/content/manuals/ai/sandboxes/security/_index.md index 5af246133cf2..c94faf14b752 100644 --- a/content/manuals/ai/sandboxes/security/_index.md +++ b/content/manuals/ai/sandboxes/security/_index.md @@ -53,7 +53,15 @@ sandbox VM. An agent can invoke the tools those servers expose through the MCP gateway, subject to MCP policies when organization governance is active. Treat local MCP servers as trusted host integrations. - +### MicroVM isolation + +This topology shows what is private to the microVM, what is explicitly mounted, +and which host resources remain outside the agent's reach. + +{{< interactive-diagram src="../diagrams/trust-boundary-topology.yaml" >}} + +To follow an outbound request through network policy and credential injection, +see [Architecture](../architecture.md#follow-an-authenticated-request). ## Isolation layers diff --git a/layouts/_partials/interactive-diagram-script.html b/layouts/_partials/interactive-diagram-script.html new file mode 100644 index 000000000000..84c0ec2fa443 --- /dev/null +++ b/layouts/_partials/interactive-diagram-script.html @@ -0,0 +1,4 @@ +{{ $diagram := resources.Get "js/interactive-diagram.js" + | js.Build (dict "minify" true "targetPath" "interactive-diagram.js") +}} + diff --git a/layouts/_shortcodes/interactive-diagram.html b/layouts/_shortcodes/interactive-diagram.html new file mode 100644 index 000000000000..eb77a8bd8006 --- /dev/null +++ b/layouts/_shortcodes/interactive-diagram.html @@ -0,0 +1,126 @@ +{{- $src := .Get "src" -}} +{{- if not $src -}} + {{- errorf "interactive-diagram shortcode: missing src: %s" .Position -}} +{{- end -}} +{{- $filePath := path.Join .Page.File.Dir $src -}} +{{- if not (fileExists $filePath) -}} + {{- errorf "interactive-diagram shortcode: file %q not found: %s" $filePath .Position -}} +{{- end -}} +{{- $diagram := readFile $filePath | transform.Unmarshal -}} +{{- $type := $diagram.type | default "sequence" -}} +{{- $id := printf "interactive-diagram-%s-%d" .Page.File.UniqueID .Ordinal -}} +{{- .Page.Store.Set "interactive-diagram" true -}} + + + + + {{ $diagram.title }} + + {{ $diagram.description }} + + {{- if eq $type "sequence" }} + + Play + + {{- end }} + + + {{- if eq $type "topology" }} + + + {{ $diagram.overview.category }} + + + + {{ $diagram.overview.label }} + + + {{ $diagram.overview.body }} + + + + {{- else }} + + + + + + + + + + + + + + + + Previous + + + + Next + + + {{- end }} + + {{- if eq $type "topology" }} + + {{- range $diagram.nodes }} + {{ .label }}: {{ .details }} + {{- end }} + {{- range $diagram.edges }} + {{ .label }}: {{ .details }} + {{- end }} + + {{- else }} + + {{- range $diagram.steps }} + {{ .label }}: {{ .body }} + {{- end }} + + {{- end }} + + + diff --git a/layouts/_shortcodes/interactive-diagram.markdown.md b/layouts/_shortcodes/interactive-diagram.markdown.md new file mode 100644 index 000000000000..c50b4317a993 --- /dev/null +++ b/layouts/_shortcodes/interactive-diagram.markdown.md @@ -0,0 +1,13 @@ +{{- $src := .Get "src" -}} +{{- $filePath := path.Join .Page.File.Dir $src -}} +{{- if not (fileExists $filePath) -}} + {{- errorf "interactive-diagram shortcode: file %q not found: %s" $filePath .Position -}} +{{- end -}} +{{- $diagram := readFile $filePath | transform.Unmarshal -}} +{{ $diagram.title }} + +{{ $diagram.description }} + +{{ range $index, $step := $diagram.steps -}} +{{ add $index 1 }}. {{ $step.label }}: {{ $step.body }} +{{ end -}} diff --git a/layouts/baseof.html b/layouts/baseof.html index 823d4a087912..b862091a975e 100644 --- a/layouts/baseof.html +++ b/layouts/baseof.html @@ -95,5 +95,9 @@ {{ with .Store.Get "mermaid" }} {{ partialCached "mermaid-script.html" "-" "-" }} {{ end }} + {{/* Load interactive diagrams only on pages that contain the shortcode */}} + {{ with .Store.Get "interactive-diagram" }} + {{ partialCached "interactive-diagram-script.html" "-" "-" }} + {{ end }}
+ {{ $diagram.title }} +
{{ $diagram.description }}
+ {{ $diagram.overview.category }} +
+ {{ $diagram.overview.label }} +
+ {{ $diagram.overview.body }} +