Files
workbuddy_skills/product-planning/assets/diagram-design/assets/example-paved-road-animated.html
T
admin 8160f91559 技能库同步:另一台机的 2026-10-16 包 + element-picker 入包 + 工具归拢到技能级 assets/
product-planning(同步 26 改 65 新)
- 目录约定:docs/pm/<项目>/ ⇒ <工作区>/项目/<产品slug>/ 为产品根,下分 产品规划/ 与 产品原型/{designs,pages}
- ④段骨架六节 ⇒ 七节(新增「原型地址」;后两节改「页面导航」「原型状态取值」)
- ③段新增真机组件库;结案「源站只有暗色主题」,tokens.css(亮色)标为不可用于生产
- ③段改版规则反转为「一个产品只用一套原型、只留当前态」;②段新增 产品规划模板.md(表头唯一定义处)
- scripts:产品根定位重写(含 .registry/current-project 指针),新增 check_terms.py 与 selftest_product_root.py

session-mechanism(同步 3 改 2 新)
- references/06-技能优化规程.md 新增 §5「要求落在生成时看得见的面上」,总表 16 ⇒ 17 条;SKILL.md 同步
- scripts/collabctl.py 取回 _find_board() 候选定位(旧版写死 scripts/board.py,而看板已搬进 boards/会话看板/ ⇒ 旧版本就是坏的)

element-picker(新装)
- 按用户口径作「独立功能加入 product-planning」,非独立全局技能
- 修掉它自身三处路径漂移(技能根写法 ⇒ 同目录;README 两处旧落点;剪贴板示例文案与代码对齐)

工具归拢到技能级 assets/
- diagram-design / design-capture / element-picker / video-capture 从各段 assets/ 移到技能包 assets/
- 新增红线:本包有两处 assets/(技能级工具箱 vs 段内资产),引工具一律写「技能级 assets/<工具名>/」
- 路径口径由两类改三类(技能级工具箱 / 本段自带资产 / 同根技能)

不入库(磁盘保留)
- design-system-tiaoyue 整个目录(用户 2026-10-16 令):git rm -r --cached + .gitignore,理由=14 MB 且可 CDP 重采;
  已在 ③段 SKILL.md 注明「取仓后这一层是空的属正常,用自带套需另取或重采」
- element-picker 的运行期存档 marks/(服务自己会建目录)
2026-10-16 23:13:33 +08:00

547 lines
28 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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 &amp; 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 &amp; 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 &amp; 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>