fix(motion): harden accessible motion contract
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: diagram-design
|
||||
description: Create branded technical and product diagrams in 27 visual types, with semantic patterns, optional accessible motion, and .drawio/Mermaid import.
|
||||
description: Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, or DP security matrix diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.
|
||||
license: MIT
|
||||
metadata:
|
||||
version: "2.3"
|
||||
@@ -18,6 +18,8 @@ Twenty-seven visual types. Semantic patterns describe behavior independently; ty
|
||||
|
||||
**Before generating your first diagram in a new project, verify the style guide has been customized.**
|
||||
|
||||
Don't silently ship default-skinned diagrams into a branded project.
|
||||
|
||||
Open [`references/style-guide.md`](references/style-guide.md) and check the default tokens. If they're still the shipped defaults (paper `#faf7f2`, ink `#1c1917`, accent `#b5523a` rust), **pause and ask the user**:
|
||||
|
||||
> *"This is your first Schematic in this project. The style guide is still at the default (neutral stone + rust). Do you want to customize it to match your brand first? Options: (a) pull from your website URL, (b) extract from an installed skill, (c) extract from a local folder / design-system directory, (d) paste tokens manually, (e) proceed with the default for now."*
|
||||
@@ -51,7 +53,7 @@ Applied to schematics:
|
||||
|
||||
## 2. When to Use
|
||||
|
||||
Use for any of the 27 diagram types (§3) when a reader will learn more from a visual than from prose, a table, or a bulleted list.
|
||||
Use for any of the 27 visual types (§3) when a reader will learn more from a visual than from prose, a table, or a bulleted list.
|
||||
|
||||
**Don't use for:**
|
||||
|
||||
@@ -457,7 +459,7 @@ Run before producing any diagram.
|
||||
- [ ] No vertical `writing-mode` text?
|
||||
- [ ] `viewBox` expanded for the legend strip (~60px)?
|
||||
- [ ] Every font size, coord, width, height, gap divisible by 4?
|
||||
- [ ] If animated, does the complete static/no-JS frame work, does reduced motion hide/disable playback, is the controller copied verbatim from `template-motion.html`, and did I run `verify-motion.py --shipped` plus the skin linter?
|
||||
- [ ] If animated, does the complete static/no-JS frame work, does reduced motion hide/disable playback, and is the controller copied verbatim from `template-motion.html`? In this repository, run `python3 scripts/verify-motion.py path/to/generated.html` plus the skin linter; from an installed skill, manually check no-JS, reduced-motion, print, and static-query states because the repository verifiers are not packaged.
|
||||
|
||||
**Typography:**
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
--motion-fast: 160ms;
|
||||
--motion-step: 480ms;
|
||||
--motion-hold: 720ms;
|
||||
--motion-total: 3600ms;
|
||||
--motion-ease: cubic-bezier(.2,.8,.2,1);
|
||||
}
|
||||
body {
|
||||
@@ -103,10 +104,10 @@
|
||||
font: 500 9px/1.4 var(--mono);
|
||||
}
|
||||
[data-motion-status] {
|
||||
margin-left: auto;
|
||||
display: block;
|
||||
min-width: 272px;
|
||||
padding-left: 16px;
|
||||
border-left: 1px solid var(--rule);
|
||||
min-height: 16px;
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
font: 500 10px/1.4 var(--mono);
|
||||
text-align: right;
|
||||
@@ -143,6 +144,12 @@
|
||||
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 {
|
||||
@@ -271,15 +278,15 @@
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div data-motion-controls aria-label="Policy trace playback controls">
|
||||
<div data-motion-controls role="group" aria-label="Policy trace 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 reset</span>
|
||||
<span data-motion-status role="status" aria-live="polite" aria-atomic="true">Static frame · all 5 rules visible</span>
|
||||
<span class="keyboard-help">←/→ step · Space play/pause · R replay · Home/End jump</span>
|
||||
</div>
|
||||
<span data-motion-status role="status" aria-live="polite" aria-atomic="true">Static frame · all 5 rules visible</span>
|
||||
<noscript><p>Animation controls require JavaScript. The complete final policy trace is shown above.</p></noscript>
|
||||
</main>
|
||||
|
||||
@@ -293,6 +300,7 @@
|
||||
|
||||
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]')];
|
||||
@@ -355,7 +363,7 @@
|
||||
if (step >= count) { finish(); return; }
|
||||
render(step + 1, false);
|
||||
if (step >= count) { finish(); return; }
|
||||
timer = window.setTimeout(tick, 720);
|
||||
timer = window.setTimeout(tick, hold);
|
||||
}
|
||||
|
||||
function play(fromStart = false, userInitiated = false) {
|
||||
@@ -365,7 +373,7 @@
|
||||
root.classList.add('is-playing');
|
||||
buttons('play').setAttribute('aria-pressed', 'true');
|
||||
buttons('pause').setAttribute('aria-pressed', 'false');
|
||||
timer = window.setTimeout(tick, 720);
|
||||
timer = window.setTimeout(tick, hold);
|
||||
}
|
||||
|
||||
controls.addEventListener('click', (event) => {
|
||||
@@ -387,7 +395,7 @@
|
||||
playing ? pause(true) : play(false, true);
|
||||
return;
|
||||
}
|
||||
if (event.key.toLowerCase() === 'r') {
|
||||
if (!event.ctrlKey && !event.metaKey && !event.altKey && event.key.toLowerCase() === 'r') {
|
||||
event.preventDefault();
|
||||
play(true, true);
|
||||
return;
|
||||
@@ -439,7 +447,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
root.classList.add('motion-ready');
|
||||
if (staticOverride) {
|
||||
document.documentElement.dataset.motion = 'static';
|
||||
pause(false);
|
||||
@@ -475,6 +482,7 @@
|
||||
render(0, false);
|
||||
status.textContent = `Ready · step 0 of ${count}`;
|
||||
}
|
||||
root.classList.add('motion-ready');
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
--motion-fast: 160ms;
|
||||
--motion-step: 480ms;
|
||||
--motion-hold: 720ms;
|
||||
--motion-total: 4320ms;
|
||||
--motion-total: 3600ms;
|
||||
--motion-ease: cubic-bezier(.2,.8,.2,1);
|
||||
}
|
||||
body {
|
||||
@@ -46,7 +46,7 @@
|
||||
|
||||
/* Static source is complete. Only an initialized enhancement hides items. */
|
||||
.motion-ready [data-motion-item] {
|
||||
opacity: 0;
|
||||
opacity: .12;
|
||||
transform: translateY(8px);
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
@@ -128,6 +128,11 @@
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
noscript p {
|
||||
margin: 12px 0 0;
|
||||
color: var(--color-muted);
|
||||
font: 500 12px/1.4 var(--font-mono);
|
||||
}
|
||||
|
||||
html[data-motion="static"] [data-motion-controls],
|
||||
html[data-motion="static"] [data-motion-decorative] { display: none !important; }
|
||||
@@ -216,16 +221,17 @@
|
||||
<circle data-motion-item data-step="3" data-motion-decorative aria-hidden="true" focusable="false" class="flow-token" cx="556" cy="180" r="6" fill="#eb6c36"/>
|
||||
</svg>
|
||||
|
||||
<div data-motion-controls aria-label="Diagram playback 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">Arrow keys step · Home/End jump</span>
|
||||
<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>
|
||||
@@ -238,6 +244,7 @@
|
||||
|
||||
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]')];
|
||||
@@ -300,7 +307,7 @@
|
||||
if (step >= count) { finish(); return; }
|
||||
render(step + 1, false);
|
||||
if (step >= count) { finish(); return; }
|
||||
timer = window.setTimeout(tick, 720);
|
||||
timer = window.setTimeout(tick, hold);
|
||||
}
|
||||
|
||||
function play(fromStart = false, userInitiated = false) {
|
||||
@@ -310,7 +317,7 @@
|
||||
root.classList.add('is-playing');
|
||||
buttons('play').setAttribute('aria-pressed', 'true');
|
||||
buttons('pause').setAttribute('aria-pressed', 'false');
|
||||
timer = window.setTimeout(tick, 720);
|
||||
timer = window.setTimeout(tick, hold);
|
||||
}
|
||||
|
||||
controls.addEventListener('click', (event) => {
|
||||
@@ -332,7 +339,7 @@
|
||||
playing ? pause(true) : play(false, true);
|
||||
return;
|
||||
}
|
||||
if (event.key.toLowerCase() === 'r') {
|
||||
if (!event.ctrlKey && !event.metaKey && !event.altKey && event.key.toLowerCase() === 'r') {
|
||||
event.preventDefault();
|
||||
play(true, true);
|
||||
return;
|
||||
@@ -384,7 +391,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
root.classList.add('motion-ready');
|
||||
if (staticOverride) {
|
||||
document.documentElement.dataset.motion = 'static';
|
||||
pause(false);
|
||||
@@ -420,6 +426,7 @@
|
||||
render(0, false);
|
||||
status.textContent = `Ready · step 0 of ${count}`;
|
||||
}
|
||||
root.classList.add('motion-ready');
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -9,12 +9,14 @@ Choose one mode per figure with `data-motion-mode="none|reveal|step|loop"`.
|
||||
| Mode | Behavior | Controls / implementation | Use |
|
||||
|---|---|---|---|
|
||||
| `none` | Complete stable figure | No JavaScript | Default, print, screenshot, export, reduced-motion fallback with playback controls unavailable |
|
||||
| `reveal` | One deterministic run ending complete | CSS-only for ≤5s; otherwise use the scoped controller | Short ordered explanation; never auto-replay |
|
||||
| `reveal` | One deterministic autoplay run ending complete | CSS-only for ≤5s; otherwise use the scoped controller | Short ordered explanation; never auto-replay |
|
||||
| `step` | Paused semantic states | Minimal inline JS for Play, Pause, Replay, Previous, Next | Teaching, comparison, policy traces |
|
||||
| `loop` | One decorative token repeats without changing meaning | CSS-only default | Quiet flow hint; ≥3s cycle |
|
||||
|
||||
Only `loop` repeats. Queue state, typing, field values, policy outcomes, containment, and audit entries use `reveal` or `step` and finish complete.
|
||||
|
||||
`reveal` is the sole sanctioned autoplay mode: it may run once on initial load when motion was explicitly requested, then remains complete. It never restarts on viewport re-entry or without an explicit Replay action.
|
||||
|
||||
## Static-first enhancement contract
|
||||
|
||||
1. **Source is complete.** Every semantic node, label, connector, status, and outcome is visible in the HTML/SVG before enhancement. Only selectors below `.motion-ready` may hide or transform them.
|
||||
@@ -24,7 +26,7 @@ Only `loop` repeats. Queue state, typing, field values, policy outcomes, contain
|
||||
5. **Explicit order.** Mark items `data-motion-item data-step="N"` for integer steps 1–8. DOM order follows narrative order. At most two items enter per step.
|
||||
6. **Stable end.** Completion exposes all items and sets `data-frame="end"`. Replay resets to step 0 first. Pause clears the pending timer and resume continues from the same step.
|
||||
7. **Scoped state.** Controls operate on their nearest `[data-motion-root]`; IDs, timers, live regions, and step state never cross figure boundaries.
|
||||
8. **Failure-safe startup.** JavaScript adds `.motion-ready` only after controls are bound. A script error before that point leaves the complete source visible.
|
||||
8. **Failure-safe startup.** JavaScript adds `.motion-ready` only after controls are bound and the initial render succeeds. A script error before that point leaves the complete source visible.
|
||||
|
||||
## Semantic primitives
|
||||
|
||||
@@ -48,10 +50,11 @@ Do not animate layout coordinates, connector routes, `viewBox`, node dimensions,
|
||||
--motion-fast: 160ms;
|
||||
--motion-step: 480ms;
|
||||
--motion-hold: 720ms;
|
||||
--motion-total: 3600ms; /* five steps × hold; set this per diagram */
|
||||
--motion-ease: cubic-bezier(.2,.8,.2,1);
|
||||
}
|
||||
.motion-ready [data-motion-item] {
|
||||
opacity: 0;
|
||||
opacity: .12;
|
||||
transform: translateY(8px);
|
||||
transition: opacity var(--motion-step) var(--motion-ease),
|
||||
transform var(--motion-step) var(--motion-ease);
|
||||
@@ -83,9 +86,9 @@ Do not animate layout coordinates, connector routes, `viewBox`, node dimensions,
|
||||
|
||||
Every interactive `step` figure provides native buttons for **Play, Pause, Replay, Previous, and Next**, outside the SVG. Use `data-motion-action="play|pause|replay|prev|next"`, ≥44×44px targets, visible focus, disabled state for unavailable actions, and `aria-pressed` for play/pause state.
|
||||
|
||||
When focus is within the motion root: `ArrowRight` advances, `ArrowLeft` goes back, `Home` resets, `End` completes, and `Space` toggles play/pause when focus is not already on a native control. Do not capture keys from inputs, links, or unrelated regions. Never move focus as the frame changes.
|
||||
When focus is within the motion root: `ArrowRight` advances, `ArrowLeft` goes back, `Home` resets, `End` completes, `Space` toggles play/pause when focus is not already on a native control, and unmodified `R` replays. Never intercept `R` when Control, Command, or Alt is held. Do not capture keys from inputs, links, or unrelated regions. Never move focus as the frame changes.
|
||||
|
||||
Provide visible instructions and a scoped `role="status" aria-live="polite" aria-atomic="true"`. Announce user actions such as “Step 3 of 5: first divergence”; do not announce every autoplay frame. Controls operate only on their nearest `[data-motion-root]`.
|
||||
Provide visible instructions and a scoped `role="status" aria-live="polite" aria-atomic="true"`. Keep that live region inside the motion root but outside `[data-motion-controls]`, so hiding controls for reduced/static states cannot hide announcements. Announce user actions such as “Step 3 of 5: first divergence”; do not announce every autoplay frame. Controls operate only on their nearest `[data-motion-root]`.
|
||||
|
||||
Use [`assets/template-motion.html`](../assets/template-motion.html) rather than inventing another controller. Its inline controller is the executable implementation contract: copy that script body verbatim. The skin linter rejects modified or additional controllers, even when they carry `data-diagram-controls`. Replace diagram content and slug-prefixed IDs, but preserve the controller and its state/control attributes.
|
||||
|
||||
@@ -101,7 +104,7 @@ Use [`assets/template-motion.html`](../assets/template-motion.html) rather than
|
||||
|
||||
Motion does not raise the static diagram budget: ≤8 semantic steps (target 3–6), ≤12 marked items, ≤2 simultaneous reveals, ≤2 drawn paths, one flow-token loop, 160–600ms transitions, 400–1200ms holds, ≤24px translation, and 3–8s total autoplay.
|
||||
|
||||
Declare `data-step-count`; do not infer steps from transition events. Use one `setTimeout` chain per root, clear it on Pause/Replay/page hide and immediately after rendering the final step, and never use `setInterval` for semantic playback. Pause when `document.visibilityState` becomes hidden and do not catch up later. `?motion=step&step=N` may expose an exact zero-duration frame for visual regression only when `N` is a non-negative base-10 integer from 0 through `data-step-count`; missing, fractional, negative, and over-budget values leave normal playback in place.
|
||||
Declare `data-step-count`; do not infer steps from transition events. Set `--motion-total` to step count × `--motion-hold` and keep it within the 8-second budget. Use one `setTimeout` chain per root, derive its hold from `--motion-hold`, clear it on Pause/Replay/page hide and immediately after rendering the final step, and never use `setInterval` for semantic playback. Pause when `document.visibilityState` becomes hidden and do not catch up later. `?motion=step&step=N` may expose an exact zero-duration frame for visual regression only when `N` is a non-negative base-10 integer from 0 through `data-step-count`; missing, fractional, negative, and over-budget values leave normal playback in place.
|
||||
|
||||
The final-state capture contract is synchronous: `?motion=static`, `<html data-motion="static">`, or mode `none` exposes every semantic item, hides controls and decorative overlays, and sets `data-frame="static"`. Wait for `document.fonts.ready` before capture. Two captures from the same URL, viewport, fonts, and device scale must be pixel-identical; random delays, generated IDs, clocks, and runtime path measurement are forbidden.
|
||||
|
||||
@@ -130,7 +133,7 @@ Then verify in a browser:
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Autoplay on load, viewport re-entry, or an endless semantic loop.
|
||||
- Unrequested autoplay, autoplay outside the single sanctioned `reveal` run, viewport re-entry, or an endless semantic loop.
|
||||
- A blank/partial no-JS or reduced-motion frame.
|
||||
- Motion that rescues an over-dense or unlabeled static diagram.
|
||||
- Pass/fail, queue fullness, or outcome encoded only by hue.
|
||||
|
||||
Reference in New Issue
Block a user