transitions-refine 0.3.9 → 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 +118 -65
- package/README.md +40 -13
- package/bin/cli.mjs +167 -58
- package/demo.html +1252 -195
- package/package.json +2 -1
- package/server/motion-tokens.mjs +116 -2
- package/server/relay.mjs +195 -15
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": {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
],
|
|
15
15
|
"scripts": {
|
|
16
16
|
"prepack": "node scripts/sync-skill.mjs",
|
|
17
|
+
"build:website-demo": "node scripts/build-website-demo.mjs",
|
|
17
18
|
"live": "node bin/cli.mjs live",
|
|
18
19
|
"relay": "node server/relay.mjs"
|
|
19
20
|
},
|
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 } 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;
|
|
@@ -55,6 +55,13 @@ const AGENT_CMD = augmentAgentCmd(process.env.REFINE_AGENT_CMD || null);
|
|
|
55
55
|
// (Opus / GPT-5.5) — forcing a fast model here keeps the initial scan snappy.
|
|
56
56
|
// Override with REFINE_SCAN_MODEL="" to fall back to the agent's default.
|
|
57
57
|
const SCAN_MODEL = process.env.REFINE_SCAN_MODEL ?? "composer-2.5-fast";
|
|
58
|
+
// Pin a fast model for refine (suggestion) jobs too. The motion-token vocabulary
|
|
59
|
+
// is inlined into the prompt (see MOTION_TOKENS_BLOCK) so a fast model has every
|
|
60
|
+
// fact it needs for the common token-tweak path — keeping suggestions snappy
|
|
61
|
+
// without falling back to the user's (possibly heavy/slow) default model.
|
|
62
|
+
// Override with REFINE_MODEL="" to use the agent's default model if you ever find
|
|
63
|
+
// the fast model regresses a tricky `replace`/recipe judgement. cursor-agent only.
|
|
64
|
+
const REFINE_MODEL = process.env.REFINE_MODEL ?? "composer-2.5-fast";
|
|
58
65
|
const AGENT_TIMEOUT_MS = Number(process.env.REFINE_AGENT_TIMEOUT_MS) || 120000;
|
|
59
66
|
|
|
60
67
|
// Inject `--model <m>` into a `cursor-agent …` command (after the binary).
|
|
@@ -79,6 +86,11 @@ const POLLER_TTL_MS = Number(process.env.REFINE_POLLER_TTL_MS) || 120000;
|
|
|
79
86
|
// How long a pending LLM job waits to be claimed before erroring. Comfortably
|
|
80
87
|
// above one long-poll cycle so a transient polling gap doesn't fail the job.
|
|
81
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;
|
|
82
94
|
|
|
83
95
|
/** @type {Map<string, Job>} */
|
|
84
96
|
const jobs = new Map();
|
|
@@ -90,6 +102,15 @@ let lastPollAt = 0;
|
|
|
90
102
|
const pollerActive = () => now() - lastPollAt < POLLER_TTL_MS;
|
|
91
103
|
const llmAvailable = () => Boolean(AGENT_CMD) || pollerActive();
|
|
92
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
|
+
|
|
93
114
|
// Whether the Cursor CLI (cursor-agent) is installed on this machine. Drives the
|
|
94
115
|
// panel's two agent-unavailable states: "Cursor CLI not installed" vs. simply
|
|
95
116
|
// "run /refine live". A wired REFINE_AGENT_CMD implies it's present.
|
|
@@ -124,6 +145,7 @@ function createJob(request) {
|
|
|
124
145
|
updatedAt: now(),
|
|
125
146
|
};
|
|
126
147
|
jobs.set(id, job);
|
|
148
|
+
lastJobAt = now(); // real work → reset the chat-loop idle auto-stop window
|
|
127
149
|
if (jobs.size > 100) {
|
|
128
150
|
const oldest = [...jobs.values()].sort((a, b) => a.updatedAt - b.updatedAt)[0];
|
|
129
151
|
if (oldest && oldest.status !== "pending" && oldest.status !== "working") jobs.delete(oldest.id);
|
|
@@ -144,42 +166,122 @@ function nextPendingLlm() {
|
|
|
144
166
|
|
|
145
167
|
// ── answering a job (one run per job) ────────────────────────────────────────
|
|
146
168
|
|
|
169
|
+
// Inlined transitions.dev motion-token vocabulary (mirrors motion-tokens.mjs and
|
|
170
|
+
// the transitions-dev skill's "## Motion tokens"). Embedding it in the refine
|
|
171
|
+
// prompt means the common token-tweak path needs ZERO file reads — only recipe
|
|
172
|
+
// selection (replace/both) still opens the skill + a reference file.
|
|
173
|
+
const MOTION_TOKENS_BLOCK = [
|
|
174
|
+
"Motion tokens (transitions.dev) — match on USAGE intent, not the nearest number:",
|
|
175
|
+
"Durations:",
|
|
176
|
+
...DURATION_TOKENS.map((t) => ` - ${t.ms}ms ${t.name}: ${t.usage}`),
|
|
177
|
+
`Default easing "Smooth ease out": ${SMOOTH_OUT}.`,
|
|
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).",
|
|
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}`),
|
|
184
|
+
].join("\n");
|
|
185
|
+
|
|
186
|
+
// Inlined recipe catalog + decision hints (mirrors the transitions-dev skill's
|
|
187
|
+
// "## Quick reference" + "## Decision rules"). Embedding it lets the `replace`
|
|
188
|
+
// path pick a recipe WITHOUT reading SKILL.md — at most it opens the ONE chosen
|
|
189
|
+
// reference file for exact structure/timings.
|
|
190
|
+
const RECIPES_BLOCK = [
|
|
191
|
+
"transitions.dev recipes — match the inferred USAGE to ONE recipe (reference file in parens):",
|
|
192
|
+
"- Card resize: a container changes width/height on a layout change (01-card-resize.md)",
|
|
193
|
+
"- Number pop-in: a number/digit updates (02-number-pop-in.md)",
|
|
194
|
+
"- Notification badge: a small dot/badge appears on a trigger (03-notification-badge.md)",
|
|
195
|
+
"- Text states swap: text content changes in place (04-text-states-swap.md)",
|
|
196
|
+
"- Menu dropdown: an anchored surface grows from its trigger (05-menu-dropdown.md)",
|
|
197
|
+
"- Modal open/close: a centered dialog scales up, softer scale-down on close (06-modal.md)",
|
|
198
|
+
"- Panel reveal: a surface slides into a region with a cross-blur (07-panel-reveal.md)",
|
|
199
|
+
"- Page side-by-side: slide between list<->detail or step1<->step2 (08-page-side-by-side.md)",
|
|
200
|
+
"- Icon swap: two icons cross-fade in the same slot (09-icon-swap.md)",
|
|
201
|
+
"- Success check: a checkmark celebration, fade+rotate+bob+stroke-draw (10-success-check.md)",
|
|
202
|
+
"- Avatar group hover: hover lifts an item in a horizontal stack (11-avatar-group-hover.md)",
|
|
203
|
+
"- Error state shake: invalid-input shake (12-error-state-shake.md)",
|
|
204
|
+
"- Input clear with dissolve: clearing a text field (13-input-clear-dissolve.md)",
|
|
205
|
+
"- Skeleton loader and reveal: placeholder pulses then swaps to real content (14-skeleton-reveal.md)",
|
|
206
|
+
"- Shimmer text: in-progress/'thinking' text shimmer (15-shimmer-text.md)",
|
|
207
|
+
"- Tabs sliding: a moving highlight across segmented options (16-tabs-sliding.md)",
|
|
208
|
+
"- Tooltip open/close: delayed fade+scale in, instant out (17-tooltip.md)",
|
|
209
|
+
"- Texts reveal: staggered blurred rise of stacked text lines (18-texts-reveal.md)",
|
|
210
|
+
"- Card hover tilt: 3D tilt toward the pointer (19-card-tilt.md)",
|
|
211
|
+
"- Plus to menu morph: a circular trigger becomes the surface it opens (20-plus-menu-morph.md)",
|
|
212
|
+
"- Accordion expand: a collapsible body grows/shrinks in height (21-accordion.md)",
|
|
213
|
+
"Tie-break: prefer the lower-overhead recipe (card resize over panel reveal, dropdown over modal). If no recipe genuinely fits, return an empty suggestions array.",
|
|
214
|
+
].join("\n");
|
|
215
|
+
|
|
147
216
|
function buildPrompt(job) {
|
|
148
217
|
const r = job.request || {};
|
|
149
218
|
const rawType = r.refineType || "small";
|
|
150
219
|
const refineType = rawType === "replace" ? "replace" : rawType === "both" ? "both" : "small";
|
|
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;
|
|
151
227
|
const lines = [
|
|
152
228
|
"You are refining ONE CSS transition against the transitions.dev library and motion tokens.",
|
|
153
|
-
|
|
229
|
+
MOTION_TOKENS_BLOCK,
|
|
154
230
|
"",
|
|
155
231
|
"Transition context (JSON):",
|
|
156
|
-
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),
|
|
157
233
|
"",
|
|
158
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.",
|
|
159
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
|
+
"",
|
|
160
238
|
];
|
|
239
|
+
if (needsRecipe) {
|
|
240
|
+
lines.push(
|
|
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.",
|
|
242
|
+
"",
|
|
243
|
+
RECIPES_BLOCK,
|
|
244
|
+
"",
|
|
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
|
+
}
|
|
252
|
+
}
|
|
161
253
|
if (refineType === "replace") {
|
|
162
254
|
lines.push(
|
|
163
255
|
"refineType is \"replace\": suggest a WHOLE-TRANSITION replacement ONLY — do NOT propose motion-token tweaks (no kind \"duration\"/\"delay\"/\"easing\").",
|
|
164
|
-
|
|
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.",
|
|
259
|
+
"Answer in ONE response — do NOT read or search files.",
|
|
165
260
|
);
|
|
166
261
|
} else if (refineType === "both") {
|
|
167
262
|
lines.push(
|
|
168
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.",
|
|
169
|
-
"(1) Motion-token tweaks (kind \"duration\"/\"delay\"/\"easing\"): for each declaration, propose the token value only where it DIFFERS from the current one.",
|
|
170
|
-
|
|
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.",
|
|
268
|
+
"Answer in ONE response — do NOT read or search files.",
|
|
171
269
|
);
|
|
172
270
|
} else {
|
|
173
271
|
lines.push(
|
|
174
|
-
"refineType is \"small\":
|
|
175
|
-
"
|
|
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.",
|
|
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.",
|
|
176
274
|
);
|
|
177
275
|
}
|
|
178
276
|
lines.push(
|
|
179
277
|
"",
|
|
180
278
|
"Output ONLY a JSON object — no prose, no markdown fences — shaped exactly like:",
|
|
181
|
-
'{"summary":"…","suggestions":[{"id":"width-duration","kind":"duration","property":"width","title":"Duration → Fast","from":"400ms","to":"250ms","patch":{"property":"width","durationMs":250},"reason":"…"}]}',
|
|
182
|
-
'
|
|
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.',
|
|
183
285
|
);
|
|
184
286
|
return lines.join("\n");
|
|
185
287
|
}
|
|
@@ -220,7 +322,31 @@ function parseScanOutput(stdout) {
|
|
|
220
322
|
return { groups: obj.groups, summary: obj.summary ?? null };
|
|
221
323
|
}
|
|
222
324
|
|
|
223
|
-
|
|
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) {
|
|
224
350
|
return new Promise((resolve, reject) => {
|
|
225
351
|
const child = spawn("sh", ["-c", cmd], { stdio: ["pipe", "pipe", "pipe"] });
|
|
226
352
|
let out = "";
|
|
@@ -249,6 +375,34 @@ function runAgentCmd(cmd, prompt, parse = parseAgentOutput) {
|
|
|
249
375
|
});
|
|
250
376
|
}
|
|
251
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
|
+
|
|
252
406
|
// Prompt for an "apply" job: the agent edits the user's source so the selected
|
|
253
407
|
// transition uses the approved timings.
|
|
254
408
|
function buildApplyPrompt(job) {
|
|
@@ -259,11 +413,11 @@ function buildApplyPrompt(job) {
|
|
|
259
413
|
"Change context (JSON):",
|
|
260
414
|
JSON.stringify({ label: r.label, selector: r.selector, component: r.component, group: r.group, phase: r.phase, changes: r.changes }, null, 2),
|
|
261
415
|
"",
|
|
262
|
-
"
|
|
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.",
|
|
263
417
|
"",
|
|
264
418
|
"Steps:",
|
|
265
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.",
|
|
266
|
-
"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.",
|
|
267
421
|
"3. Make the minimal edit. Do not reformat or change unrelated code.",
|
|
268
422
|
"",
|
|
269
423
|
'Output ONLY a JSON object — no prose, no markdown fences — shaped exactly like:',
|
|
@@ -308,7 +462,8 @@ function refineDeterministic(job) {
|
|
|
308
462
|
summary: "Replacing a whole transition needs the agent — switch to the Agent tab and run `/refine live`.",
|
|
309
463
|
};
|
|
310
464
|
}
|
|
311
|
-
const
|
|
465
|
+
const r = job.request || {};
|
|
466
|
+
const suggestions = refineTimings(r.timings || [], { label: r.label, selector: r.selector, phase: r.phase });
|
|
312
467
|
return {
|
|
313
468
|
suggestions,
|
|
314
469
|
summary: suggestions.length
|
|
@@ -372,7 +527,9 @@ async function answerJob(job) {
|
|
|
372
527
|
);
|
|
373
528
|
}
|
|
374
529
|
job.statusLog.push({ message: "Asking your agent…", at: now() });
|
|
375
|
-
|
|
530
|
+
const t0 = now();
|
|
531
|
+
result = await runAgentCmd(withModel(AGENT_CMD, REFINE_MODEL), buildPrompt(job));
|
|
532
|
+
console.log(` ⏱ refine agent ${job.id.slice(0, 8)} (${job.request?.refineType || "small"}) ${now() - t0}ms`);
|
|
376
533
|
} else {
|
|
377
534
|
job.statusLog.push({ message: "Matching values to the motion tokens…", at: now() });
|
|
378
535
|
result = refineDeterministic(job);
|
|
@@ -497,11 +654,27 @@ const server = createServer(async (req, res) => {
|
|
|
497
654
|
// GET /jobs/next — long-poll claimed by a `/refine live` agent (LLM jobs).
|
|
498
655
|
if (method === "GET" && path === "/jobs/next") {
|
|
499
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
|
+
}
|
|
500
666
|
const deadline = now() + LONGPOLL_MS;
|
|
501
667
|
const attempt = () => {
|
|
502
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
|
+
}
|
|
503
675
|
const job = nextPendingLlm();
|
|
504
676
|
if (job) {
|
|
677
|
+
lastJobAt = now();
|
|
505
678
|
job.status = "working";
|
|
506
679
|
job.updatedAt = now();
|
|
507
680
|
return send(res, 200, { id: job.id, request: job.request });
|
|
@@ -512,6 +685,13 @@ const server = createServer(async (req, res) => {
|
|
|
512
685
|
return attempt();
|
|
513
686
|
}
|
|
514
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
|
+
|
|
515
695
|
const m = path.match(/^\/jobs\/([^/]+)(?:\/(status|result|error))?$/);
|
|
516
696
|
if (m) {
|
|
517
697
|
const job = jobs.get(m[1]);
|