transitions-refine 0.3.5 → 0.3.7

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.
@@ -203,21 +203,31 @@ separately. You fix that by reading the source. The request looks like:
203
203
  }
204
204
  ```
205
205
 
206
+ **Be fast.** The `raw.timings` are already accurate for each element's *current*
207
+ on-screen state — treat them as ground truth and reuse them verbatim. Read as
208
+ little source as you need: only to (a) group elements into components, (b)
209
+ recover the *opposite* phase (e.g. close) that isn't in the DOM right now, and
210
+ (c) find the toggled state. Don't open files just to re-read timings you were
211
+ already given.
212
+
206
213
  Do this:
207
214
 
208
215
  1. **Identify each animated component** the raw entries belong to (dropdown,
209
- modal, tooltip, accordion, drawer, toast…). Use the selectors/labels as hints,
210
- then read the source plain CSS / CSS Modules, styled-components/emotion,
211
- Tailwind, inline styles, or Motion/Framer variants.
216
+ modal, tooltip, accordion, drawer, toast…). The selectors/labels usually make
217
+ this obvious — only read source (plain CSS / CSS Modules,
218
+ styled-components/emotion, Tailwind, inline styles, Motion/Framer variants)
219
+ when the grouping is genuinely unclear.
212
220
  2. **Split each component into phases** — usually `open` and `close` (a hover-only
213
- component can be a single phase). Open and close often live on different
214
- selectors (`.is-open` vs `.is-closing`) with different timings; report **both**
215
- even though only one is in the DOM right now.
221
+ component can be a single phase). The phase matching the current DOM reuses the
222
+ provided timings; the *opposite* phase often lives on a different selector
223
+ (`.is-open` vs `.is-closing`) with different timings read source for that one.
224
+ Report **both** even though only one is in the DOM right now.
216
225
  3. **List each phase's members** — the elements that animate in that phase. Give
217
226
  each a stable `id`, a human `label`, a live-resolvable CSS `selector`, an
218
227
  optional `toState` hint (the class/attribute that drives the phase, e.g.
219
- `.is-open`), and its real `propertyTimings`. **Quote the real timings from the
220
- source never invent.**
228
+ `.is-open`), and its `propertyTimings`. For the current-state phase, **copy the
229
+ provided `raw.timings` verbatim**; for the opposite phase, **quote the real
230
+ timings from source — never invent.**
221
231
  4. **Post the groups** (this completes the job):
222
232
 
223
233
  ```bash
package/bin/cli.mjs CHANGED
@@ -24,6 +24,13 @@ import { homedir } from "node:os";
24
24
  const PKG_ROOT = fileURLToPath(new URL("..", import.meta.url));
25
25
  const CWD = process.cwd();
26
26
  const HOME = process.env.HOME || homedir();
27
+ const PKG_VERSION = (() => {
28
+ try {
29
+ return JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8")).version || "0";
30
+ } catch {
31
+ return "0";
32
+ }
33
+ })();
27
34
 
28
35
  const MARK_START = "<!-- timeline-inject:start -->";
29
36
  const MARK_END = "<!-- timeline-inject:end -->";
@@ -81,14 +88,27 @@ function escapeRe(s) {
81
88
 
82
89
  // Copy a whole skill directory from the package into the user's project so the
83
90
  // in-IDE agent (/refine live) and any spawned cursor-agent can read it.
91
+ //
92
+ // Crucially this REFRESHES a stale copy on upgrade. An older installed skill can
93
+ // shadow newer job handling — e.g. a pre-scan `refine-live` skill that doesn't
94
+ // know how to answer kind:"scan" jobs, so scan jobs time out and the panel hangs
95
+ // on "Agent is scanning…". We stamp the package version into the skill dir and
96
+ // re-copy whenever it's missing or mismatched (so we don't clobber every run).
84
97
  function dropSkill(name) {
85
98
  const src = join(PKG_ROOT, ".agents/skills", name);
86
99
  const destDir = join(CWD, ".agents/skills", name);
87
100
  if (!existsSync(src)) return false;
88
- if (existsSync(destDir)) return "exists";
101
+ const marker = join(destDir, ".refine-version");
102
+ const existed = existsSync(destDir);
103
+ if (existed) {
104
+ let installed = null;
105
+ try { installed = readFileSync(marker, "utf8").trim(); } catch {}
106
+ if (installed === PKG_VERSION) return "exists";
107
+ }
89
108
  mkdirSync(dirname(destDir), { recursive: true });
90
- cpSync(src, destDir, { recursive: true });
91
- return true;
109
+ cpSync(src, destDir, { recursive: true, force: true });
110
+ try { writeFileSync(marker, PKG_VERSION + "\n"); } catch {}
111
+ return existed ? "updated" : true;
92
112
  }
93
113
 
94
114
  // ── agent CLI (for the persistent LLM path) ──────────────────────────────────
@@ -162,7 +182,8 @@ function cmdLive(args) {
162
182
  for (const name of ["refine-live", "transitions-dev"]) {
163
183
  const r = dropSkill(name);
164
184
  if (r === true) log(`✓ added .agents/skills/${name}`);
165
- else if (r === "exists") log(`✓ ${name} skill already present`);
185
+ else if (r === "updated") log(`✓ updated .agents/skills/${name} (now v${PKG_VERSION})`);
186
+ else if (r === "exists") log(`✓ ${name} skill already present (v${PKG_VERSION})`);
166
187
  }
167
188
 
168
189
  // 2.5) ensure an agent CLI so the relay can answer LLM jobs itself — this is
package/demo.html CHANGED
@@ -1085,8 +1085,20 @@
1085
1085
  .tl-gate-beam-fill { display: block; width: 100%; height: 100%; border-radius: 36px; background: #fff; }
1086
1086
  .tl-gate-sub { margin: 16px 0 0; max-width: 244px; font-size: 12px; font-weight: 400; line-height: 14px;
1087
1087
  color: #6f6f6f; text-wrap: pretty; }
1088
+ /* recovery actions when a scan errored/timed out — never trap the panel */
1089
+ .tl-gate-actions { display: flex; align-items: center; gap: 8px; margin: 18px 0 0; }
1090
+ .tl-gate-btn { height: 32px; padding: 0 14px; border: 0; border-radius: 60px; cursor: pointer;
1091
+ font: 500 12px/14px inherit; color: #2c2c2c; background: #f3f3f3; white-space: nowrap;
1092
+ box-shadow: 0 1px 3px 0 rgba(0,0,0,0.04), inset 0 0 0 1px rgba(0,0,0,0.06), inset 0 -1px 0 0 rgba(0,0,0,0.10);
1093
+ transition: background 140ms cubic-bezier(0.4,0,0.2,1); }
1094
+ .tl-gate-btn:hover { background: #ececec; }
1095
+ .tl-gate-btn:active { background: #e6e6e6; }
1096
+ .tl-gate-btn-primary { color: #fff; background: #0a84ff;
1097
+ box-shadow: 0 0 0 1px rgba(0,101,208,0.10) inset, 0 -1px 0 0 rgba(3,66,142,0.15) inset, 0 1px 3px 0 rgba(4,41,117,0.08); }
1098
+ .tl-gate-btn-primary:hover { background: #0a78e6; }
1088
1099
  @media (prefers-reduced-motion: reduce) {
1089
- .tl-gate, .tl-gate-pill-wrap .tl-gate-beam { animation: none !important; } }
1100
+ .tl-gate, .tl-gate-pill-wrap .tl-gate-beam { animation: none !important; }
1101
+ .tl-gate-btn { transition: none !important; } }
1090
1102
  /* dot-matrix loader — ported from @dotmatrix/dotm-square-14
1091
1103
  (github.com/zzzzshawn/matrix, MIT). A 5×5 dot grid cross-fades through four
1092
1104
  frame masks in the sequence 0→1→2→3→2→1, dot opacities x=1 / o=0.52 / .=0.08.
@@ -2718,6 +2730,9 @@
2718
2730
  const[acceptError,setAcceptError]=useState(null);
2719
2731
  // ── grouped scan (agent reads source → Open/Close phases) ──
2720
2732
  const[groupScanState,setGroupScanState]=useState("idle"); // idle | scanning | done | error
2733
+ // Escape hatch for the gate: if a scan errors/times out the user can choose
2734
+ // to proceed with the flat (ungrouped) list instead of being trapped.
2735
+ const[skipGrouping,setSkipGrouping]=useState(false);
2721
2736
  const didGroupScanRef=useRef(false);
2722
2737
  const[refineLabel,setRefineLabel]=useState(null);
2723
2738
  const[appliedIds,setAppliedIds]=useState({});
@@ -2966,11 +2981,13 @@
2966
2981
  // Capped (~12s) so a page with no resolvable flat entries can never hang
2967
2982
  // the panel on "Grouping…" — it falls through to the empty-flat check below.
2968
2983
  let prev=-1,stable=0,iters=0;
2969
- while(scanTokenRef.current===token&&iters++<40){
2984
+ while(scanTokenRef.current===token&&iters++<60){
2970
2985
  const n=registry.getAll().filter(e=>e.kind!=="phase").length;
2971
- if(n>0&&n===prev){if(++stable>=2)break;}else stable=0;
2986
+ // two consecutive equal reads is enough; at 150ms ticks that's ~300ms
2987
+ // instead of the old ~900ms, shaving dead time off every scan.
2988
+ if(n>0&&n===prev){if(++stable>=1)break;}else stable=0;
2972
2989
  prev=n;
2973
- await new Promise(r=>setTimeout(r,300));
2990
+ await new Promise(r=>setTimeout(r,150));
2974
2991
  }
2975
2992
  if(scanTokenRef.current!==token)return;
2976
2993
  const flat=registry.getAll().filter(e=>e.kind!=="phase");
@@ -2987,9 +3004,9 @@
2987
3004
  timings:(e.baseLanes||[]).map(l=>({property:l.property,durationMs:l.durationMs,delayMs:l.delayMs,easing:l.easing}))}));
2988
3005
  try{
2989
3006
  const{id}=await relayCreateJob({kind:"scan",url:location.href,raw});
2990
- for(let i=0;i<520;i++){
3007
+ for(let i=0;i<500;i++){
2991
3008
  if(scanTokenRef.current!==token)return; // superseded / unmounted
2992
- await new Promise(r=>setTimeout(r,500));
3009
+ await new Promise(r=>setTimeout(r,300));
2993
3010
  if(scanTokenRef.current!==token)return;
2994
3011
  const job=await relayGetJob(id);
2995
3012
  if(job.status==="done"){
@@ -3010,6 +3027,7 @@
3010
3027
  const rescanTransitions=useCallback(()=>{
3011
3028
  try{localStorage.removeItem(GROUP_STORE_KEY);}catch{}
3012
3029
  registry.clearGroups();
3030
+ setSkipGrouping(false); // re-engage the gate so a fresh scan can be shown
3013
3031
  runGroupScan();
3014
3032
  },[runGroupScan,GROUP_STORE_KEY,registry]);
3015
3033
  // Gate the auto group-scan behind a live agent: the panel stays on the
@@ -3067,13 +3085,19 @@
3067
3085
 
3068
3086
  // gate: the panel is unusable until a live agent is connected AND it has
3069
3087
  // scanned the page's transitions.
3070
- // loading → first /health probe pending (blank, avoids a text flash)
3071
- // blocked → no live agent → "Before we start" (run /refine live)
3072
- // scanning → live agent, scan in flight → "Agent is scanning…"
3073
- // ready live + scan done the real timeline UI
3088
+ // loading → first /health probe pending (blank, avoids a text flash)
3089
+ // blocked → no live agent → "Before we start" (run /refine live)
3090
+ // scanning → live agent, scan in flight → "Agent is scanning…"
3091
+ // scan-error scan failed/timed outrecoverable (retry / continue)
3092
+ // ready → live + scan settled (done or idle, or user skipped) → real UI
3093
+ // NOTE: "idle" and "error" must NOT keep us on the scanning screen, or a
3094
+ // timed-out scan (e.g. an older /refine-live skill that can't answer scan
3095
+ // jobs) would trap the panel forever with no way out.
3074
3096
  const gate = !live
3075
3097
  ? (llmAvailable===null ? "loading" : "blocked")
3076
- : (groupScanState==="done" ? "ready" : "scanning");
3098
+ : groupScanState==="scanning" ? "scanning"
3099
+ : (groupScanState==="error" && !skipGrouping) ? "scan-error"
3100
+ : "ready";
3077
3101
  return h(React.Fragment,null,
3078
3102
  render&&h("div",{className:"t-panel-slide","data-timeline-panel":true,
3079
3103
  "data-open":panelOpen?"true":"false","data-phase":phase,style:{height:panelHeight+"px"}},
@@ -3109,7 +3133,16 @@
3109
3133
  h("span",{className:"tl-gate-pill"},
3110
3134
  h(DotmLoader),
3111
3135
  h("span",{className:"tl-gate-pill-label"},"Agent is scanning your transitions"))),
3112
- h("p",{className:"tl-gate-sub"},"Just a moment while we get things ready.")))),
3136
+ h("p",{className:"tl-gate-sub"},"Just a moment while we get things ready."))),
3137
+ gate==="scan-error" && h("div",{className:"tl-gate"},
3138
+ h("div",{className:"tl-gate-col"},
3139
+ h("div",{className:"tl-gate-title"},"We couldn’t scan your transitions"),
3140
+ h("p",{className:"tl-gate-text"},
3141
+ "The agent didn’t finish grouping. Make sure ",h("code",{className:"tl-code"},"/refine live"),
3142
+ " is running with the latest skill, then try again — or continue with the ungrouped list."),
3143
+ h("div",{className:"tl-gate-actions"},
3144
+ h("button",{className:"tl-gate-btn tl-gate-btn-primary",onClick:rescanTransitions},"Try again"),
3145
+ h("button",{className:"tl-gate-btn",onClick:()=>setSkipGrouping(true)},"Continue without grouping"))))),
3113
3146
  toast&&createPortal(
3114
3147
  h("div",{className:"tl-toast-wrap","aria-live":"polite"},
3115
3148
  h("div",{className:cx("tl-toast",toast.closing&&"is-closing")},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "transitions-refine",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
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/relay.mjs CHANGED
@@ -50,7 +50,26 @@ function augmentAgentCmd(cmd) {
50
50
  return extra.length ? `${cmd} ${extra.join(" ")}` : cmd;
51
51
  }
52
52
  const AGENT_CMD = augmentAgentCmd(process.env.REFINE_AGENT_CMD || null);
53
+ // Pin a fast model for scan jobs. Grouping is a structured task that doesn't
54
+ // need a heavy reasoning model, and the user's *default* model may be a slow one
55
+ // (Opus / GPT-5.5) — forcing a fast model here keeps the initial scan snappy.
56
+ // Override with REFINE_SCAN_MODEL="" to fall back to the agent's default.
57
+ const SCAN_MODEL = process.env.REFINE_SCAN_MODEL ?? "composer-2.5-fast";
53
58
  const AGENT_TIMEOUT_MS = Number(process.env.REFINE_AGENT_TIMEOUT_MS) || 120000;
59
+
60
+ // Inject `--model <m>` into a `cursor-agent …` command (after the binary).
61
+ // IMPORTANT: `--model` and the SCAN_MODEL slug (e.g. "composer-2.5-fast") are
62
+ // cursor-agent-specific. If a user wired a different CLI (Codex, Claude Code, …)
63
+ // into REFINE_AGENT_CMD, appending the flag would be invalid and break the scan,
64
+ // so we leave non-cursor commands untouched — those agents still get the speedup
65
+ // from the trimmed scan prompt. Also a no-op when the model is empty
66
+ // (REFINE_SCAN_MODEL="") or a model is already pinned explicitly.
67
+ function withModel(cmd, model) {
68
+ if (!cmd || !model) return cmd;
69
+ if (!/cursor-agent/.test(cmd)) return cmd; // not cursor-agent → don't touch
70
+ if (/(^|\s)--model(\s|=)/.test(cmd)) return cmd; // respect an explicit choice
71
+ return cmd.replace(/^(\s*\S+)/, `$1 --model ${model}`);
72
+ }
54
73
  const LONGPOLL_MS = Number(process.env.REFINE_LONGPOLL_MS) || 25000;
55
74
  // Grace window after a `/refine live` agent's last poll during which LLM mode is
56
75
  // still reported "available". Kept well above LONGPOLL_MS so the normal gaps
@@ -260,21 +279,19 @@ function buildScanPrompt(job) {
260
279
  return [
261
280
  "You are GROUPING UI transitions by reading the user's SOURCE CODE. A naive DOM scan only sees each element's current computed transition — it cannot tell open from close, and lists related elements separately. Fix that.",
262
281
  "",
263
- "Raw DOM-detected transitions (JSON) use as hints to locate the components, not as the final answer:",
282
+ "Raw DOM-detected transitions (JSON). These timings are ALREADY ACCURATE for the component's CURRENT on-screen state — treat them as ground truth, do NOT re-derive them from source:",
264
283
  JSON.stringify({ url: r.url, raw: r.raw }, null, 2),
265
284
  "",
285
+ "To stay fast, read as little source as you need — only to (a) group elements into components, (b) recover the OPPOSITE phase (e.g. close) that isn't in the DOM right now, and (c) find the toggled state. Do not open files just to re-read timings you were already given.",
286
+ "",
266
287
  "Steps:",
267
- "1. Identify each animated UI component (dropdown, modal, tooltip, accordion, drawer, toast…). Read its source (CSS/CSS Modules, styled-components/emotion, Tailwind, inline styles, Motion/Framer variants).",
268
- "2. For each component, split into PHASES — typically `open` and `close` (a hover-only component may have a single phase). Open and close often live on different selectors (e.g. `.is-open` vs `.is-closing`) with different timings; report BOTH even though only one is in the DOM right now.",
288
+ "1. Identify each animated UI component (dropdown, modal, tooltip, accordion, drawer, toast…). The provided `label`/`selector`/`properties` usually make the grouping obvious; only read source when the grouping is genuinely unclear.",
289
+ "2. For each component, split into PHASES — typically `open` and `close` (a hover-only component may have a single phase). The phase matching the CURRENT DOM state reuses the provided timings verbatim. The OPPOSITE phase often lives on a different selector (e.g. `.is-open` vs `.is-closing`) with different timings read source for that one. Report BOTH even though only one is in the DOM right now.",
269
290
  "3. PHASE STATE — how the phase is driven (REQUIRED for playback to work). For each phase provide:",
270
291
  " - `stateTarget`: a CSS selector for the ONE element whose class/attribute is toggled to drive the whole phase (e.g. the dropdown root, the `.modal`, the element with `[data-open]`). It MUST resolve in the live DOM RIGHT NOW, in any state — so it must NOT itself contain the toggled state (write `.t-morph`, never `.t-morph[data-open=\"true\"]`).",
271
292
  " - `fromState` and `toState`: the class/attribute on `stateTarget` at the START and END of this phase, as a token: a class `\".is-open\"`, an attribute `\"[data-open=\\\"true\\\"]\"`, or `null`/`\"\"` for the base/no-class state. OPEN usually goes base→open (`fromState:null`, `toState:\".is-open\"`); CLOSE goes open→base (`fromState:\".is-open\"`, `toState:null`). Get the DIRECTION right — open must animate into the open look, close must animate back out.",
272
- "4. For each phase, list its MEMBER elements (panel, backdrop, the staggered items…). Give each member a stable `id`, a human `label`, a CSS `selector`, and its real `propertyTimings`. The member `selector` MUST resolve in the live DOM RIGHT NOW regardless of phase — use the BASE element selector and do NOT bake the phase's toggled class/attribute into it (write `.t-morph .t-morph-plus`, never `.t-morph[data-open=\"true\"] .t-morph-plus`). The toggled state belongs only in the phase's `stateTarget`/`toState`.",
273
- "5. TIMINGS MUST BE EXACT AND PER-PROPERTY. This is the most common mistake do not make it:",
274
- " - List one `propertyTimings` entry per animated property. Read EACH property's own duration/delay/easing from the shorthand `transition:` list (or the property-specific longhand). Do NOT copy one property's duration onto the others, and do NOT use the phase's longest/representative duration for every lane.",
275
- " - Resolve CSS custom properties (e.g. `var(--morph-fade-dur)`) to concrete numbers by following the `:root`/scope where they're defined; convert `s`→ms (`0.25s`→250). Never emit a `var(...)` or a guess.",
276
- " - It is normal and expected for properties within one phase to differ (e.g. opacity/filter 200ms but transform 350ms). If every property in a phase ends up identical, re-read the source — you probably collapsed them by mistake.",
277
- " - Open and close usually have DIFFERENT durations/easings; report each from its own rule.",
293
+ "4. For each phase, list its MEMBER elements (panel, backdrop, the staggered items…). Give each member a stable `id`, a human `label`, a CSS `selector`, and its `propertyTimings`. For the phase that matches the current DOM, COPY each member's `propertyTimings` straight from the provided `raw.timings` (same per-property duration/delay/easing) — don't change numbers you were handed. The member `selector` MUST resolve in the live DOM RIGHT NOW regardless of phase — use the BASE element selector and do NOT bake the phase's toggled class/attribute into it (write `.t-morph .t-morph-plus`, never `.t-morph[data-open=\"true\"] .t-morph-plus`). The toggled state belongs only in the phase's `stateTarget`/`toState`.",
294
+ "5. TIMINGS ARE PER-PROPERTY. For the OPPOSITE phase you read from source: list one `propertyTimings` entry per animated property with its own duration/delay/easing; resolve CSS custom properties (e.g. `var(--morph-fade-dur)`) to concrete numbers and convert `s`→ms (`0.25s`→250); never emit a `var(...)` or a guess. Open and close usually have DIFFERENT durations/easings. (The current-state phase just reuses the provided numbers.)",
278
295
  "",
279
296
  "Output ONLY a JSON object — no prose, no markdown fences — shaped exactly like:",
280
297
  '{"summary":"Grouped 3 components.","groups":[{"id":"dropdown","label":"Dropdown","component":"src/Dropdown.tsx","phases":[{"id":"dropdown:open","phase":"open","label":"Open","stateTarget":".dropdown","fromState":null,"toState":".is-open","members":[{"id":"panel","label":"Panel","selector":".dropdown .dropdown-panel","propertyTimings":[{"property":"opacity","durationMs":200,"delayMs":0,"easing":"ease-out"},{"property":"transform","durationMs":200,"delayMs":0,"easing":"cubic-bezier(0.22, 1, 0.36, 1)"}]}]},{"id":"dropdown:close","phase":"close","label":"Close","stateTarget":".dropdown","fromState":".is-open","toState":null,"members":[{"id":"panel","label":"Panel","selector":".dropdown .dropdown-panel","propertyTimings":[{"property":"opacity","durationMs":150,"delayMs":0,"easing":"ease-in"},{"property":"transform","durationMs":150,"delayMs":0,"easing":"ease-in"}]}]}]}]}',
@@ -340,7 +357,7 @@ async function answerJob(job) {
340
357
  );
341
358
  }
342
359
  job.statusLog.push({ message: "Reading components from source…", at: now() });
343
- result = await runAgentCmd(AGENT_CMD, buildScanPrompt(job), parseScanOutput);
360
+ result = await runAgentCmd(withModel(AGENT_CMD, SCAN_MODEL), buildScanPrompt(job), parseScanOutput);
344
361
  job.result = { groups: result.groups, summary: result.summary };
345
362
  job.status = "done";
346
363
  job.updatedAt = now();