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.
- package/.agents/skills/refine-live/SKILL.md +53 -1
- package/README.md +9 -3
- package/demo.html +184 -15
- package/package.json +1 -1
- package/server/relay.mjs +69 -12
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
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
|
|
1214
|
-
//
|
|
1215
|
-
//
|
|
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
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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)
|
|
154
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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 });
|