@birdapi/velinstyle 1.2.2 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.de.md +18 -4
  2. package/README.md +42 -7
  3. package/cli/cli-manifest.json +441 -174
  4. package/cli/docgen/extract-cli.js +2 -1
  5. package/cli/docs-generate.js +9 -0
  6. package/cli/experience.js +173 -0
  7. package/cli/index.js +1294 -417
  8. package/cli/motion.js +738 -0
  9. package/cli/production/extract.js +46 -6
  10. package/cli/production/report.js +104 -14
  11. package/cli/production/run.js +44 -1
  12. package/cli/security.js +190 -0
  13. package/cli/skills.js +53 -10
  14. package/cli/transparency.js +140 -41
  15. package/cli/workflow.js +32 -17
  16. package/components/velin-theme-toggle.js +15 -1
  17. package/core/a11y/component-contracts.json +391 -274
  18. package/core/attributes/registry.js +1 -1
  19. package/core/motion/analyze.js +38 -0
  20. package/core/motion/blueprint.js +51 -0
  21. package/core/motion/composer.js +114 -0
  22. package/core/motion/data/registry.json +352 -0
  23. package/core/motion/effects.js +17 -1
  24. package/core/motion/index.js +112 -9
  25. package/core/motion/optimize.js +44 -0
  26. package/core/motion/policy.js +77 -0
  27. package/core/motion/registry.js +131 -0
  28. package/core/motion/review.js +59 -0
  29. package/core/motion/scan.js +150 -0
  30. package/core/motion/timeline.js +68 -0
  31. package/core/motion/transitions.js +49 -0
  32. package/core/motion/triggers.js +168 -0
  33. package/core/security/checks/fs.js +92 -0
  34. package/core/security/detect.js +35 -0
  35. package/core/security/engine.js +116 -0
  36. package/core/security/index.js +18 -0
  37. package/core/security/registry.js +85 -0
  38. package/core/security/report/report.js +113 -0
  39. package/core/security/rules/ci/index.js +146 -0
  40. package/core/security/rules/consumer/index.js +174 -0
  41. package/core/security/rules/deps/index.js +182 -0
  42. package/core/security/rules/index.js +24 -0
  43. package/core/security/rules/publish/index.js +230 -0
  44. package/core/security/rules/repo/index.js +143 -0
  45. package/core/security/rules/secrets/index.js +133 -0
  46. package/core/security/score/dependency-health.js +53 -0
  47. package/core/security/score/release-health.js +66 -0
  48. package/core/security/score/score.js +102 -0
  49. package/core/security/timeline/timeline.js +76 -0
  50. package/dist/chunks/attributes-HK7VIOLA.js +1080 -0
  51. package/dist/chunks/attributes-VRHHVNDO.js +1080 -0
  52. package/dist/chunks/chunk-MYKUI364.js +116 -0
  53. package/dist/chunks/chunk-NEVBPD5T.js +116 -0
  54. package/dist/chunks/runtime-entry.js +1 -1
  55. package/dist/chunks/velin-theme-toggle-J2NHC7IM.js +304 -0
  56. package/dist/llms.txt +2 -2
  57. package/dist/search-index.json +166 -2
  58. package/dist/velin-agent.json +156 -32
  59. package/dist/velinstyle-components.iife.js +862 -17
  60. package/dist/velinstyle-components.js +862 -17
  61. package/dist/velinstyle-components.min.js +126 -126
  62. package/dist/velinstyle.css +42 -7
  63. package/dist/velinstyle.min.css +1 -1
  64. package/package.json +158 -143
  65. package/packages/velinstyle-cli-core/package.json +11 -0
  66. package/packages/velinstyle-cli-core/src/config.js +84 -0
  67. package/packages/velinstyle-cli-core/src/contract.js +56 -0
  68. package/packages/velinstyle-cli-core/src/event-bus.js +50 -0
  69. package/packages/velinstyle-cli-core/src/index.js +18 -0
  70. package/packages/velinstyle-cli-core/src/lifecycle.js +33 -0
  71. package/packages/velinstyle-cli-core/src/runner.js +129 -0
  72. package/packages/velinstyle-cli-registry/data/commands.json +1216 -0
  73. package/packages/velinstyle-cli-registry/data/layouts.json +33 -0
  74. package/packages/velinstyle-cli-registry/data/widgets.json +35 -0
  75. package/packages/velinstyle-cli-registry/package.json +13 -0
  76. package/packages/velinstyle-cli-registry/src/index.js +88 -0
  77. package/packages/velinstyle-cli-renderer/package.json +8 -0
  78. package/packages/velinstyle-cli-renderer/src/index.js +286 -0
  79. package/packages/velinstyle-cli-theme/package.json +8 -0
  80. package/packages/velinstyle-cli-theme/src/index.js +84 -0
  81. package/packages/velinstyle-cli-ui/package.json +8 -0
  82. package/packages/velinstyle-cli-ui/src/index.js +34 -0
  83. package/packages/velinstyle-motion/README.md +81 -0
  84. package/packages/velinstyle-motion/package.json +37 -0
  85. package/packages/velinstyle-motion/src/ai/mini-prompt.js +119 -0
  86. package/packages/velinstyle-motion/src/ai/provider.js +59 -0
  87. package/packages/velinstyle-motion/src/ai/providers/pixverse-setup.js +402 -0
  88. package/packages/velinstyle-motion/src/ai/providers/pixverse.js +245 -0
  89. package/packages/velinstyle-motion/src/config.js +257 -0
  90. package/packages/velinstyle-motion/src/export/html-clip.js +141 -0
  91. package/packages/velinstyle-motion/src/index.js +304 -0
  92. package/packages/velinstyle-motion/src/jobs/cache.js +30 -0
  93. package/packages/velinstyle-motion/src/jobs/queue.js +69 -0
  94. package/packages/velinstyle-motion/src/jobs/usage.js +53 -0
  95. package/packages/velinstyle-motion/src/local/effects.js +139 -0
  96. package/packages/velinstyle-motion/src/local/engine.js +96 -0
  97. package/packages/velinstyle-motion/src/providers-catalog.js +44 -0
  98. package/packages/velinstyle-motion/src/registry/presets.js +25 -0
  99. package/packages/velinstyle-motion/src/registry/presets.json +85 -0
  100. package/packages/velinstyle-motion/src/ux/cost-gate.js +37 -0
  101. package/packages/velinstyle-motion/src/ux/pixverse-gate.js +153 -0
  102. package/packages/velinstyle-motion/src/ux/wizard.js +171 -0
  103. package/packages/velinstyle-motion/src/validate-image.js +91 -0
  104. package/packages/velinstyle-skills/catalog.json +1 -1
  105. package/packages/velinstyle-skills/registry.json +1 -142
  106. package/packages/velinstyle-skills/skills/velin-motion-reduced-safe/SKILL.md +1 -1
  107. package/packages/velinstyle-skills/skills/velin-motion-reveal-feedback/SKILL.md +1 -1
  108. package/src/tokens/motion.css +7 -0
  109. package/src/utilities/scroll-animation.css +66 -0
@@ -2,24 +2,62 @@ import { observeInView, disconnectInViewObserver } from './scheduler.js';
2
2
  import { applyEffects, markVisible, prefersReducedMotion, VISIBLE_CLASS } from './effects.js';
3
3
  import { enhanceStagger } from './stagger.js';
4
4
  import { bindSmoothScroll } from './scroll.js';
5
+ import { armMotionElement, readTrigger } from './triggers.js';
6
+ import { applyMotionVars, resolvePolicy, shouldDisableMotion } from './policy.js';
7
+ import { enhanceTimelines } from './timeline.js';
8
+ import { enhanceSequences, Motion } from './composer.js';
9
+ import {
10
+ loadMotionRegistry,
11
+ listPresets,
12
+ listPolicies,
13
+ catalogSummary,
14
+ getPreset,
15
+ resolvePresetId,
16
+ } from './registry.js';
5
17
 
6
- const MOTION_ATTRS = ['velin-reveal', 'velin-fade', 'velin-slide', 'velin-scale', 'velin-parallax', 'velin-hover'];
18
+ const LEGACY_ATTRS = ['velin-reveal', 'velin-fade', 'velin-slide', 'velin-scale', 'velin-parallax', 'velin-hover'];
7
19
 
8
20
  let teardownFns = [];
9
21
 
10
22
  /**
11
- * Initialize motion system: in-view reveals, stagger, smooth scroll anchors.
23
+ * Create a motion engine bound to an optional policy.
24
+ * @param {{ policy?: string }} [options]
25
+ */
26
+ export function createMotionEngine(options = {}) {
27
+ const policy = resolvePolicy(options.policy || 'safe');
28
+ return {
29
+ policy,
30
+ registry: () => loadMotionRegistry(),
31
+ catalog: catalogSummary,
32
+ listPresets,
33
+ listPolicies,
34
+ init: (opts = {}) => initMotion({ ...opts, policy: policy.id }),
35
+ Motion,
36
+ };
37
+ }
38
+
39
+ /**
40
+ * Initialize motion system: unified velin-motion + triggers + legacy attrs.
12
41
  * @param {object} [options]
13
42
  * @param {HTMLElement|Document} [options.root]
14
43
  * @param {string} [options.selector]
44
+ * @param {string} [options.policy]
15
45
  */
16
46
  export function initMotion(options = {}) {
17
47
  const root = options.root || (typeof document !== 'undefined' ? document : null);
18
48
  if (!root) return () => {};
19
49
 
50
+ const policy = resolvePolicy(
51
+ options.policy
52
+ || root.documentElement?.getAttribute?.('data-velin-motion-policy')
53
+ || root.querySelector?.('[data-velin-motion-policy]')?.getAttribute('data-velin-motion-policy')
54
+ || 'safe',
55
+ );
56
+
20
57
  const selector =
21
58
  options.selector ||
22
59
  [
60
+ '[velin-motion]',
23
61
  '[velin-reveal]',
24
62
  '[velin-fade]',
25
63
  '[velin-slide]',
@@ -30,32 +68,48 @@ export function initMotion(options = {}) {
30
68
  ].join(',');
31
69
 
32
70
  const elements = root.querySelectorAll(selector);
33
- const reduced = prefersReducedMotion();
71
+ const reduced = shouldDisableMotion(policy) || prefersReducedMotion();
34
72
 
35
73
  for (const el of elements) {
36
74
  if (el.dataset.velinMotionInit) continue;
37
75
  el.dataset.velinMotionInit = '1';
38
76
 
77
+ applyMotionVars(el, policy);
78
+
39
79
  const attrs = {};
40
- for (const name of MOTION_ATTRS) {
80
+ const motionPreset = el.getAttribute('velin-motion');
81
+ if (motionPreset) {
82
+ attrs.motion = motionPreset;
83
+ const resolved = resolvePresetId(motionPreset);
84
+ if (resolved?.id) attrs[resolved.id] = 'true';
85
+ }
86
+ for (const name of LEGACY_ATTRS) {
41
87
  if (el.hasAttribute(name)) attrs[name.replace('velin-', '')] = el.getAttribute(name) || 'true';
42
88
  }
43
89
  applyEffects(el, attrs);
44
90
 
45
- if (reduced) {
91
+ if (reduced || el.dataset.velinMotion === 'off') {
46
92
  markVisible(el);
47
93
  continue;
48
94
  }
49
95
 
50
- const stop = observeInView(el, (node) => markVisible(node));
96
+ const stop = armMotionElement(el, () => applyEffects(el, attrs));
51
97
  teardownFns.push(stop);
52
98
  }
53
99
 
54
100
  enhanceStagger(root);
55
101
  bindSmoothScroll(root);
102
+ enhanceTimelines(root);
103
+ enhanceSequences(root);
56
104
 
57
105
  return () => {
58
- teardownFns.forEach((fn) => fn());
106
+ teardownFns.forEach((fn) => {
107
+ try {
108
+ fn();
109
+ } catch {
110
+ /* ignore */
111
+ }
112
+ });
59
113
  teardownFns = [];
60
114
  disconnectInViewObserver();
61
115
  };
@@ -69,8 +123,57 @@ export function initReveal(options = {}) {
69
123
  });
70
124
  }
71
125
 
72
- export const velinMotion = { init: initMotion, observe: observeInView, markVisible, VISIBLE_CLASS };
126
+ export const velinMotion = {
127
+ init: initMotion,
128
+ observe: observeInView,
129
+ markVisible,
130
+ VISIBLE_CLASS,
131
+ createEngine: createMotionEngine,
132
+ readTrigger,
133
+ };
134
+
73
135
  export { observeInView, disconnectInViewObserver } from './scheduler.js';
74
- export { applyEffects, markVisible, prefersReducedMotion, VISIBLE_CLASS } from './effects.js';
136
+ export { applyEffects, markVisible, prefersReducedMotion, VISIBLE_CLASS, EFFECT_MAP } from './effects.js';
75
137
  export { enhanceStagger, applyStagger } from './stagger.js';
76
138
  export { smoothScrollTo, bindSmoothScroll } from './scroll.js';
139
+ export {
140
+ loadMotionRegistry,
141
+ listPresets,
142
+ listPolicies,
143
+ listTriggers,
144
+ listBlueprints,
145
+ listTimelines,
146
+ listMotion,
147
+ getPreset,
148
+ getPolicy,
149
+ getTrigger,
150
+ getTimeline,
151
+ getBlueprint,
152
+ catalogSummary,
153
+ resolvePresetId,
154
+ NAMESPACES,
155
+ } from './registry.js';
156
+ export {
157
+ bindTrigger,
158
+ readTrigger,
159
+ armMotionElement,
160
+ TRIGGER_V1,
161
+ TRIGGER_V2,
162
+ ALL_TRIGGERS,
163
+ } from './triggers.js';
164
+ export {
165
+ resolvePolicy,
166
+ listMotionPolicies,
167
+ shouldDisableMotion,
168
+ capDuration,
169
+ validatePolicy,
170
+ applyMotionVars,
171
+ } from './policy.js';
172
+ export { scanMotionHtml, doctorMotionHtml, collectMotionElements } from './scan.js';
173
+ export { analyzeMotionHtml } from './analyze.js';
174
+ export { runTimeline, enhanceTimelines } from './timeline.js';
175
+ export { Motion, sequence, fade, slide, wait, rotate, blur, preset, enhanceSequences } from './composer.js';
176
+ export { runViewTransition, applyTransitionHint, getTransitionPreset } from './transitions.js';
177
+ export { emitMotionBlueprint } from './blueprint.js';
178
+ export { optimizeMotionHtml } from './optimize.js';
179
+ export { reviewMotionHtml } from './review.js';
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Safe motion optimize — cap durations, strip infinite where safe.
3
+ * @param {string} html
4
+ * @param {{ write?: boolean, maxDuration?: number }} [opts]
5
+ */
6
+ export function optimizeMotionHtml(html, opts = {}) {
7
+ const max = opts.maxDuration ?? 800;
8
+ let next = String(html || '');
9
+ const changes = [];
10
+
11
+ next = next.replace(/velin-duration\s*=\s*["'](\d+)["']/gi, (full, n) => {
12
+ const v = Number(n);
13
+ if (v > max) {
14
+ changes.push(`capped duration ${v} → ${max}`);
15
+ return `velin-duration="${max}"`;
16
+ }
17
+ return full;
18
+ });
19
+
20
+ next = next.replace(/velin-delay\s*=\s*["'](\d+)["']/gi, (full, n) => {
21
+ const v = Number(n);
22
+ if (v > 500) {
23
+ changes.push(`capped delay ${v} → 500`);
24
+ return `velin-delay="500"`;
25
+ }
26
+ return full;
27
+ });
28
+
29
+ const beforeInf = next;
30
+ next = next.replace(/\s*velin-animate[^\s"']*--infinite/gi, (m) => {
31
+ changes.push(`removed infinite modifier ${m.trim()}`);
32
+ return '';
33
+ });
34
+ if (next !== beforeInf && !changes.some((c) => c.includes('infinite'))) {
35
+ changes.push('stripped infinite animation classes');
36
+ }
37
+
38
+ return {
39
+ ok: true,
40
+ changed: changes.length > 0,
41
+ changes,
42
+ html: next,
43
+ };
44
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Motion Policies — Safe / Reduced / Marketing / …
3
+ */
4
+ import { getPolicy, listPolicies } from './registry.js';
5
+ import { prefersReducedMotion } from './effects.js';
6
+
7
+ /**
8
+ * @param {string} [id='safe']
9
+ */
10
+ export function resolvePolicy(id = 'safe') {
11
+ return getPolicy(id) || getPolicy('safe');
12
+ }
13
+
14
+ export function listMotionPolicies() {
15
+ return listPolicies();
16
+ }
17
+
18
+ /**
19
+ * Whether motion should be skipped under policy + user preference.
20
+ * @param {object|null} policy
21
+ */
22
+ export function shouldDisableMotion(policy) {
23
+ if (!policy) return prefersReducedMotion();
24
+ if (policy.disabled) return true;
25
+ if (policy.forceReduced && prefersReducedMotion()) return true;
26
+ if (prefersReducedMotion() && policy.id !== 'marketing') return true;
27
+ return false;
28
+ }
29
+
30
+ /**
31
+ * Cap a duration against policy.
32
+ * @param {number} ms
33
+ * @param {object|null} policy
34
+ */
35
+ export function capDuration(ms, policy) {
36
+ const max = policy?.maxDurationMs ?? 800;
37
+ if (policy?.disabled) return 0;
38
+ return Math.min(Number(ms) || 0, max);
39
+ }
40
+
41
+ /**
42
+ * Validate policy id exists.
43
+ */
44
+ export function validatePolicy(id) {
45
+ const p = getPolicy(id);
46
+ if (!p) return { ok: false, error: `Unknown motion policy "${id}"` };
47
+ return { ok: true, policy: p };
48
+ }
49
+
50
+ /**
51
+ * Apply CSS variables for duration/ease from attributes + policy.
52
+ * @param {HTMLElement} el
53
+ * @param {object|null} policy
54
+ */
55
+ export function applyMotionVars(el, policy) {
56
+ const duration = el.getAttribute('velin-duration');
57
+ const delay = el.getAttribute('velin-delay');
58
+ const ease = el.getAttribute('velin-ease');
59
+ if (duration) {
60
+ const capped = capDuration(Number(duration), policy);
61
+ el.style.setProperty('--velin-motion-duration', `${capped}ms`);
62
+ el.style.animationDuration = `${capped}ms`;
63
+ }
64
+ if (delay) el.style.animationDelay = `${Number(delay)}ms`;
65
+ if (ease) {
66
+ const map = {
67
+ spring: 'var(--velin-ease-spring)',
68
+ bounce: 'var(--velin-ease-bounce)',
69
+ elastic: 'var(--velin-ease-elastic)',
70
+ default: 'var(--velin-ease-default)',
71
+ };
72
+ el.style.animationTimingFunction = map[ease] || ease;
73
+ }
74
+ if (policy?.disabled || shouldDisableMotion(policy)) {
75
+ el.dataset.velinMotion = 'off';
76
+ }
77
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Velin Motion Registry — all building blocks (Studio-ready source of truth).
3
+ * Browser-safe: no fs — data imported as JSON module.
4
+ */
5
+ import data from './data/registry.json' with { type: 'json' };
6
+
7
+ /** @type {object | null} */
8
+ let cached = data;
9
+
10
+ const NAMESPACES = [
11
+ 'presets',
12
+ 'timelines',
13
+ 'transitions',
14
+ 'physics',
15
+ 'composers',
16
+ 'triggers',
17
+ 'policies',
18
+ 'blueprints',
19
+ ];
20
+
21
+ /**
22
+ * @returns {object}
23
+ */
24
+ export function loadMotionRegistry(override = null) {
25
+ if (override) {
26
+ cached = override;
27
+ return cached;
28
+ }
29
+ return cached || data;
30
+ }
31
+
32
+ export function clearMotionRegistryCache() {
33
+ cached = data;
34
+ }
35
+
36
+ export function listNamespaces() {
37
+ return [...NAMESPACES];
38
+ }
39
+
40
+ /**
41
+ * @param {string} namespace
42
+ * @param {{ status?: string, category?: string, tag?: string }} [filter]
43
+ */
44
+ export function listMotion(namespace, filter = {}) {
45
+ const reg = loadMotionRegistry();
46
+ const key = namespace.endsWith('s') ? namespace : `${namespace}s`;
47
+ const items = reg[key] || reg[namespace] || [];
48
+ return items.filter((item) => {
49
+ if (filter.status && item.status !== filter.status) return false;
50
+ if (filter.category && item.category !== filter.category) return false;
51
+ if (filter.tag && !(item.tags || []).includes(filter.tag)) return false;
52
+ return true;
53
+ });
54
+ }
55
+
56
+ export function getMotion(namespace, id) {
57
+ return listMotion(namespace).find((item) => item.id === id) || null;
58
+ }
59
+
60
+ export function getPreset(id) {
61
+ return getMotion('presets', id);
62
+ }
63
+
64
+ export function getPolicy(id) {
65
+ return getMotion('policies', id);
66
+ }
67
+
68
+ export function getTrigger(id) {
69
+ return getMotion('triggers', id);
70
+ }
71
+
72
+ export function getTimeline(id) {
73
+ return getMotion('timelines', id);
74
+ }
75
+
76
+ export function getBlueprint(id) {
77
+ return getMotion('blueprints', id);
78
+ }
79
+
80
+ export function listPresets(filter) {
81
+ return listMotion('presets', filter);
82
+ }
83
+
84
+ export function listPolicies(filter) {
85
+ return listMotion('policies', filter);
86
+ }
87
+
88
+ export function listTriggers(filter) {
89
+ return listMotion('triggers', filter);
90
+ }
91
+
92
+ export function listBlueprints(filter) {
93
+ return listMotion('blueprints', filter);
94
+ }
95
+
96
+ export function listTimelines(filter) {
97
+ return listMotion('timelines', filter);
98
+ }
99
+
100
+ export function catalogSummary() {
101
+ const reg = loadMotionRegistry();
102
+ const counts = {};
103
+ for (const ns of NAMESPACES) {
104
+ counts[ns] = (reg[ns] || []).length;
105
+ }
106
+ return {
107
+ schema: reg.schema,
108
+ version: reg.version,
109
+ counts,
110
+ namespaces: NAMESPACES,
111
+ };
112
+ }
113
+
114
+ export function exportMotionRegistry() {
115
+ return structuredClone(loadMotionRegistry());
116
+ }
117
+
118
+ export function resolvePresetId(id) {
119
+ if (!id) return null;
120
+ const direct = getPreset(id);
121
+ if (direct) return direct;
122
+ const aliases = {
123
+ scale: 'zoom',
124
+ 'hover-lift': 'lift',
125
+ reveal: 'fade',
126
+ };
127
+ const mapped = aliases[id];
128
+ return mapped ? getPreset(mapped) : null;
129
+ }
130
+
131
+ export { NAMESPACES };
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Review motion markup — counts, delays, reduced-motion, layout-shift, FPS hints.
3
+ */
4
+ import { analyzeMotionHtml } from './analyze.js';
5
+ import { doctorMotionHtml } from './scan.js';
6
+
7
+ /**
8
+ * @param {string} html
9
+ */
10
+ export function reviewMotionHtml(html) {
11
+ const analysis = analyzeMotionHtml(html);
12
+ const doctor = doctorMotionHtml(html);
13
+ const findings = [...doctor.findings];
14
+
15
+ if (analysis.metrics.animations > 40) {
16
+ findings.push({
17
+ severity: 'warning',
18
+ code: 'motion.too-many',
19
+ message: `${analysis.metrics.animations} motion elements — consider reducing`,
20
+ });
21
+ }
22
+ if (analysis.metrics.averageMs > 600) {
23
+ findings.push({
24
+ severity: 'warning',
25
+ code: 'motion.avg-slow',
26
+ message: `Average duration ${analysis.metrics.averageMs}ms is high`,
27
+ });
28
+ }
29
+ if (analysis.metrics.layoutShiftRisk === 'HIGH') {
30
+ findings.push({
31
+ severity: 'error',
32
+ code: 'motion.cls-risk',
33
+ message: 'Layout shift risk HIGH (parallax/infinite combination)',
34
+ });
35
+ }
36
+ if (analysis.metrics.infinite > 0) {
37
+ findings.push({
38
+ severity: 'warning',
39
+ code: 'motion.fps-risk',
40
+ message: `${analysis.metrics.infinite} infinite animation(s) — FPS risk on low-end devices`,
41
+ });
42
+ }
43
+
44
+ const errors = findings.filter((f) => f.severity === 'error').length;
45
+ const warnings = findings.filter((f) => f.severity === 'warning').length;
46
+ const score = Math.max(0, 100 - errors * 25 - warnings * 8);
47
+
48
+ return {
49
+ ok: errors === 0,
50
+ gate: errors ? 'fail' : warnings ? 'warn' : 'pass',
51
+ scores: {
52
+ motion: score,
53
+ accessibility: analysis.metrics.reducedMotion === 'PASS' ? 100 : 40,
54
+ performance: analysis.metrics.layoutShiftRisk === 'LOW' ? 90 : analysis.metrics.layoutShiftRisk === 'MEDIUM' ? 60 : 30,
55
+ },
56
+ metrics: analysis.metrics,
57
+ findings,
58
+ };
59
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Collect motion-related nodes from HTML markup (string or DOM-less regex).
3
+ */
4
+ const MOTION_ATTR_RE =
5
+ /\s(?:velin-motion|velin-reveal|velin-fade|velin-slide|velin-scale|velin-parallax|velin-hover|velin-stagger|velin-scroll|velin-trigger|velin-motion-sequence|data-velin-motion|data-velin-reveal-auto)\s*(?:=\s*["']([^"']*)["'])?/gi;
6
+
7
+ const CLASS_MOTION_RE =
8
+ /\b(?:velin-animate(?:-[\w-]+)?|velin-parallax|velin-skeleton|velin-in-view)\b/g;
9
+
10
+ const DURATION_RE = /velin-duration\s*=\s*["'](\d+)["']/gi;
11
+ const INFINITE_RE = /velin-animate[^\s"']*--infinite|animation:\s*[^;]*infinite/gi;
12
+
13
+ /**
14
+ * @param {string} html
15
+ */
16
+ export function collectMotionElements(html) {
17
+ const text = String(html || '');
18
+ const elements = [];
19
+ const seen = new Set();
20
+
21
+ for (const m of text.matchAll(/<([a-z0-9-]+)([^>]*)>/gi)) {
22
+ const tag = m[1];
23
+ const attrs = m[2] || '';
24
+ if (!/velin-(?:motion|reveal|fade|slide|scale|parallax|hover|stagger|scroll|trigger)/i.test(attrs)
25
+ && !/velin-animate|velin-parallax|velin-skeleton/i.test(attrs)) {
26
+ continue;
27
+ }
28
+ const preset = attrs.match(/velin-motion\s*=\s*["']([^"']+)["']/i)?.[1]
29
+ || (attrs.includes('velin-fade') ? 'fade' : null)
30
+ || (attrs.includes('velin-reveal') ? 'fade' : null)
31
+ || (attrs.includes('velin-slide') ? 'slide' : null)
32
+ || (attrs.includes('velin-scale') ? 'zoom' : null)
33
+ || (attrs.includes('velin-hover') ? 'lift' : null)
34
+ || (attrs.includes('velin-parallax') ? 'parallax' : null)
35
+ || null;
36
+ const trigger = attrs.match(/velin-trigger\s*=\s*["']([^"']+)["']/i)?.[1] || 'visible';
37
+ const duration = Number(attrs.match(/velin-duration\s*=\s*["'](\d+)["']/i)?.[1] || 0);
38
+ const key = `${tag}:${preset}:${trigger}:${attrs.slice(0, 40)}`;
39
+ if (seen.has(key)) continue;
40
+ seen.add(key);
41
+ elements.push({
42
+ tag,
43
+ preset,
44
+ trigger,
45
+ duration,
46
+ hasReducedHint: /prefers-reduced-motion|data-velin-motion\s*=\s*["']off["']/i.test(attrs),
47
+ infinite: /--infinite|infinite/i.test(attrs),
48
+ attrs: attrs.trim().slice(0, 120),
49
+ });
50
+ }
51
+
52
+ const classHits = [...text.matchAll(CLASS_MOTION_RE)].length;
53
+ return { elements, classHits, rawLength: text.length };
54
+ }
55
+
56
+ /**
57
+ * @param {string} html
58
+ * @param {{ policyId?: string }} [opts]
59
+ */
60
+ export function scanMotionHtml(html, opts = {}) {
61
+ const { elements, classHits } = collectMotionElements(html);
62
+ const presets = new Map();
63
+ const triggers = new Map();
64
+ let infinite = 0;
65
+ let longCount = 0;
66
+ const findings = [];
67
+
68
+ for (const el of elements) {
69
+ if (el.preset) presets.set(el.preset, (presets.get(el.preset) || 0) + 1);
70
+ if (el.trigger) triggers.set(el.trigger, (triggers.get(el.trigger) || 0) + 1);
71
+ if (el.infinite) infinite += 1;
72
+ if (el.duration > 800) {
73
+ longCount += 1;
74
+ findings.push({
75
+ severity: 'warning',
76
+ code: 'motion.long-duration',
77
+ message: `Duration ${el.duration}ms exceeds 800ms soft cap`,
78
+ });
79
+ }
80
+ }
81
+
82
+ if (infinite) {
83
+ findings.push({
84
+ severity: 'warning',
85
+ code: 'motion.infinite',
86
+ message: `${infinite} infinite animation hint(s)`,
87
+ });
88
+ }
89
+
90
+ const dupPresets = [...presets.entries()].filter(([, n]) => n > 8);
91
+ for (const [id, n] of dupPresets) {
92
+ findings.push({
93
+ severity: 'info',
94
+ code: 'motion.duplicate-preset',
95
+ message: `Preset "${id}" used ${n} times`,
96
+ });
97
+ }
98
+
99
+ return {
100
+ ok: !findings.some((f) => f.severity === 'error'),
101
+ summary: {
102
+ elements: elements.length,
103
+ classHits,
104
+ presets: presets.size,
105
+ triggers: triggers.size,
106
+ infinite,
107
+ longCount,
108
+ policy: opts.policyId || 'safe',
109
+ },
110
+ presets: Object.fromEntries(presets),
111
+ triggers: Object.fromEntries(triggers),
112
+ findings,
113
+ elements: elements.slice(0, 100),
114
+ };
115
+ }
116
+
117
+ /**
118
+ * @param {string} html
119
+ */
120
+ export function doctorMotionHtml(html) {
121
+ const scan = scanMotionHtml(html);
122
+ const findings = [...scan.findings];
123
+
124
+ if (!/prefers-reduced-motion|data-velin-motion|reduced-motion/i.test(html) && scan.summary.elements > 0) {
125
+ findings.push({
126
+ severity: 'warning',
127
+ code: 'motion.missing-reduced',
128
+ message: 'No reduced-motion / data-velin-motion=off hints found near motion usage',
129
+ });
130
+ }
131
+
132
+ const long = scan.elements.filter((e) => e.duration > 1200);
133
+ for (const el of long) {
134
+ findings.push({
135
+ severity: 'error',
136
+ code: 'motion.excessive-duration',
137
+ message: `Duration ${el.duration}ms is excessive`,
138
+ });
139
+ }
140
+
141
+ const errors = findings.filter((f) => f.severity === 'error').length;
142
+ const warnings = findings.filter((f) => f.severity === 'warning').length;
143
+
144
+ return {
145
+ ok: errors === 0,
146
+ summary: { ...scan.summary, errors, warnings },
147
+ findings,
148
+ scan,
149
+ };
150
+ }