transitions-refine 0.1.2 → 0.1.3

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.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: refine-live
3
- description: Become the live "Refine" agent for the Timeline Inspector. Use when the user runs `/refine live`, asks to "refine live", "go live", "answer refine jobs", or wants the timeline panel's Refine button (LLM mode) to be backed by a real agent. Long-polls the local refine relay, reasons about each CSS transition with the transitions-dev skill, and posts suggestions back to the browser panel.
3
+ description: Become the live "Refine" agent for the Timeline Inspector. Use when the user runs `/refine live`, asks to "refine live", "go live", "answer refine jobs", or wants the timeline panel's Refine button (LLM mode) or Accept button to be backed by a real agent. Long-polls the local refine relay, reasons about each CSS transition with the transitions-dev skill, posts suggestions back to the browser panel, and for "apply" jobs writes the accepted timing changes into the user's source code.
4
4
  ---
5
5
 
6
6
  # Refine Live
@@ -54,6 +54,11 @@ never has to re-run `/refine live`.
54
54
  }
55
55
  ```
56
56
 
57
+ - **If `request.kind === "apply"`** this is not a suggestion job — the user
58
+ pressed **Accept** to write changes to their code. Jump to
59
+ [`## Apply jobs`](#apply-jobs-write-to-source) and edit the source instead of
60
+ posting suggestions. Everything below (refineType, steps 3–4) is for the
61
+ normal Refine flow.
57
62
  - `refineType` chooses what kinds of suggestions to make (it mirrors the
58
63
  panel's two tabs):
59
64
  - `"small"` (or missing) → **Small refinements**: nudge the existing
@@ -171,6 +176,53 @@ never has to re-run `/refine live`.
171
176
  stop, tell them the LLM tab will go unavailable and how to restart
172
177
  (`/refine live`).
173
178
 
179
+ ## Apply jobs (write to source)
180
+
181
+ When a claimed job has `request.kind === "apply"`, the user accepted their current
182
+ timeline values and wants them written to the codebase. The request looks like:
183
+
184
+ ```json
185
+ {
186
+ "id": "uuid",
187
+ "request": {
188
+ "kind": "apply",
189
+ "label": "div.modal.t-modal",
190
+ "selector": "div.modal > button.close",
191
+ "changes": [
192
+ { "property": "opacity", "from": { "durationMs": 300, "delayMs": 0, "easing": "ease" },
193
+ "to": { "durationMs": 150, "delayMs": 0, "easing": "cubic-bezier(0.4, 0, 1, 1)" } }
194
+ ]
195
+ }
196
+ }
197
+ ```
198
+
199
+ Do this:
200
+
201
+ 1. **Locate the real declaration in the source.** The `selector` is a DOM-path
202
+ *hint*, not necessarily the source selector. Search by the label/class names and
203
+ handle whatever the project uses: plain CSS / CSS Modules, styled-components or
204
+ emotion template literals, Tailwind utilities (`duration-300`, arbitrary
205
+ `[transition-duration:300ms]`, or the `tailwind.config` theme), and inline
206
+ `style={{ transition: … }}` objects. Match by the `from` values to disambiguate.
207
+ 2. **Edit each change's property** to its `to` values (`durationMs` ms, `easing`,
208
+ `delayMs` ms). Keep the file's existing unit/format (`0.25s` vs `250ms`) and
209
+ touch only that property's timing. If a CSS variable / design token backs the
210
+ value, update it at the single most sensible place.
211
+ 3. **Minimal edit** — no reformatting or unrelated changes.
212
+ 4. **Post the outcome** (this completes the job):
213
+
214
+ ```bash
215
+ curl -s -X POST http://localhost:7331/jobs/<id>/result \
216
+ -H 'Content-Type: application/json' \
217
+ -d '{"applied":true,"summary":"Set .t-modal transition to 150ms ease-in","files":["src/Modal.css:42"]}'
218
+ ```
219
+
220
+ If you cannot confidently find the declaration, post
221
+ `{"applied":false,"summary":"<what you searched and why not found>"}` (still a
222
+ `result`, not an `error`). Reserve `/jobs/<id>/error` for unexpected failures.
223
+
224
+ Then go back to step 1 of the loop.
225
+
174
226
  ## Suggestion shape (must match the panel)
175
227
 
176
228
  Each suggestion object:
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  A live, agent-driven **Refine** panel for CSS and [Motion](https://motion.dev) transitions. One command injects a docked timeline + Refine panel onto your running app — no `npm install`, no source edits of your own — and every "Refine" click asks a coding agent to review the selected transition against the [transitions.dev](https://transitions.dev) motion tokens and suggest token-aligned values (or a whole-transition replacement from the library).
4
4
 
5
- The feedback shows up **in a panel that slides in from the right** — not in your chat — and you pick which suggestions to apply. Applied suggestions are **live overrides** (instant preview, reversible) — the same path as dragging the timeline bars.
5
+ The feedback shows up **in a panel that slides in from the right** — not in your chat — and you pick which suggestions to apply. Applied suggestions are **live overrides** (instant preview, reversible) — the same path as dragging the timeline bars. When you're happy, **Accept** writes those values back into your source via the agent.
6
6
 
7
7
  Inspired by the [impeccable.style](https://impeccable.style/live-mode/) "live" pattern: the browser drops a job in a tiny local relay, and the relay answers it with **one agent run per click**. No standing loop, nothing to start per click — you just keep the relay running.
8
8
 
@@ -59,6 +59,12 @@ The CLI must have the `transitions-dev` skill available (the prompt tells it to
59
59
  - **Small refinements** — keeps the transition, suggests motion-token tweaks (duration/easing), and may add a whole-transition replacement when one clearly fits better.
60
60
  - **Replace transition** — only whole-transition replacements from the transitions.dev library (no token tweaks). This path needs the agent; the deterministic answerer will tell you to switch to the LLM.
61
61
 
62
+ ## Accept — write changes to your code
63
+
64
+ The **Accept** button (next to Refine) is enabled whenever the selected transition has unsaved changes — whether you edited the bars/easing by hand or applied a Refine suggestion. Pressing it sends an **apply job** to the relay: the agent finds where that transition is declared in your source (plain CSS, CSS Modules, styled-components/emotion, Tailwind, or inline styles), edits only the changed timings, and reports back. The button shows a spinner while saving and flips to **Done** on success.
65
+
66
+ Like Replace, Accept needs the agent — run `/refine live` (or `--llm` / `REFINE_AGENT_CMD`). The deterministic answerer can't edit files. Play preview also no longer needs you to trigger the transition first: it recovers the end-state from your stylesheets (hover/focus pseudo-states and toggled classes like `.modal.open`), so opening the panel and pressing Play just works.
67
+
62
68
  ## Pieces
63
69
 
64
70
  | Piece | File | Role |
@@ -80,9 +86,9 @@ The CLI must have the `transitions-dev` skill available (the prompt tells it to
80
86
  | `REFINE_AUTO=0` | — | disable auto-answer and wait for an external poller |
81
87
  | `window.REFINE_RELAY_URL` | injected origin | browser override for the relay URL |
82
88
 
83
- Endpoints: `POST /jobs`, `GET /jobs/:id` (browser). In `REFINE_AUTO=0` mode an external poller also uses `GET /jobs/next` and `POST /jobs/:id/{status,result,error}`.
89
+ Endpoints: `POST /jobs` (refine or `kind: "apply"`), `GET /jobs/:id` (browser). In `REFINE_AUTO=0` mode an external poller also uses `GET /jobs/next` and `POST /jobs/:id/{status,result,error}`.
84
90
 
85
- Writing a value back to your source is a separate, explicit step (treat it like a normal edit / `transitions apply`).
91
+ Refine suggestions stay as live overrides until you press **Accept**, which is the explicit step that writes them into your source.
86
92
 
87
93
  ## License
88
94
 
package/demo.html CHANGED
@@ -164,7 +164,21 @@
164
164
  .tl-header .tl-ghost-btn,
165
165
  .tl-header .tl-icon-btn,
166
166
  .tl-header .tl-sec-btn,
167
+ .tl-header .tl-accept-btn,
167
168
  .tl-header .tl-refine-btn { border-radius: 60px; }
169
+ .tl-accept-btn { display: inline-flex; align-items: center; gap: 8px; height: 36px; padding: 0 14px;
170
+ border: none; cursor: pointer; font-weight: 500; font-size: 13px; line-height: 14px; color: #17181C;
171
+ background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.04),
172
+ inset 0 0 0 1px rgba(0,0,0,0.06), inset 0 -1px 0 0 rgba(0,0,0,0.06), inset 0 0 0 1px rgba(196,196,196,0.10);
173
+ transition: background 140ms ease, scale 140ms ease, opacity 140ms ease; }
174
+ .tl-accept-btn > svg { width: 16px; height: 16px; color: #17181C; flex: none; }
175
+ .tl-accept-btn:hover:not(:disabled) { background: #f9f9f9; }
176
+ .tl-accept-btn:active:not(:disabled) { background: #f9f9f9; scale: 0.96; }
177
+ .tl-accept-btn:disabled { opacity: 0.5; cursor: default; }
178
+ .tl-accept-spin { width: 16px; height: 16px; flex: none; border-radius: 50%;
179
+ border: 2px solid rgba(0,0,0,0.15); border-top-color: #17181C; animation: tl-accept-rot 0.7s linear infinite; }
180
+ @keyframes tl-accept-rot { to { transform: rotate(360deg); } }
181
+ @media (prefers-reduced-motion: reduce) { .tl-accept-spin { animation-duration: 1.4s; } }
168
182
 
169
183
  /* ghost button: transparent, grey on hover/pressed (Figma #f7f7f7) */
170
184
  .tl-ghost-btn { position: relative; display: inline-flex; align-items: center; gap: 6px; height: 36px;
@@ -1169,6 +1183,82 @@
1169
1183
  // capture phase + the bubbling transitionrun event reaches document
1170
1184
  document.addEventListener("transitionrun",_txOnRun,true);
1171
1185
  }
1186
+ // from/to recovered from a previously observed real run
1187
+ function _txCaptured(el,et){
1188
+ const rec=_txCapture.get(el);if(!rec)return null;
1189
+ const from={},to={};let n=0;
1190
+ for(const t of et){const c=rec.get(t.property);if(c){from[t.property]=c.from;to[t.property]=c.to;n++;}}
1191
+ return n?{from,to}:null;
1192
+ }
1193
+ // Discover the transition's end-state WITHOUT interaction by reading the
1194
+ // stylesheets: find rules that set a transitioning prop and represent a
1195
+ // *state* of the element (a :hover/:focus pseudo, or a toggled class/attr
1196
+ // like `.modal.open`). from = current computed value, to = the rule's value.
1197
+ const _TX_STATE_PSEUDO=/:{1,2}(hover|focus|focus-visible|focus-within|active|checked|enabled|disabled|target|visited|link|valid|invalid|placeholder-shown|default)\b(\([^)]*\))?/g;
1198
+ function _txReduceLast(el,sel){
1199
+ const parts=sel.split(/(\s*[>+~]\s*|\s+)/);
1200
+ let i=parts.length-1;while(i>=0&&/^\s*[>+~]?\s*$/.test(parts[i]))i--;
1201
+ if(i<0)return null;
1202
+ const toks=parts[i].match(/[.#]?[\w-]+|\[[^\]]*\]|::?[\w-]+(?:\([^)]*\))?|\*/g)||[];
1203
+ let dropped=false,hasId=false;
1204
+ const kept=toks.filter(tk=>{
1205
+ if(tk[0]==="."){const ok=el.classList.contains(tk.slice(1));if(ok)hasId=true;else dropped=true;return ok;}
1206
+ if(tk[0]==="["){let ok;try{ok=el.matches(tk);}catch{ok=false;}if(ok)hasId=true;else dropped=true;return ok;}
1207
+ if(tk[0]==="#"){const ok=el.id&&("#"+el.id)===tk;if(ok)hasId=true;else dropped=true;return ok;}
1208
+ if(tk[0]===":"){dropped=true;return false;} // pseudo = the state delta
1209
+ return true; // tag / *
1210
+ });
1211
+ // Only a real *variant of this element*: we must have removed a state token
1212
+ // AND kept a class/id/attr that ties the rule to el. Dropping every class to
1213
+ // land on a bare tag/`*` would falsely match unrelated rules.
1214
+ if(!dropped||!hasId)return null;
1215
+ parts[i]=kept.join("");
1216
+ return parts.join("");
1217
+ }
1218
+ function _txIsStateOf(el,sel){
1219
+ try{if(el.matches(sel))return false;}catch{return false;}
1220
+ const noP=sel.replace(_TX_STATE_PSEUDO,"").trim();
1221
+ if(noP&&noP!==sel){try{if(el.matches(noP))return true;}catch{}}
1222
+ const red=_txReduceLast(el,sel);
1223
+ if(red&&red!==sel){try{if(el.matches(red))return true;}catch{}}
1224
+ return false;
1225
+ }
1226
+ function _txScanRules(rules,el,props,to){
1227
+ for(const rule of rules){
1228
+ // Read style rules directly. NOTE: in browsers with CSS Nesting a plain
1229
+ // CSSStyleRule also has a (usually empty) .cssRules, so test selectorText
1230
+ // first — don't treat every style rule as a grouping rule.
1231
+ const decl=rule.style;
1232
+ if(decl&&rule.selectorText){
1233
+ let sets=false;for(const p of props){if(decl.getPropertyValue(p)){sets=true;break;}}
1234
+ if(sets){
1235
+ for(const sub of rule.selectorText.split(",")){
1236
+ const s=sub.trim();if(!s)continue;
1237
+ if(_txIsStateOf(el,s)){for(const p of props){const v=decl.getPropertyValue(p);if(v&&!(p in to))to[p]=v.trim();}}
1238
+ }
1239
+ }
1240
+ }
1241
+ // Recurse into @media / @supports / @layer and any nested rules.
1242
+ if(rule.cssRules&&rule.cssRules.length){
1243
+ try{if(rule.media&&!window.matchMedia(rule.media.mediaText).matches)continue;}catch{}
1244
+ _txScanRules(rule.cssRules,el,props,to);
1245
+ }
1246
+ }
1247
+ }
1248
+ function _txDiscover(el,et){
1249
+ if(typeof document==="undefined"||!el.matches)return null;
1250
+ const props=et.map(t=>t.property).filter(p=>p&&p!=="all");
1251
+ if(!props.length)return null;
1252
+ const to={};
1253
+ for(const sheet of Array.from(document.styleSheets||[])){
1254
+ let rules;try{rules=sheet.cssRules;}catch{continue;} // cross-origin sheet
1255
+ if(rules)_txScanRules(rules,el,props,to);
1256
+ }
1257
+ const keys=Object.keys(to);if(!keys.length)return null;
1258
+ const cs=getComputedStyle(el);const from={};
1259
+ for(const p of keys)from[p]=cs.getPropertyValue(p)||cs[_txCamel(p)]||"";
1260
+ return {from,to};
1261
+ }
1172
1262
 
1173
1263
  class PreviewController {
1174
1264
  state="idle"; listeners=new Set(); cleanups=[]; animations=[]; progressListeners=new Set(); _rafId=null; scanner=null; _gen=0;
@@ -1210,23 +1300,39 @@
1210
1300
  this._pendingSeek=timeMs;
1211
1301
  }
1212
1302
  _finish(){this._stopPL();if(this.animations.length>0){let end=0;for(const a of this.animations){const t=a.effect?.getTiming();const e=(t?.delay??0)+(Number(t?.duration)||0);if(e>end)end=e;}this._ep(end);}this.animations=[];for(const c of this.cleanups)c();this.cleanups=[];if(this.scanner)this.scanner.unpause();this._setState("idle");}
1213
- // Arm an element so its transition runs now. If we captured the real
1214
- // transition earlier, replay it from→to (no click, no side effects);
1215
- // otherwise fall back to clicking the element. Returns a restore fn.
1303
+ // Arm an element so its transition runs now, without needing the user to
1304
+ // trigger it first. Priority:
1305
+ // 1. a previously observed real run (exact),
1306
+ // 2. end-state discovered from the stylesheets (hover/focus/toggled class),
1307
+ // 3. a synthetic opacity/transform pulse to preview the timing,
1308
+ // 4. click the element (last resort, may have side effects).
1309
+ // Returns a restore fn.
1216
1310
  _arm(el,et){
1217
1311
  const saved=el.style.cssText;
1218
1312
  const tv=et.map(t=>`${t.property} ${formatCssTime(t.durationMs)} ${t.easing} ${formatCssTime(t.delayMs)}`).join(", ");
1219
- const rec=_txCapture.get(el);
1220
- const caps=rec?et.map(t=>[t.property,rec.get(t.property)]).filter(x=>x[1]):[];
1221
- if(caps.length){
1222
- el.style.transition="none";
1223
- for(const[p,v]of caps)el.style.setProperty(p,v.from);
1224
- void el.offsetWidth; // commit the from-state before transitioning
1225
- el.style.transition=tv;
1226
- for(const[p,v]of caps)el.style.setProperty(p,v.to);
1227
- }else{
1228
- el.style.transition=tv;el.click();
1313
+ const states=_txCaptured(el,et)||_txDiscover(el,et);
1314
+ if(states){
1315
+ const props=et.map(t=>t.property).filter(p=>states.from[p]!=null&&states.to[p]!=null);
1316
+ if(props.length){
1317
+ el.style.transition="none";
1318
+ for(const p of props)el.style.setProperty(p,states.from[p]);
1319
+ void el.offsetWidth; // commit the from-state before transitioning
1320
+ el.style.transition=tv;
1321
+ for(const p of props)el.style.setProperty(p,states.to[p]);
1322
+ return ()=>{try{el.style.cssText=saved;}catch{}};
1323
+ }
1229
1324
  }
1325
+ const synth=et.filter(t=>t.property==="opacity"||t.property==="transform"||t.property==="all");
1326
+ if(synth.length){
1327
+ const dur=Math.max(...et.map(t=>(t.durationMs||0)+(t.delayMs||0)))||et[0].durationMs||300;
1328
+ const ease=et[0].easing||"ease";
1329
+ const hasOp=synth.some(t=>t.property!=="transform"),hasTr=synth.some(t=>t.property!=="opacity");
1330
+ const k0={},k1={},k2={};
1331
+ if(hasOp){k0.opacity=1;k1.opacity=0.35;k2.opacity=1;}
1332
+ if(hasTr){k0.transform="none";k1.transform="translateY(8px)";k2.transform="none";}
1333
+ try{el.animate([k0,k1,k2],{duration:dur,easing:ease,fill:"none"});return ()=>{};}catch{}
1334
+ }
1335
+ el.style.transition=tv;el.click();
1230
1336
  return ()=>{try{el.style.cssText=saved;}catch{}};
1231
1337
  }
1232
1338
  _playCss(entry,gen){if(entry.bindings.type!=="css")return;this.animations=[];
@@ -1642,7 +1748,25 @@
1642
1748
  h("div",{className:"tl-refine-foot"},foot)));
1643
1749
  }
1644
1750
 
1645
- function Header({entries,active,onSelect,onReset,onCopy,copied,loop,setLoop,snap,setSnap,onMinimize,onRefine,refineActive}){
1751
+ // Diff the active transition's effective (edited/refined) timings against its
1752
+ // originally-scanned values — the set of changes Accept writes to source.
1753
+ function computeChanges(active){
1754
+ if(!active)return[];
1755
+ const base=active.propertyTimings||(active.properties||[]).map(p=>({property:p,durationMs:active.durationMs,delayMs:active.delayMs,easing:active.easing}));
1756
+ const bmap=new Map(base.map(t=>[t.property,t]));
1757
+ const out=[];
1758
+ for(const t of (active.effectiveTimings||[])){
1759
+ const b=bmap.get(t.property);if(!b)continue;
1760
+ if(t.durationMs!==b.durationMs||t.delayMs!==b.delayMs||(t.easing||"")!==(b.easing||"")){
1761
+ out.push({property:t.property,
1762
+ from:{durationMs:b.durationMs,delayMs:b.delayMs,easing:b.easing},
1763
+ to:{durationMs:t.durationMs,delayMs:t.delayMs,easing:t.easing}});
1764
+ }
1765
+ }
1766
+ return out;
1767
+ }
1768
+
1769
+ function Header({entries,active,onSelect,onReset,onCopy,copied,loop,setLoop,snap,setSnap,onMinimize,onRefine,refineActive,onAccept,acceptState,acceptDisabled,acceptError}){
1646
1770
  const[pick,setPick]=useState(false);
1647
1771
  const[setg,setSetg]=useState(false);
1648
1772
  const pickRef=useRef(null), gearRef=useRef(null);
@@ -1672,6 +1796,18 @@
1672
1796
  h("span",{className:"t-icon","data-icon":"a"},h(Ic,{name:"copy"})),
1673
1797
  h("span",{className:"t-icon","data-icon":"b"},h(Ic,{name:"check"})))),
1674
1798
  h("span",{className:"t-tt tl-tt-below",role:"tooltip"},copied?"Copied":"Copy values")),
1799
+ h("span",{className:"t-tt-wrap"},
1800
+ h("button",{className:cx("tl-accept-btn",acceptState==="saving"&&"is-saving",acceptState==="done"&&"is-done"),
1801
+ disabled:!active||acceptDisabled||acceptState==="saving"||acceptState==="done",onClick:onAccept,"aria-label":"Accept changes to your code"},
1802
+ acceptState==="saving"
1803
+ ? h("span",{className:"tl-accept-spin","aria-hidden":"true"})
1804
+ : h(Ic,{name:"check"}),
1805
+ h("span",null,acceptState==="done"?"Done":"Accept")),
1806
+ h("span",{className:"t-tt tl-tt-below",role:"tooltip"},
1807
+ acceptState==="error"&&acceptError?acceptError
1808
+ :acceptState==="done"?"Saved to your code"
1809
+ :acceptDisabled?"No changes to save"
1810
+ :"Save changes to your codebase")),
1675
1811
  h("button",{className:cx("tl-refine-btn",refineActive&&"is-active"),disabled:!active,onClick:onRefine},
1676
1812
  h(Ic,{name:"wand"}),
1677
1813
  h("span",{className:"tl-refine-sparks","aria-hidden":"true"},
@@ -2295,6 +2431,9 @@
2295
2431
  const[refineSuggestions,setRefineSuggestions]=useState([]);
2296
2432
  const[refineSummary,setRefineSummary]=useState(null);
2297
2433
  const[refineError,setRefineError]=useState(null);
2434
+ // ── accept (write to source) ──
2435
+ const[acceptState,setAcceptState]=useState("idle"); // idle | saving | done | error
2436
+ const[acceptError,setAcceptError]=useState(null);
2298
2437
  const[refineLabel,setRefineLabel]=useState(null);
2299
2438
  const[appliedIds,setAppliedIds]=useState({});
2300
2439
  const[refineMode,setRefineMode]=useState("llm"); // llm (Agent) | deterministic
@@ -2391,6 +2530,35 @@
2391
2530
  navigator.clipboard.writeText("transition: "+css+";").then(()=>{setCopied(true);setTimeout(()=>setCopied(false),1500);});
2392
2531
  },[active]);
2393
2532
  const resetOverrides=useCallback(()=>{if(active)registry.clearOverride(active.id);},[registry,active]);
2533
+ // Accept → send an "apply" job so the agent writes the edited timings into
2534
+ // the user's source, then reflect saving / done / error on the button.
2535
+ const onAccept=useCallback(async()=>{
2536
+ const changes=computeChanges(active);
2537
+ if(!active||!changes.length)return;
2538
+ setAcceptState("saving");setAcceptError(null);
2539
+ try{
2540
+ const{id}=await relayCreateJob({kind:"apply",transitionId:active.id,label:active.label,
2541
+ selector:active.bindings&&active.bindings.selector,changes});
2542
+ let settled=false;
2543
+ for(let i=0;i<240&&!settled;i++){
2544
+ await new Promise(r=>setTimeout(r,500));
2545
+ const job=await relayGetJob(id);
2546
+ if(job.status==="done"){settled=true;
2547
+ if(job.result&&job.result.applied===false){
2548
+ setAcceptState("error");setAcceptError((job.result&&job.result.summary)||"The agent couldn't find this transition in your source.");
2549
+ }else{
2550
+ setAcceptState("done");setTimeout(()=>setAcceptState("idle"),2500);
2551
+ }
2552
+ }else if(job.status==="error"){settled=true;setAcceptState("error");setAcceptError(job.error||"The agent reported an error.");}
2553
+ }
2554
+ if(!settled){setAcceptState("error");setAcceptError("Timed out waiting for the agent.");}
2555
+ }catch(e){
2556
+ setAcceptState("error");
2557
+ setAcceptError("Couldn't reach the relay. Run: npx transitions-refine live");
2558
+ }
2559
+ },[active]);
2560
+ // reset Accept feedback when switching transitions
2561
+ useEffect(()=>{setAcceptState("idle");setAcceptError(null);},[active&&active.id]);
2394
2562
 
2395
2563
  // whole-component open/close uses the transitions.dev panel reveal:
2396
2564
  // keep the panel mounted while it animates, flip data-open on the next
@@ -2439,7 +2607,8 @@
2439
2607
  h("div",{className:"tl-panel-body"},
2440
2608
  h("div",{className:"tl-panel-main"},
2441
2609
  h(Header,{entries,active,onSelect:setActiveId,onReset:resetOverrides,onCopy:copyValues,copied,
2442
- loop,setLoop,snap,setSnap,onMinimize:()=>setMinimized(true),onRefine:openRefine,refineActive:refineOpen}),
2610
+ loop,setLoop,snap,setSnap,onMinimize:()=>setMinimized(true),onRefine:openRefine,refineActive:refineOpen,
2611
+ onAccept,acceptState,acceptDisabled:computeChanges(active).length===0,acceptError}),
2443
2612
  active
2444
2613
  ?h(Body,{entry:active,onPropChange:(prop,o)=>setPropOverride(prop,o),
2445
2614
  state,play,pause,resume,restart,stop,speed,setSpeed,snap})
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "transitions-refine",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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
@@ -140,24 +140,35 @@ function buildPrompt(job) {
140
140
  return lines.join("\n");
141
141
  }
142
142
 
143
- function parseAgentOutput(stdout) {
143
+ function parseJsonish(stdout) {
144
144
  let s = (stdout || "").trim();
145
145
  const fence = s.match(/```(?:json)?\s*([\s\S]*?)```/i);
146
146
  if (fence) s = fence[1].trim();
147
- let obj;
148
147
  try {
149
- obj = JSON.parse(s);
148
+ return JSON.parse(s);
150
149
  } catch {
151
150
  const a = s.indexOf("{");
152
151
  const b = s.lastIndexOf("}");
153
- if (a >= 0 && b > a) obj = JSON.parse(s.slice(a, b + 1));
154
- else throw new Error("agent output was not JSON");
152
+ if (a >= 0 && b > a) return JSON.parse(s.slice(a, b + 1));
153
+ throw new Error("agent output was not JSON");
155
154
  }
155
+ }
156
+
157
+ function parseAgentOutput(stdout) {
158
+ const obj = parseJsonish(stdout);
156
159
  if (!obj || !Array.isArray(obj.suggestions)) throw new Error("agent output missing suggestions[]");
157
160
  return { suggestions: obj.suggestions, summary: obj.summary ?? null };
158
161
  }
159
162
 
160
- function runAgentCmd(cmd, prompt) {
163
+ // Apply jobs ask the agent to edit the user's source, so the result is an
164
+ // outcome, not suggestions.
165
+ function parseApplyOutput(stdout) {
166
+ const obj = parseJsonish(stdout);
167
+ if (!obj || typeof obj.applied === "undefined") throw new Error("agent output missing `applied`");
168
+ return { applied: Boolean(obj.applied), summary: obj.summary ?? null, files: Array.isArray(obj.files) ? obj.files : null };
169
+ }
170
+
171
+ function runAgentCmd(cmd, prompt, parse = parseAgentOutput) {
161
172
  return new Promise((resolve, reject) => {
162
173
  const child = spawn("sh", ["-c", cmd], { stdio: ["pipe", "pipe", "pipe"] });
163
174
  let out = "";
@@ -176,7 +187,7 @@ function runAgentCmd(cmd, prompt) {
176
187
  clearTimeout(timer);
177
188
  if (code !== 0) return reject(new Error(`agent exited ${code}: ${err.slice(0, 300)}`));
178
189
  try {
179
- resolve(parseAgentOutput(out));
190
+ resolve(parse(out));
180
191
  } catch (e) {
181
192
  reject(new Error(`${e.message} — got: ${out.slice(0, 200)}`));
182
193
  }
@@ -186,6 +197,27 @@ function runAgentCmd(cmd, prompt) {
186
197
  });
187
198
  }
188
199
 
200
+ // Prompt for an "apply" job: the agent edits the user's source so the selected
201
+ // transition uses the approved timings.
202
+ function buildApplyPrompt(job) {
203
+ const r = job.request || {};
204
+ return [
205
+ "You are APPLYING an approved transition change to the user's SOURCE CODE. Edit files; do not just suggest.",
206
+ "",
207
+ "Change context (JSON):",
208
+ JSON.stringify({ label: r.label, selector: r.selector, changes: r.changes }, null, 2),
209
+ "",
210
+ "Steps:",
211
+ "1. Find where this transition is defined in the source. Search by the selector/label/class names. Handle plain CSS, CSS Modules, styled-components/emotion template literals, Tailwind utilities/config, and inline style objects — the browser selector is a hint, the real declaration may live in any of these.",
212
+ "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. If a CSS variable / design token backs the value, update it at the most sensible single place.",
213
+ "3. Make the minimal edit. Do not reformat or change unrelated code.",
214
+ "",
215
+ 'Output ONLY a JSON object — no prose, no markdown fences — shaped exactly like:',
216
+ '{"applied":true,"summary":"Set .modal transition to 250ms ease-out","files":["src/Modal.css:42"]}',
217
+ 'If you cannot confidently locate the declaration, output {"applied":false,"summary":"<what you looked for and why it was not found>"}.',
218
+ ].join("\n");
219
+ }
220
+
189
221
  function refineDeterministic(job) {
190
222
  // Whole-transition replacement needs usage inference + recipe selection, which
191
223
  // only the agent (LLM) path can do. Deterministic can only snap to tokens.
@@ -207,13 +239,30 @@ function refineDeterministic(job) {
207
239
  async function answerJob(job) {
208
240
  job.status = "working";
209
241
  job.updatedAt = now();
242
+ const isApply = job.request?.kind === "apply";
210
243
  const label = job.request?.label || job.request?.selector || "transition";
211
244
  // The browser picks the mode per job via the LLM / Deterministic tabs.
212
245
  // Default: LLM when a command is configured, otherwise deterministic.
213
246
  const mode = job.request?.mode || (AGENT_CMD ? "llm" : "deterministic");
214
- job.statusLog.push({ message: `Scanning "${label}"…`, at: now() });
247
+ job.statusLog.push({ message: isApply ? `Writing "${label}" to your code…` : `Scanning "${label}"…`, at: now() });
215
248
  try {
216
249
  let result;
250
+ if (isApply) {
251
+ // Editing source can only be done by the agent.
252
+ if (!AGENT_CMD) {
253
+ throw new Error(
254
+ "Saving to your code needs the agent. Run `/refine live` in your editor, " +
255
+ "or start the relay with REFINE_AGENT_CMD set."
256
+ );
257
+ }
258
+ job.statusLog.push({ message: "Editing source files…", at: now() });
259
+ result = await runAgentCmd(AGENT_CMD, buildApplyPrompt(job), parseApplyOutput);
260
+ job.result = { applied: result.applied, summary: result.summary, files: result.files };
261
+ job.status = "done";
262
+ job.updatedAt = now();
263
+ console.log(` ✓ apply ${job.id.slice(0, 8)} — applied=${result.applied}`);
264
+ return;
265
+ }
217
266
  if (mode === "llm") {
218
267
  if (!AGENT_CMD) {
219
268
  throw new Error(
@@ -313,7 +362,10 @@ const server = createServer(async (req, res) => {
313
362
  return send(res, 400, { error: "Body must be { request: {...} }" });
314
363
  }
315
364
  const job = createJob(body.request);
316
- const mode = job.request.mode || (llmAvailable() ? "llm" : "deterministic");
365
+ // Apply jobs edit source agent only, never deterministic.
366
+ const mode = job.request.kind === "apply"
367
+ ? "llm"
368
+ : (job.request.mode || (llmAvailable() ? "llm" : "deterministic"));
317
369
  job.request.mode = mode;
318
370
 
319
371
  if (!AUTO) {
@@ -386,9 +438,14 @@ const server = createServer(async (req, res) => {
386
438
 
387
439
  if (method === "POST" && sub === "result") {
388
440
  const body = await readJson(req);
389
- const suggestions = body && Array.isArray(body.suggestions) ? body.suggestions : null;
390
- if (!suggestions) return send(res, 400, { error: "Body must be { suggestions: [...] }" });
391
- job.result = { suggestions, summary: body.summary ?? null };
441
+ if (body && Array.isArray(body.suggestions)) {
442
+ job.result = { suggestions: body.suggestions, summary: body.summary ?? null };
443
+ } else if (body && typeof body.applied !== "undefined") {
444
+ // apply-job result from a `/refine live` agent
445
+ job.result = { applied: Boolean(body.applied), summary: body.summary ?? null, files: Array.isArray(body.files) ? body.files : null };
446
+ } else {
447
+ return send(res, 400, { error: "Body must be { suggestions: [...] } or { applied, summary }" });
448
+ }
392
449
  job.status = "done";
393
450
  job.updatedAt = now();
394
451
  return send(res, 200, { ok: true });