From 4cf7655efe7dec5c4e4b6252f7c3c48a12481c40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20S=C3=A4ck?= Date: Fri, 21 Aug 2026 19:18:23 +0200 Subject: [PATCH] docs: add interactive ControlPlane lifecycle demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a 7-step browser-based interactive demo to the Concepts section that walks users through the full ManagedControlPlane provisioning flow: - Steps 1-3: architecture diagram builds up box-by-box - Step 4: ManagedControlPlane YAML shown - Step 5: user types kubectl apply, animated balls show reconciliation flow - Step 6: user types kubectl get deployments on the new ControlPlane - Step 7: CTA linking to the local quickstart Components: ControlPlaneDemo (index, ArchDiagram, InteractiveTerminal, FakeTerminal, demo-config, styles) Signed-off-by: Jan Säck --- docs/users/concepts/controlplane-demo.mdx | 37 ++ .../ControlPlaneDemo/ArchDiagram.js | 197 +++++++++ .../ControlPlaneDemo/FakeTerminal.js | 113 +++++ .../ControlPlaneDemo/InteractiveTerminal.js | 126 ++++++ .../ControlPlaneDemo/demo-config.js | 12 + src/components/ControlPlaneDemo/index.js | 240 ++++++++++ .../ControlPlaneDemo/styles.module.css | 409 ++++++++++++++++++ 7 files changed, 1134 insertions(+) create mode 100644 docs/users/concepts/controlplane-demo.mdx create mode 100644 src/components/ControlPlaneDemo/ArchDiagram.js create mode 100644 src/components/ControlPlaneDemo/FakeTerminal.js create mode 100644 src/components/ControlPlaneDemo/InteractiveTerminal.js create mode 100644 src/components/ControlPlaneDemo/demo-config.js create mode 100644 src/components/ControlPlaneDemo/index.js create mode 100644 src/components/ControlPlaneDemo/styles.module.css diff --git a/docs/users/concepts/controlplane-demo.mdx b/docs/users/concepts/controlplane-demo.mdx new file mode 100644 index 0000000..be995a0 --- /dev/null +++ b/docs/users/concepts/controlplane-demo.mdx @@ -0,0 +1,37 @@ +--- +sidebar_position: 10 +id: controlplane-demo +title: Interactive Demo — ControlPlane +--- + +import ControlPlaneDemo from '@site/src/components/ControlPlaneDemo'; + +# Interactive Demo: ControlPlane Lifecycle + +Experience how **OpenControlPlane** provisions and manages a `ControlPlane` — entirely in your browser, no cluster required. + +This demo walks you through the full lifecycle: + +1. **Apply** a `ControlPlane` manifest to the Onboarding Cluster +2. **Watch** the operator reconcile and delegate to the Cluster Provider +3. **See** the new ControlPlane cluster come up +4. **Connect** and start deploying your resources + +--- + + + +--- + +## What just happened? + +- You wrote a **single manifest** declaring the desired state of a `ControlPlane`. +- The **openmcp-operator** on the Platform Cluster picked it up and orchestrated cluster creation via the configured Cluster Provider (e.g. Gardener). +- Once ready, a dedicated Kubernetes cluster was spun up — your **ControlPlane** — isolated from other tenants. +- You connected to it directly using `kubectl` with the context written by the operator. + +## Next steps + +- [ControlPlane CRD reference](/reference/core/controlplane) +- [Getting started guide](/users/getting-started) +- [Configure a Cluster Provider](/operators/overview) diff --git a/src/components/ControlPlaneDemo/ArchDiagram.js b/src/components/ControlPlaneDemo/ArchDiagram.js new file mode 100644 index 0000000..b73ff78 --- /dev/null +++ b/src/components/ControlPlaneDemo/ArchDiagram.js @@ -0,0 +1,197 @@ +import React, { useEffect, useRef } from 'react'; +import styles from './styles.module.css'; + +const COLORS = { + onboarding: '#2CE0BF', + platform: '#60a5fa', + clusterProvider: '#f59e0b', + controlplane: '#a78bfa', + terminal: '#98989f', +}; + +// Box layout (viewBox 100x70) +// Left col: Onboarding (top), Platform (mid) +// Right col: ControlPlane (tall) +// Bottom: ClusterProvider spans full width +const BOXES = { + onboarding: { x: 2, y: 2, w: 44, h: 22 }, + platform: { x: 2, y: 28, w: 44, h: 22 }, + clusterProvider: { x: 2, y: 56, w: 96, h: 12 }, + controlplane: { x: 54, y: 2, w: 44, h: 48 }, +}; + +function boxCenter(key) { + // 'terminal' is below the diagram — map it to just outside the bottom of onboarding box + if (key === 'terminal') { + const ob = BOXES.onboarding; + return { x: ob.x + ob.w / 2, y: 75 }; // below viewBox bottom (70), ball animates in from below + } + const b = BOXES[key]; + if (!b) return { x: 50, y: 35 }; + return { x: b.x + b.w / 2, y: b.y + b.h / 2 }; +} + +// Animated ball as a React component using requestAnimationFrame +function Ball({ from, to, color, onDone }) { + const circleRef = useRef(null); + const startRef = useRef(null); + const DURATION = 900; // ms + + useEffect(() => { + const fc = boxCenter(from); + const tc = boxCenter(to); + + function frame(ts) { + if (!startRef.current) startRef.current = ts; + const elapsed = ts - startRef.current; + const t = Math.min(elapsed / DURATION, 1); + // ease in-out + const ease = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t; + const cx = fc.x + (tc.x - fc.x) * ease; + const cy = fc.y + (tc.y - fc.y) * ease; + const opacity = t < 0.85 ? 1 : (1 - t) / 0.15; + if (circleRef.current) { + circleRef.current.setAttribute('cx', cx); + circleRef.current.setAttribute('cy', cy); + circleRef.current.setAttribute('opacity', opacity); + } + if (t < 1) { + requestAnimationFrame(frame); + } else { + if (onDone) onDone(); + } + } + requestAnimationFrame(frame); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const fc = boxCenter(from); + return ( + + ); +} + +function BoxSvg({ id, show, spawned, healthy, applied }) { + if (!show && !spawned) return null; + const b = BOXES[id]; + const color = COLORS[id]; + + return ( + + + {/* label */} + + {id === 'onboarding' ? 'Onboarding Cluster' : + id === 'platform' ? 'Platform Cluster' : + id === 'clusterProvider' ? 'Cluster Provider' : + 'ControlPlane Cluster'} + + {/* CR resource card inside onboarding box after apply */} + {id === 'onboarding' && applied && ( + + + ManagedControlPlane + name: my-control-plane + namespace: my-workspace + + )} + {id === 'clusterProvider' && ( + <> + e.g. Gardener + + + )} + {/* tags */} + {id === 'onboarding' && ( + <> + + + + )} + {id === 'platform' && ( + + )} + {id === 'controlplane' && ( + <> + + + {healthy && ( + <> + + crossplane — healthy + + )} + + )} + + ); +} + +function TagRect({ x, y, label }) { + return ( + + + {label} + + ); +} + +export default function ArchDiagram({ + show = {}, + ballStep = -1, + spawnedCP = false, + crossplaneHealthy = false, + ballSequence = [], + applied = false, +}) { + const currentBall = ballStep >= 0 && ballStep < ballSequence.length + ? ballSequence[ballStep] + : null; + + return ( +
+ + + + + + + {currentBall && ( + + )} + + + {currentBall && ( +
{currentBall.label}
+ )} +
+ ); +} diff --git a/src/components/ControlPlaneDemo/FakeTerminal.js b/src/components/ControlPlaneDemo/FakeTerminal.js new file mode 100644 index 0000000..dad9ca8 --- /dev/null +++ b/src/components/ControlPlaneDemo/FakeTerminal.js @@ -0,0 +1,113 @@ +import React, { useState, useRef, useEffect } from 'react'; +import styles from './styles.module.css'; + +const TARGET = 'kubectl apply -f controlplane.yaml'; + +export default function FakeTerminal({ onApply, applied, crReady, onReset }) { + const [typed, setTyped] = useState(''); + const [submitted, setSubmitted] = useState(false); + const termRef = useRef(null); + + // Reset when parent resets + useEffect(() => { + if (!applied) { + setTyped(''); + setSubmitted(false); + } + }, [applied]); + + function handleKey(e) { + if (submitted) return; + + // Prevent ALL default browser actions (scroll, tab, etc.) + if (!e.metaKey && !e.ctrlKey && !e.altKey) { + e.preventDefault(); + } + + if (e.key === 'Enter') { + if (typed.trim() === TARGET) { + setSubmitted(true); + onApply(); + } else { + setTyped(''); // wrong — clear + } + return; + } + if (e.key === 'Backspace') { + setTyped(t => t.slice(0, -1)); + return; + } + if (e.key.length === 1) { + setTyped(t => t + e.key); + } + } + + function handleReset() { + setTyped(''); + setSubmitted(false); + onReset(); + setTimeout(() => termRef.current?.focus(), 50); + } + + const isCorrectSoFar = TARGET.startsWith(typed); + const focused = !submitted; + + return ( +
!submitted && termRef.current?.focus()} + style={{ cursor: submitted ? 'default' : 'text', outline: 'none' }} + > +
+ + + + onboarding-cluster +
+
+ {!submitted && ( + <> +
+ $ + + {typed} + + +
+ {typed.length === 0 && ( +
+ Click here, then type: {TARGET} +
+ )} + + )} + {submitted && ( + <> +
+ $ + {TARGET} +
+
+ managedcontrolplane.core.openmcp.cloud/my-control-plane created +
+ {crReady && ( +
✓ ManagedControlPlane is Ready
+ )} + {!crReady && ( +
⟳ Waiting for reconciliation…
+ )} + + + )} +
+
+ ); +} diff --git a/src/components/ControlPlaneDemo/InteractiveTerminal.js b/src/components/ControlPlaneDemo/InteractiveTerminal.js new file mode 100644 index 0000000..e43b9cc --- /dev/null +++ b/src/components/ControlPlaneDemo/InteractiveTerminal.js @@ -0,0 +1,126 @@ +import React, { useState, useRef, useEffect } from 'react'; +import styles from './styles.module.css'; + +/** + * Generic interactive terminal — user types the target command themselves. + * Props: + * target: string — the exact command they must type + * contextLabel: string — shown in the terminal title bar + * contextColor: string — color for prompt + title + * output: string — output shown after correct Enter (pre-formatted) + * successMsg: string — optional green line shown when successReady=true + * successReady: bool — when true, shows successMsg (for async flows) + * pendingMsg: string — shown while successReady is false after submit + * disabled: bool — greys out + blocks input + * onSubmit: fn — called when correct command is entered + */ +export default function InteractiveTerminal({ + target, + contextLabel, + contextColor = '#2CE0BF', + output, + successMsg, + successReady = true, + pendingMsg, + disabled = false, + onSubmit, +}) { + const [typed, setTyped] = useState(''); + const [submitted, setSubmitted] = useState(false); + const termRef = useRef(null); + + // Auto-focus when enabled + useEffect(() => { + if (!disabled && !submitted && termRef.current) { + termRef.current.focus(); + } + }, [disabled, submitted]); + + // Reset when disabled flips back to true (parent reset) + const prevDisabled = useRef(disabled); + useEffect(() => { + if (!prevDisabled.current && disabled) { + setTyped(''); + setSubmitted(false); + } + prevDisabled.current = disabled; + }, [disabled]); + + function handleKey(e) { + if (disabled || submitted) return; + if (!e.metaKey && !e.ctrlKey && !e.altKey) { + e.preventDefault(); + } + if (e.key === 'Enter') { + if (typed.trim() === target) { + setSubmitted(true); + if (onSubmit) onSubmit(); + } else { + setTyped(''); + } + return; + } + if (e.key === 'Backspace') { + setTyped(t => t.slice(0, -1)); + return; + } + if (e.key.length === 1) { + setTyped(t => t + e.key); + } + } + + const isCorrectSoFar = target.startsWith(typed); + + return ( +
!disabled && !submitted && termRef.current?.focus()} + style={{ cursor: disabled || submitted ? 'default' : 'text', outline: 'none' }} + > +
+ + + + {contextLabel} +
+
+ {!submitted && ( + <> +
+ $ + + {typed} + + {!disabled && } +
+ {typed.length === 0 && ( +
+ {disabled + ? '(complete the previous step first)' + : Click here, then type: {target}} +
+ )} + + )} + {submitted && ( + <> +
+ $ + {target} +
+ {output &&
{output}
} + {successMsg && successReady && ( +
{successMsg}
+ )} + {pendingMsg && !successReady && ( +
{pendingMsg}
+ )} + + )} +
+
+ ); +} diff --git a/src/components/ControlPlaneDemo/demo-config.js b/src/components/ControlPlaneDemo/demo-config.js new file mode 100644 index 0000000..02dd3f0 --- /dev/null +++ b/src/components/ControlPlaneDemo/demo-config.js @@ -0,0 +1,12 @@ +export const YAML_TEXT = `apiVersion: core.openmcp.cloud/v1alpha1 +kind: ManagedControlPlane +metadata: + name: my-control-plane + namespace: my-workspace +spec: + components: + crossplane: + version: 1.17.1`; + +export const CP_NAME = 'my-control-plane'; +export const WORKSPACE = 'my-workspace'; diff --git a/src/components/ControlPlaneDemo/index.js b/src/components/ControlPlaneDemo/index.js new file mode 100644 index 0000000..1d08920 --- /dev/null +++ b/src/components/ControlPlaneDemo/index.js @@ -0,0 +1,240 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import BrowserOnly from '@docusaurus/BrowserOnly'; +import { YAML_TEXT, CP_NAME, WORKSPACE } from './demo-config'; +import ArchDiagram from './ArchDiagram'; +import InteractiveTerminal from './InteractiveTerminal'; +import styles from './styles.module.css'; + +function StepBox({ num, title, disabled, children }) { + const ref = useRef(null); + const [visible, setVisible] = useState(false); + + useEffect(() => { + const el = ref.current; + if (!el) return; + const observer = new IntersectionObserver( + ([entry]) => { if (entry.isIntersecting) setVisible(true); }, + { threshold: 0.1 } + ); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + return ( +
+
+
{num}
+

{title}

+
+
{children}
+
+ ); +} + +function Connector() { + return
; +} + +// Ball animation sequence for step 5 (the reconcile flow) +// Each entry: { from, to, label, delay } +const BALL_SEQUENCE = [ + { from: 'terminal', to: 'onboarding', label: 'kubectl apply', delay: 0 }, + { from: 'onboarding', to: 'platform', label: 'CR observed', delay: 1400 }, + { from: 'platform', to: 'onboarding', label: 'acknowledged', delay: 2600 }, + { from: 'platform', to: 'clusterProvider', label: 'provision cluster', delay: 3800 }, + { from: 'clusterProvider', to: 'platform', label: 'cluster ready', delay: 5800 }, + { from: 'platform', to: 'controlplane', label: 'install crossplane', delay: 7000 }, + { from: 'controlplane', to: 'platform', label: 'crossplane healthy', delay: 9200 }, + { from: 'platform', to: 'onboarding', label: 'CR Ready', delay: 10400 }, +]; + +function ControlPlaneDemoInner() { + const [applied, setApplied] = useState(false); + const [ballStep, setBallStep] = useState(-1); // which ball is currently flying + const [spawnedCP, setSpawnedCP] = useState(false); + const [crossplaneHealthy, setCrossplaneHealthy] = useState(false); + const [crReady, setCrReady] = useState(false); + const timersRef = useRef([]); + + const clearTimers = () => { + timersRef.current.forEach(clearTimeout); + timersRef.current = []; + }; + + const handleApply = useCallback(() => { + if (applied) return; + setApplied(true); + + BALL_SEQUENCE.forEach((seq, i) => { + const t = setTimeout(() => { + setBallStep(i); + // At step 3 (cluster provider → platform), spawn the ControlPlane box + if (i === 3) { + setTimeout(() => setSpawnedCP(true), 900); + } + // At step 6 (platform → controlplane), mark crossplane healthy after ball arrives + if (i === 5) { + setTimeout(() => setCrossplaneHealthy(true), 1100); + } + // At step 7 (platform → onboarding), CR is ready + if (i === 7) { + setTimeout(() => setCrReady(true), 1100); + } + }, seq.delay); + timersRef.current.push(t); + }); + }, [applied]); + + const handleReset = () => { + clearTimers(); + setApplied(false); + setBallStep(-1); + setSpawnedCP(false); + setCrossplaneHealthy(false); + setCrReady(false); + }; + + return ( +
+ + {/* Step 1: Cluster Provider */} + +

+ A Cluster Provider is the engine that creates and deletes Kubernetes clusters on request. + OpenMCP abstracts the underlying technology (Gardener, kind, …) behind a uniform interface. + Platform operators install one cluster provider per environment. +

+ +
+ + + + {/* Step 2: Onboarding Cluster */} + +

+ The Onboarding Cluster is the Kubernetes cluster you interact with. + You kubectl apply a ManagedControlPlane CR here — that is the only action you need to take. +

+ +
+ + + + {/* Step 3: Platform Cluster */} + +

+ The Platform Cluster runs the openmcp-operator. + It watches the Onboarding Cluster for new ManagedControlPlane CRs and orchestrates + all downstream actions — cluster provisioning, component installation, status reporting. +

+ +
+ + + + {/* Step 4: The YAML */} + +

+ Declare what you want in a single manifest. Here you request a dedicated control plane + with Crossplane pre-installed. +

+
{YAML_TEXT}
+
+ + + + {/* Step 5: Apply + animated flow */} + +

+ Type kubectl apply -f controlplane.yaml and press Enter. + Watch each message travel between components in the diagram. +

+ + +
+ + + + {/* Step 6: Connect to ControlPlane */} + +

+ Your ManagedControlPlane is ready. Switch context and verify Crossplane is running: +

+ +
+ + + + {/* Step 7: Try it yourself */} + +

+ Ready to run this for real? The quickstart gets you a full OpenControlPlane environment + running locally with Docker in minutes — no cloud account needed. +

+ +
+
🚀
+
+
Quickstart — Run locally with Docker
+
Install OpenControlPlane, create your first ControlPlane, and deploy Crossplane — all on your laptop.
+
+
+
+
+
+ +
+ ); +} + +export default function ControlPlaneDemo() { + return ( + Loading interactive demo…
}> + {() => } + + ); +} diff --git a/src/components/ControlPlaneDemo/styles.module.css b/src/components/ControlPlaneDemo/styles.module.css new file mode 100644 index 0000000..59b22c9 --- /dev/null +++ b/src/components/ControlPlaneDemo/styles.module.css @@ -0,0 +1,409 @@ +/* ===== Demo wrapper ===== */ +.demoWrapper { + margin: 1.5rem 0; + font-family: var(--ifm-font-family-base); +} + +/* ===== Step boxes (BTP-demo style) ===== */ +.stepBox { + background: var(--ifm-card-background-color, #202127); + border: 1px solid #3c3f44; + border-radius: 10px; + padding: 1.25rem 1.5rem; + opacity: 0; + transform: translateY(18px); + transition: opacity 0.45s ease, transform 0.45s ease; +} + +.stepBox.visible { + opacity: 1; + transform: translateY(0); +} + +.stepBox.disabled { + opacity: 0.35; + pointer-events: none; +} + +.stepHeader { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.stepBadge { + width: 28px; + height: 28px; + border-radius: 50%; + background: #049F9A; + color: #fff; + font-size: 0.78rem; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.stepTitle { + margin: 0; + font-size: 1rem; + font-weight: 700; + color: var(--ifm-heading-color); +} + +.stepContent { + /* children flow naturally */ +} + +.stepDesc { + font-size: 0.9rem; + color: var(--ifm-color-content-secondary); + line-height: 1.55; + margin: 0 0 0.9rem; +} + +.stepConnector { + width: 2px; + height: 32px; + background: #3c3f44; + margin: 0 0 0 22px; +} + +/* ===== YAML block ===== */ +.yamlBlock { + background: #0d0d0f; + border: 1px solid #3c3f44; + border-radius: 8px; + padding: 1rem 1.25rem; + font-size: 0.8rem; + line-height: 1.6; + color: #2CE0BF; + font-family: var(--ifm-font-family-monospace); + overflow-x: auto; + margin: 0; +} + +/* ===== Architecture diagram ===== */ +.archWrapper { + position: relative; + width: 100%; + aspect-ratio: 100 / 70; + margin-top: 0.9rem; + border: 1px solid #3c3f44; + border-radius: 8px; + background: #0d0d0f; + overflow: hidden; +} + +.archBox { + position: absolute; + border: 2px solid #3c3f44; + border-radius: 7px; + padding: 0.5rem 0.7rem; + background: #1a1a1f; + box-sizing: border-box; + transition: border-color 0.35s, box-shadow 0.35s; + overflow: hidden; +} + +.archBoxActive { + border-color: var(--box-color); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--box-color) 20%, transparent); +} + +.archBoxSpawned { + animation: popIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both; +} + +@keyframes popIn { + from { opacity: 0; transform: scale(0.7); } + to { opacity: 1; transform: scale(1); } +} + +.archBoxLabel { + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #98989f; + line-height: 1.2; +} + +.archBoxSub { + font-size: 0.67rem; + color: #6a6a71; + margin-top: 1px; +} + +.archBoxTags { + margin-top: 0.35rem; + display: flex; + flex-wrap: wrap; + gap: 3px; +} + +.archTag { + font-size: 0.65rem; + background: #2a2a30; + color: #98989f; + padding: 1px 5px; + border-radius: 3px; +} + +.archHealthy { + margin-top: 0.4rem; + display: flex; + align-items: center; + gap: 4px; + font-size: 0.68rem; + color: #2CE0BF; + font-weight: 600; +} + +.archHealthyDot { + width: 7px; + height: 7px; + border-radius: 50%; + background: #2CE0BF; + animation: pulse 1.2s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.5; transform: scale(0.8); } +} + +/* Flying ball (SVG-based, animated via keyframes) */ +.ballSvg { + position: absolute; + inset: 0; + pointer-events: none; +} + +.flyBall { + animation: flyAlongPath 1.1s ease-in-out forwards; + transform-origin: center; +} + +@keyframes flyAlongPath { + 0% { cx: var(--fx); cy: var(--fy); opacity: 1; r: 1.8; } + 50% { r: 2.2; } + 90% { opacity: 1; } + 100% { cx: var(--tx); cy: var(--ty); opacity: 0; r: 1.8; } +} + +.ballLabel { + position: absolute; + bottom: 6px; + left: 50%; + transform: translateX(-50%); + font-size: 0.68rem; + color: #98989f; + background: rgba(13,13,15,0.85); + padding: 2px 8px; + border-radius: 10px; + white-space: nowrap; + pointer-events: none; + animation: fadeInOut 1.1s ease-in-out forwards; +} + +@keyframes fadeInOut { + 0% { opacity: 0; } + 15% { opacity: 1; } + 80% { opacity: 1; } + 100% { opacity: 0; } +} + +/* ===== Terminal pane ===== */ +.terminalPane { + background: #0d0d0f; + border: 1px solid #3c3f44; + border-radius: 8px; + overflow: hidden; + margin-top: 0.75rem; +} + +.terminalBar { + background: #1b1b1f; + padding: 0.4rem 0.75rem; + display: flex; + align-items: center; + gap: 0.35rem; + border-bottom: 1px solid #2e2e32; +} + +.termDot { + width: 10px; + height: 10px; + border-radius: 50%; + display: inline-block; +} + +.termTitle { + margin-left: 0.5rem; + font-size: 0.75rem; + color: #98989f; + font-family: var(--ifm-font-family-monospace); +} + +.terminalBody { + padding: 0.75rem 1rem; + min-height: 64px; + font-family: var(--ifm-font-family-monospace); + font-size: 0.82rem; +} + +.termLine { + display: flex; + align-items: baseline; + flex-wrap: wrap; + position: relative; +} + +.termPrompt { + margin-right: 4px; + font-weight: 700; + flex-shrink: 0; +} + +.termCmd { + color: #dfdfd6; +} + +.termOutput { + display: block; + margin-top: 0.35rem; + color: #98989f; + white-space: pre; + font-size: 0.78rem; +} + +.termSuccess { + margin-top: 0.35rem; + color: #2CE0BF; + font-weight: 600; +} + +.termPending { + margin-top: 0.35rem; + color: #98989f; + font-style: italic; +} + +.termHint { + margin-top: 0.25rem; + font-size: 0.78rem; + color: #6a6a71; +} + +.cursor { + display: inline-block; + color: #2CE0BF; + animation: blink 0.8s step-end infinite; + font-size: 0.9em; + margin-left: 1px; +} + +@keyframes blink { + 50% { opacity: 0; } +} + +.hiddenInput { + position: absolute; + opacity: 0; + width: 1px; + height: 1px; + pointer-events: none; +} + +/* ===== CTA card (Step 7) ===== */ +.ctaLink { + text-decoration: none; + display: block; + margin-top: 0.5rem; +} + +.ctaCard { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem 1.25rem; + border: 1px solid #049F9A; + border-radius: 8px; + background: rgba(4, 159, 154, 0.06); + transition: background 0.2s, border-color 0.2s; + cursor: pointer; +} + +.ctaCard:hover { + background: rgba(44, 224, 191, 0.1); + border-color: #2CE0BF; +} + +.ctaIcon { + font-size: 1.8rem; + flex-shrink: 0; +} + +.ctaText { + flex: 1; +} + +.ctaTitle { + font-size: 0.95rem; + font-weight: 700; + color: #2CE0BF; + margin-bottom: 0.25rem; +} + +.ctaDesc { + font-size: 0.82rem; + color: #98989f; + line-height: 1.4; +} + +.ctaArrow { + font-size: 1.2rem; + color: #2CE0BF; + flex-shrink: 0; +} +.btnPrimary { + background: #049F9A; + color: #fff; + border: none; + border-radius: 6px; + padding: 0.45rem 1.1rem; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; +} + +.btnPrimary:hover:not(:disabled) { + background: #2CE0BF; + color: #012931; +} + +.btnPrimary:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.btnSecondary { + background: transparent; + color: #98989f; + border: 1px solid #3c3f44; + border-radius: 6px; + padding: 0.35rem 0.9rem; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: border-color 0.2s, color 0.2s; +} + +.btnSecondary:hover { + border-color: #98989f; + color: #dfdfd6; +}