transitions-refine 0.3.10 → 0.3.11
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 +833 -146
- package/package.json +1 -1
- 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.11",
|
|
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": {
|
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;
|
package/server/relay.mjs
CHANGED
|
@@ -28,7 +28,7 @@ import { spawn } from "node:child_process";
|
|
|
28
28
|
import { existsSync } from "node:fs";
|
|
29
29
|
import { homedir } from "node:os";
|
|
30
30
|
import { delimiter, join } from "node:path";
|
|
31
|
-
import { refineTimings, DURATION_TOKENS, SMOOTH_OUT } from "./motion-tokens.mjs";
|
|
31
|
+
import { refineTimings, DURATION_TOKENS, SCALE_TOKENS, BLUR_TOKENS, SMOOTH_OUT } from "./motion-tokens.mjs";
|
|
32
32
|
import { buildInjectModule } from "./inject.mjs";
|
|
33
33
|
|
|
34
34
|
const PORT = Number(process.env.REFINE_RELAY_PORT) || 7331;
|
|
@@ -86,6 +86,11 @@ const POLLER_TTL_MS = Number(process.env.REFINE_POLLER_TTL_MS) || 120000;
|
|
|
86
86
|
// How long a pending LLM job waits to be claimed before erroring. Comfortably
|
|
87
87
|
// above one long-poll cycle so a transient polling gap doesn't fail the job.
|
|
88
88
|
const PENDING_TIMEOUT_MS = Number(process.env.REFINE_PENDING_TIMEOUT_MS) || 120000;
|
|
89
|
+
// Idle auto-stop for the in-chat `/refine live` loop. The chat poll loop costs
|
|
90
|
+
// agent turns even while idle, so after this long with no job we tell the loop
|
|
91
|
+
// to stop (it returns {stop:true} from /jobs/next). 0 disables. Only the chat
|
|
92
|
+
// loop is affected — a wired REFINE_AGENT_CMD never polls /jobs/next.
|
|
93
|
+
const POLLER_IDLE_STOP_MS = Number(process.env.REFINE_POLLER_IDLE_STOP_MS) || 600000;
|
|
89
94
|
|
|
90
95
|
/** @type {Map<string, Job>} */
|
|
91
96
|
const jobs = new Map();
|
|
@@ -97,6 +102,15 @@ let lastPollAt = 0;
|
|
|
97
102
|
const pollerActive = () => now() - lastPollAt < POLLER_TTL_MS;
|
|
98
103
|
const llmAvailable = () => Boolean(AGENT_CMD) || pollerActive();
|
|
99
104
|
|
|
105
|
+
// Stop signal for the in-chat `/refine live` loop. Set by POST /poller/stop
|
|
106
|
+
// (the panel's "Stop" button) or by the idle auto-stop; consumed by the next
|
|
107
|
+
// GET /jobs/next, which returns {stop:true} so the loop exits cleanly.
|
|
108
|
+
let stopRequested = false;
|
|
109
|
+
// When did a real job last arrive? Drives idle auto-stop so a forgotten loop
|
|
110
|
+
// can't poll (and bill) forever. Seeded on first poll so a fresh loop gets the
|
|
111
|
+
// full idle window before any auto-stop.
|
|
112
|
+
let lastJobAt = 0;
|
|
113
|
+
|
|
100
114
|
// Whether the Cursor CLI (cursor-agent) is installed on this machine. Drives the
|
|
101
115
|
// panel's two agent-unavailable states: "Cursor CLI not installed" vs. simply
|
|
102
116
|
// "run /refine live". A wired REFINE_AGENT_CMD implies it's present.
|
|
@@ -131,6 +145,7 @@ function createJob(request) {
|
|
|
131
145
|
updatedAt: now(),
|
|
132
146
|
};
|
|
133
147
|
jobs.set(id, job);
|
|
148
|
+
lastJobAt = now(); // real work → reset the chat-loop idle auto-stop window
|
|
134
149
|
if (jobs.size > 100) {
|
|
135
150
|
const oldest = [...jobs.values()].sort((a, b) => a.updatedAt - b.updatedAt)[0];
|
|
136
151
|
if (oldest && oldest.status !== "pending" && oldest.status !== "working") jobs.delete(oldest.id);
|
|
@@ -162,6 +177,10 @@ const MOTION_TOKENS_BLOCK = [
|
|
|
162
177
|
`Default easing "Smooth ease out": ${SMOOTH_OUT}.`,
|
|
163
178
|
"Token easings (already on-grid — leave unchanged): ease-out, ease-in-out, linear, cubic-bezier(0.34, 1.36, 0.64, 1) (bouncy overshoot, badge pop), cubic-bezier(0.34, 3.85, 0.64, 1) (strong overshoot, avatar return).",
|
|
164
179
|
'Generic curves to nudge toward "Smooth ease out": "ease", "ease-in", or any hand-rolled cubic-bezier()/linear() that is not a token above.',
|
|
180
|
+
"Scales (the non-resting 'pre' scale a surface animates FROM — it always settles to 1):",
|
|
181
|
+
...SCALE_TOKENS.map((t) => ` - ${t.v} ${t.name}: ${t.usage}`),
|
|
182
|
+
"Blur (the non-resting 'pre' blur a surface animates FROM — it always settles to 0):",
|
|
183
|
+
...BLUR_TOKENS.map((t) => ` - ${t.px}px ${t.name}: ${t.usage}`),
|
|
165
184
|
].join("\n");
|
|
166
185
|
|
|
167
186
|
// Inlined recipe catalog + decision hints (mirrors the transitions-dev skill's
|
|
@@ -199,48 +218,70 @@ function buildPrompt(job) {
|
|
|
199
218
|
const rawType = r.refineType || "small";
|
|
200
219
|
const refineType = rawType === "replace" ? "replace" : rawType === "both" ? "both" : "small";
|
|
201
220
|
const needsRecipe = refineType === "replace" || refineType === "both";
|
|
221
|
+
// A grouped transition usually has related phases (open + close). When the panel
|
|
222
|
+
// sends them, a recipe swap is ONE motion and must update every phase together —
|
|
223
|
+
// so the agent returns a `patches` array (one entry per phase) instead of a
|
|
224
|
+
// single-phase `patch`.
|
|
225
|
+
const phases = Array.isArray(r.phases) ? r.phases.filter((p) => p && p.phase) : [];
|
|
226
|
+
const multiPhase = needsRecipe && phases.length > 1;
|
|
202
227
|
const lines = [
|
|
203
228
|
"You are refining ONE CSS transition against the transitions.dev library and motion tokens.",
|
|
204
229
|
MOTION_TOKENS_BLOCK,
|
|
205
230
|
"",
|
|
206
231
|
"Transition context (JSON):",
|
|
207
|
-
JSON.stringify({ label: r.label, selector: r.selector, refineType, timings: r.timings }, null, 2),
|
|
232
|
+
JSON.stringify({ label: r.label, selector: r.selector, refineType, timings: r.timings, phases: phases.length ? phases : undefined }, null, 2),
|
|
208
233
|
"",
|
|
209
234
|
"Infer each declaration's USAGE (modal close, dropdown open, tooltip, badge, resize, color/theme change…) from the label/selector. Match on usage intent, not the nearest number.",
|
|
210
235
|
"",
|
|
236
|
+
"Some timings carry VALUES too: a `transform` lane may include `scale` (its non-resting pre-scale, e.g. 0.8) and a `filter` lane may include `blur` (its non-resting pre-blur in px). When present, also check these against the Scales/Blur tokens by USAGE (a dropdown-open surface should pre-scale to 0.97, not whatever number it has) and propose a fix when they differ. A lane may also carry `varName` (the CSS custom property backing the value) — pass it straight through in the patch so the edit targets that variable.",
|
|
237
|
+
"",
|
|
211
238
|
];
|
|
212
239
|
if (needsRecipe) {
|
|
213
240
|
lines.push(
|
|
214
|
-
"To pick a whole-transition replacement, match the inferred USAGE against the recipe list below (the skill's decision rules are summarised here — do NOT read SKILL.md or any reference file). Derive the
|
|
241
|
+
"To pick a whole-transition replacement, match the inferred USAGE against the recipe list below (the skill's decision rules are summarised here — do NOT read SKILL.md or any reference file). Derive the duration/easing from the MOTION TOKENS above for the recipe's phase (open vs close), and put the recipe's reference filename in `reference` so the user can paste the full recipe themselves. The patch only drives the live preview; exact keyframes/structure come from that pasted file — so you never need to open it.",
|
|
215
242
|
"",
|
|
216
243
|
RECIPES_BLOCK,
|
|
217
244
|
"",
|
|
218
245
|
);
|
|
246
|
+
if (multiPhase) {
|
|
247
|
+
lines.push(
|
|
248
|
+
"RELATED PHASES: `phases` lists this transition's related states (e.g. open AND close). They are ONE motion — a recipe swap must update them TOGETHER. For the replace suggestion emit a `patches` array with ONE entry per phase in `phases`, each shaped `{\"phase\":<that phase's name verbatim>,\"property\":\"all\",\"durationMs\":<recipe duration for THAT phase>,\"easing\":<recipe easing>,\"scale\":<recipe pre-scale for THAT phase>,\"blur\":<recipe pre-blur px for THAT phase>}`. Take each phase's duration from the MOTION TOKENS for that phase — open is usually slower than close (e.g. 250ms open / 150ms close) — and use the same easing for both unless the recipe differs. Include `scale`/`blur` ONLY when the recipe animates transform-scale / filter-blur (omit them otherwise), using the Scales/Blur tokens for that phase. Also include a single `patch` equal to the FIRST phase's entry (it drives the live preview). Apply will write every phase from `patches`.",
|
|
249
|
+
"",
|
|
250
|
+
);
|
|
251
|
+
}
|
|
219
252
|
}
|
|
220
253
|
if (refineType === "replace") {
|
|
221
254
|
lines.push(
|
|
222
255
|
"refineType is \"replace\": suggest a WHOLE-TRANSITION replacement ONLY — do NOT propose motion-token tweaks (no kind \"duration\"/\"delay\"/\"easing\").",
|
|
223
|
-
|
|
256
|
+
multiPhase
|
|
257
|
+
? "Pick the SINGLE best-fit recipe from the list above. Emit ONE suggestion with kind \"replace\": include the `patches` array (one entry per related phase, as described above) AND a single `patch` (the first phase) for the live preview, add a `reference` field with the reference filename, and name the recipe in `title`/`reason`. If no recipe genuinely fits the usage, return an empty suggestions array."
|
|
258
|
+
: "Pick the SINGLE best-fit recipe from the list above. Emit ONE suggestion with kind \"replace\": set its `patch` to the motion-token duration/easing for the recipe's phase on the property that already transitions (or \"all\") so Apply works live, add a `reference` field with the reference filename, and name the recipe in `title`/`reason`. If no recipe genuinely fits the usage, return an empty suggestions array.",
|
|
224
259
|
"Answer in ONE response — do NOT read or search files.",
|
|
225
260
|
);
|
|
226
261
|
} else if (refineType === "both") {
|
|
227
262
|
lines.push(
|
|
228
263
|
"refineType is \"both\": produce TWO independent groups in the SAME suggestions array — the UI shows them in separate tabs, so include each group whenever it applies.",
|
|
229
|
-
"(1) Motion-token tweaks (kind \"duration\"/\"delay\"/\"easing\"): for each declaration, propose the token value only where it DIFFERS from the current one.",
|
|
230
|
-
|
|
264
|
+
"(1) Motion-token tweaks (kind \"duration\"/\"delay\"/\"easing\"/\"scale\"/\"blur\"): for each declaration, propose the token value only where it DIFFERS from the current one. Use \"scale\" for an off-token transform pre-scale and \"blur\" for an off-token filter pre-blur, picked by usage.",
|
|
265
|
+
multiPhase
|
|
266
|
+
? "(2) Whole-transition replacement (kind \"replace\"): ALWAYS evaluate one — pick the SINGLE best-fit recipe. Emit at most ONE \"replace\" suggestion with the `patches` array (one entry per related phase) AND a single `patch` (the first phase), a `reference` field, and the recipe named in `title`/`reason`. If no recipe genuinely fits, omit the replace suggestion."
|
|
267
|
+
: "(2) Whole-transition replacement (kind \"replace\"): ALWAYS evaluate one — pick the SINGLE best-fit recipe from the list above. Emit at most ONE \"replace\" suggestion: set its `patch` to the motion-token duration/easing for the recipe's phase on the property that already transitions (or \"all\"), add a `reference` field with the reference filename, and name the recipe in `title`/`reason`. If no recipe genuinely fits, omit the replace suggestion.",
|
|
231
268
|
"Answer in ONE response — do NOT read or search files.",
|
|
232
269
|
);
|
|
233
270
|
} else {
|
|
234
271
|
lines.push(
|
|
235
|
-
"refineType is \"small\": suggest motion-token tweaks ONLY — for each declaration, propose the token value only where it DIFFERS from the current one (kind \"duration\"/\"delay\"/\"easing\"). Do NOT propose a whole-transition replacement (no kind \"replace\") — the Replace tab requests that separately.",
|
|
272
|
+
"refineType is \"small\": suggest motion-token tweaks ONLY — for each declaration, propose the token value only where it DIFFERS from the current one (kind \"duration\"/\"delay\"/\"easing\"/\"scale\"/\"blur\"). Use \"scale\" for an off-token transform pre-scale and \"blur\" for an off-token filter pre-blur, picked by usage. Do NOT propose a whole-transition replacement (no kind \"replace\") — the Replace tab requests that separately.",
|
|
236
273
|
"Answer in ONE response using ONLY the data above. Do NOT read or search files, run tools, spawn subagents, or explore the codebase — the motion tokens contain everything you need. This is a quick judgement, not a coding task.",
|
|
237
274
|
);
|
|
238
275
|
}
|
|
239
276
|
lines.push(
|
|
240
277
|
"",
|
|
241
278
|
"Output ONLY a JSON object — no prose, no markdown fences — shaped exactly like:",
|
|
242
|
-
'{"summary":"…","suggestions":[{"id":"width-duration","kind":"duration","property":"width","title":"Duration → Fast","from":"400ms","to":"250ms","patch":{"property":"width","durationMs":250},"reason":"…"}]}',
|
|
243
|
-
'
|
|
279
|
+
'{"summary":"…","suggestions":[{"id":"width-duration","kind":"duration","property":"width","member":"Container","title":"Duration → Fast","from":"400ms","to":"250ms","patch":{"property":"width","member":"Container","durationMs":250},"reason":"…"}]}',
|
|
280
|
+
'A scale tweak looks like {"id":"transform-scale","kind":"scale","property":"transform","member":"Menu","title":"Scale → Medium","from":"0.8","to":"0.97","patch":{"property":"transform","member":"Menu","scale":0.97},"reason":"…"}; a blur tweak like {"id":"filter-blur","kind":"blur","property":"filter","member":"Panel","title":"Blur → Small","from":"8px","to":"2px","patch":{"property":"filter","member":"Panel","blur":2},"reason":"…"}.',
|
|
281
|
+
"CRITICAL — `member`: every suggestion and its `patch` MUST echo the `member` of the input lane it came from, copied VERBATIM from that lane in the data above. This is REQUIRED whenever any input lane carries a `member` — most importantly when several lanes share the same `property` (e.g. a dropdown where a caret does `transform: rotate` AND a panel does `transform: scale`): the `member` is the ONLY way to tell which lane a `transform` tweak targets. Omitting it mislabels the suggestion onto the wrong element. Omit `member` only for lanes that genuinely have none.",
|
|
282
|
+
multiPhase
|
|
283
|
+
? 'A multi-phase replace suggestion ALSO carries a `patches` array, e.g. "patches":[{"phase":"open","property":"all","durationMs":250,"easing":"cubic-bezier(0.22, 1, 0.36, 1)","scale":0.97},{"phase":"close","property":"all","durationMs":150,"easing":"cubic-bezier(0.22, 1, 0.36, 1)","scale":0.99}]. In each `patch`/`patches` entry include only the changed fields (durationMs, delayMs, easing, scale, blur — plus `member` copied from the input lane, and `varName` if the input lane had one); `property` must match an input property or "all".'
|
|
284
|
+
: 'In each `patch` include only the changed fields (durationMs, delayMs, easing, scale, blur — plus `member` copied from the input lane, and `varName` if the input lane had one); `property` must match an input property or "all". If nothing should change, return an empty suggestions array.',
|
|
244
285
|
);
|
|
245
286
|
return lines.join("\n");
|
|
246
287
|
}
|
|
@@ -281,7 +322,31 @@ function parseScanOutput(stdout) {
|
|
|
281
322
|
return { groups: obj.groups, summary: obj.summary ?? null };
|
|
282
323
|
}
|
|
283
324
|
|
|
284
|
-
|
|
325
|
+
// Serialize cursor-agent spawns. Two cursor-agent processes started at once race
|
|
326
|
+
// on their shared CLI config — one renames `…/cli-config.json.tmp → cli-config.json`
|
|
327
|
+
// while the other already moved it, so the loser crashes with ENOENT mid config
|
|
328
|
+
// save and its scan/refine job fails. Because the relay fires every job via
|
|
329
|
+
// setImmediate, a cold-start scan + any other job spawn in parallel and trip this.
|
|
330
|
+
// Running cursor-agent one-at-a-time removes the race; jobs are rarely concurrent
|
|
331
|
+
// so the queuing cost is negligible (and each attempt is still bounded by
|
|
332
|
+
// AGENT_TIMEOUT_MS). Non-cursor CLIs are left to run freely.
|
|
333
|
+
let agentLock = Promise.resolve();
|
|
334
|
+
function withAgentLock(fn) {
|
|
335
|
+
const run = agentLock.then(fn, fn);
|
|
336
|
+
// keep the chain alive regardless of this run's outcome
|
|
337
|
+
agentLock = run.then(() => {}, () => {});
|
|
338
|
+
return run;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Failures worth retrying: transient agent startup / shared-config races, not
|
|
342
|
+
// genuine "the model answered wrong" errors (those reject from parse, not here)
|
|
343
|
+
// or timeouts (a retry would just time out again).
|
|
344
|
+
const AGENT_TRANSIENT_RE = /ENOENT|EAGAIN|EBUSY|ECONNRESET|EPIPE|cli-config|\brename\b|failed to start/i;
|
|
345
|
+
const AGENT_RETRIES = Number.isFinite(Number(process.env.REFINE_AGENT_RETRIES))
|
|
346
|
+
? Number(process.env.REFINE_AGENT_RETRIES)
|
|
347
|
+
: 2;
|
|
348
|
+
|
|
349
|
+
function runAgentOnce(cmd, prompt, parse) {
|
|
285
350
|
return new Promise((resolve, reject) => {
|
|
286
351
|
const child = spawn("sh", ["-c", cmd], { stdio: ["pipe", "pipe", "pipe"] });
|
|
287
352
|
let out = "";
|
|
@@ -310,6 +375,34 @@ function runAgentCmd(cmd, prompt, parse = parseAgentOutput) {
|
|
|
310
375
|
});
|
|
311
376
|
}
|
|
312
377
|
|
|
378
|
+
// Spawn the agent CLI once per attempt, serializing cursor-agent runs and
|
|
379
|
+
// retrying transient startup/config-race failures with backoff. A retry re-spawns
|
|
380
|
+
// a fresh process (read-only scan/refine, idempotent apply), so it's safe — and
|
|
381
|
+
// the backoff happens OUTSIDE the lock so a retrying job doesn't block others.
|
|
382
|
+
async function runAgentCmd(cmd, prompt, parse = parseAgentOutput) {
|
|
383
|
+
const isCursor = /(^|\s|\/)cursor-agent(\s|$)/.test(cmd || "");
|
|
384
|
+
const attempt = () =>
|
|
385
|
+
isCursor ? withAgentLock(() => runAgentOnce(cmd, prompt, parse)) : runAgentOnce(cmd, prompt, parse);
|
|
386
|
+
const maxTries = 1 + Math.max(0, AGENT_RETRIES);
|
|
387
|
+
let lastErr;
|
|
388
|
+
for (let i = 0; i < maxTries; i++) {
|
|
389
|
+
try {
|
|
390
|
+
return await attempt();
|
|
391
|
+
} catch (e) {
|
|
392
|
+
lastErr = e;
|
|
393
|
+
const msg = String((e && e.message) || e);
|
|
394
|
+
if (i < maxTries - 1 && AGENT_TRANSIENT_RE.test(msg)) {
|
|
395
|
+
const backoff = 250 * (i + 1) + Math.floor(Math.random() * 150);
|
|
396
|
+
console.warn(` ↻ agent attempt ${i + 1}/${maxTries} failed (${msg.slice(0, 120)}) — retrying in ${backoff}ms`);
|
|
397
|
+
await new Promise((r) => setTimeout(r, backoff));
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
throw e;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
throw lastErr;
|
|
404
|
+
}
|
|
405
|
+
|
|
313
406
|
// Prompt for an "apply" job: the agent edits the user's source so the selected
|
|
314
407
|
// transition uses the approved timings.
|
|
315
408
|
function buildApplyPrompt(job) {
|
|
@@ -320,11 +413,11 @@ function buildApplyPrompt(job) {
|
|
|
320
413
|
"Change context (JSON):",
|
|
321
414
|
JSON.stringify({ label: r.label, selector: r.selector, component: r.component, group: r.group, phase: r.phase, changes: r.changes }, null, 2),
|
|
322
415
|
"",
|
|
323
|
-
"
|
|
416
|
+
"Each change may carry its own `phase` (e.g. \"open\"/\"close\"), `member`, and `selector`. A change's `phase` targets ONE state of the component — edit the rule for THAT state (e.g. the `.is-open` rule for open; the `.is-closing`/base rule for close), NOT the other phase. The top-level `phase` is only a fallback for changes that omit their own. A recipe swap may include changes for BOTH open and close — apply each to its own phase's rule so the open and close timings end up different where the recipe says so.",
|
|
324
417
|
"",
|
|
325
418
|
"Steps:",
|
|
326
419
|
"1. Find where this transition is defined in the source. Search by the per-change `selector`/`member`, the `component` hint, and class names. Handle plain CSS, CSS Modules, styled-components/emotion template literals, Tailwind utilities/config, inline style objects, and Motion/Framer variants — the browser selector is a hint, the real declaration may live in any of these.",
|
|
327
|
-
"2. For each change, edit the source so that property's transition uses the `to` values
|
|
420
|
+
"2. For each change, edit the source so that property's transition uses the `to` values. A change's `to` may include timing (durationMs in ms, easing, delayMs in ms) AND/OR values: `scale` (the non-resting transform pre-scale, e.g. set `transform: scale(0.97)` on the pre-open/closed state — never the resting scale(1)) and `blur` (the non-resting filter pre-blur in px, e.g. `filter: blur(2px)` on that same state). If the change carries `varName`, the value is backed by that CSS custom property — update the variable's value at its definition instead of the inline declaration. Keep the file's existing unit/format conventions (e.g. `0.25s` vs `250ms`) and only touch the named property on the right member + phase.",
|
|
328
421
|
"3. Make the minimal edit. Do not reformat or change unrelated code.",
|
|
329
422
|
"",
|
|
330
423
|
'Output ONLY a JSON object — no prose, no markdown fences — shaped exactly like:',
|
|
@@ -369,7 +462,8 @@ function refineDeterministic(job) {
|
|
|
369
462
|
summary: "Replacing a whole transition needs the agent — switch to the Agent tab and run `/refine live`.",
|
|
370
463
|
};
|
|
371
464
|
}
|
|
372
|
-
const
|
|
465
|
+
const r = job.request || {};
|
|
466
|
+
const suggestions = refineTimings(r.timings || [], { label: r.label, selector: r.selector, phase: r.phase });
|
|
373
467
|
return {
|
|
374
468
|
suggestions,
|
|
375
469
|
summary: suggestions.length
|
|
@@ -560,11 +654,27 @@ const server = createServer(async (req, res) => {
|
|
|
560
654
|
// GET /jobs/next — long-poll claimed by a `/refine live` agent (LLM jobs).
|
|
561
655
|
if (method === "GET" && path === "/jobs/next") {
|
|
562
656
|
lastPollAt = now();
|
|
657
|
+
if (!lastJobAt) lastJobAt = now(); // seed idle window on the loop's first poll
|
|
658
|
+
// Stop signal (manual Stop button or idle auto-stop) → tell the loop to exit.
|
|
659
|
+
// A pending job always wins so we never drop real work on the stop edge.
|
|
660
|
+
if ((stopRequested || (POLLER_IDLE_STOP_MS && now() - lastJobAt >= POLLER_IDLE_STOP_MS)) && !nextPendingLlm()) {
|
|
661
|
+
stopRequested = false;
|
|
662
|
+
lastJobAt = 0;
|
|
663
|
+
lastPollAt = 0; // loop is exiting → report it inactive immediately on /health
|
|
664
|
+
return send(res, 200, { stop: true });
|
|
665
|
+
}
|
|
563
666
|
const deadline = now() + LONGPOLL_MS;
|
|
564
667
|
const attempt = () => {
|
|
565
668
|
if (res.writableEnded) return;
|
|
669
|
+
if (stopRequested && !nextPendingLlm()) {
|
|
670
|
+
stopRequested = false;
|
|
671
|
+
lastJobAt = 0;
|
|
672
|
+
lastPollAt = 0; // loop is exiting → report it inactive immediately on /health
|
|
673
|
+
return send(res, 200, { stop: true });
|
|
674
|
+
}
|
|
566
675
|
const job = nextPendingLlm();
|
|
567
676
|
if (job) {
|
|
677
|
+
lastJobAt = now();
|
|
568
678
|
job.status = "working";
|
|
569
679
|
job.updatedAt = now();
|
|
570
680
|
return send(res, 200, { id: job.id, request: job.request });
|
|
@@ -575,6 +685,13 @@ const server = createServer(async (req, res) => {
|
|
|
575
685
|
return attempt();
|
|
576
686
|
}
|
|
577
687
|
|
|
688
|
+
// POST /poller/stop — the panel's "Stop" button. Flags the in-chat loop to
|
|
689
|
+
// exit on its next poll. No-op for a wired REFINE_AGENT_CMD (never polls).
|
|
690
|
+
if (method === "POST" && path === "/poller/stop") {
|
|
691
|
+
stopRequested = true;
|
|
692
|
+
return send(res, 200, { ok: true, stopping: pollerActive() });
|
|
693
|
+
}
|
|
694
|
+
|
|
578
695
|
const m = path.match(/^\/jobs\/([^/]+)(?:\/(status|result|error))?$/);
|
|
579
696
|
if (m) {
|
|
580
697
|
const job = jobs.get(m[1]);
|