@supericons/mcp 0.4.6

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.
@@ -0,0 +1,174 @@
1
+ export const MATERIAL_EXPORT_SOURCE = {
2
+ ref: 'master',
3
+ baseUrl: 'https://raw.githubusercontent.com/google/material-design-icons/master/symbols/web',
4
+ styleDir: 'materialsymbolsoutlined',
5
+ };
6
+
7
+ export const MATERIAL_EXPORT_STORAGE = {
8
+ mode: 'owned-static-and-cache',
9
+ localBasePath: '/material-export',
10
+ functionBaseUrl: 'https://kcjmkakdhsqplvasgkjv.supabase.co/functions/v1/serve-material-snapshot',
11
+ bucket: 'material-icons',
12
+ styleDir: 'materialsymbolsoutlined',
13
+ };
14
+
15
+ export const MATERIAL_EXPORT_SUPPORTED_AXES = {
16
+ fill: [0, 1],
17
+ wght: [100, 200, 300, 400, 500, 600, 700],
18
+ grad: [-25, 0, 200],
19
+ opsz: [20, 24, 40, 48],
20
+ };
21
+
22
+ export const MATERIAL_EXPORT_DEFAULT_AXES = {
23
+ fill: 0,
24
+ wght: 300,
25
+ grad: 0,
26
+ opsz: 24,
27
+ };
28
+
29
+ function clamp(value, min, max) {
30
+ return Math.min(Math.max(value, min), max);
31
+ }
32
+
33
+ function nearest(value, supported) {
34
+ let best = supported[0];
35
+ let bestDistance = Math.abs(value - best);
36
+ for (const candidate of supported) {
37
+ const distance = Math.abs(value - candidate);
38
+ if (distance < bestDistance) {
39
+ best = candidate;
40
+ bestDistance = distance;
41
+ }
42
+ }
43
+ return best;
44
+ }
45
+
46
+ export function normalizeMaterialExportAxes(customize = {}) {
47
+ const raw = {
48
+ fill: Number.isFinite(customize.materialFill) ? customize.materialFill : MATERIAL_EXPORT_DEFAULT_AXES.fill,
49
+ wght: Number.isFinite(customize.materialWeight) ? customize.materialWeight : MATERIAL_EXPORT_DEFAULT_AXES.wght,
50
+ grad: Number.isFinite(customize.materialGrade) ? customize.materialGrade : MATERIAL_EXPORT_DEFAULT_AXES.grad,
51
+ opsz: Number.isFinite(customize.materialOpticalSize) ? customize.materialOpticalSize : MATERIAL_EXPORT_DEFAULT_AXES.opsz,
52
+ };
53
+
54
+ const normalized = {
55
+ fill: nearest(clamp(raw.fill, 0, 1), MATERIAL_EXPORT_SUPPORTED_AXES.fill),
56
+ wght: nearest(clamp(raw.wght, 100, 700), MATERIAL_EXPORT_SUPPORTED_AXES.wght),
57
+ grad: nearest(clamp(raw.grad, -25, 200), MATERIAL_EXPORT_SUPPORTED_AXES.grad),
58
+ opsz: nearest(clamp(raw.opsz, 20, 48), MATERIAL_EXPORT_SUPPORTED_AXES.opsz),
59
+ };
60
+
61
+ const snapped =
62
+ normalized.fill !== raw.fill ||
63
+ normalized.wght !== raw.wght ||
64
+ normalized.grad !== raw.grad ||
65
+ normalized.opsz !== raw.opsz;
66
+
67
+ return { ...normalized, snapped };
68
+ }
69
+
70
+ function formatMaterialGradToken(grad) {
71
+ if (grad === 200) return 'grad200';
72
+ if (grad === -25) return 'gradN25';
73
+ return '';
74
+ }
75
+
76
+ function formatMaterialOwnedGradSegment(grad) {
77
+ return grad < 0 ? `grad-neg${Math.abs(grad)}` : `grad-${grad}`;
78
+ }
79
+
80
+ export function buildMaterialUpstreamSnapshotFilename(iconId, axes) {
81
+ let suffix = '';
82
+
83
+ if (axes.wght !== 400) suffix += `wght${axes.wght}`;
84
+ const gradToken = formatMaterialGradToken(axes.grad);
85
+ if (gradToken) suffix += gradToken;
86
+ if (axes.fill === 1) suffix += 'fill1';
87
+
88
+ return `${iconId}${suffix ? `_${suffix}` : ''}_${axes.opsz}px.svg`;
89
+ }
90
+
91
+ export function buildMaterialUpstreamSnapshotUrl(iconId, axes, source = MATERIAL_EXPORT_SOURCE) {
92
+ const filename = buildMaterialUpstreamSnapshotFilename(iconId, axes);
93
+ return `${source.baseUrl}/${encodeURIComponent(iconId)}/${source.styleDir}/${filename}`;
94
+ }
95
+
96
+ export function buildMaterialCacheKey(iconId, axes) {
97
+ return `material:${iconId}:f${axes.fill}:w${axes.wght}:g${axes.grad}:o${axes.opsz}`;
98
+ }
99
+
100
+ export function buildMaterialOwnedStoragePath(iconId, axes, storage = MATERIAL_EXPORT_STORAGE) {
101
+ return [
102
+ storage.styleDir || MATERIAL_EXPORT_STORAGE.styleDir,
103
+ iconId,
104
+ `fill-${axes.fill}`,
105
+ `wght-${axes.wght}`,
106
+ formatMaterialOwnedGradSegment(axes.grad),
107
+ `opsz-${axes.opsz}.svg`,
108
+ ].join('/');
109
+ }
110
+
111
+ export function parseMaterialOwnedStoragePath(path) {
112
+ const normalized = String(path || '').replace(/\\/g, '/').replace(/^\/+/, '');
113
+ const match = normalized.match(
114
+ /^([^/]+)\/([^/]+)\/fill-(0|1)\/wght-(100|200|300|400|500|600|700)\/(grad-(?:0|200)|grad-neg25)\/opsz-(20|24|40|48)\.svg$/
115
+ );
116
+
117
+ if (!match) return null;
118
+
119
+ const [, styleDir, iconId, fill, wght, gradSegment, opsz] = match;
120
+ const grad = gradSegment === 'grad-neg25' ? -25 : Number(gradSegment.replace('grad-', ''));
121
+
122
+ return {
123
+ styleDir,
124
+ iconId,
125
+ axes: {
126
+ fill: Number(fill),
127
+ wght: Number(wght),
128
+ grad,
129
+ opsz: Number(opsz),
130
+ snapped: false,
131
+ },
132
+ };
133
+ }
134
+
135
+ export function normalizeMaterialSnapshotSvg(rawSvg) {
136
+ if (!rawSvg) return null;
137
+ if (/\bfill="/.test(rawSvg)) return rawSvg;
138
+ return rawSvg.replace(/<svg([^>]*)>/, '<svg$1 fill="currentColor">');
139
+ }
140
+
141
+ export function resolveMaterialExportStorage(manifest = {}) {
142
+ return {
143
+ ...MATERIAL_EXPORT_STORAGE,
144
+ ...(manifest?.storage || {}),
145
+ };
146
+ }
147
+
148
+ export function getMaterialManifestEntry(manifest, iconId, axes) {
149
+ if (!manifest?.entries) return null;
150
+ return manifest.entries[buildMaterialCacheKey(iconId, axes)] || null;
151
+ }
152
+
153
+ export function buildMaterialOwnedSnapshotUrl(iconId, axes, manifest = {}) {
154
+ const storage = resolveMaterialExportStorage(manifest);
155
+ const entry = getMaterialManifestEntry(manifest, iconId, axes);
156
+
157
+ if (entry?.url) return entry.url;
158
+
159
+ if (entry?.path) {
160
+ const basePath = String(storage.localBasePath || '/material-export').replace(/\/$/, '');
161
+ const path = String(entry.path).replace(/^\/+/, '');
162
+ return `${basePath}/${path}`;
163
+ }
164
+
165
+ const params = new URLSearchParams({
166
+ icon: iconId,
167
+ fill: String(axes.fill),
168
+ wght: String(axes.wght),
169
+ grad: String(axes.grad),
170
+ opsz: String(axes.opsz),
171
+ });
172
+
173
+ return `${storage.functionBaseUrl}?${params.toString()}`;
174
+ }
@@ -0,0 +1,132 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ const localeDataUrl = new URL('./generated/mcp-output-locales.json', import.meta.url);
4
+ const localeDataset = JSON.parse(readFileSync(localeDataUrl, 'utf8'));
5
+
6
+ export const SUPPORTED_MCP_OUTPUT_LOCALES = Object.freeze(Object.keys(localeDataset.locales || {}));
7
+
8
+ function cloneJson(value) {
9
+ return JSON.parse(JSON.stringify(value));
10
+ }
11
+
12
+ export function normalizeMcpOutputLocale(locale) {
13
+ return SUPPORTED_MCP_OUTPUT_LOCALES.includes(locale) ? locale : null;
14
+ }
15
+
16
+ function getLocale(locale) {
17
+ const normalized = normalizeMcpOutputLocale(locale);
18
+ return normalized ? localeDataset.locales[normalized] : null;
19
+ }
20
+
21
+ function formatMessage(template, replacements = {}) {
22
+ if (typeof template !== 'string') return null;
23
+ return template.replace(/\{([a-zA-Z0-9_]+)\}/g, (match, key) =>
24
+ Object.hasOwn(replacements, key) ? String(replacements[key]) : match
25
+ );
26
+ }
27
+
28
+ function localizeTriggerLabels(triggers = [], localeRecord) {
29
+ return triggers.map((trigger) => ({
30
+ id: trigger,
31
+ label: localeRecord.motionLab.triggers[trigger] || trigger,
32
+ }));
33
+ }
34
+
35
+ export function localizeMotionPresetSummary(record, locale) {
36
+ const localeRecord = getLocale(locale);
37
+ if (!localeRecord) return record;
38
+
39
+ const preset = localeRecord.motionLab.presets[record.preset];
40
+ if (!preset) return record;
41
+
42
+ return {
43
+ ...record,
44
+ localized: {
45
+ locale: normalizeMcpOutputLocale(locale),
46
+ label: preset.label,
47
+ group: preset.group,
48
+ description: preset.description,
49
+ supported_triggers: localizeTriggerLabels(record.supported_triggers || [], localeRecord),
50
+ },
51
+ };
52
+ }
53
+
54
+ export function localizeMotionRecipe(recipe, locale) {
55
+ const localeRecord = getLocale(locale);
56
+ if (!localeRecord || !recipe) return recipe;
57
+
58
+ const presetId = recipe.preset_id || recipe.preset;
59
+ const preset = localeRecord.motionLab.presets[presetId];
60
+ if (!preset) return recipe;
61
+
62
+ return {
63
+ ...recipe,
64
+ localized: {
65
+ locale: normalizeMcpOutputLocale(locale),
66
+ preset: preset.label,
67
+ group: preset.group,
68
+ description: preset.description,
69
+ ...(preset.visual_character ? { visual_character: preset.visual_character } : {}),
70
+ ...(preset.emotional_tone ? { emotional_tone: [...preset.emotional_tone] } : {}),
71
+ ...(preset.recommended_contexts ? { recommended_contexts: [...preset.recommended_contexts] } : {}),
72
+ ...(preset.avoid_for ? { avoid_for: [...preset.avoid_for] } : {}),
73
+ ...(recipe.trigger ? { trigger: localeRecord.motionLab.triggers[recipe.trigger] || recipe.trigger } : {}),
74
+ },
75
+ };
76
+ }
77
+
78
+ export function localizeSelectorInstructions(selectorMode, selectorToken, locale) {
79
+ const localeRecord = getLocale(locale);
80
+ if (!localeRecord) return null;
81
+
82
+ const selectorMessages = localeRecord.messages?.selector || {};
83
+ if (selectorMode === 'literal') return selectorMessages.literal || null;
84
+ if (selectorToken) {
85
+ return formatMessage(selectorMessages.placeholder, { selectorToken });
86
+ }
87
+ return selectorMessages.fallback || null;
88
+ }
89
+
90
+ export function localizeSearchNoResultsHint(locale, hasLocale) {
91
+ const localeRecord = getLocale(locale);
92
+ if (!localeRecord) return null;
93
+ return hasLocale
94
+ ? localeRecord.messages?.hints?.noResultsWithLocale || null
95
+ : localeRecord.messages?.hints?.noResultsNoLocale || null;
96
+ }
97
+
98
+ export function localizeIconNotFoundHint(locale) {
99
+ const localeRecord = getLocale(locale);
100
+ return localeRecord?.messages?.hints?.iconNotFound || null;
101
+ }
102
+
103
+ export function localizeWorkflowAccessPayload(payload, locale) {
104
+ const localeRecord = getLocale(locale);
105
+ if (!localeRecord || !payload || typeof payload !== 'object') return payload;
106
+
107
+ const proKeyHint = localeRecord.messages?.hints?.proKey;
108
+ if (!proKeyHint) return payload;
109
+
110
+ return {
111
+ ...payload,
112
+ localized: {
113
+ locale: normalizeMcpOutputLocale(locale),
114
+ hint: proKeyHint,
115
+ },
116
+ };
117
+ }
118
+
119
+ export function localizeConverterOptions(options, locale) {
120
+ const localeRecord = getLocale(locale);
121
+ if (!localeRecord) return options;
122
+
123
+ const localizedConverter = localeRecord.converter || {};
124
+ return {
125
+ ...options,
126
+ localized: {
127
+ locale: normalizeMcpOutputLocale(locale),
128
+ guidance: cloneJson(localizedConverter.guidance || {}),
129
+ traceClasses: cloneJson(localizedConverter.traceClasses || {}),
130
+ },
131
+ };
132
+ }
@@ -0,0 +1,347 @@
1
+ import { hashApiKey, getConfiguredApiKey, SUPABASE_ANON, SUPABASE_URL } from './auth.js';
2
+
3
+ const HOSTED_FUNCTIONS_BASE_URL = (process.env.SUPERICONS_MOTION_LAB_BASE_URL || `${SUPABASE_URL}/functions/v1`).replace(/\/+$/, '');
4
+ const SESSION_ENDPOINT = `${HOSTED_FUNCTIONS_BASE_URL}/motion-lab-session`;
5
+ const RECIPE_ENDPOINT = `${HOSTED_FUNCTIONS_BASE_URL}/motion-lab-recipe`;
6
+ const CSS_ENDPOINT = `${HOSTED_FUNCTIONS_BASE_URL}/motion-lab-render-css`;
7
+ const ANIMATED_SVG_ENDPOINT = `${HOSTED_FUNCTIONS_BASE_URL}/motion-lab-render-animated-svg`;
8
+ const LOCAL_WORKFLOW_MODULE_URL = new URL('../lib/motion-lab-workflow.js', import.meta.url);
9
+ const PLACEHOLDER_SELECTOR = '{{ICON_SELECTOR}}';
10
+ const MCP_CLIENT_VERSION = '0.3.0';
11
+
12
+ class MotionLabClientError extends Error {
13
+ constructor(message, {
14
+ code = 'motion_lab_service_unavailable',
15
+ status = 503,
16
+ hint = 'Retry when the Motion Lab service is available.',
17
+ retryable = true,
18
+ retryAfterSeconds = null,
19
+ limitScope = null,
20
+ cause = null,
21
+ } = {}) {
22
+ super(message);
23
+ this.name = 'MotionLabClientError';
24
+ this.code = code;
25
+ this.status = status;
26
+ this.hint = hint;
27
+ this.retryable = retryable;
28
+ this.retry_after_seconds = typeof retryAfterSeconds === 'number' ? retryAfterSeconds : null;
29
+ this.limit_scope = typeof limitScope === 'string' ? limitScope : null;
30
+ this.cause = cause;
31
+ }
32
+ }
33
+
34
+ let cachedSession = null;
35
+ let localFallbackWarningShown = false;
36
+
37
+ function normalizeErrorFromBody(body, status) {
38
+ if (status === 404) {
39
+ return new MotionLabClientError('Motion Lab hosted endpoint is not available.', {
40
+ code: 'motion_lab_service_unavailable',
41
+ status,
42
+ hint: 'Deploy the Motion Lab hosted functions or use the repo-local fallback during development.',
43
+ retryable: true,
44
+ });
45
+ }
46
+
47
+ if (body && typeof body === 'object' && !Array.isArray(body)) {
48
+ return new MotionLabClientError(
49
+ typeof body.message === 'string' ? body.message : `Motion Lab request failed (${status}).`,
50
+ {
51
+ code: typeof body.error === 'string' ? body.error : 'motion_lab_service_unavailable',
52
+ status,
53
+ hint: typeof body.hint === 'string' ? body.hint : 'Retry when the Motion Lab service is available.',
54
+ retryable: typeof body.retryable === 'boolean' ? body.retryable : status >= 500,
55
+ retryAfterSeconds: typeof body.retry_after_seconds === 'number' ? body.retry_after_seconds : null,
56
+ limitScope: typeof body.limit_scope === 'string' ? body.limit_scope : null,
57
+ }
58
+ );
59
+ }
60
+
61
+ return new MotionLabClientError(`Motion Lab request failed (${status}).`, {
62
+ status,
63
+ retryable: status >= 500,
64
+ });
65
+ }
66
+
67
+ async function readJsonResponse(response) {
68
+ const text = await response.text();
69
+ if (!text) return null;
70
+ try {
71
+ return JSON.parse(text);
72
+ } catch {
73
+ return { message: text };
74
+ }
75
+ }
76
+
77
+ function getExpiryTimestamp(expiresAt) {
78
+ const expiry = Date.parse(expiresAt || '');
79
+ return Number.isFinite(expiry) ? expiry : 0;
80
+ }
81
+
82
+ function isSessionFresh(session) {
83
+ if (!session?.sessionToken || !session?.expiresAtMs) return false;
84
+ return session.expiresAtMs - Date.now() > 30_000;
85
+ }
86
+
87
+ async function exchangeSessionToken() {
88
+ const apiKey = getConfiguredApiKey();
89
+ if (!apiKey) {
90
+ throw new MotionLabClientError('Motion Lab MCP requires SUPERICONS_API_KEY for premium calls.', {
91
+ code: 'motion_lab_auth_required',
92
+ status: 401,
93
+ hint: 'Set SUPERICONS_API_KEY before using Motion Lab MCP premium tools.',
94
+ retryable: false,
95
+ });
96
+ }
97
+
98
+ const api_key_hash = await hashApiKey(apiKey);
99
+
100
+ let response;
101
+ try {
102
+ response = await fetch(SESSION_ENDPOINT, {
103
+ method: 'POST',
104
+ headers: {
105
+ 'Content-Type': 'application/json',
106
+ 'apikey': SUPABASE_ANON,
107
+ },
108
+ body: JSON.stringify({
109
+ api_key_hash,
110
+ client: {
111
+ surface: 'mcp',
112
+ version: MCP_CLIENT_VERSION,
113
+ },
114
+ }),
115
+ });
116
+ } catch (error) {
117
+ throw new MotionLabClientError('Motion Lab session exchange failed.', {
118
+ cause: error,
119
+ retryable: true,
120
+ });
121
+ }
122
+
123
+ const body = await readJsonResponse(response);
124
+ if (!response.ok) {
125
+ throw normalizeErrorFromBody(body, response.status);
126
+ }
127
+
128
+ const sessionToken = body?.session_token;
129
+ const expiresAt = body?.expires_at;
130
+ if (typeof sessionToken !== 'string' || typeof expiresAt !== 'string') {
131
+ throw new MotionLabClientError('Motion Lab session exchange returned an invalid payload.', {
132
+ retryable: false,
133
+ });
134
+ }
135
+
136
+ cachedSession = {
137
+ sessionToken,
138
+ expiresAtMs: getExpiryTimestamp(expiresAt),
139
+ };
140
+ return cachedSession.sessionToken;
141
+ }
142
+
143
+ async function getSessionToken(forceRefresh = false) {
144
+ if (!forceRefresh && isSessionFresh(cachedSession)) {
145
+ return cachedSession.sessionToken;
146
+ }
147
+ return exchangeSessionToken();
148
+ }
149
+
150
+ async function callHostedMotionLab(endpoint, body) {
151
+ let token = await getSessionToken();
152
+
153
+ for (let attempt = 0; attempt < 2; attempt += 1) {
154
+ let response;
155
+ try {
156
+ response = await fetch(endpoint, {
157
+ method: 'POST',
158
+ headers: {
159
+ 'Content-Type': 'application/json',
160
+ 'apikey': SUPABASE_ANON,
161
+ 'Authorization': `Bearer ${token}`,
162
+ },
163
+ body: JSON.stringify(body),
164
+ });
165
+ } catch (error) {
166
+ throw new MotionLabClientError('Motion Lab hosted call failed.', {
167
+ cause: error,
168
+ retryable: true,
169
+ });
170
+ }
171
+
172
+ const responseBody = await readJsonResponse(response);
173
+ if (response.ok) {
174
+ return responseBody;
175
+ }
176
+
177
+ if (response.status === 401 && attempt === 0) {
178
+ token = await getSessionToken(true);
179
+ continue;
180
+ }
181
+
182
+ throw normalizeErrorFromBody(responseBody, response.status);
183
+ }
184
+
185
+ throw new MotionLabClientError('Motion Lab hosted call failed after token refresh.', {
186
+ retryable: true,
187
+ });
188
+ }
189
+
190
+ async function loadLocalFallbackModule() {
191
+ try {
192
+ return await import(LOCAL_WORKFLOW_MODULE_URL.href);
193
+ } catch {
194
+ return null;
195
+ }
196
+ }
197
+
198
+ function shouldFallbackToLocal(error) {
199
+ if (process.env.SUPERICONS_MOTION_LAB_LOCAL_FALLBACK === '0') {
200
+ return false;
201
+ }
202
+ return Boolean(error?.retryable);
203
+ }
204
+
205
+ async function withOptionalLocalFallback(hostedCall, localResolver) {
206
+ try {
207
+ return await hostedCall();
208
+ } catch (error) {
209
+ if (!shouldFallbackToLocal(error)) {
210
+ throw error;
211
+ }
212
+ const localWorkflow = await loadLocalFallbackModule();
213
+ if (!localWorkflow) {
214
+ throw error;
215
+ }
216
+ if (!localFallbackWarningShown) {
217
+ console.error('[Motion Lab MCP] Hosted premium path unavailable, using local workflow fallback in this repo checkout.');
218
+ localFallbackWarningShown = true;
219
+ }
220
+ return localResolver(localWorkflow);
221
+ }
222
+ }
223
+
224
+ function replaceLegacyCssSelector(css, selector) {
225
+ return css.replace(/#icon-container svg/g, selector);
226
+ }
227
+
228
+ function buildLocalCssResponse(localWorkflow, { preset, trigger, duration_ms, intensity_percent, selector = null }) {
229
+ const recipe = localWorkflow.buildMotionLabRecipe({
230
+ presetId: preset,
231
+ trigger,
232
+ durationMs: duration_ms,
233
+ intensityPercent: intensity_percent,
234
+ });
235
+ const cssSelector = selector || PLACEHOLDER_SELECTOR;
236
+ return {
237
+ recipe: {
238
+ preset_id: recipe.preset_id,
239
+ preset: recipe.preset,
240
+ group: recipe.group,
241
+ },
242
+ css: replaceLegacyCssSelector(
243
+ localWorkflow.buildMotionLabExternalCss({
244
+ presetId: preset,
245
+ trigger,
246
+ durationMs: duration_ms,
247
+ intensityPercent: intensity_percent,
248
+ }),
249
+ cssSelector
250
+ ),
251
+ selector_mode: selector ? 'literal' : 'placeholder',
252
+ ...(selector ? {} : { selector_token: PLACEHOLDER_SELECTOR }),
253
+ };
254
+ }
255
+
256
+ export async function getMotionLabRecipeHosted({ preset, trigger = 'loop', duration_ms = 500, intensity_percent = 100 }) {
257
+ return withOptionalLocalFallback(
258
+ async () => {
259
+ const response = await callHostedMotionLab(RECIPE_ENDPOINT, {
260
+ preset,
261
+ trigger,
262
+ duration_ms,
263
+ intensity_percent,
264
+ });
265
+ return response.recipe;
266
+ },
267
+ async (localWorkflow) => localWorkflow.buildMotionLabRecipe({
268
+ presetId: preset,
269
+ trigger,
270
+ durationMs: duration_ms,
271
+ intensityPercent: intensity_percent,
272
+ })
273
+ );
274
+ }
275
+
276
+ export async function renderMotionLabCssHosted({ preset, trigger = 'loop', duration_ms = 500, intensity_percent = 100, selector = null }) {
277
+ return withOptionalLocalFallback(
278
+ async () => callHostedMotionLab(CSS_ENDPOINT, {
279
+ preset,
280
+ trigger,
281
+ duration_ms,
282
+ intensity_percent,
283
+ ...(selector ? { selector } : {}),
284
+ }),
285
+ async (localWorkflow) => buildLocalCssResponse(localWorkflow, {
286
+ preset,
287
+ trigger,
288
+ duration_ms,
289
+ intensity_percent,
290
+ selector,
291
+ })
292
+ );
293
+ }
294
+
295
+ export async function renderMotionLabAnimatedSvgHosted({ svg, preset, trigger = 'loop', duration_ms = 500, intensity_percent = 100, color = null }) {
296
+ return withOptionalLocalFallback(
297
+ async () => callHostedMotionLab(ANIMATED_SVG_ENDPOINT, {
298
+ svg,
299
+ preset,
300
+ trigger,
301
+ duration_ms,
302
+ intensity_percent,
303
+ ...(color ? { color } : {}),
304
+ }),
305
+ async (localWorkflow) => {
306
+ const recipe = localWorkflow.buildMotionLabRecipe({
307
+ presetId: preset,
308
+ trigger,
309
+ durationMs: duration_ms,
310
+ intensityPercent: intensity_percent,
311
+ });
312
+ return {
313
+ recipe: {
314
+ preset_id: recipe.preset_id,
315
+ preset: recipe.preset,
316
+ group: recipe.group,
317
+ },
318
+ animated_svg: localWorkflow.buildMotionLabAnimatedSvg({
319
+ svg,
320
+ presetId: preset,
321
+ trigger,
322
+ durationMs: duration_ms,
323
+ intensityPercent: intensity_percent,
324
+ color,
325
+ }),
326
+ applied_color: color || null,
327
+ };
328
+ }
329
+ );
330
+ }
331
+
332
+ export async function animateMotionLabIconHosted({ svg, preset, trigger = 'loop', duration_ms = 500, intensity_percent = 100, color = null }) {
333
+ const [recipe, cssResponse, animatedSvgResponse] = await Promise.all([
334
+ getMotionLabRecipeHosted({ preset, trigger, duration_ms, intensity_percent }),
335
+ renderMotionLabCssHosted({ preset, trigger, duration_ms, intensity_percent }),
336
+ renderMotionLabAnimatedSvgHosted({ svg, preset, trigger, duration_ms, intensity_percent, color }),
337
+ ]);
338
+
339
+ return {
340
+ recipe,
341
+ css: cssResponse.css,
342
+ animated_svg: animatedSvgResponse.animated_svg,
343
+ selector_mode: cssResponse.selector_mode,
344
+ ...(cssResponse.selector_token ? { selector_token: cssResponse.selector_token } : {}),
345
+ ...(animatedSvgResponse.applied_color ? { applied_color: animatedSvgResponse.applied_color } : {}),
346
+ };
347
+ }
package/motion-lab.js ADDED
@@ -0,0 +1,21 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { localizeMotionPresetSummary } from './mcp-output-localization.js';
3
+
4
+ const baselineUrl = new URL('./generated/motion-lab-baseline.json', import.meta.url);
5
+ const baselineDataset = JSON.parse(readFileSync(baselineUrl, 'utf8'));
6
+ const baselinePresets = Object.freeze(
7
+ (baselineDataset.presets || []).map((record) => Object.freeze({
8
+ ...record,
9
+ supported_triggers: Object.freeze([...(record.supported_triggers || [])]),
10
+ }))
11
+ );
12
+
13
+ export function listMotionLabPresets(locale = null) {
14
+ return baselinePresets.map((record) => ({
15
+ preset: record.preset,
16
+ label: record.label,
17
+ group: record.group,
18
+ description: record.description,
19
+ supported_triggers: [...record.supported_triggers],
20
+ })).map((record) => localizeMotionPresetSummary(record, locale));
21
+ }