* feat(motion): add base SVG layout and trust boundaries for example-paved-road-animated.html * feat(motion): add 5-step interactive semantic items for Pattern #5 (Secure Paved Road) * feat(motion): attach canonical SHA-pinned controller and WCAG AA controls * docs(gallery): register example-paved-road-animated.html in assets/index.html gallery * fix(motion): adjust attacker node Y and bypass connector to run cleanly through empty gap without crossing audit service box * fix(motion): redesign paved road architecture with dedicated firewall node and solid opaque badge chips * fix(motion): add signature legend bar and route audit connectors clear of firewall node * docs(motion): add footer legend bar to fan-in queue animated diagram * fix(motion): implement true SVG visual legend footer with mini-arrows and mini-rects * fix(motion): correct Security Gate deployment flow, SOC arrow direction, and footer legend labels * fix(motion): complete footer legend with 4 distinct box types matching diagram elements exactly * chore(clean): remove queue diff from paved-road branch so diff contains only paved-road example * chore(release): bump plugin version to 2.3.4 for synchronized version gate
547 lines
28 KiB
HTML
547 lines
28 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Secure Paved Road Architecture</title>
|
||
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||
<style>
|
||
*, *::before, *::after { box-sizing: border-box; }
|
||
:root {
|
||
--paper: #f5f5f5;
|
||
--paper-2: #ececec;
|
||
--ink: #2d3142;
|
||
--muted: #4f5d75;
|
||
--soft: #7a8399;
|
||
--rule: rgba(45,49,66,0.12);
|
||
--rule-strong: rgba(45,49,66,0.30);
|
||
--accent: #eb6c36;
|
||
--accent-tint: rgba(235,108,54,0.08);
|
||
--sans: 'Geist', system-ui, sans-serif;
|
||
--serif: 'Instrument Serif', serif;
|
||
--mono: 'Geist Mono', ui-monospace, monospace;
|
||
--motion-fast: 160ms;
|
||
--motion-step: 480ms;
|
||
--motion-hold: 720ms;
|
||
--motion-total: 3600ms;
|
||
--motion-ease: cubic-bezier(.2,.8,.2,1);
|
||
}
|
||
body {
|
||
margin: 0;
|
||
min-height: 100vh;
|
||
padding: 32px;
|
||
color: var(--ink);
|
||
background: var(--paper);
|
||
font-family: var(--sans);
|
||
}
|
||
[data-motion-root] {
|
||
width: min(100%, 1280px);
|
||
margin: 0 auto;
|
||
}
|
||
.page-header {
|
||
display: flex;
|
||
align-items: end;
|
||
justify-content: space-between;
|
||
gap: 32px;
|
||
margin-bottom: 16px;
|
||
}
|
||
.eyebrow {
|
||
margin: 0 0 8px;
|
||
color: var(--muted);
|
||
font: 500 10px/1.4 var(--mono);
|
||
letter-spacing: .18em;
|
||
text-transform: uppercase;
|
||
}
|
||
h1 {
|
||
margin: 0;
|
||
font: 400 clamp(28px, 3vw, 40px)/1.08 var(--serif);
|
||
letter-spacing: -.02em;
|
||
}
|
||
.lede {
|
||
max-width: 540px;
|
||
margin: 0 0 4px;
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
line-height: 1.6;
|
||
}
|
||
.diagram-wrap {
|
||
overflow-x: auto;
|
||
border-top: 1px solid var(--rule);
|
||
border-bottom: 1px solid var(--rule);
|
||
}
|
||
svg { display: block; width: 100%; min-width: 920px; }
|
||
|
||
/* Motion item states */
|
||
.motion-ready [data-motion-item] {
|
||
opacity: .12;
|
||
transform: translateY(8px);
|
||
transform-box: fill-box;
|
||
transform-origin: center;
|
||
transition: opacity var(--motion-step) var(--motion-ease),
|
||
transform var(--motion-step) var(--motion-ease);
|
||
}
|
||
.motion-ready [data-motion-item].is-visible,
|
||
.motion-ready[data-frame="end"] [data-motion-item],
|
||
.motion-ready[data-frame="static"] [data-motion-item] {
|
||
opacity: 1;
|
||
transform: none;
|
||
}
|
||
|
||
html[data-motion="static"] [data-motion-controls] { display: none !important; }
|
||
html[data-motion="static"] [data-motion-item] {
|
||
opacity: 1 !important;
|
||
transform: none !important;
|
||
transition: none !important;
|
||
}
|
||
html[data-motion="step"] [data-motion-controls],
|
||
html[data-motion="step"] [data-motion-decorative] { display: none !important; }
|
||
html[data-motion="step"] [data-motion-item] {
|
||
animation: none !important;
|
||
transition: none !important;
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
*, *::before, *::after {
|
||
animation-duration: .001ms !important;
|
||
animation-iteration-count: 1 !important;
|
||
scroll-behavior: auto !important;
|
||
transition-duration: .001ms !important;
|
||
}
|
||
[data-motion-item] { opacity: 1 !important; transform: none !important; }
|
||
[data-motion-decorative] { display: none !important; }
|
||
[data-motion-controls] { display: none !important; }
|
||
}
|
||
@media print {
|
||
body { padding: 0; }
|
||
[data-motion-controls], [data-motion-decorative] { display: none !important; }
|
||
[data-motion-item] { opacity: 1 !important; transform: none !important; }
|
||
}
|
||
|
||
[data-motion-controls] {
|
||
display: none;
|
||
align-items: center;
|
||
gap: 8px;
|
||
min-height: 60px;
|
||
padding: 8px 0;
|
||
}
|
||
.motion-ready [data-motion-controls] { display: flex; }
|
||
[data-motion-controls][hidden] { display: none !important; }
|
||
[data-motion-controls] button {
|
||
min-width: 44px;
|
||
min-height: 44px;
|
||
padding: 8px 12px;
|
||
border: 1px solid var(--muted);
|
||
border-radius: 4px;
|
||
color: var(--ink);
|
||
background: var(--paper);
|
||
font: 600 12px/1 var(--sans);
|
||
cursor: pointer;
|
||
}
|
||
[data-motion-controls] button:hover { border-color: var(--accent); }
|
||
[data-motion-controls] button:focus-visible,
|
||
[data-motion-root]:focus-visible {
|
||
outline: 3px solid var(--accent);
|
||
outline-offset: 2px;
|
||
}
|
||
[data-motion-controls] button:disabled { cursor: not-allowed; opacity: .45; }
|
||
.keyboard-help {
|
||
margin-left: 8px;
|
||
color: var(--soft);
|
||
font: 500 9px/1.4 var(--mono);
|
||
}
|
||
[data-motion-status] {
|
||
display: block;
|
||
min-width: 272px;
|
||
min-height: 16px;
|
||
margin: 8px 0 0;
|
||
color: var(--muted);
|
||
font: 500 10px/1.4 var(--mono);
|
||
text-align: right;
|
||
}
|
||
noscript p {
|
||
margin: 12px 0 0;
|
||
color: var(--muted);
|
||
font: 500 10px/1.4 var(--mono);
|
||
}
|
||
.sr-only {
|
||
position: absolute;
|
||
width: 1px;
|
||
height: 1px;
|
||
padding: 0;
|
||
margin: -1px;
|
||
overflow: hidden;
|
||
clip: rect(0, 0, 0, 0);
|
||
white-space: nowrap;
|
||
border: 0;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main data-motion-root data-motion-mode="step" data-step-count="5" data-step-current="5" data-frame="static" data-static-frame="complete" tabindex="-1">
|
||
<header class="page-header">
|
||
<div>
|
||
<p class="eyebrow">Semantic pattern · Secure paved road</p>
|
||
<h1>Secure Paved Road Architecture</h1>
|
||
</div>
|
||
<p class="lede">
|
||
Authorized deployments follow a paved road with verified identity gates, while unauthorized bypass attempts are blocked at the trust boundary.
|
||
</p>
|
||
</header>
|
||
|
||
<div class="diagram-wrap">
|
||
<svg role="img" aria-labelledby="paved-road-animated-title paved-road-animated-desc" viewBox="0 0 1160 620" xmlns="http://www.w3.org/2000/svg" data-motion-primitive="secure-paved-road">
|
||
<title id="paved-road-animated-title">Secure Paved Road Architecture</title>
|
||
<desc id="paved-road-animated-desc">Interactive step-by-step demonstration of authorized paved-road deployment versus blocked unauthorized bypass attempts at trust boundaries.</desc>
|
||
<rect width="1160" height="620" fill="#f5f5f5"/>
|
||
<defs>
|
||
<marker id="arrow-ink" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||
<path d="M 0 1 L 10 5 L 0 9 z" fill="#2d3142" />
|
||
</marker>
|
||
<marker id="arrow-accent" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||
<path d="M 0 1 L 10 5 L 0 9 z" fill="#eb6c36" />
|
||
</marker>
|
||
</defs>
|
||
|
||
<!-- Base Layout: Trust Zone 1 - Public Ingress -->
|
||
<rect x="40" y="40" width="240" height="490" rx="8" fill="#ececec" stroke="rgba(45,49,66,0.12)" stroke-width="1" />
|
||
<text x="60" y="72" fill="#4f5d75" font-family="'Geist Mono', monospace" font-size="11" letter-spacing="1.6">PUBLIC INGRESS ZONE</text>
|
||
|
||
<!-- Node: Developer Client -->
|
||
<rect x="60" y="96" width="200" height="72" rx="6" fill="#ffffff" stroke="#2d3142" stroke-width="1.5" />
|
||
<text x="76" y="128" fill="#2d3142" font-family="'Geist', sans-serif" font-size="14" font-weight="600">Developer Workstation</text>
|
||
<text x="76" y="148" fill="#4f5d75" font-family="'Geist Mono', monospace" font-size="11">Identity: dev_alice (mTLS)</text>
|
||
|
||
<!-- Node: Unauthorized Attacker -->
|
||
<rect x="60" y="325" width="200" height="72" rx="6" fill="#ffffff" stroke="#eb6c36" stroke-width="1.5" />
|
||
<text x="76" y="357" fill="#eb6c36" font-family="'Geist', sans-serif" font-size="14" font-weight="600">Unauthorized Attacker</text>
|
||
<text x="76" y="377" fill="#eb6c36" font-family="'Geist Mono', monospace" font-size="11">Bypass Attempt (No Key)</text>
|
||
|
||
<!-- Base Layout: Trust Zone 2 - Corporate CI/CD & Governance -->
|
||
<rect x="330" y="40" width="380" height="490" rx="8" fill="#ececec" stroke="rgba(45,49,66,0.12)" stroke-width="1" />
|
||
<text x="350" y="72" fill="#4f5d75" font-family="'Geist Mono', monospace" font-size="11" letter-spacing="1.6">CORPORATE GOVERNANCE ZONE</text>
|
||
|
||
<!-- Node: CI/CD Pipeline -->
|
||
<rect x="350" y="96" width="340" height="72" rx="6" fill="#ffffff" stroke="#2d3142" stroke-width="1.5" />
|
||
<text x="370" y="128" fill="#2d3142" font-family="'Geist', sans-serif" font-size="14" font-weight="600">CI/CD Paved Road Pipeline</text>
|
||
<text x="370" y="148" fill="#4f5d75" font-family="'Geist Mono', monospace" font-size="11">Build: Signed Artifact & Policy Check</text>
|
||
|
||
<!-- Node: Privileged Security Gate -->
|
||
<rect x="350" y="210" width="340" height="76" rx="6" fill="#ffffff" stroke="#2d3142" stroke-width="1.5" />
|
||
<text x="370" y="238" fill="#2d3142" font-family="'Geist', sans-serif" font-size="14" font-weight="600">Privileged Security Gate</text>
|
||
<text x="370" y="258" fill="#4f5d75" font-family="'Geist Mono', monospace" font-size="11">Policy: OPA Signature & Vulnerability Scan</text>
|
||
|
||
<!-- Node: Boundary Firewall & WAF (Left side x=350..560) -->
|
||
<rect x="350" y="325" width="210" height="72" rx="6" fill="rgba(235,108,54,0.08)" stroke="#eb6c36" stroke-width="1.5" />
|
||
<text x="366" y="355" fill="#eb6c36" font-family="'Geist', sans-serif" font-size="13" font-weight="600">Boundary Firewall & WAF</text>
|
||
<text x="366" y="375" fill="#eb6c36" font-family="'Geist Mono', monospace" font-size="10.5" font-weight="600">Status: × BLOCKED</text>
|
||
|
||
<!-- Node: Audit Log Service -->
|
||
<rect x="350" y="435" width="340" height="72" rx="6" fill="#ffffff" stroke="#2d3142" stroke-width="1.5" />
|
||
<text x="370" y="465" fill="#2d3142" font-family="'Geist', sans-serif" font-size="14" font-weight="600">Centralized Audit Log Service</text>
|
||
<text x="370" y="485" fill="#4f5d75" font-family="'Geist Mono', monospace" font-size="11">Storage: Immutable SIEM Audit Trail</text>
|
||
|
||
<!-- Base Layout: Trust Zone 3 - Isolated Production Runtime -->
|
||
<rect x="760" y="40" width="360" height="490" rx="8" fill="#ececec" stroke="rgba(45,49,66,0.12)" stroke-width="1" />
|
||
<text x="780" y="72" fill="#4f5d75" font-family="'Geist Mono', monospace" font-size="11" letter-spacing="1.6">ISOLATED PRODUCTION RUNTIME</text>
|
||
|
||
<!-- Node: Production API Gateway -->
|
||
<rect x="780" y="96" width="320" height="72" rx="6" fill="#ffffff" stroke="#2d3142" stroke-width="1.5" />
|
||
<text x="800" y="128" fill="#2d3142" font-family="'Geist', sans-serif" font-size="14" font-weight="600">Production API Gateway</text>
|
||
<text x="800" y="148" fill="#4f5d75" font-family="'Geist Mono', monospace" font-size="11">Status: ADMITTED [TLS 1.3 Strict]</text>
|
||
|
||
<!-- Node: Production Database -->
|
||
<rect x="780" y="210" width="320" height="76" rx="6" fill="#ffffff" stroke="#2d3142" stroke-width="1.5" />
|
||
<text x="800" y="238" fill="#2d3142" font-family="'Geist', sans-serif" font-size="14" font-weight="600">Isolated Core Database</text>
|
||
<text x="800" y="258" fill="#4f5d75" font-family="'Geist Mono', monospace" font-size="11">Access: Paved Road Only (KMS Encrypted)</text>
|
||
|
||
<!-- Node: Security Operations Center -->
|
||
<rect x="780" y="435" width="320" height="72" rx="6" fill="#ffffff" stroke="#2d3142" stroke-width="1.5" />
|
||
<text x="800" y="465" fill="#2d3142" font-family="'Geist', sans-serif" font-size="14" font-weight="600">Security Operations Center</text>
|
||
<text x="800" y="485" fill="#4f5d75" font-family="'Geist Mono', monospace" font-size="11">Monitoring: Real-time Alerting Active</text>
|
||
|
||
<!-- STEP 1: Authorized Developer Code Ingress -->
|
||
<g data-motion-item data-step="1" aria-label="Step 1: Developer workstation pushes signed code commit into CI/CD Paved Road pipeline">
|
||
<path d="M 260 132 L 350 132" fill="none" stroke="#2d3142" stroke-width="2" marker-end="url(#arrow-ink)" />
|
||
<rect x="275" y="104" width="60" height="18" rx="3" fill="#ffffff" stroke="#2d3142" stroke-width="1" />
|
||
<text x="305" y="117" text-anchor="middle" fill="#2d3142" font-family="'Geist Mono', monospace" font-size="9" font-weight="600">GIT PUSH</text>
|
||
</g>
|
||
|
||
<!-- STEP 2: CI/CD Build & Cryptographic Signing -->
|
||
<g data-motion-item data-step="2" aria-label="Step 2: CI/CD pipeline builds binary and generates cryptographic provenance signature">
|
||
<path d="M 520 168 L 520 210" fill="none" stroke="#2d3142" stroke-width="2" marker-end="url(#arrow-ink)" />
|
||
<rect x="440" y="178" width="160" height="20" rx="4" fill="#ffffff" stroke="#2d3142" stroke-width="1" />
|
||
<text x="520" y="192" text-anchor="middle" fill="#2d3142" font-family="'Geist Mono', monospace" font-size="9" font-weight="600">PROVENANCE [SIGNED]</text>
|
||
</g>
|
||
|
||
<!-- STEP 3: Security Gate Approval → Production Gateway → Database Deployment -->
|
||
<g data-motion-item data-step="3" aria-label="Step 3: Security gate verifies OPA policy, approves artifact, and deploys via Production API Gateway to Isolated Core Database">
|
||
<!-- Gate ✓ PASS → Production API Gateway (diagonal: gate right edge to gateway left edge) -->
|
||
<path d="M 690 230 L 780 132" fill="none" stroke="#2d3142" stroke-width="2" marker-end="url(#arrow-ink)" />
|
||
<rect x="700" y="166" width="65" height="18" rx="3" fill="#ffffff" stroke="#2d3142" stroke-width="1" />
|
||
<text x="732" y="179" text-anchor="middle" fill="#2d3142" font-family="'Geist Mono', monospace" font-size="9" font-weight="600">✓ PASS</text>
|
||
<!-- Production API Gateway → Isolated Core Database (vertical: gateway bottom to db top) -->
|
||
<path d="M 940 168 L 940 210" fill="none" stroke="#2d3142" stroke-width="2" marker-end="url(#arrow-ink)" />
|
||
</g>
|
||
|
||
<!-- STEP 4: Unauthorized Bypass Attempt Blocked at Boundary Firewall -->
|
||
<g data-motion-item data-step="4" aria-label="Step 4: Unauthorized attacker connection attempt is blocked at trust boundary firewall">
|
||
<path d="M 260 361 L 350 361" fill="none" stroke="#eb6c36" stroke-width="2" stroke-dasharray="4 4" marker-end="url(#arrow-accent)" />
|
||
<rect x="272" y="345" width="65" height="18" rx="3" fill="#ffffff" stroke="#eb6c36" stroke-width="1" />
|
||
<text x="304" y="358" text-anchor="middle" fill="#eb6c36" font-family="'Geist Mono', monospace" font-size="8.5" font-weight="600">BYPASS</text>
|
||
</g>
|
||
|
||
<!-- STEP 5: Audit Log & SIEM Security Monitoring Record -->
|
||
<g data-motion-item data-step="5" aria-label="Step 5: Immutable SIEM audit log records both authorized deployment and blocked attack attempt">
|
||
<!-- Gate Audit Trail (Runs cleanly down the right side x=620, clear of Firewall node x=350..560!) -->
|
||
<path d="M 620 286 L 620 435" fill="none" stroke="#2d3142" stroke-width="2" marker-end="url(#arrow-ink)" />
|
||
<!-- WAF Audit Trail (Runs directly down x=455 into Audit Log) -->
|
||
<path d="M 455 397 L 455 435" fill="none" stroke="#eb6c36" stroke-width="2" stroke-dasharray="4 4" marker-end="url(#arrow-accent)" />
|
||
<!-- SOC Monitoring Link (Data flows FROM Audit Log TO SOC for real-time monitoring) -->
|
||
<path d="M 690 471 L 780 471" fill="none" stroke="#2d3142" stroke-width="2" marker-end="url(#arrow-ink)" />
|
||
<!-- Audit Badge Chip -->
|
||
<rect x="570" y="350" width="100" height="20" rx="3" fill="#ffffff" stroke="#2d3142" stroke-width="1" />
|
||
<text x="620" y="364" text-anchor="middle" fill="#2d3142" font-family="'Geist Mono', monospace" font-size="9" font-weight="600">AUDIT LOGGED</text>
|
||
</g>
|
||
|
||
<!-- Precision SVG Legend Footer (Exact visual mini-arrows and mini-rects) -->
|
||
<g data-motion-decorative aria-hidden="true">
|
||
<!-- Flow Line 1: Authorized Paved Road -->
|
||
<line x1="40" y1="584" x2="65" y2="584" stroke="#2d3142" stroke-width="2" marker-end="url(#arrow-ink)" />
|
||
<text x="73" y="588" fill="#2d3142" font-family="'Geist Mono', monospace" font-size="9.5">Authorized Paved Road</text>
|
||
|
||
<!-- Flow Line 2: Blocked Bypass Attempt -->
|
||
<line x1="225" y1="584" x2="250" y2="584" stroke="#eb6c36" stroke-width="2" stroke-dasharray="3 3" marker-end="url(#arrow-accent)" />
|
||
<text x="258" y="588" fill="#2d3142" font-family="'Geist Mono', monospace" font-size="9.5">Blocked Bypass Attempt</text>
|
||
|
||
<!-- Box 1: Paved Road Node -->
|
||
<rect x="425" y="577" width="16" height="12" rx="2" fill="#ffffff" stroke="#2d3142" stroke-width="1.5" />
|
||
<text x="447" y="588" fill="#2d3142" font-family="'Geist Mono', monospace" font-size="9.5">Paved Road Node</text>
|
||
|
||
<!-- Box 2: Attacker Node (White Fill + Orange Border) -->
|
||
<rect x="565" y="577" width="16" height="12" rx="2" fill="#ffffff" stroke="#eb6c36" stroke-width="1.5" />
|
||
<text x="587" y="588" fill="#2d3142" font-family="'Geist Mono', monospace" font-size="9.5">Attacker Node</text>
|
||
|
||
<!-- Box 3: Firewall Blocked Node (Orange Tint Fill + Orange Border) -->
|
||
<rect x="690" y="577" width="16" height="12" rx="2" fill="rgba(235,108,54,0.08)" stroke="#eb6c36" stroke-width="1.5" />
|
||
<text x="712" y="588" fill="#2d3142" font-family="'Geist Mono', monospace" font-size="9.5">Firewall Blocked</text>
|
||
|
||
<!-- Box 4: Action / Status Chip (White Chip + Solid Border) -->
|
||
<rect x="830" y="577" width="16" height="12" rx="2" fill="#ffffff" stroke="#2d3142" stroke-width="1" />
|
||
<text x="852" y="588" fill="#2d3142" font-family="'Geist Mono', monospace" font-size="9.5">Action / Status Chip</text>
|
||
</g>
|
||
</svg>
|
||
</div>
|
||
|
||
<!-- Pinned Motion Controls -->
|
||
<div data-motion-controls role="group" aria-label="Diagram playback controls">
|
||
<button type="button" data-motion-action="prev">Previous</button>
|
||
<button type="button" data-motion-action="play" aria-pressed="false">Play</button>
|
||
<button type="button" data-motion-action="pause" aria-pressed="true">Pause</button>
|
||
<button type="button" data-motion-action="next">Next</button>
|
||
<button type="button" data-motion-action="replay">Replay</button>
|
||
<span class="keyboard-help">←/→ step · Space play/pause · R replay · Home/End jump</span>
|
||
<span data-motion-status-visible aria-hidden="true">Step <span data-motion-step-label>5</span> of 5</span>
|
||
</div>
|
||
<p class="sr-only" data-motion-status role="status" aria-live="polite" aria-atomic="true"></p>
|
||
<noscript><p>Animation controls require JavaScript. The complete final diagram is shown above.</p></noscript>
|
||
</main>
|
||
|
||
<script data-diagram-controls>
|
||
(() => {
|
||
const params = new URLSearchParams(location.search);
|
||
const staticOverride = params.get('motion') === 'static' || document.documentElement.dataset.motion === 'static';
|
||
const requestedStep = params.get('step');
|
||
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
|
||
document.fonts.ready.then(() => { document.documentElement.dataset.fontsReady = 'true'; });
|
||
|
||
document.querySelectorAll('[data-motion-root]').forEach((root) => {
|
||
const count = Number(root.dataset.stepCount);
|
||
const hold = parseFloat(getComputedStyle(root).getPropertyValue('--motion-hold')) || 720;
|
||
const parsedStep = requestedStep !== null && /^\d+$/.test(requestedStep) ? Number(requestedStep) : null;
|
||
const exactStep = params.get('motion') === 'step' && Number.isSafeInteger(parsedStep) && parsedStep >= 0 && parsedStep <= count ? parsedStep : null;
|
||
const items = [...root.querySelectorAll('[data-motion-item]')];
|
||
const labels = Array.from({ length: count + 1 }, (_, index) => index === 0
|
||
? 'Ready'
|
||
: items.filter((item) => !item.hasAttribute('data-motion-decorative') && Number(item.dataset.step) === index)
|
||
.map((item) => item.getAttribute('aria-label')).join('; '));
|
||
const controls = root.querySelector('[data-motion-controls]');
|
||
const status = root.querySelector('[data-motion-status]');
|
||
const visibleStep = root.querySelector('[data-motion-step-label]');
|
||
let step = count;
|
||
let timer = 0;
|
||
let playing = false;
|
||
let stepBeforeReduce = 0;
|
||
let resumeAfterReduce = root.dataset.motionMode === 'reveal';
|
||
|
||
const buttons = (action) => controls.querySelector(`[data-motion-action="${action}"]`);
|
||
const clearClock = () => { window.clearTimeout(timer); timer = 0; };
|
||
const announce = () => { status.textContent = `Step ${step} of ${count}: ${labels[step] || 'Complete'}`; };
|
||
const setControlsAvailable = (available) => {
|
||
controls.hidden = !available;
|
||
controls.setAttribute('aria-hidden', String(!available));
|
||
controls.dataset.playbackDisabled = String(!available);
|
||
controls.querySelectorAll('button').forEach((control) => { control.disabled = !available; });
|
||
};
|
||
|
||
function render(next, userInitiated = false) {
|
||
step = Math.max(0, Math.min(count, next));
|
||
root.dataset.stepCurrent = String(step);
|
||
root.dataset.frame = step === count ? 'end' : step === 0 ? 'start' : 'step';
|
||
items.forEach((item) => {
|
||
const itemStep = Number(item.dataset.step);
|
||
item.classList.toggle('is-visible', itemStep <= step);
|
||
item.classList.toggle('is-current', itemStep === step);
|
||
});
|
||
if (visibleStep) visibleStep.textContent = String(step);
|
||
const unavailable = controls.dataset.playbackDisabled === 'true';
|
||
buttons('prev').disabled = unavailable || step === 0;
|
||
buttons('next').disabled = unavailable || step === count;
|
||
if (userInitiated) announce();
|
||
}
|
||
|
||
function pause(userInitiated = false) {
|
||
clearClock();
|
||
playing = false;
|
||
root.classList.remove('is-playing');
|
||
buttons('play').setAttribute('aria-pressed', 'false');
|
||
buttons('pause').setAttribute('aria-pressed', 'true');
|
||
if (userInitiated) status.textContent = `Paused at step ${step} of ${count}`;
|
||
}
|
||
|
||
function finish() {
|
||
pause(false);
|
||
root.dataset.frame = 'end';
|
||
status.textContent = `Complete · step ${count} of ${count}`;
|
||
}
|
||
|
||
function tick() {
|
||
if (!playing) return;
|
||
if (step >= count) { finish(); return; }
|
||
render(step + 1, false);
|
||
if (step >= count) { finish(); return; }
|
||
timer = window.setTimeout(tick, hold);
|
||
}
|
||
|
||
function play(fromStart = false, userInitiated = false) {
|
||
clearClock();
|
||
if (fromStart || step >= count) render(0, userInitiated);
|
||
playing = true;
|
||
root.classList.add('is-playing');
|
||
buttons('play').setAttribute('aria-pressed', 'true');
|
||
buttons('pause').setAttribute('aria-pressed', 'false');
|
||
timer = window.setTimeout(tick, hold);
|
||
}
|
||
|
||
controls.addEventListener('click', (event) => {
|
||
const button = event.target.closest('[data-motion-action]');
|
||
if (!button) return;
|
||
const action = button.dataset.motionAction;
|
||
if (action === 'play') play(false, true);
|
||
if (action === 'pause') pause(true);
|
||
if (action === 'replay') play(true, true);
|
||
if (action === 'prev') { pause(false); render(step - 1, true); }
|
||
if (action === 'next') { pause(false); render(step + 1, true); }
|
||
});
|
||
|
||
root.addEventListener('keydown', (event) => {
|
||
if (event.target.matches('input, textarea, select, a[href]')) return;
|
||
const keys = { ArrowLeft: step - 1, ArrowRight: step + 1, Home: 0, End: count };
|
||
if (event.code === 'Space' && !event.target.closest('button')) {
|
||
event.preventDefault();
|
||
playing ? pause(true) : play(false, true);
|
||
return;
|
||
}
|
||
if (!event.ctrlKey && !event.metaKey && !event.altKey && event.key.toLowerCase() === 'r') {
|
||
event.preventDefault();
|
||
play(true, true);
|
||
return;
|
||
}
|
||
if (!(event.key in keys)) return;
|
||
event.preventDefault();
|
||
pause(false);
|
||
render(keys[event.key], true);
|
||
});
|
||
|
||
document.addEventListener('visibilitychange', () => {
|
||
if (document.visibilityState === 'hidden' && playing) pause(false);
|
||
});
|
||
reduce.addEventListener('change', (event) => {
|
||
if (event.matches && root.dataset.motionState !== 'reduced') {
|
||
stepBeforeReduce = step;
|
||
resumeAfterReduce = playing;
|
||
}
|
||
pause(false);
|
||
if (event.matches) {
|
||
setControlsAvailable(false);
|
||
render(count, false);
|
||
root.dataset.frame = 'static';
|
||
root.dataset.motionState = 'reduced';
|
||
status.textContent = 'Reduced motion · complete static frame · playback controls unavailable';
|
||
} else if (staticOverride || root.dataset.motionMode === 'none') {
|
||
setControlsAvailable(false);
|
||
render(count, false);
|
||
root.dataset.frame = 'static';
|
||
root.dataset.motionState = 'static';
|
||
status.textContent = 'Static frame · complete diagram · playback controls unavailable';
|
||
} else {
|
||
delete root.dataset.motionState;
|
||
if (exactStep !== null) {
|
||
setControlsAvailable(false);
|
||
render(exactStep, false);
|
||
root.dataset.frame = 'step';
|
||
root.dataset.motionState = 'test';
|
||
status.textContent = `Test frame · step ${step} of ${count} · playback controls unavailable`;
|
||
} else {
|
||
setControlsAvailable(true);
|
||
render(stepBeforeReduce, false);
|
||
status.textContent = `Ready · step ${step} of ${count}`;
|
||
if (resumeAfterReduce && root.dataset.motionMode === 'reveal') {
|
||
play(false, false);
|
||
}
|
||
}
|
||
resumeAfterReduce = false;
|
||
}
|
||
});
|
||
|
||
if (staticOverride) {
|
||
document.documentElement.dataset.motion = 'static';
|
||
pause(false);
|
||
setControlsAvailable(false);
|
||
render(count, false);
|
||
root.dataset.frame = 'static';
|
||
root.dataset.motionState = 'static';
|
||
status.textContent = 'Static frame · complete diagram · playback controls unavailable';
|
||
} else if (reduce.matches || root.dataset.motionMode === 'none') {
|
||
pause(false);
|
||
setControlsAvailable(false);
|
||
render(count, false);
|
||
root.dataset.frame = 'static';
|
||
root.dataset.motionState = reduce.matches ? 'reduced' : 'static';
|
||
status.textContent = reduce.matches
|
||
? 'Reduced motion · complete static frame · playback controls unavailable'
|
||
: 'Static frame · complete diagram · playback controls unavailable';
|
||
} else if (exactStep !== null) {
|
||
document.documentElement.dataset.motion = 'step';
|
||
pause(false);
|
||
setControlsAvailable(false);
|
||
render(exactStep, false);
|
||
root.dataset.frame = 'step';
|
||
root.dataset.motionState = 'test';
|
||
status.textContent = `Test frame · step ${step} of ${count} · playback controls unavailable`;
|
||
} else if (root.dataset.motionMode === 'reveal') {
|
||
setControlsAvailable(true);
|
||
render(0, false);
|
||
play(false, false);
|
||
} else {
|
||
pause(false);
|
||
setControlsAvailable(true);
|
||
render(0, false);
|
||
status.textContent = `Ready · step 0 of ${count}`;
|
||
}
|
||
root.classList.add('motion-ready');
|
||
});
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|