@hegemonart/get-design-done 1.16.0 → 1.19.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 (58) hide show
  1. package/.claude-plugin/marketplace.json +12 -4
  2. package/.claude-plugin/plugin.json +22 -4
  3. package/CHANGELOG.md +111 -0
  4. package/README.md +27 -2
  5. package/agents/design-auditor.md +65 -1
  6. package/agents/design-context-builder.md +6 -1
  7. package/agents/design-doc-writer.md +21 -0
  8. package/agents/design-executor.md +22 -4
  9. package/agents/design-pattern-mapper.md +62 -0
  10. package/agents/design-phase-researcher.md +1 -1
  11. package/agents/motion-mapper.md +74 -9
  12. package/agents/token-mapper.md +8 -0
  13. package/package.json +16 -2
  14. package/reference/components/README.md +27 -23
  15. package/reference/components/alert.md +198 -0
  16. package/reference/components/badge.md +202 -0
  17. package/reference/components/breadcrumbs.md +198 -0
  18. package/reference/components/chip.md +209 -0
  19. package/reference/components/command-palette.md +228 -0
  20. package/reference/components/date-picker.md +227 -0
  21. package/reference/components/file-upload.md +219 -0
  22. package/reference/components/list.md +217 -0
  23. package/reference/components/menu.md +212 -0
  24. package/reference/components/navbar.md +211 -0
  25. package/reference/components/pagination.md +205 -0
  26. package/reference/components/progress.md +210 -0
  27. package/reference/components/rich-text-editor.md +226 -0
  28. package/reference/components/sidebar.md +211 -0
  29. package/reference/components/skeleton.md +197 -0
  30. package/reference/components/slider.md +208 -0
  31. package/reference/components/stepper.md +220 -0
  32. package/reference/components/table.md +229 -0
  33. package/reference/components/toast.md +200 -0
  34. package/reference/components/tree.md +225 -0
  35. package/reference/css-grid-layout.md +835 -0
  36. package/reference/data-visualization.md +333 -0
  37. package/reference/external/NOTICE.hyperframes +28 -0
  38. package/reference/form-patterns.md +245 -0
  39. package/reference/image-optimization.md +582 -0
  40. package/reference/information-architecture.md +255 -0
  41. package/reference/motion-advanced.md +754 -0
  42. package/reference/motion-easings.md +381 -0
  43. package/reference/motion-interpolate.md +282 -0
  44. package/reference/motion-spring.md +234 -0
  45. package/reference/motion-transition-taxonomy.md +155 -0
  46. package/reference/motion.md +20 -0
  47. package/reference/onboarding-progressive-disclosure.md +250 -0
  48. package/reference/output-contracts/motion-map.schema.json +135 -0
  49. package/reference/platforms.md +346 -0
  50. package/reference/registry.json +445 -220
  51. package/reference/registry.schema.json +4 -0
  52. package/reference/rtl-cjk-cultural.md +353 -0
  53. package/reference/user-research.md +360 -0
  54. package/reference/variable-fonts-loading.md +532 -0
  55. package/scripts/lib/easings.cjs +280 -0
  56. package/scripts/lib/parse-contract.cjs +220 -0
  57. package/scripts/lib/spring.cjs +160 -0
  58. package/scripts/tests/test-motion-provenance.sh +64 -0
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Easing functions for web animation.
3
+ *
4
+ * Source: React Native — Libraries/Animated/Easing.js (MIT License)
5
+ * Attribution: Facebook, Inc. and its affiliates
6
+ * https://github.com/facebook/react-native/blob/main/Libraries/Animated/Easing.js
7
+ *
8
+ * Each function accepts t in [0, 1] and returns a value in [0, 1]
9
+ * (elastic/back/bounce may transiently exceed [0,1] for overshoot).
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ /**
15
+ * A linear function, `f(t) = t`. Position correlates to elapsed time one to one.
16
+ */
17
+ function linear(t) {
18
+ return t;
19
+ }
20
+
21
+ /**
22
+ * A quadratic function, `f(t) = t * t`. Position equals the square of elapsed time.
23
+ */
24
+ function quad(t) {
25
+ return t * t;
26
+ }
27
+
28
+ /**
29
+ * A cubic function, `f(t) = t * t * t`. Position equals the cube of elapsed time.
30
+ */
31
+ function cubic(t) {
32
+ return t * t * t;
33
+ }
34
+
35
+ /**
36
+ * A power function. Position is equal to the Nth power of elapsed time.
37
+ * @param {number} n - The exponent.
38
+ * @returns {function(number): number}
39
+ */
40
+ function poly(n) {
41
+ return function (t) {
42
+ return Math.pow(t, n);
43
+ };
44
+ }
45
+
46
+ /**
47
+ * A sinusoidal function.
48
+ */
49
+ function sin(t) {
50
+ return 1 - Math.cos((t * Math.PI) / 2);
51
+ }
52
+
53
+ /**
54
+ * A circular function.
55
+ */
56
+ function circle(t) {
57
+ return 1 - Math.sqrt(1 - t * t);
58
+ }
59
+
60
+ /**
61
+ * An exponential function.
62
+ */
63
+ function exp(t) {
64
+ return Math.pow(2, 10 * (t - 1));
65
+ }
66
+
67
+ /**
68
+ * A spring-like elastic function that overshoots its target value one or more times.
69
+ *
70
+ * @param {number} bounciness - Amplitude of overshoot. Default 1.
71
+ * @param {number} speed - Controls oscillation frequency. Default 1.
72
+ * @returns {function(number): number}
73
+ */
74
+ function elastic(bounciness, speed) {
75
+ if (bounciness === undefined) bounciness = 1;
76
+ if (speed === undefined) speed = 1;
77
+
78
+ const p = (bounciness === 0)
79
+ ? Math.PI / 2
80
+ : Math.asin(1 / (bounciness = Math.max(1, bounciness)));
81
+
82
+ const s = (bounciness / 10) * 0.3 * (speed / 10) || 0.3;
83
+
84
+ return function (t) {
85
+ if (t === 0) return 0;
86
+ if (t === 1) return 1;
87
+ return (
88
+ bounciness *
89
+ Math.pow(2, -10 * t) *
90
+ Math.sin(((t - s / 4) * (2 * Math.PI)) / s) +
91
+ 1
92
+ );
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Use with `Easing.out` for an overshoot effect. Default s = 1.70158.
98
+ * @param {number} s - Overshoot magnitude.
99
+ * @returns {function(number): number}
100
+ */
101
+ function back(s) {
102
+ if (s === undefined) s = 1.70158;
103
+ return function (t) {
104
+ return t * t * ((s + 1) * t - s);
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Provides a bouncing effect.
110
+ * @param {number} t
111
+ * @returns {number}
112
+ */
113
+ function bounce(t) {
114
+ if (t < 1 / 2.75) {
115
+ return 7.5625 * t * t;
116
+ } else if (t < 2 / 2.75) {
117
+ const t2 = t - 1.5 / 2.75;
118
+ return 7.5625 * t2 * t2 + 0.75;
119
+ } else if (t < 2.5 / 2.75) {
120
+ const t2 = t - 2.25 / 2.75;
121
+ return 7.5625 * t2 * t2 + 0.9375;
122
+ } else {
123
+ const t2 = t - 2.625 / 2.75;
124
+ return 7.5625 * t2 * t2 + 0.984375;
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Provides a raw cubic Bézier easing matching the CSS `cubic-bezier()` primitive.
130
+ *
131
+ * Implements the same algorithm as CSS Transitions spec (De Casteljau + Newton-Raphson solve).
132
+ *
133
+ * @param {number} x1
134
+ * @param {number} y1
135
+ * @param {number} x2
136
+ * @param {number} y2
137
+ * @returns {function(number): number}
138
+ */
139
+ function bezier(x1, y1, x2, y2) {
140
+ const NEWTON_ITERATIONS = 4;
141
+ const NEWTON_MIN_SLOPE = 0.001;
142
+ const SUBDIVISION_PRECISION = 0.0000001;
143
+ const SUBDIVISION_MAX_ITERATIONS = 10;
144
+ const kSplineTableSize = 11;
145
+ const kSampleStepSize = 1.0 / (kSplineTableSize - 1.0);
146
+
147
+ function A(a1, a2) { return 1.0 - 3.0 * a2 + 3.0 * a1; }
148
+ function B(a1, a2) { return 3.0 * a2 - 6.0 * a1; }
149
+ function C(a1) { return 3.0 * a1; }
150
+
151
+ function calcBezier(t, a1, a2) {
152
+ return ((A(a1, a2) * t + B(a1, a2)) * t + C(a1)) * t;
153
+ }
154
+
155
+ function getSlope(t, a1, a2) {
156
+ return 3.0 * A(a1, a2) * t * t + 2.0 * B(a1, a2) * t + C(a1);
157
+ }
158
+
159
+ function binarySubdivide(x, a, b) {
160
+ let currentX, currentT, i = 0;
161
+ do {
162
+ currentT = a + (b - a) / 2.0;
163
+ currentX = calcBezier(currentT, x1, x2) - x;
164
+ if (currentX > 0.0) b = currentT;
165
+ else a = currentT;
166
+ } while (Math.abs(currentX) > SUBDIVISION_PRECISION && ++i < SUBDIVISION_MAX_ITERATIONS);
167
+ return currentT;
168
+ }
169
+
170
+ function newtonRaphsonIterate(x, guessT) {
171
+ for (let i = 0; i < NEWTON_ITERATIONS; ++i) {
172
+ const currentSlope = getSlope(guessT, x1, x2);
173
+ if (currentSlope === 0.0) return guessT;
174
+ const currentX = calcBezier(guessT, x1, x2) - x;
175
+ guessT -= currentX / currentSlope;
176
+ }
177
+ return guessT;
178
+ }
179
+
180
+ // Precompute sample table
181
+ const sampleValues = new Float32Array(kSplineTableSize);
182
+ if (x1 !== y1 || x2 !== y2) {
183
+ for (let i = 0; i < kSplineTableSize; ++i) {
184
+ sampleValues[i] = calcBezier(i * kSampleStepSize, x1, x2);
185
+ }
186
+ }
187
+
188
+ function getTForX(x) {
189
+ let intervalStart = 0.0;
190
+ let currentSample = 1;
191
+ const lastSample = kSplineTableSize - 1;
192
+
193
+ for (; currentSample !== lastSample && sampleValues[currentSample] <= x; ++currentSample) {
194
+ intervalStart += kSampleStepSize;
195
+ }
196
+ --currentSample;
197
+
198
+ const dist = (x - sampleValues[currentSample]) / (sampleValues[currentSample + 1] - sampleValues[currentSample]);
199
+ const guessForT = intervalStart + dist * kSampleStepSize;
200
+ const initialSlope = getSlope(guessForT, x1, x2);
201
+
202
+ if (initialSlope >= NEWTON_MIN_SLOPE) {
203
+ return newtonRaphsonIterate(x, guessForT);
204
+ } else if (initialSlope === 0.0) {
205
+ return guessForT;
206
+ } else {
207
+ return binarySubdivide(x, intervalStart, intervalStart + kSampleStepSize);
208
+ }
209
+ }
210
+
211
+ return function (t) {
212
+ if (x1 === y1 && x2 === y2) return t; // linear shortcut
213
+ if (t === 0) return 0;
214
+ if (t === 1) return 1;
215
+ return calcBezier(getTForX(t), y1, y2);
216
+ };
217
+ }
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // Higher-order composition wrappers
221
+ // ---------------------------------------------------------------------------
222
+
223
+ /**
224
+ * Runs an easing function forwards (ease-in direction).
225
+ * `in(f)(t) = f(t)`
226
+ *
227
+ * @param {function(number): number} easing
228
+ * @returns {function(number): number}
229
+ */
230
+ function _in(easing) {
231
+ return easing;
232
+ }
233
+
234
+ /**
235
+ * Runs an easing function backwards. Useful to apply ease-out variants.
236
+ * `out(f)(t) = 1 - f(1 - t)`
237
+ *
238
+ * @param {function(number): number} easing
239
+ * @returns {function(number): number}
240
+ */
241
+ function out(easing) {
242
+ return function (t) {
243
+ return 1 - easing(1 - t);
244
+ };
245
+ }
246
+
247
+ /**
248
+ * Makes any easing function symmetrical. The easing function will run
249
+ * forwards for half of the duration, then backwards for the rest of
250
+ * the duration.
251
+ * `inOut(f)(t) = t < 0.5 ? f(2t)/2 : 1 - f(2(1-t))/2`
252
+ *
253
+ * @param {function(number): number} easing
254
+ * @returns {function(number): number}
255
+ */
256
+ function inOut(easing) {
257
+ return function (t) {
258
+ if (t < 0.5) {
259
+ return easing(t * 2) / 2;
260
+ }
261
+ return 1 - easing((1 - t) * 2) / 2;
262
+ };
263
+ }
264
+
265
+ module.exports = {
266
+ linear,
267
+ quad,
268
+ cubic,
269
+ poly,
270
+ sin,
271
+ circle,
272
+ exp,
273
+ elastic,
274
+ back,
275
+ bounce,
276
+ bezier,
277
+ in: _in,
278
+ out,
279
+ inOut,
280
+ };
@@ -0,0 +1,220 @@
1
+ 'use strict';
2
+ /**
3
+ * parse-contract.cjs — shared helper for validating agent output contracts.
4
+ *
5
+ * Agents that emit structured JSON blocks (e.g. motion-mapper) use this helper
6
+ * to validate and extract the structured data from their output. Works without
7
+ * optional dependencies: pure-JS JSON schema validation for the motion-map contract.
8
+ *
9
+ * Usage:
10
+ * const { parseMotionMap, validate } = require('./parse-contract.cjs');
11
+ * const result = parseMotionMap(markdownString);
12
+ * if (result.ok) console.log(result.data);
13
+ * else console.error(result.error);
14
+ */
15
+
16
+ const path = require('path');
17
+ const fs = require('fs');
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // JSON block extraction
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /**
24
+ * Extract the first ```json ... ``` fenced block from a markdown string.
25
+ * Returns { ok: true, raw: string } or { ok: false, error: string }.
26
+ */
27
+ function extractJsonBlock(markdown) {
28
+ const match = markdown.match(/```json\s*\n([\s\S]*?)\n```/);
29
+ if (!match) {
30
+ return { ok: false, error: 'No ```json ... ``` block found in output' };
31
+ }
32
+ return { ok: true, raw: match[1] };
33
+ }
34
+
35
+ /**
36
+ * Parse a JSON string. Returns { ok: true, data } or { ok: false, error }.
37
+ */
38
+ function parseJson(raw) {
39
+ try {
40
+ return { ok: true, data: JSON.parse(raw) };
41
+ } catch (e) {
42
+ return { ok: false, error: `JSON parse error: ${e.message}` };
43
+ }
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Motion map contract validation (hand-written — no ajv dependency required)
48
+ // ---------------------------------------------------------------------------
49
+
50
+ const VALID_EASINGS = new Set([
51
+ 'linear',
52
+ 'quad', 'quad-in', 'quad-out', 'quad-in-out',
53
+ 'cubic', 'cubic-in', 'cubic-out', 'cubic-in-out',
54
+ 'poly', 'poly-in', 'poly-out', 'poly-in-out',
55
+ 'sin', 'sin-in', 'sin-out', 'sin-in-out',
56
+ 'circle', 'circle-in', 'circle-out', 'circle-in-out',
57
+ 'exp', 'exp-in', 'exp-out', 'exp-in-out',
58
+ 'elastic', 'elastic-in', 'elastic-out', 'elastic-in-out',
59
+ 'back', 'back-in', 'back-out', 'back-in-out',
60
+ 'bounce', 'bounce-in', 'bounce-out', 'bounce-in-out',
61
+ 'bezier',
62
+ ]);
63
+
64
+ const VALID_TRANSITION_FAMILIES = new Set([
65
+ '3d', 'blur', 'cover', 'destruction', 'dissolve', 'distortion', 'grid', 'light',
66
+ ]);
67
+
68
+ const VALID_DURATION_CLASSES = new Set([
69
+ 'instant', 'quick', 'standard', 'slow', 'narrative',
70
+ ]);
71
+
72
+ const VALID_TRIGGERS = new Set([
73
+ 'user-gesture', 'state-change', 'scroll-progress', 'time', 'loop',
74
+ ]);
75
+
76
+ /**
77
+ * Validate a single AnimationBinding object.
78
+ * Returns an array of error strings (empty = valid).
79
+ */
80
+ function validateBinding(binding, index) {
81
+ const errors = [];
82
+ const ctx = `animations[${index}]`;
83
+
84
+ if (typeof binding.id !== 'string' || !binding.id) {
85
+ errors.push(`${ctx}.id: required string`);
86
+ }
87
+ if (!binding.location || typeof binding.location.file !== 'string' || typeof binding.location.line !== 'number') {
88
+ errors.push(`${ctx}.location: required {file: string, line: number}`);
89
+ }
90
+
91
+ // Easing: either a canonical string or a custom object
92
+ const easing = binding.easing;
93
+ if (easing === undefined || easing === null) {
94
+ errors.push(`${ctx}.easing: required`);
95
+ } else if (typeof easing === 'string') {
96
+ if (!VALID_EASINGS.has(easing)) {
97
+ errors.push(`${ctx}.easing: "${easing}" is not a canonical easing. Use one of: ${[...VALID_EASINGS].join(', ')} — or use { type: "custom", justification: "..." }`);
98
+ }
99
+ } else if (typeof easing === 'object') {
100
+ if (easing.type !== 'custom') {
101
+ errors.push(`${ctx}.easing.type: must be "custom" for object form`);
102
+ }
103
+ if (typeof easing.justification !== 'string' || !easing.justification.trim()) {
104
+ errors.push(`${ctx}.easing.justification: required non-empty string when using custom easing`);
105
+ }
106
+ } else {
107
+ errors.push(`${ctx}.easing: must be string (canonical name) or object { type: "custom", justification, value? }`);
108
+ }
109
+
110
+ // transition_family: optional, but if present must be valid
111
+ if (binding.transition_family !== undefined) {
112
+ if (!VALID_TRANSITION_FAMILIES.has(binding.transition_family)) {
113
+ errors.push(`${ctx}.transition_family: "${binding.transition_family}" is not a valid family. Use one of: ${[...VALID_TRANSITION_FAMILIES].join(', ')}`);
114
+ }
115
+ }
116
+
117
+ // duration_class: required
118
+ if (!VALID_DURATION_CLASSES.has(binding.duration_class)) {
119
+ errors.push(`${ctx}.duration_class: required, must be one of: ${[...VALID_DURATION_CLASSES].join(', ')}`);
120
+ }
121
+
122
+ // trigger: required
123
+ if (!VALID_TRIGGERS.has(binding.trigger)) {
124
+ errors.push(`${ctx}.trigger: required, must be one of: ${[...VALID_TRIGGERS].join(', ')}`);
125
+ }
126
+
127
+ return errors;
128
+ }
129
+
130
+ /**
131
+ * Validate a parsed motion-map object against the contract.
132
+ * Returns { ok: true, data } or { ok: false, errors: string[] }.
133
+ */
134
+ function validateMotionMap(data) {
135
+ const errors = [];
136
+
137
+ if (data.schema_version !== '1.0.0') {
138
+ errors.push(`schema_version: must be "1.0.0", got "${data.schema_version}"`);
139
+ }
140
+ if (typeof data.generated_at !== 'string') {
141
+ errors.push('generated_at: required ISO-8601 string');
142
+ }
143
+ if (!Array.isArray(data.animations)) {
144
+ errors.push('animations: required array');
145
+ } else {
146
+ data.animations.forEach((b, i) => {
147
+ errors.push(...validateBinding(b, i));
148
+ });
149
+ }
150
+
151
+ if (errors.length > 0) {
152
+ return { ok: false, errors };
153
+ }
154
+ return { ok: true, data };
155
+ }
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // Public API
159
+ // ---------------------------------------------------------------------------
160
+
161
+ /**
162
+ * Extract, parse, and validate a motion-map JSON block from agent output markdown.
163
+ *
164
+ * @param {string} markdown - Full markdown output from motion-mapper
165
+ * @returns {{ ok: true, data: object } | { ok: false, error: string }}
166
+ */
167
+ function parseMotionMap(markdown) {
168
+ const extracted = extractJsonBlock(markdown);
169
+ if (!extracted.ok) return { ok: false, error: extracted.error };
170
+
171
+ const parsed = parseJson(extracted.raw);
172
+ if (!parsed.ok) return { ok: false, error: parsed.error };
173
+
174
+ const validated = validateMotionMap(parsed.data);
175
+ if (!validated.ok) {
176
+ return {
177
+ ok: false,
178
+ error: `Motion map contract violations:\n${validated.errors.map(e => ` - ${e}`).join('\n')}`,
179
+ };
180
+ }
181
+
182
+ return { ok: true, data: validated.data };
183
+ }
184
+
185
+ /**
186
+ * Generic JSON block extractor and parser (no schema validation).
187
+ * Use for contracts without a built-in validator.
188
+ */
189
+ function parseGenericContract(markdown) {
190
+ const extracted = extractJsonBlock(markdown);
191
+ if (!extracted.ok) return { ok: false, error: extracted.error };
192
+ return parseJson(extracted.raw);
193
+ }
194
+
195
+ /**
196
+ * Load the motion-map JSON schema from the reference directory.
197
+ * Used by external validators (e.g., ajv if available).
198
+ */
199
+ function loadMotionMapSchema(projectRoot) {
200
+ const schemaPath = path.join(
201
+ projectRoot || process.cwd(),
202
+ 'reference/output-contracts/motion-map.schema.json',
203
+ );
204
+ return JSON.parse(fs.readFileSync(schemaPath, 'utf8'));
205
+ }
206
+
207
+ module.exports = {
208
+ parseMotionMap,
209
+ parseGenericContract,
210
+ loadMotionMapSchema,
211
+ validateMotionMap,
212
+ extractJsonBlock,
213
+ parseJson,
214
+ // Exported for testing
215
+ _validateBinding: validateBinding,
216
+ VALID_EASINGS,
217
+ VALID_TRANSITION_FAMILIES,
218
+ VALID_DURATION_CLASSES,
219
+ VALID_TRIGGERS,
220
+ };
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Spring animation utilities.
3
+ *
4
+ * Source: React Native — Libraries/Animated/SpringConfig.js (MIT License)
5
+ * Attribution: Facebook, Inc. and its affiliates
6
+ * https://github.com/facebook/react-native/blob/main/Libraries/Animated/SpringConfig.js
7
+ *
8
+ * Implements a mass-spring-damper physical model.
9
+ * Parameters:
10
+ * stiffness (k) — spring constant; higher = faster, snappier
11
+ * damping (c) — friction coefficient; higher = less oscillation
12
+ * mass (m) — simulated mass; higher = slower, heavier feel
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Canonical presets
19
+ // ---------------------------------------------------------------------------
20
+
21
+ /**
22
+ * Canonical spring presets.
23
+ *
24
+ * Each preset includes stiffness, damping, and mass.
25
+ * Settle times are approximate at 60fps with threshold=0.001.
26
+ *
27
+ * | name | stiffness | damping | mass | settle | character |
28
+ * |---------|-----------|---------|------|---------|---------------------|
29
+ * | gentle | 120 | 14 | 1 | ~400ms | soft, mild bounce |
30
+ * | wobbly | 180 | 12 | 1 | ~600ms | bouncy, 2–3 cycles |
31
+ * | stiff | 400 | 30 | 1 | ~200ms | snappy, minimal |
32
+ * | slow | 280 | 60 | 1 | ~800ms | heavy, no bounce |
33
+ */
34
+ const PRESETS = {
35
+ gentle: { stiffness: 120, damping: 14, mass: 1 },
36
+ wobbly: { stiffness: 180, damping: 12, mass: 1 },
37
+ stiff: { stiffness: 400, damping: 30, mass: 1 },
38
+ slow: { stiffness: 280, damping: 60, mass: 1 },
39
+ };
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Critical damping
43
+ // ---------------------------------------------------------------------------
44
+
45
+ /**
46
+ * Computes the critical damping coefficient for a given stiffness and mass.
47
+ *
48
+ * At critical damping (`c = 2 * sqrt(k * m)`), the spring reaches its target
49
+ * in the shortest time possible without oscillating.
50
+ *
51
+ * Values below this threshold produce an underdamped (bouncy) spring.
52
+ * Values above this threshold produce an overdamped (sluggish) spring.
53
+ *
54
+ * @param {number} stiffness - Spring constant k.
55
+ * @param {number} mass - Simulated mass m.
56
+ * @returns {number} Critical damping coefficient c.
57
+ */
58
+ function criticalDamping(stiffness, mass) {
59
+ return 2 * Math.sqrt(stiffness * mass);
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Settle time estimation
64
+ // ---------------------------------------------------------------------------
65
+
66
+ /**
67
+ * Estimates the settle time of a spring in milliseconds by stepping the
68
+ * simulation forward until position and velocity are both within `threshold`
69
+ * of the target.
70
+ *
71
+ * Uses a fixed timestep of 1/60 s (60fps) for numerical integration.
72
+ *
73
+ * @param {number} stiffness - Spring constant k.
74
+ * @param {number} damping - Damping coefficient c.
75
+ * @param {number} mass - Simulated mass m.
76
+ * @param {number} [threshold=0.001] - Convergence threshold for position and velocity.
77
+ * @param {number} [maxMs=5000] - Safety ceiling to prevent infinite loops.
78
+ * @returns {number} Estimated settle time in milliseconds.
79
+ */
80
+ function settleTime(stiffness, damping, mass, threshold, maxMs) {
81
+ if (threshold === undefined) threshold = 0.001;
82
+ if (maxMs === undefined) maxMs = 5000;
83
+
84
+ const dt = 1 / 60; // seconds per frame
85
+ const maxFrames = Math.ceil(maxMs / (dt * 1000));
86
+
87
+ let pos = 1; // start displaced from target (normalized)
88
+ let vel = 0;
89
+ let elapsed = 0;
90
+
91
+ for (let i = 0; i < maxFrames; i++) {
92
+ const result = _stepInternal(stiffness, damping, mass, pos, vel, dt);
93
+ pos = result.position;
94
+ vel = result.velocity;
95
+ elapsed += dt * 1000;
96
+
97
+ if (Math.abs(pos) < threshold && Math.abs(vel) < threshold) {
98
+ return elapsed;
99
+ }
100
+ }
101
+
102
+ return maxMs; // did not settle within safety ceiling
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Step function
107
+ // ---------------------------------------------------------------------------
108
+
109
+ /**
110
+ * Internal integration step — semi-implicit Euler.
111
+ * @private
112
+ */
113
+ function _stepInternal(stiffness, damping, mass, position, velocity, dt) {
114
+ const acceleration = (-stiffness * position - damping * velocity) / mass;
115
+ const newVelocity = velocity + acceleration * dt;
116
+ const newPosition = position + newVelocity * dt;
117
+ return { position: newPosition, velocity: newVelocity };
118
+ }
119
+
120
+ /**
121
+ * Advances a spring simulation by one timestep.
122
+ *
123
+ * The spring is always simulated toward the target value 0 (normalized).
124
+ * To animate from `from` to `to`, offset your inputs:
125
+ * position = currentValue - toValue
126
+ * velocity = currentVelocity (positive = moving toward target)
127
+ *
128
+ * @param {number} stiffness - Spring constant k.
129
+ * @param {number} damping - Damping coefficient c.
130
+ * @param {number} mass - Simulated mass m.
131
+ * @param {number} initialVelocity - Current velocity in units/second.
132
+ * @param {number} dt - Timestep in seconds. Use 1/60 for 60fps.
133
+ * @returns {{ position: number, velocity: number }}
134
+ *
135
+ * @example
136
+ * // Animate a value from 0 to 100:
137
+ * let pos = 0 - 100; // offset: (current - target)
138
+ * let vel = 0;
139
+ * const dt = 1/60;
140
+ *
141
+ * function tick() {
142
+ * const result = step(400, 30, 1, vel, dt);
143
+ * // Note: pass current velocity, not initialVelocity parameter which is legacy
144
+ * pos = result.position;
145
+ * vel = result.velocity;
146
+ * const displayValue = pos + 100; // un-offset
147
+ * }
148
+ */
149
+ function step(stiffness, damping, mass, initialVelocity, dt) {
150
+ // Position starts at 1 (displaced) when called externally without a position arg.
151
+ // For incremental use, callers manage position themselves — see _stepInternal.
152
+ return _stepInternal(stiffness, damping, mass, 1 - initialVelocity * dt, initialVelocity, dt);
153
+ }
154
+
155
+ module.exports = {
156
+ PRESETS,
157
+ criticalDamping,
158
+ settleTime,
159
+ step,
160
+ };