transitions-refine 0.3.10 → 0.3.12
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.
- package/.agents/skills/refine-live/SKILL.md +46 -16
- package/README.md +40 -13
- package/bin/cli.mjs +167 -58
- package/demo.html +832 -145
- package/package.json +1 -1
- package/server/live-refine-loop.mjs +473 -0
- package/server/motion-tokens.mjs +116 -2
- package/server/relay.mjs +130 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "transitions-refine",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.12",
|
|
4
4
|
"description": "Live, agent-driven Refine panel for CSS/Motion transitions — injects a timeline + Refine UI and runs transitions.dev suggestions via your coding agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
// Live Refine agent loop — polls GET /jobs/next and answers LLM jobs per refine-live skill.
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
3
|
+
import { join, dirname } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { SMOOTH_OUT } from "./motion-tokens.mjs";
|
|
6
|
+
|
|
7
|
+
const RELAY = process.env.REFINE_RELAY_URL || "http://localhost:7331";
|
|
8
|
+
const WORKSPACE = process.env.REFINE_WORKSPACE || join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
9
|
+
const LOG = join(WORKSPACE, ".refine-live.log");
|
|
10
|
+
|
|
11
|
+
let jobsHandled = 0;
|
|
12
|
+
let errors = 0;
|
|
13
|
+
|
|
14
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
15
|
+
|
|
16
|
+
function log(msg) {
|
|
17
|
+
const line = `[${new Date().toISOString()}] ${msg}\n`;
|
|
18
|
+
try {
|
|
19
|
+
writeFileSync(LOG, line, { flag: "a" });
|
|
20
|
+
} catch {}
|
|
21
|
+
console.log(msg);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function relayFetch(path, opts = {}) {
|
|
25
|
+
const res = await fetch(`${RELAY}${path}`, opts);
|
|
26
|
+
return res;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function post(path, body) {
|
|
30
|
+
await relayFetch(path, {
|
|
31
|
+
method: "POST",
|
|
32
|
+
headers: { "Content-Type": "application/json" },
|
|
33
|
+
body: JSON.stringify(body),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── usage inference ──────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
function inferUsage(label = "", selector = "") {
|
|
40
|
+
const s = `${label} ${selector}`.toLowerCase();
|
|
41
|
+
if (/modal|dialog/.test(s)) return "modal";
|
|
42
|
+
if (/dropdown|menu|popover/.test(s)) return "dropdown";
|
|
43
|
+
if (/tooltip/.test(s)) return "tooltip";
|
|
44
|
+
if (/badge|notification/.test(s)) return "badge";
|
|
45
|
+
if (/accordion|collapse/.test(s)) return "accordion";
|
|
46
|
+
if (/panel|drawer|sidebar/.test(s)) return "panel";
|
|
47
|
+
if (/toast/.test(s)) return "toast";
|
|
48
|
+
if (/tab/.test(s)) return "tabs";
|
|
49
|
+
if (/shake|error|invalid/.test(s)) return "shake";
|
|
50
|
+
if (/skeleton|shimmer/.test(s)) return "skeleton";
|
|
51
|
+
if (/icon/.test(s)) return "icon";
|
|
52
|
+
if (/resize|width|height|size/.test(s)) return "resize";
|
|
53
|
+
if (/color|background|theme/.test(s)) return "color";
|
|
54
|
+
if (/opacity|fade/.test(s)) return "fade";
|
|
55
|
+
if (/transform|slide|translate/.test(s)) return "slide";
|
|
56
|
+
return "generic";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function targetDurationMs(usage, property) {
|
|
60
|
+
const p = (property || "").toLowerCase();
|
|
61
|
+
switch (usage) {
|
|
62
|
+
case "modal":
|
|
63
|
+
case "dropdown":
|
|
64
|
+
return 250; // open fast; close handled separately in scan
|
|
65
|
+
case "tooltip":
|
|
66
|
+
return p === "opacity" ? 150 : 150;
|
|
67
|
+
case "badge":
|
|
68
|
+
return 500;
|
|
69
|
+
case "panel":
|
|
70
|
+
return 400;
|
|
71
|
+
case "toast":
|
|
72
|
+
return 350;
|
|
73
|
+
case "tabs":
|
|
74
|
+
return 250;
|
|
75
|
+
case "shake":
|
|
76
|
+
return 80;
|
|
77
|
+
case "skeleton":
|
|
78
|
+
return 400;
|
|
79
|
+
case "icon":
|
|
80
|
+
return 250;
|
|
81
|
+
case "resize":
|
|
82
|
+
return 250;
|
|
83
|
+
case "accordion":
|
|
84
|
+
return 400;
|
|
85
|
+
default:
|
|
86
|
+
return 250;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function shouldNudgeEasing(easing) {
|
|
91
|
+
const n = String(easing || "").replace(/\s+/g, "").toLowerCase();
|
|
92
|
+
if (!n) return false;
|
|
93
|
+
const tokens = new Set([
|
|
94
|
+
SMOOTH_OUT.replace(/\s+/g, "").toLowerCase(),
|
|
95
|
+
"ease-in-out",
|
|
96
|
+
"ease-out",
|
|
97
|
+
"linear",
|
|
98
|
+
"cubic-bezier(0.34,1.36,0.64,1)",
|
|
99
|
+
"cubic-bezier(0.34,3.85,0.64,1)",
|
|
100
|
+
]);
|
|
101
|
+
if (tokens.has(n)) return false;
|
|
102
|
+
return n === "ease" || n === "ease-in" || n.startsWith("cubic-bezier") || n.startsWith("linear(");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function handleSmallRefine(request) {
|
|
106
|
+
const usage = inferUsage(request.label, request.selector);
|
|
107
|
+
const timings = request.timings || [];
|
|
108
|
+
const suggestions = [];
|
|
109
|
+
|
|
110
|
+
for (const t of timings) {
|
|
111
|
+
const prop = t.property || "all";
|
|
112
|
+
const target = targetDurationMs(usage, prop);
|
|
113
|
+
if (Number.isFinite(t.durationMs) && Math.abs(t.durationMs - target) > 10) {
|
|
114
|
+
suggestions.push({
|
|
115
|
+
id: `${prop}-duration`,
|
|
116
|
+
kind: "duration",
|
|
117
|
+
property: prop,
|
|
118
|
+
title: `Duration → ${target}ms`,
|
|
119
|
+
from: `${t.durationMs}ms`,
|
|
120
|
+
to: `${target}ms`,
|
|
121
|
+
patch: { property: prop, durationMs: target },
|
|
122
|
+
reason: `${usage} motion reads best at ${target}ms for ${prop}.`,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
if (shouldNudgeEasing(t.easing)) {
|
|
126
|
+
suggestions.push({
|
|
127
|
+
id: `${prop}-easing`,
|
|
128
|
+
kind: "easing",
|
|
129
|
+
property: prop,
|
|
130
|
+
title: "Easing → Smooth ease out",
|
|
131
|
+
from: t.easing,
|
|
132
|
+
to: SMOOTH_OUT,
|
|
133
|
+
patch: { property: prop, easing: SMOOTH_OUT },
|
|
134
|
+
reason: "Generic curve → transitions.dev smooth ease-out.",
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
suggestions,
|
|
141
|
+
summary: suggestions.length
|
|
142
|
+
? `Nudged ${suggestions.length} value(s) toward motion tokens.`
|
|
143
|
+
: "Already aligned to motion tokens.",
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── replace recipes ──────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
const RECIPES = [
|
|
150
|
+
{ id: "card-resize", file: "01-card-resize.md", title: "Card resize", match: /resize|width|height|size/ },
|
|
151
|
+
{ id: "menu-dropdown", file: "05-menu-dropdown.md", title: "Menu dropdown", match: /dropdown|menu|popover/ },
|
|
152
|
+
{ id: "modal", file: "06-modal.md", title: "Modal open/close", match: /modal|dialog/ },
|
|
153
|
+
{ id: "panel-reveal", file: "07-panel-reveal.md", title: "Panel reveal", match: /panel|drawer|sidebar/ },
|
|
154
|
+
{ id: "tooltip", file: "17-tooltip.md", title: "Tooltip", match: /tooltip/ },
|
|
155
|
+
{ id: "notification-badge", file: "03-notification-badge.md", title: "Notification badge", match: /badge|notification/ },
|
|
156
|
+
{ id: "accordion", file: "21-accordion.md", title: "Accordion", match: /accordion|collapse/ },
|
|
157
|
+
{ id: "icon-swap", file: "09-icon-swap.md", title: "Icon swap", match: /icon/ },
|
|
158
|
+
{ id: "tabs-sliding", file: "16-tabs-sliding.md", title: "Tabs sliding", match: /tab/ },
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
function pickRecipe(request) {
|
|
162
|
+
const s = `${request.label || ""} ${request.selector || ""}`.toLowerCase();
|
|
163
|
+
for (const r of RECIPES) {
|
|
164
|
+
if (r.match.test(s)) return r;
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function handleReplaceRefine(request) {
|
|
170
|
+
const recipe = pickRecipe(request);
|
|
171
|
+
if (!recipe) {
|
|
172
|
+
return { suggestions: [], summary: "No matching transitions.dev recipe for this usage." };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const prop = request.timings?.[0]?.property || "all";
|
|
176
|
+
const phases = Array.isArray(request.phases) ? request.phases.filter((p) => p?.phase) : [];
|
|
177
|
+
const hasOpenClose = phases.some((p) => p.phase === "open") && phases.some((p) => p.phase === "close");
|
|
178
|
+
|
|
179
|
+
const basePatch = {
|
|
180
|
+
property: prop,
|
|
181
|
+
durationMs: 250,
|
|
182
|
+
easing: SMOOTH_OUT,
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const suggestion = {
|
|
186
|
+
id: `replace-${recipe.id}`,
|
|
187
|
+
kind: "replace",
|
|
188
|
+
property: prop,
|
|
189
|
+
title: `Replace with ${recipe.title}`,
|
|
190
|
+
from: "hand-rolled transition",
|
|
191
|
+
to: `transitions.dev · ${recipe.title}`,
|
|
192
|
+
patch: basePatch,
|
|
193
|
+
reference: `transitions-dev/${recipe.file}`,
|
|
194
|
+
reason: `Usage matches ${recipe.title}. Paste ${recipe.file} for full structure.`,
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
if (hasOpenClose) {
|
|
198
|
+
suggestion.patches = [
|
|
199
|
+
{ ...basePatch, durationMs: 250, phase: "open" },
|
|
200
|
+
{ ...basePatch, durationMs: 150, phase: "close", easing: SMOOTH_OUT },
|
|
201
|
+
];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
suggestions: [suggestion],
|
|
206
|
+
summary: `Suggested ${recipe.title} recipe.`,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── scan: parse cssRules ─────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
function parseTransitionDecl(text) {
|
|
213
|
+
const out = [];
|
|
214
|
+
const re = /([\w-]+)\s+(\d+(?:\.\d+)?)(ms|s)(?:\s+([\w().,-]+))?(?:\s+(\d+(?:\.\d+)?)(ms|s))?/gi;
|
|
215
|
+
let m;
|
|
216
|
+
while ((m = re.exec(text)) !== null) {
|
|
217
|
+
let ms = parseFloat(m[2]);
|
|
218
|
+
if (m[3] === "s") ms *= 1000;
|
|
219
|
+
let delay = 0;
|
|
220
|
+
if (m[5]) {
|
|
221
|
+
delay = parseFloat(m[5]);
|
|
222
|
+
if (m[6] === "s") delay *= 1000;
|
|
223
|
+
}
|
|
224
|
+
out.push({
|
|
225
|
+
property: m[1],
|
|
226
|
+
durationMs: Math.round(ms),
|
|
227
|
+
delayMs: Math.round(delay),
|
|
228
|
+
easing: m[4] || "ease",
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function slug(s) {
|
|
235
|
+
return String(s || "item")
|
|
236
|
+
.replace(/[^a-z0-9]+/gi, "-")
|
|
237
|
+
.replace(/^-|-$/g, "")
|
|
238
|
+
.toLowerCase() || "item";
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function detectPhase(selector) {
|
|
242
|
+
const s = String(selector || "").toLowerCase();
|
|
243
|
+
if (/closing|close|hide|exit|leave/.test(s)) return "close";
|
|
244
|
+
if (/open|show|enter|visible|is-active/.test(s)) return "open";
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function handleScan(request) {
|
|
249
|
+
const raw = request.raw || [];
|
|
250
|
+
const groupMap = new Map();
|
|
251
|
+
|
|
252
|
+
for (const entry of raw) {
|
|
253
|
+
const sel = entry.selector || entry.label || "unknown";
|
|
254
|
+
const baseKey = sel.split(/[\s>+~]/).pop()?.replace(/^[.#]/, "") || slug(entry.label);
|
|
255
|
+
const groupId = slug(baseKey.split("-")[0] || baseKey);
|
|
256
|
+
const groupLabel = groupId.charAt(0).toUpperCase() + groupId.slice(1);
|
|
257
|
+
|
|
258
|
+
if (!groupMap.has(groupId)) {
|
|
259
|
+
groupMap.set(groupId, {
|
|
260
|
+
id: groupId,
|
|
261
|
+
label: groupLabel,
|
|
262
|
+
component: request.url || null,
|
|
263
|
+
phases: new Map(),
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
const group = groupMap.get(groupId);
|
|
267
|
+
const rules = entry.cssRules || [];
|
|
268
|
+
const currentTimings = entry.timings || [];
|
|
269
|
+
|
|
270
|
+
if (rules.length) {
|
|
271
|
+
for (const rule of rules) {
|
|
272
|
+
const phaseHint = detectPhase(rule) || "open";
|
|
273
|
+
const phaseId = `${groupId}:${phaseHint}`;
|
|
274
|
+
if (!group.phases.has(phaseId)) {
|
|
275
|
+
group.phases.set(phaseId, {
|
|
276
|
+
id: phaseId,
|
|
277
|
+
phase: phaseHint,
|
|
278
|
+
label: phaseHint === "close" ? "Close" : "Open",
|
|
279
|
+
members: [],
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
const timings = parseTransitionDecl(rule);
|
|
283
|
+
const memberTimings = timings.length ? timings : currentTimings;
|
|
284
|
+
group.phases.get(phaseId).members.push({
|
|
285
|
+
id: slug(entry.label || sel),
|
|
286
|
+
label: entry.label || sel,
|
|
287
|
+
selector: sel,
|
|
288
|
+
toState: phaseHint === "close" ? ".is-closing" : ".is-open",
|
|
289
|
+
propertyTimings: memberTimings.map((t) => ({ ...t })),
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
} else {
|
|
293
|
+
const phaseId = `${groupId}:open`;
|
|
294
|
+
if (!group.phases.has(phaseId)) {
|
|
295
|
+
group.phases.set(phaseId, {
|
|
296
|
+
id: phaseId,
|
|
297
|
+
phase: "open",
|
|
298
|
+
label: "Open",
|
|
299
|
+
members: [],
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
group.phases.get(phaseId).members.push({
|
|
303
|
+
id: slug(entry.label || sel),
|
|
304
|
+
label: entry.label || sel,
|
|
305
|
+
selector: sel,
|
|
306
|
+
propertyTimings: currentTimings.map((t) => ({ ...t })),
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const groups = [...groupMap.values()].map((g) => ({
|
|
312
|
+
id: g.id,
|
|
313
|
+
label: g.label,
|
|
314
|
+
component: g.component,
|
|
315
|
+
phases: [...g.phases.values()],
|
|
316
|
+
}));
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
groups,
|
|
320
|
+
summary: groups.length ? `Grouped into ${groups.length} component(s).` : "Could not group transitions.",
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ── apply: edit source ───────────────────────────────────────────────────────
|
|
325
|
+
|
|
326
|
+
function findFile(componentHint) {
|
|
327
|
+
if (!componentHint) return null;
|
|
328
|
+
const candidates = [
|
|
329
|
+
join(WORKSPACE, componentHint),
|
|
330
|
+
join(WORKSPACE, "..", componentHint),
|
|
331
|
+
join(process.cwd(), componentHint),
|
|
332
|
+
];
|
|
333
|
+
for (const p of candidates) {
|
|
334
|
+
if (existsSync(p)) return p;
|
|
335
|
+
}
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function replaceTimingInText(text, change) {
|
|
340
|
+
const { property, from, to } = change;
|
|
341
|
+
const prop = property || "all";
|
|
342
|
+
const fromDur = from?.durationMs;
|
|
343
|
+
const toDur = to?.durationMs;
|
|
344
|
+
const toEase = to?.easing;
|
|
345
|
+
|
|
346
|
+
let out = text;
|
|
347
|
+
if (Number.isFinite(fromDur) && Number.isFinite(toDur)) {
|
|
348
|
+
const patterns = [
|
|
349
|
+
new RegExp(`(${prop}\\s+)${fromDur}(ms)`, "g"),
|
|
350
|
+
new RegExp(`(${prop}\\s+)${fromDur / 1000}(s)`, "g"),
|
|
351
|
+
new RegExp(`(transition(?:-duration)?\\s*:\\s*)${fromDur}(ms)`, "g"),
|
|
352
|
+
];
|
|
353
|
+
for (const re of patterns) {
|
|
354
|
+
out = out.replace(re, `$1${toDur}$2`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (toEase && from?.easing && from.easing !== toEase) {
|
|
358
|
+
out = out.replace(new RegExp(escapeRe(from.easing), "g"), toEase);
|
|
359
|
+
}
|
|
360
|
+
return out;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function escapeRe(s) {
|
|
364
|
+
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function handleApply(request) {
|
|
368
|
+
const file = findFile(request.component);
|
|
369
|
+
if (!file) {
|
|
370
|
+
return {
|
|
371
|
+
applied: false,
|
|
372
|
+
summary: `Could not find source for ${request.component || request.selector}.`,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
let text = readFileSync(file, "utf8");
|
|
377
|
+
const changes = request.changes || [];
|
|
378
|
+
let changed = false;
|
|
379
|
+
|
|
380
|
+
for (const c of changes) {
|
|
381
|
+
const next = replaceTimingInText(text, c);
|
|
382
|
+
if (next !== text) {
|
|
383
|
+
text = next;
|
|
384
|
+
changed = true;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (changed) {
|
|
389
|
+
writeFileSync(file, text);
|
|
390
|
+
return {
|
|
391
|
+
applied: true,
|
|
392
|
+
summary: `Updated timings in ${request.label || request.selector}.`,
|
|
393
|
+
files: [`${file}`],
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return {
|
|
398
|
+
applied: false,
|
|
399
|
+
summary: `Found ${file} but could not match declarations to update.`,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ── dispatch ─────────────────────────────────────────────────────────────────
|
|
404
|
+
|
|
405
|
+
async function handleJob(job) {
|
|
406
|
+
const { id, request } = job;
|
|
407
|
+
const kind = request?.kind;
|
|
408
|
+
const label = request?.label || request?.selector || kind || "job";
|
|
409
|
+
log(`▸ job ${id.slice(0, 8)} — ${label} (${kind || request?.refineType || "refine"})`);
|
|
410
|
+
|
|
411
|
+
await post(`/jobs/${id}/status`, { message: "Processing…" });
|
|
412
|
+
|
|
413
|
+
let result;
|
|
414
|
+
if (kind === "scan") {
|
|
415
|
+
result = handleScan(request);
|
|
416
|
+
} else if (kind === "apply") {
|
|
417
|
+
result = handleApply(request);
|
|
418
|
+
} else if (request?.refineType === "replace") {
|
|
419
|
+
result = handleReplaceRefine(request);
|
|
420
|
+
} else {
|
|
421
|
+
result = handleSmallRefine(request);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
await post(`/jobs/${id}/result`, result);
|
|
425
|
+
jobsHandled++;
|
|
426
|
+
log(` ✓ done (${jobsHandled} total)`);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function pollOnce() {
|
|
430
|
+
const res = await relayFetch("/jobs/next");
|
|
431
|
+
if (res.status === 204) return false;
|
|
432
|
+
if (!res.ok) throw new Error(`poll HTTP ${res.status}`);
|
|
433
|
+
const job = await res.json();
|
|
434
|
+
// Stop signal from the panel's STOP button (relay POST /poller/stop → the
|
|
435
|
+
// relay answers the next /jobs/next with {stop:true}). Honor it and exit.
|
|
436
|
+
// Without this the {stop:true} body is treated as a malformed job, throws,
|
|
437
|
+
// and the loop re-polls ~2s later — so the relay reports pollerActive again
|
|
438
|
+
// and the panel flips "Live" back on shortly after Stop.
|
|
439
|
+
if (job && job.stop) {
|
|
440
|
+
log(`stop signal received — exiting (jobs=${jobsHandled} errors=${errors})`);
|
|
441
|
+
process.exit(0);
|
|
442
|
+
}
|
|
443
|
+
await handleJob(job);
|
|
444
|
+
return true;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async function loop() {
|
|
448
|
+
log(`live refine loop → ${RELAY} (workspace: ${WORKSPACE})`);
|
|
449
|
+
// eslint-disable-next-line no-constant-condition
|
|
450
|
+
while (true) {
|
|
451
|
+
try {
|
|
452
|
+
const hadJob = await pollOnce();
|
|
453
|
+
if (!hadJob) await sleep(100);
|
|
454
|
+
} catch (e) {
|
|
455
|
+
errors++;
|
|
456
|
+
log(` ✗ ${e.message} — retry in 2s`);
|
|
457
|
+
await sleep(2000);
|
|
458
|
+
try {
|
|
459
|
+
const h = await relayFetch("/health");
|
|
460
|
+
if (!h.ok) log(" relay health check failed");
|
|
461
|
+
} catch {
|
|
462
|
+
log(" relay unreachable");
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
process.on("SIGINT", () => {
|
|
469
|
+
log(`stopped — jobs=${jobsHandled} errors=${errors}`);
|
|
470
|
+
process.exit(0);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
loop();
|
package/server/motion-tokens.mjs
CHANGED
|
@@ -20,6 +20,23 @@ export const DURATION_TOKENS = [
|
|
|
20
20
|
// The transitions.dev default ease-out — "Smooth ease out" in the skill.
|
|
21
21
|
export const SMOOTH_OUT = "cubic-bezier(0.22, 1, 0.36, 1)";
|
|
22
22
|
|
|
23
|
+
// Scales — from the skill's "## Motion tokens" Scales table. These are the
|
|
24
|
+
// non-resting ("pre") scale a surface animates FROM (it always settles to 1).
|
|
25
|
+
export const SCALE_TOKENS = [
|
|
26
|
+
{ v: 0.96, name: "Large", usage: "modal open / close" },
|
|
27
|
+
{ v: 0.97, name: "Medium", usage: "dropdown open" },
|
|
28
|
+
{ v: 0.98, name: "Small", usage: "tooltip open" },
|
|
29
|
+
{ v: 0.99, name: "Tiny", usage: "dropdown close" },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
// Blur (px) — from the skill's "## Motion tokens" Blur table. The non-resting
|
|
33
|
+
// blur a surface animates FROM (it always settles to 0).
|
|
34
|
+
export const BLUR_TOKENS = [
|
|
35
|
+
{ px: 2, name: "Small", usage: "panel reveal, icon swap, text swap, skeleton reveal, number pop-in" },
|
|
36
|
+
{ px: 3, name: "Medium", usage: "page slide, text reveal" },
|
|
37
|
+
{ px: 8, name: "Large", usage: "success check open" },
|
|
38
|
+
];
|
|
39
|
+
|
|
23
40
|
// Easing values that ARE motion tokens — leave these alone.
|
|
24
41
|
const TOKEN_EASINGS = new Set(
|
|
25
42
|
[
|
|
@@ -49,6 +66,54 @@ function nearestDuration(ms) {
|
|
|
49
66
|
return { token: best, delta: bestDelta };
|
|
50
67
|
}
|
|
51
68
|
|
|
69
|
+
function nearestScale(v) {
|
|
70
|
+
let best = SCALE_TOKENS[0];
|
|
71
|
+
let bestDelta = Infinity;
|
|
72
|
+
for (const t of SCALE_TOKENS) {
|
|
73
|
+
const d = Math.abs(t.v - v);
|
|
74
|
+
if (d < bestDelta) { bestDelta = d; best = t; }
|
|
75
|
+
}
|
|
76
|
+
return { token: best, delta: bestDelta };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function nearestBlur(px) {
|
|
80
|
+
let best = BLUR_TOKENS[0];
|
|
81
|
+
let bestDelta = Infinity;
|
|
82
|
+
for (const t of BLUR_TOKENS) {
|
|
83
|
+
const d = Math.abs(t.px - px);
|
|
84
|
+
if (d < bestDelta) { bestDelta = d; best = t; }
|
|
85
|
+
}
|
|
86
|
+
return { token: best, delta: bestDelta };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const norm = (s) => String(s || "").toLowerCase();
|
|
90
|
+
|
|
91
|
+
// Usage-aware token pick — the skill says "match on USAGE, not the nearest
|
|
92
|
+
// number". Even deterministic should honour this when the label/selector/phase
|
|
93
|
+
// reveal the component, so a dropdown's 0.8 pre-scale snaps to 0.97 (dropdown
|
|
94
|
+
// open), not to 0.96 (the nearest number). Returns null when usage is unclear,
|
|
95
|
+
// and the caller falls back to nearest-by-magnitude.
|
|
96
|
+
function pickScaleByUsage(hint) {
|
|
97
|
+
const h = norm(hint);
|
|
98
|
+
if (!h) return null;
|
|
99
|
+
const isClose = /clos/.test(h);
|
|
100
|
+
if (/\bmodal\b|\bdialog\b|\blightbox\b/.test(h)) return SCALE_TOKENS.find((t) => t.v === 0.96);
|
|
101
|
+
if (/\btooltip\b|\btip\b|\bpopover\b/.test(h)) return SCALE_TOKENS.find((t) => t.v === 0.98);
|
|
102
|
+
if (/\bdropdown\b|drop-down|\bmenu\b|\bselect\b|\bcombobox\b|\bcaret\b/.test(h))
|
|
103
|
+
return SCALE_TOKENS.find((t) => t.v === (isClose ? 0.99 : 0.97));
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function pickBlurByUsage(hint) {
|
|
108
|
+
const h = norm(hint);
|
|
109
|
+
if (!h) return null;
|
|
110
|
+
if (/success|check/.test(h)) return BLUR_TOKENS.find((t) => t.px === 8);
|
|
111
|
+
if (/\bpage\b|\bslide\b/.test(h)) return BLUR_TOKENS.find((t) => t.px === 3);
|
|
112
|
+
if (/text/.test(h) && /reveal/.test(h)) return BLUR_TOKENS.find((t) => t.px === 3);
|
|
113
|
+
if (/panel|icon|swap|skeleton|number|digit|reveal/.test(h)) return BLUR_TOKENS.find((t) => t.px === 2);
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
52
117
|
// A generic/non-token easing the skill would nudge toward the default ease-out.
|
|
53
118
|
function shouldRefineEasing(easing) {
|
|
54
119
|
const n = normEase(easing);
|
|
@@ -60,15 +125,21 @@ function shouldRefineEasing(easing) {
|
|
|
60
125
|
|
|
61
126
|
/**
|
|
62
127
|
* Produce token-alignment suggestions for a list of property timings.
|
|
63
|
-
* @param {{property:string,durationMs:number,delayMs:number,easing:string}[]} timings
|
|
128
|
+
* @param {{property:string,durationMs:number,delayMs:number,easing:string,scale?:number,blur?:number,varName?:string,hint?:string}[]} timings
|
|
129
|
+
* @param {{label?:string,selector?:string,phase?:string}} [ctx] usage hints for scale/blur token selection
|
|
64
130
|
* @returns {object[]} suggestions
|
|
65
131
|
*/
|
|
66
|
-
export function refineTimings(timings) {
|
|
132
|
+
export function refineTimings(timings, ctx) {
|
|
67
133
|
const suggestions = [];
|
|
68
134
|
if (!Array.isArray(timings)) return suggestions;
|
|
135
|
+
const c = ctx || {};
|
|
69
136
|
|
|
70
137
|
for (const t of timings) {
|
|
71
138
|
const prop = t.property || "all";
|
|
139
|
+
// Usage hint for scale/blur: combine the transition's label/selector/phase
|
|
140
|
+
// with this lane's own property/member so component words (dropdown, modal,
|
|
141
|
+
// page slide…) are visible to the usage matcher.
|
|
142
|
+
const hint = [c.label, c.selector, c.phase, t.hint, t.property, t.member].filter(Boolean).join(" ");
|
|
72
143
|
|
|
73
144
|
// Duration → nearest token (skip if already on-grid or within 10ms).
|
|
74
145
|
if (Number.isFinite(t.durationMs)) {
|
|
@@ -102,6 +173,49 @@ export function refineTimings(timings) {
|
|
|
102
173
|
reason: `"${t.easing}" is a generic curve. The transitions.dev standard ease-out reads more intentional on opens, closes, slides, and resizes.`,
|
|
103
174
|
});
|
|
104
175
|
}
|
|
176
|
+
|
|
177
|
+
// Scale → a transitions.dev scale token (usage-first, nearest fallback). The
|
|
178
|
+
// captured value is the non-resting "pre" scale (settles to 1); skip 1/none.
|
|
179
|
+
if (Number.isFinite(t.scale) && Math.abs(t.scale - 1) > 1e-4) {
|
|
180
|
+
const usage = pickScaleByUsage(hint);
|
|
181
|
+
const { token: near, delta } = nearestScale(t.scale);
|
|
182
|
+
const token = usage || near;
|
|
183
|
+
// Suggest when usage names a token, or the current value is off-grid.
|
|
184
|
+
if (token && (usage || delta > 5e-3) && Math.abs(token.v - t.scale) > 1e-4) {
|
|
185
|
+
suggestions.push({
|
|
186
|
+
id: `${prop}-scale`,
|
|
187
|
+
kind: "scale",
|
|
188
|
+
property: prop,
|
|
189
|
+
member: t.member || null,
|
|
190
|
+
title: `Scale → ${token.name}`,
|
|
191
|
+
from: String(t.scale),
|
|
192
|
+
to: String(token.v),
|
|
193
|
+
patch: { property: prop, scale: token.v, ...(t.varName ? { varName: t.varName } : {}), ...(t.member ? { member: t.member } : {}) },
|
|
194
|
+
reason: `${token.name} (${token.v}) is the transitions.dev scale token for ${token.usage}. ${t.scale} is ${usage ? "off the usage token" : "off-grid"}.`,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Blur → a transitions.dev blur token (usage-first, nearest fallback). The
|
|
200
|
+
// captured value is the non-resting "pre" blur (settles to 0); skip 0/none.
|
|
201
|
+
if (Number.isFinite(t.blur) && t.blur > 1e-4) {
|
|
202
|
+
const usage = pickBlurByUsage(hint);
|
|
203
|
+
const { token: near, delta } = nearestBlur(t.blur);
|
|
204
|
+
const token = usage || near;
|
|
205
|
+
if (token && (usage || delta > 0.5) && Math.abs(token.px - t.blur) > 1e-4) {
|
|
206
|
+
suggestions.push({
|
|
207
|
+
id: `${prop}-blur`,
|
|
208
|
+
kind: "blur",
|
|
209
|
+
property: prop,
|
|
210
|
+
member: t.member || null,
|
|
211
|
+
title: `Blur → ${token.name}`,
|
|
212
|
+
from: `${t.blur}px`,
|
|
213
|
+
to: `${token.px}px`,
|
|
214
|
+
patch: { property: prop, blur: token.px, ...(t.varName ? { varName: t.varName } : {}), ...(t.member ? { member: t.member } : {}) },
|
|
215
|
+
reason: `${token.name} (${token.px}px) is the transitions.dev blur token for ${token.usage}. ${t.blur}px is ${usage ? "off the usage token" : "off-grid"}.`,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
105
219
|
}
|
|
106
220
|
|
|
107
221
|
return suggestions;
|