From d48ff7658510fffafe4e07bfd9c8e314d71e288e Mon Sep 17 00:00:00 2001 From: jwinterm Date: Tue, 7 Jul 2026 11:10:14 -0400 Subject: [PATCH] Add AMPGateway visual site --- README.md | 2 + examples/ampgateway-online.gateway.toml | 76 ++++ sites/ampgateway-online/app.js | 248 ++++++++++++ sites/ampgateway-online/index.html | 177 ++++++++ sites/ampgateway-online/styles.css | 512 ++++++++++++++++++++++++ 5 files changed, 1015 insertions(+) create mode 100644 examples/ampgateway-online.gateway.toml create mode 100644 sites/ampgateway-online/app.js create mode 100644 sites/ampgateway-online/index.html create mode 100644 sites/ampgateway-online/styles.css diff --git a/README.md b/README.md index 4bb8eb2..bccf8a3 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,8 @@ API remains internal even when the public site is available on Tor or I2P. - [Interactive applications](docs/interactive-apps.md) - [Wownero integration](docs/wownero-integration.md) - [Autodocs and drift gates](docs/autodocs.md) +- [AMPGateway visual site source](sites/ampgateway-online/index.html) +- [AMPGateway visual site config](examples/ampgateway-online.gateway.toml) ## Checks diff --git a/examples/ampgateway-online.gateway.toml b/examples/ampgateway-online.gateway.toml new file mode 100644 index 0000000..cac04c9 --- /dev/null +++ b/examples/ampgateway-online.gateway.toml @@ -0,0 +1,76 @@ +[gateway] +state_dir = "./.ampg/ampgateway-online/state" +cache_dir = "./.ampg/ampgateway-online/cache" +run_dir = "./.ampg/ampgateway-online/run" +user = "ampg" + +[profiles.visual-vps] +description = "Full VPS deployment for the AMPGateway visual docs site." +protocols = ["clearnet", "tor", "i2p", "gemini"] +platform = "linux-systemd" +write_artifacts = true + +[profiles.visual-lan] +description = "Clearnet-only LAN deployment for the AMPGateway visual docs site." +protocols = ["clearnet"] +platform = "linux-user" + +[[site]] +id = "ampgateway-online" +domain = "ampgateway.online" + +[site.source] +kind = "static-html" +path = "../sites/ampgateway-online" +canonical_url = "https://ampgateway.online" + +[site.outputs] +root = "../dist/ampgateway-online" +plan_root = "../dist/ampg-plan" + +[site.interactions] +default_tier = "static" +deny_routes = ["/admin/*", "/internal/*", "/webhooks/*"] + +[site.protocols.clearnet] +enabled = true +renderer = "clearnet" +daemon = "nginx" +daemon_policy = "external" + +[site.protocols.tor] +enabled = true +renderer = "privacy-html" +daemon = "tor" +daemon_policy = "auto" +local_port = 18180 +hidden_service_dir = "/var/lib/ampg/tor/ampgateway-online" +onion_location = "auto" +max_asset_bytes = 1048576 +script_policy = "strip" + +[site.protocols.i2p] +enabled = true +renderer = "privacy-html" +daemon = "i2pd" +daemon_policy = "auto" +local_port = 18181 +keys_file = "ampgateway-online-web.dat" +tunnel_name = "ampgateway-online-web" +max_asset_bytes = 1048576 +script_policy = "strip" + +[site.protocols.gemini] +enabled = true +renderer = "gemtext" +daemon = "agate" +daemon_policy = "auto" +port = 1965 +max_asset_bytes = 1048576 + +[site.protocols.reticulum] +enabled = false +renderer = "micron" +daemon = "rnsd" +daemon_policy = "auto" +aspect = "web" diff --git a/sites/ampgateway-online/app.js b/sites/ampgateway-online/app.js new file mode 100644 index 0000000..23ecc06 --- /dev/null +++ b/sites/ampgateway-online/app.js @@ -0,0 +1,248 @@ +(function () { + const canvas = document.getElementById("topologyCanvas"); + const caption = document.getElementById("topologyCaption"); + const buttons = Array.from(document.querySelectorAll("[data-topology]")); + const signalWord = document.getElementById("signalWord"); + + if (!canvas || !caption || buttons.length === 0) { + return; + } + + const ctx = canvas.getContext("2d"); + if (!ctx) { + return; + } + + const palette = { + text: "#f6f1e4", + muted: "rgba(246, 241, 228, 0.52)", + line: "rgba(246, 241, 228, 0.18)", + teal: "#39d4c5", + green: "#8fe388", + amber: "#f0b84a", + coral: "#f06f5f" + }; + + const topologies = { + vps: { + caption: "A VPS can publish clearnet, Tor, I2P, and Gemini outputs from one AMPG plan.", + nodes: [ + ["source", "source site", 0.16, 0.42, "amber"], + ["ampg", "AMPG", 0.34, 0.42, "teal"], + ["vps", "VPS", 0.52, 0.42, "green"], + ["web", "clearnet", 0.72, 0.22, "teal"], + ["tor", "Tor", 0.78, 0.39, "coral"], + ["i2p", "I2P", 0.76, 0.56, "amber"], + ["gemini", "Gemini", 0.68, 0.72, "green"] + ], + links: [ + ["source", "ampg"], + ["ampg", "vps"], + ["vps", "web"], + ["vps", "tor"], + ["vps", "i2p"], + ["vps", "gemini"] + ] + }, + router: { + caption: "Behind a router, clearnet may need DNS and port choices; Tor and I2P can avoid inbound port forwarding.", + nodes: [ + ["visitor", "visitor", 0.13, 0.33, "green"], + ["dns", "DNS/DDNS", 0.3, 0.24, "amber"], + ["router", "router", 0.45, 0.38, "coral"], + ["host", "home host", 0.61, 0.38, "teal"], + ["ampg", "AMPG", 0.76, 0.38, "teal"], + ["tor", "Tor", 0.64, 0.66, "coral"], + ["i2p", "I2P", 0.82, 0.66, "amber"] + ], + links: [ + ["visitor", "dns"], + ["dns", "router"], + ["router", "host"], + ["host", "ampg"], + ["ampg", "tor"], + ["ampg", "i2p"], + ["visitor", "tor"], + ["visitor", "i2p"] + ] + }, + phone: { + caption: "A spare Android phone can run Termux packages, AMPG state, and user-space transport services.", + nodes: [ + ["phone", "Android", 0.24, 0.45, "green"], + ["pkg", "pkg", 0.43, 0.26, "amber"], + ["ampg", "AMPG", 0.45, 0.45, "teal"], + ["state", "state", 0.43, 0.64, "coral"], + ["services", "services", 0.64, 0.45, "green"], + ["routes", "routes", 0.82, 0.45, "teal"] + ], + links: [ + ["phone", "pkg"], + ["phone", "ampg"], + ["phone", "state"], + ["pkg", "services"], + ["ampg", "services"], + ["state", "services"], + ["services", "routes"] + ] + }, + browser: { + caption: "Fixture manifests let AMPB or another checker verify every route through the expected transport profile.", + nodes: [ + ["site", "site", 0.17, 0.5, "amber"], + ["ampg", "AMPG", 0.33, 0.5, "teal"], + ["manifest", "manifest", 0.5, 0.5, "green"], + ["ampb", "AMPB", 0.66, 0.5, "coral"], + ["transport", "transport", 0.82, 0.5, "teal"] + ], + links: [ + ["site", "ampg"], + ["ampg", "manifest"], + ["manifest", "ampb"], + ["ampb", "transport"], + ["transport", "ampb"] + ] + } + }; + + const words = ["clearnet", "Tor", "I2P", "Gemini", "IPFS", "Reticulum"]; + let wordIndex = 0; + let active = "vps"; + let width = 0; + let height = 0; + let start = performance.now(); + + function resize() { + const rect = canvas.getBoundingClientRect(); + const ratio = Math.min(window.devicePixelRatio || 1, 2); + width = Math.max(1, rect.width); + height = Math.max(1, rect.height); + canvas.width = Math.floor(width * ratio); + canvas.height = Math.floor(height * ratio); + ctx.setTransform(ratio, 0, 0, ratio, 0, 0); + } + + function nodeMap(topology) { + return new Map(topology.nodes.map((node) => [node[0], node])); + } + + function draw(now) { + const topology = topologies[active]; + const nodes = nodeMap(topology); + const tick = (now - start) / 1000; + ctx.clearRect(0, 0, width, height); + drawField(tick); + topology.links.forEach((link, index) => drawLink(nodes.get(link[0]), nodes.get(link[1]), tick, index)); + topology.nodes.forEach((node, index) => drawNode(node, tick, index)); + requestAnimationFrame(draw); + } + + function drawField(tick) { + ctx.save(); + ctx.globalAlpha = 0.38; + for (let i = 0; i < 36; i += 1) { + const x = ((i * 137 + tick * 16) % (width + 120)) - 60; + const y = ((i * 71 + tick * 9) % (height + 120)) - 60; + ctx.fillStyle = i % 3 === 0 ? palette.teal : i % 3 === 1 ? palette.amber : palette.green; + ctx.beginPath(); + ctx.arc(x, y, i % 2 === 0 ? 1.6 : 1, 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); + } + + function drawLink(from, to, tick, index) { + if (!from || !to) return; + const ax = from[2] * width; + const ay = from[3] * height; + const bx = to[2] * width; + const by = to[3] * height; + ctx.save(); + ctx.strokeStyle = palette.line; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(ax, ay); + ctx.lineTo(bx, by); + ctx.stroke(); + + const progress = (tick * 0.22 + index * 0.13) % 1; + const px = ax + (bx - ax) * progress; + const py = ay + (by - ay) * progress; + ctx.fillStyle = index % 2 === 0 ? palette.teal : palette.amber; + ctx.beginPath(); + ctx.arc(px, py, 3.5, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } + + function drawNode(node, tick, index) { + const [, label, nx, ny, colorKey] = node; + const x = nx * width; + const y = ny * height; + const pulse = Math.sin(tick * 2.2 + index) * 2; + const color = palette[colorKey] || palette.teal; + ctx.save(); + ctx.shadowColor = color; + ctx.shadowBlur = 20; + ctx.fillStyle = "rgba(16, 16, 15, 0.86)"; + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.roundRect(x - 58, y - 23, 116, 46, 8); + ctx.fill(); + ctx.stroke(); + ctx.shadowBlur = 0; + ctx.fillStyle = palette.text; + ctx.font = "700 13px system-ui, sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(label, x, y + pulse * 0.25); + ctx.restore(); + } + + function setTopology(name) { + if (!topologies[name]) return; + active = name; + caption.textContent = topologies[name].caption; + buttons.forEach((button) => { + const selected = button.dataset.topology === name; + button.classList.toggle("active", selected); + button.setAttribute("aria-pressed", selected ? "true" : "false"); + }); + } + + buttons.forEach((button) => { + button.addEventListener("click", () => setTopology(button.dataset.topology)); + }); + + if (signalWord) { + window.setInterval(() => { + wordIndex = (wordIndex + 1) % words.length; + signalWord.textContent = words[wordIndex]; + signalWord.classList.remove("swap"); + void signalWord.offsetWidth; + signalWord.classList.add("swap"); + }, 1800); + } + + if (!CanvasRenderingContext2D.prototype.roundRect) { + CanvasRenderingContext2D.prototype.roundRect = function (x, y, w, h, r) { + this.beginPath(); + this.moveTo(x + r, y); + this.lineTo(x + w - r, y); + this.quadraticCurveTo(x + w, y, x + w, y + r); + this.lineTo(x + w, y + h - r); + this.quadraticCurveTo(x + w, y + h, x + w - r, y + h); + this.lineTo(x + r, y + h); + this.quadraticCurveTo(x, y + h, x, y + h - r); + this.lineTo(x, y + r); + this.quadraticCurveTo(x, y, x + r, y); + this.closePath(); + }; + } + + window.addEventListener("resize", resize); + resize(); + setTopology(active); + requestAnimationFrame(draw); +})(); diff --git a/sites/ampgateway-online/index.html b/sites/ampgateway-online/index.html new file mode 100644 index 0000000..ee0c47f --- /dev/null +++ b/sites/ampgateway-online/index.html @@ -0,0 +1,177 @@ + + + + + + AMPGateway + + + + + + + + +
+
+ +
+

Adaptive Multi-Protocol Gateway

+

AMPGateway

+

+ One site source, many transport outputs, one staged deploy spine from package + install to published health checks. +

+

+ publish over + clearnet + without losing the map. +

+ +
+
+
Topology
+
+ + + + +
+

+ A VPS can publish clearnet, Tor, I2P, and Gemini outputs from one AMPG plan. +

+
+
+ +
+
+

Network topologies

+

Deploy from a clean VPS, a laptop behind a router, or an old phone.

+
+
+
+

Full VPS

+

+ ampgateway.site is the target for a full public example with + clearnet, Tor, I2P, and Gemini routes managed from one deploy plan. +

+
+
+

Behind router

+

+ ampgateway.website is the target for a home or lab deployment + that shows Dynamic DNS, router constraints, and transports that avoid + inbound ports. +

+
+
+

Mobile server

+

+ Android and Termux can run user-space daemons for small static sites, + demos, and transport experiments on hardware that would otherwise sit idle. +

+
+
+

Single transport

+

+ AMPG can deploy only Tor, only I2P, only Gemini, or a selected subset when + the operator wants a narrow surface. +

+
+
+
+ +
+
+

Example domains

+

Three public names, three jobs.

+
+
+ +
+

VPS example

+

ampgateway.site

+

+ Planned all-transport reference deployment on a normal server, useful for + showing the complete package, state, supervisor, start, address, and health path. +

+ ampgateway.site +
+
+

Behind-router example

+

ampgateway.website

+

+ Planned home-network reference deployment for Dynamic DNS, port-forwarding + choices, and transports that do not need clearnet inbound reachability. +

+ ampgateway.website +
+
+
+ +
+
+

Deploy spine

+

Narrow stages, visible state, reversible thinking.

+
+
    +
  1. Build

    Generate clearnet, privacy HTML, Gemtext, and other selected outputs.

  2. +
  3. Packages

    Install selected daemon packages with allowlisted platform commands.

  4. +
  5. State

    Copy approved generated config into AMPG-owned state.

  6. +
  7. Supervisor

    Install AMPG-named service files after config exists.

  8. +
  9. Start

    Start AMPG-owned services with structured platform commands.

  10. +
  11. Addresses

    Capture daemon-written onion, I2P, or other public identities.

  12. +
  13. Health

    Verify published fixture URLs through the selected transport profiles.

  14. +
+
+ +
+
+

Public docs

+

The visual site stays tied to the public spec.

+

+ The source for this page lives in the AMPG repository and can be rendered by + AMPG like any other static site. The Markdown docs stay concise; this site + turns the same concepts into topology-first views. +

+
+ +
+
+ + + + diff --git a/sites/ampgateway-online/styles.css b/sites/ampgateway-online/styles.css new file mode 100644 index 0000000..1768b1e --- /dev/null +++ b/sites/ampgateway-online/styles.css @@ -0,0 +1,512 @@ +:root { + color-scheme: dark; + --bg: #10100f; + --panel: #1b1c1a; + --panel-strong: #25241f; + --text: #f6f1e4; + --muted: #b8b0a0; + --line: rgba(246, 241, 228, 0.18); + --teal: #39d4c5; + --green: #8fe388; + --amber: #f0b84a; + --coral: #f06f5f; + --ink: #080808; + --max: 1160px; +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + background: + linear-gradient(rgba(246, 241, 228, 0.035) 1px, transparent 1px), + linear-gradient(90deg, rgba(246, 241, 228, 0.03) 1px, transparent 1px), + var(--bg); + background-size: 36px 36px; + color: var(--text); + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; + line-height: 1.5; +} + +a { + color: inherit; +} + +.skip-link { + position: fixed; + top: 12px; + left: 12px; + z-index: 20; + padding: 8px 10px; + background: var(--text); + color: var(--ink); + transform: translateY(-140%); +} + +.skip-link:focus { + transform: translateY(0); +} + +.site-header { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 10; + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + padding: 18px clamp(18px, 4vw, 48px); + background: rgba(16, 16, 15, 0.78); + border-bottom: 1px solid var(--line); + backdrop-filter: blur(18px); +} + +.brand, +.top-nav { + display: flex; + align-items: center; +} + +.brand { + gap: 10px; + font-weight: 800; + text-decoration: none; +} + +.brand-mark { + width: 18px; + height: 18px; + border: 2px solid var(--teal); + box-shadow: inset 0 0 0 4px var(--bg), 0 0 18px rgba(57, 212, 197, 0.4); +} + +.top-nav { + gap: clamp(10px, 2vw, 22px); + color: var(--muted); + font-size: 0.95rem; +} + +.top-nav a { + text-decoration: none; +} + +.top-nav a:hover, +.top-nav a:focus-visible { + color: var(--text); +} + +.hero { + position: relative; + min-height: min(780px, 88vh); + overflow: hidden; + border-bottom: 1px solid var(--line); +} + +#topologyCanvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.hero::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: linear-gradient(90deg, rgba(16, 16, 15, 0.94), rgba(16, 16, 15, 0.58) 48%, rgba(16, 16, 15, 0.2)); +} + +.hero-inner { + position: relative; + z-index: 1; + max-width: var(--max); + margin: 0 auto; + padding: clamp(118px, 16vh, 168px) clamp(18px, 4vw, 48px) 160px; +} + +.eyebrow, +.domain-label, +.mode-label { + margin: 0 0 12px; + color: var(--amber); + font-size: 0.76rem; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +h1, +h2, +h3, +p { + margin-top: 0; +} + +h1 { + max-width: 760px; + margin-bottom: 16px; + font-size: clamp(3.8rem, 10vw, 8.5rem); + line-height: 0.9; + letter-spacing: 0; +} + +h2 { + max-width: 780px; + margin-bottom: 22px; + font-size: clamp(2rem, 4vw, 4.25rem); + line-height: 1.02; + letter-spacing: 0; +} + +h3 { + margin-bottom: 10px; + font-size: 1.16rem; + letter-spacing: 0; +} + +.hero-copy { + max-width: 680px; + color: var(--muted); + font-size: clamp(1.08rem, 2vw, 1.42rem); +} + +.signal-line { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: baseline; + max-width: 680px; + color: var(--text); + font-size: clamp(1rem, 1.8vw, 1.28rem); +} + +.signal-word { + color: var(--teal); + font-weight: 800; +} + +.signal-word.swap { + animation: text-scan 420ms ease both; +} + +@keyframes text-scan { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.hero-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 34px; +} + +.button { + display: inline-flex; + align-items: center; + min-height: 44px; + padding: 10px 16px; + border: 1px solid var(--line); + border-radius: 6px; + font-weight: 800; + text-decoration: none; +} + +.button.primary { + background: var(--teal); + color: var(--ink); + border-color: var(--teal); +} + +.button.secondary { + background: rgba(246, 241, 228, 0.06); +} + +.topology-panel { + position: absolute; + right: clamp(18px, 4vw, 48px); + bottom: 30px; + z-index: 2; + width: min(440px, calc(100% - 36px)); + padding: 16px; + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(27, 28, 26, 0.82); + backdrop-filter: blur(18px); +} + +.segmented { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 6px; +} + +.mode-button { + min-height: 38px; + border: 1px solid var(--line); + border-radius: 6px; + background: rgba(246, 241, 228, 0.05); + color: var(--text); + font: inherit; + font-size: 0.9rem; + font-weight: 800; + cursor: pointer; +} + +.mode-button.active, +.mode-button:focus-visible { + background: var(--amber); + color: var(--ink); + border-color: var(--amber); +} + +.topology-caption { + min-height: 48px; + margin: 14px 0 0; + color: var(--muted); + font-size: 0.95rem; +} + +.section { + width: min(var(--max), calc(100% - 36px)); + margin: 0 auto; + padding: clamp(64px, 10vw, 112px) 0; +} + +.split { + display: grid; + grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr); + gap: clamp(28px, 6vw, 72px); + align-items: start; +} + +.topology-grid, +.domain-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.topology-grid article, +.domain-card { + min-height: 190px; + padding: 20px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); +} + +.topology-grid article:nth-child(2), +.domain-card.featured { + background: var(--panel-strong); +} + +.topology-grid p, +.domain-card p, +.docs-section p, +.flow-list p { + color: var(--muted); +} + +.section-heading { + margin-bottom: 28px; +} + +.domain-grid { + grid-template-columns: 1.2fr 1fr 1fr; +} + +.domain-card { + display: flex; + flex-direction: column; +} + +.domain-card h3 { + font-size: clamp(1.35rem, 2.2vw, 2rem); + word-break: break-word; +} + +.domain-card a { + margin-top: auto; + color: var(--teal); + font-weight: 800; + text-decoration: none; +} + +.domain-card a:hover, +.domain-card a:focus-visible { + text-decoration: underline; +} + +.flow-list { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 10px; + padding: 0; + list-style: none; + counter-reset: flow; +} + +.flow-list li { + position: relative; + min-height: 220px; + padding: 18px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + counter-increment: flow; +} + +.flow-list li::before { + content: counter(flow, decimal-leading-zero); + display: block; + margin-bottom: 26px; + color: var(--coral); + font-weight: 900; +} + +.flow-list span { + display: block; + margin-bottom: 10px; + font-weight: 900; +} + +.docs-section { + display: grid; + grid-template-columns: minmax(0, 1fr) 360px; + gap: clamp(28px, 6vw, 72px); + align-items: start; +} + +.doc-links { + display: grid; + gap: 10px; +} + +.doc-links a { + display: flex; + align-items: center; + min-height: 48px; + padding: 12px 14px; + border: 1px solid var(--line); + border-radius: 6px; + background: rgba(246, 241, 228, 0.05); + color: var(--text); + font-weight: 800; + text-decoration: none; +} + +.doc-links a:hover, +.doc-links a:focus-visible { + border-color: var(--teal); +} + +.site-footer { + display: flex; + justify-content: space-between; + gap: 20px; + padding: 28px clamp(18px, 4vw, 48px); + border-top: 1px solid var(--line); + color: var(--muted); +} + +.site-footer p { + margin: 0; +} + +@media (max-width: 960px) { + .top-nav { + display: none; + } + + .hero { + min-height: 860px; + } + + .hero::after { + background: linear-gradient(rgba(16, 16, 15, 0.94), rgba(16, 16, 15, 0.48)); + } + + .hero-inner { + padding-bottom: 280px; + } + + .split, + .docs-section { + grid-template-columns: 1fr; + } + + .domain-grid, + .topology-grid { + grid-template-columns: 1fr; + } + + .flow-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 640px) { + .site-header { + padding: 14px 18px; + } + + h1 { + font-size: clamp(3rem, 18vw, 5rem); + } + + .hero-actions, + .signal-line { + display: grid; + } + + .button, + .mode-button { + width: 100%; + justify-content: center; + } + + .topology-panel { + bottom: 18px; + } + + .segmented, + .flow-list { + grid-template-columns: 1fr; + } + + .flow-list li { + min-height: 0; + } + + .site-footer { + display: grid; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 1ms !important; + scroll-behavior: auto !important; + transition-duration: 1ms !important; + } +}