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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "transitions-refine",
3
- "version": "0.3.9",
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
  },
@@ -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
- "Read the transitions-dev skill's SKILL.md (look in .agents/skills/transitions-dev/ or ~/.agents/skills/transitions-dev/) and apply its `transitions refine` behaviour, `## Motion tokens`, and `## Decision rules`.",
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
- "Run the skill's `## Decision rules` on the inferred usage, pick the SINGLE best-fit transitions.dev recipe, and read its reference file (e.g. 06-modal.md) for the real timings/easing. Emit ONE suggestion with kind \"replace\": set its `patch` to the recipe's recommended duration/easing for 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`. Never invent timings — quote the reference file. If no recipe genuinely fits the usage, return an empty suggestions array.",
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
- "(2) Whole-transition replacement (kind \"replace\"): ALWAYS evaluate one — run the skill's `## Decision rules` on the inferred usage, pick the SINGLE best-fit transitions.dev recipe, and read its reference file (e.g. 06-modal.md) for the real timings/easing. Emit at most ONE \"replace\" suggestion: set its `patch` to the recipe's recommended duration/easing for the property that already transitions (or \"all\"), add a `reference` field with the reference filename, and name the recipe in `title`/`reason`. Never invent timings — quote the reference file. If no recipe genuinely fits the usage, simply omit the replace suggestion.",
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\": FIRST suggest motion-token tweaks — for each declaration, propose the token value only where it DIFFERS from the current one (kind \"duration\"/\"delay\"/\"easing\").",
175
- "THEN, when it is possible and sensible, ALSO add at most ONE kind \"replace\" suggestion (alongside, not instead of, the token tweaks): run the skill's `## Decision rules`, pick the SINGLE best-fit recipe, read its reference file for the real timings, set its `patch` to the recipe's recommended duration/easing for the existing property (or \"all\"), add a `reference` field with the reference filename, and name the recipe in `title`/`reason`. Only add it when the transition is clearly a hand-rolled version of a catalogued recipe or is missing structure the usage calls for; otherwise omit it and let the token tweaks stand alone.",
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
- 'In each `patch` include only the changed fields (durationMs, delayMs, easing); `property` must match an input property or "all". If nothing should change, return an empty suggestions array.',
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
- function runAgentCmd(cmd, prompt, parse = parseAgentOutput) {
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
- "If `phase` is set (e.g. \"open\"/\"close\"), the change targets ONE state of a 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. Each change may carry its own `member` + `selector` identifying which element it belongs to.",
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: durationMs (ms), easing, delayMs (ms). Keep the file's existing unit/format conventions (e.g. `0.25s` vs `250ms`) and only touch the timing of the named property on the right member + phase. If a CSS variable / design token backs the value, update it at the most sensible single place.",
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 suggestions = refineTimings(job.request?.timings || []);
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
- result = await runAgentCmd(AGENT_CMD, buildPrompt(job));
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]);