transitions-refine 0.1.2 → 0.2.0

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), Accept button, or grouped scan 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, for "scan" jobs groups the page's transitions into components with open/close phases by reading the source, 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,15 @@ never has to re-run `/refine live`.
54
54
  }
55
55
  ```
56
56
 
57
+ - **If `request.kind === "scan"`** this is not a suggestion job — the panel is
58
+ asking you to group the page's transitions by reading the source. Jump to
59
+ [`## Scan jobs`](#scan-jobs-group-from-source) and return `groups` instead of
60
+ suggestions.
61
+ - **If `request.kind === "apply"`** this is not a suggestion job — the user
62
+ pressed **Accept** to write changes to their code. Jump to
63
+ [`## Apply jobs`](#apply-jobs-write-to-source) and edit the source instead of
64
+ posting suggestions. Everything below (refineType, steps 3–4) is for the
65
+ normal Refine flow.
57
66
  - `refineType` chooses what kinds of suggestions to make (it mirrors the
58
67
  panel's two tabs):
59
68
  - `"small"` (or missing) → **Small refinements**: nudge the existing
@@ -171,6 +180,133 @@ never has to re-run `/refine live`.
171
180
  stop, tell them the LLM tab will go unavailable and how to restart
172
181
  (`/refine live`).
173
182
 
183
+ ## Scan jobs (group from source)
184
+
185
+ When a claimed job has `request.kind === "scan"`, the panel wants you to turn a
186
+ flat list of DOM-detected transitions into **components with phases**. A naive
187
+ DOM scan only sees each element's *current* computed transition — it can't tell
188
+ open from close, and lists related elements (panel, backdrop, staggered items)
189
+ separately. You fix that by reading the source. The request looks like:
190
+
191
+ ```json
192
+ {
193
+ "id": "uuid",
194
+ "request": {
195
+ "kind": "scan",
196
+ "url": "http://localhost:5173/",
197
+ "raw": [
198
+ { "label": "div.dropdown-panel", "selector": ".dropdown-panel",
199
+ "properties": ["opacity","transform"],
200
+ "timings": [{ "property": "opacity", "durationMs": 200, "delayMs": 0, "easing": "ease-out" }] }
201
+ ]
202
+ }
203
+ }
204
+ ```
205
+
206
+ Do this:
207
+
208
+ 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.
212
+ 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.
216
+ 3. **List each phase's members** — the elements that animate in that phase. Give
217
+ each a stable `id`, a human `label`, a live-resolvable CSS `selector`, an
218
+ 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.**
221
+ 4. **Post the groups** (this completes the job):
222
+
223
+ ```bash
224
+ curl -s -X POST http://localhost:7331/jobs/<id>/result \
225
+ -H 'Content-Type: application/json' \
226
+ -d '{
227
+ "summary": "Grouped Dropdown into Open/Close.",
228
+ "groups": [
229
+ { "id": "dropdown", "label": "Dropdown", "component": "src/Dropdown.tsx",
230
+ "phases": [
231
+ { "id": "dropdown:open", "phase": "open", "label": "Open", "members": [
232
+ { "id": "panel", "label": "Panel", "selector": ".dropdown-panel", "toState": ".is-open",
233
+ "propertyTimings": [
234
+ { "property": "opacity", "durationMs": 200, "delayMs": 0, "easing": "ease-out" },
235
+ { "property": "transform", "durationMs": 200, "delayMs": 0, "easing": "cubic-bezier(0.22, 1, 0.36, 1)" }
236
+ ] }
237
+ ] },
238
+ { "id": "dropdown:close", "phase": "close", "label": "Close", "members": [
239
+ { "id": "panel", "label": "Panel", "selector": ".dropdown-panel", "toState": ".is-closing",
240
+ "propertyTimings": [
241
+ { "property": "opacity", "durationMs": 150, "delayMs": 0, "easing": "ease-in" }
242
+ ] }
243
+ ] }
244
+ ] }
245
+ ]
246
+ }'
247
+ ```
248
+
249
+ If you can't confidently group anything, post `{"groups":[],"summary":"…"}` —
250
+ the panel keeps its flat DOM scan. Reserve `/jobs/<id>/error` for unexpected
251
+ failures.
252
+
253
+ Then go back to step 1 of the loop.
254
+
255
+ ## Apply jobs (write to source)
256
+
257
+ When a claimed job has `request.kind === "apply"`, the user accepted their current
258
+ timeline values and wants them written to the codebase. The request looks like:
259
+
260
+ ```json
261
+ {
262
+ "id": "uuid",
263
+ "request": {
264
+ "kind": "apply",
265
+ "label": "Dropdown · Close",
266
+ "selector": ".dropdown-panel",
267
+ "component": "src/Dropdown.tsx",
268
+ "group": "Dropdown",
269
+ "phase": "close",
270
+ "changes": [
271
+ { "property": "opacity", "member": "Panel", "selector": ".dropdown-panel",
272
+ "from": { "durationMs": 300, "delayMs": 0, "easing": "ease" },
273
+ "to": { "durationMs": 150, "delayMs": 0, "easing": "cubic-bezier(0.4, 0, 1, 1)" } }
274
+ ]
275
+ }
276
+ }
277
+ ```
278
+
279
+ Do this:
280
+
281
+ 1. **Locate the real declaration in the source.** The `selector` is a DOM-path
282
+ *hint*, not necessarily the source selector. Use the `component` hint and search
283
+ by the label/class names; handle whatever the project uses: plain CSS / CSS
284
+ Modules, styled-components or emotion template literals, Tailwind utilities
285
+ (`duration-300`, arbitrary `[transition-duration:300ms]`, or the
286
+ `tailwind.config` theme), inline `style={{ transition: … }}` objects, and
287
+ Motion/Framer variants. Match by the `from` values to disambiguate.
288
+ - **If `phase` is set** (e.g. `"open"`/`"close"`), edit only that state's rule
289
+ (the `.is-open` rule for open, the `.is-closing`/base rule for close) — not
290
+ the other phase. Each change's `member` + `selector` says which element.
291
+ 2. **Edit each change's property** to its `to` values (`durationMs` ms, `easing`,
292
+ `delayMs` ms) on the right member + phase. Keep the file's existing unit/format
293
+ (`0.25s` vs `250ms`) and touch only that property's timing. If a CSS variable /
294
+ design token backs the value, update it at the single most sensible place.
295
+ 3. **Minimal edit** — no reformatting or unrelated changes.
296
+ 4. **Post the outcome** (this completes the job):
297
+
298
+ ```bash
299
+ curl -s -X POST http://localhost:7331/jobs/<id>/result \
300
+ -H 'Content-Type: application/json' \
301
+ -d '{"applied":true,"summary":"Set .t-modal transition to 150ms ease-in","files":["src/Modal.css:42"]}'
302
+ ```
303
+
304
+ If you cannot confidently find the declaration, post
305
+ `{"applied":false,"summary":"<what you searched and why not found>"}` (still a
306
+ `result`, not an `error`). Reserve `/jobs/<id>/error` for unexpected failures.
307
+
308
+ Then go back to step 1 of the loop.
309
+
174
310
  ## Suggestion shape (must match the panel)
175
311
 
176
312
  Each suggestion object:
package/README.md CHANGED
@@ -2,7 +2,9 @@
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
+
7
+ Real components rarely live in one CSS rule. A dropdown has an **Open** and a **Close** phase, each animating several elements (panel, backdrop, staggered items) with different timings — and the close phase usually isn't even in the DOM while the panel is open. So when the panel opens it also asks the agent to **read your source and group** the page's transitions into components → phases → member elements. You then pick a whole phase (e.g. *Dropdown · Open*) and see every sub-transition as a labeled lane on one shared timeline; Play arms all of them together. If no agent is live, the panel falls back to the flat DOM scan with no regression.
6
8
 
7
9
  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
10
 
@@ -54,11 +56,23 @@ REFINE_AGENT_CMD='cursor-agent -p' npm run relay # or: codex exec - | claude
54
56
 
55
57
  The CLI must have the `transitions-dev` skill available (the prompt tells it to read the skill).
56
58
 
59
+ ## Grouped scan — Open / Close phases
60
+
61
+ When the panel opens it posts a **scan job** to the relay; the agent reads your source and returns the page's animated components, each split into phases (`open`, `close`, …) with their **member elements** and the *real* per-state timings — including the close transition the DOM can't show you. The picker then groups by component, you select a phase, and the timeline renders one lane per member-property (each lane labeled with its member) on a single time axis so stagger and delays line up. **Play** arms every member of the phase at once (driving each element via its `toState` class/attribute), and **Accept** writes back to the correct state rule (`.is-open` vs `.is-closing`) per member.
62
+
63
+ Grouping needs the agent (`/refine live`, `--llm`, or `REFINE_AGENT_CMD`); with no agent the panel just shows the flat DOM scan as before.
64
+
57
65
  ## Refine modes
58
66
 
59
67
  - **Small refinements** — keeps the transition, suggests motion-token tweaks (duration/easing), and may add a whole-transition replacement when one clearly fits better.
60
68
  - **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
69
 
70
+ ## Accept — write changes to your code
71
+
72
+ 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.
73
+
74
+ 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.
75
+
62
76
  ## Pieces
63
77
 
64
78
  | Piece | File | Role |
@@ -80,9 +94,9 @@ The CLI must have the `transitions-dev` skill available (the prompt tells it to
80
94
  | `REFINE_AUTO=0` | — | disable auto-answer and wait for an external poller |
81
95
  | `window.REFINE_RELAY_URL` | injected origin | browser override for the relay URL |
82
96
 
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}`.
97
+ Endpoints: `POST /jobs` (refine, `kind: "apply"`, or `kind: "scan"`), `GET /jobs/:id` (browser). In `REFINE_AUTO=0` mode an external poller also uses `GET /jobs/next` and `POST /jobs/:id/{status,result,error}` (the result body accepts `suggestions`, `groups`, or `applied`).
84
98
 
85
- Writing a value back to your source is a separate, explicit step (treat it like a normal edit / `transitions apply`).
99
+ Refine suggestions stay as live overrides until you press **Accept**, which is the explicit step that writes them into your source.
86
100
 
87
101
  ## License
88
102
 
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;
@@ -309,8 +323,15 @@
309
323
  position: relative; z-index: 5; cursor: grabbing; }
310
324
  .tl-prop-row.reordering .tl-prop-grip { color: var(--c-ruler); cursor: grabbing; }
311
325
  .tl-prop-label { font-size: 13px; font-weight: 500; line-height: 18px; color: var(--c-text-mut2); white-space: nowrap; overflow: hidden;
312
- text-overflow: ellipsis; text-transform: capitalize; }
326
+ text-overflow: ellipsis; text-transform: capitalize; display: flex; align-items: center; gap: 6px; min-width: 0; }
313
327
  .tl-prop-row.selected .tl-prop-label { color: #171717; }
328
+ .tl-prop-member { flex: none; font-size: 11px; font-weight: 600; line-height: 14px; padding: 1px 6px; border-radius: 60px;
329
+ background: var(--c-sec, rgba(0,0,0,0.05)); color: var(--c-text-faint); text-transform: none; max-width: 96px;
330
+ overflow: hidden; text-overflow: ellipsis; }
331
+ .tl-prop-row.selected .tl-prop-member { color: var(--c-text-mut2); }
332
+ .tl-prop-prop { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
333
+ .tl-insp-member { display: inline-block; font-size: 11px; font-weight: 600; line-height: 14px; padding: 1px 6px; border-radius: 60px;
334
+ background: var(--c-sec, rgba(0,0,0,0.05)); color: var(--c-text-faint); text-transform: none; margin-right: 6px; vertical-align: middle; }
314
335
  /* 16px left inset of the plot area (kept in sync with ruler/playhead/scrub) */
315
336
  .tl-prop-track { position: relative; flex: 1; user-select: none; margin-left: 16px; }
316
337
  /* timeline line — capsule (fully rounded), Figma #eeeeef default / #e9e9e9 hover */
@@ -534,6 +555,9 @@
534
555
  .tl-menu-help svg { display: block; }
535
556
  .tl-menu-check { display: flex; color: var(--c-text-strong); flex: none; }
536
557
  .tl-menu-empty { padding: 10px; color: var(--c-disabled); font-size: 13px; }
558
+ .tl-menu-section { padding: 8px 10px 4px; font-size: 11px; font-weight: 600; line-height: 14px;
559
+ letter-spacing: 0.02em; text-transform: uppercase; color: var(--c-text-faint); }
560
+ .tl-menu-section:not(:first-child) { margin-top: 2px; border-top: 1px solid var(--c-border, rgba(0,0,0,0.06)); padding-top: 8px; }
537
561
 
538
562
  /* ═════ transitions.dev — menu dropdown (verbatim) ═════ */
539
563
  .t-dropdown {
@@ -1101,36 +1125,103 @@
1101
1125
  return out;
1102
1126
  }
1103
1127
 
1104
- // ── registry (per-property overrides) ──
1128
+ // ── registry (per-lane overrides) ──
1129
+ // A "lane" is one animated property of one element. For flat DOM transitions
1130
+ // a lane's id is just the property; for agent-grouped phases it is
1131
+ // `memberId::property`, so two members can each animate e.g. `opacity`.
1132
+ // Selectable items are either flat DOM entries (kind "flat") or one phase of
1133
+ // an agent group (kind "phase"); a phase fans out into per-member lanes.
1105
1134
  class TransitionRegistry {
1106
- entries=new Map(); listeners=new Set(); propOverrides=new Map(); snapshot=[]; effectiveCache=new Map();
1107
- register(e){ this.entries.set(e.id,e); this._notify(); }
1108
- unregister(id){ this.entries.delete(id); this.propOverrides.delete(id); this._notify(); }
1109
- replaceAll(list){ this.entries.clear(); for(const e of list) this.entries.set(e.id,e); this._notify(); }
1135
+ entries=new Map(); // flat DOM entries by id
1136
+ groups=[]; // agent groups: [{id,label,component,phases:[{id,phase,label,members:[...]}]}]
1137
+ claimedEntryIds=new Set();// flat entries covered by a group member (hidden from "Ungrouped")
1138
+ listeners=new Set(); laneOverrides=new Map(); snapshot=[]; effectiveCache=new Map();
1139
+ register(e){ this.entries.set(e.id,e); this._recomputeClaimed(); this._notify(); }
1140
+ unregister(id){ this.entries.delete(id); this.laneOverrides.delete(id); this._recomputeClaimed(); this._notify(); }
1141
+ replaceAll(list){ this.entries.clear(); for(const e of list) this.entries.set(e.id,e); this._recomputeClaimed(); this._notify(); }
1142
+ setGroups(groups){ this.groups = Array.isArray(groups)?groups:[]; this._recomputeClaimed(); this._notify(); }
1143
+ clearGroups(){ this.groups=[]; this.claimedEntryIds.clear(); this._notify(); }
1110
1144
  get(id){ return this.entries.get(id); }
1111
1145
  getAll(){ return this.snapshot; }
1112
1146
  getEffective(id){ return this.effectiveCache.get(id); }
1113
- setPropOverride(id, prop, o){
1114
- const m = this.propOverrides.get(id) || {};
1115
- m[prop] = { ...m[prop], ...o };
1116
- this.propOverrides.set(id, m);
1147
+ // overrides are keyed by selectable-item id, then by laneId
1148
+ setPropOverride(id, laneId, o){
1149
+ const m = this.laneOverrides.get(id) || {};
1150
+ m[laneId] = { ...m[laneId], ...o };
1151
+ this.laneOverrides.set(id, m);
1117
1152
  this._notify();
1118
1153
  }
1119
- clearOverride(id){ this.propOverrides.delete(id); this._notify(); }
1120
- getPropOverrides(id){ return this.propOverrides.get(id); }
1154
+ clearOverride(id){ this.laneOverrides.delete(id); this._notify(); }
1155
+ getPropOverrides(id){ return this.laneOverrides.get(id); }
1121
1156
  subscribe(fn){ this.listeners.add(fn); return ()=>this.listeners.delete(fn); }
1157
+ // A flat entry is "claimed" when every live element it is bound to is also
1158
+ // matched by some group member's selector, so we don't list it twice.
1159
+ _recomputeClaimed(){
1160
+ this.claimedEntryIds.clear();
1161
+ if(!this.groups.length||typeof document==="undefined")return;
1162
+ const memberEls=new Set();
1163
+ for(const g of this.groups)for(const ph of (g.phases||[]))for(const m of (ph.members||[])){
1164
+ if(!m.selector)continue;
1165
+ let els=[];try{els=Array.from(document.querySelectorAll(m.selector));}catch{}
1166
+ for(const el of els)memberEls.add(el);
1167
+ }
1168
+ if(!memberEls.size)return;
1169
+ for(const[id,entry]of this.entries){
1170
+ const els=((entry.bindings&&entry.bindings.elements)||[]).map(w=>w.deref&&w.deref()).filter(Boolean);
1171
+ if(els.length&&els.every(el=>memberEls.has(el)))this.claimedEntryIds.add(id);
1172
+ }
1173
+ }
1174
+ _baseLanesFlat(entry){
1175
+ const timings=(entry.propertyTimings||entry.properties.map(p=>({property:p,durationMs:entry.durationMs,delayMs:entry.delayMs,easing:entry.easing})));
1176
+ return timings.map(t=>({laneId:t.property,property:t.property,memberId:null,member:null,selector:entry.bindings&&entry.bindings.selector,durationMs:t.durationMs,delayMs:t.delayMs,easing:t.easing,spring:t.spring}));
1177
+ }
1178
+ _baseLanesPhase(phase){
1179
+ const out=[];
1180
+ for(const m of (phase.members||[])){
1181
+ for(const t of (m.propertyTimings||[])){
1182
+ out.push({laneId:m.id+"::"+t.property,property:t.property,memberId:m.id,member:m.label||m.id,selector:m.selector,toState:m.toState,durationMs:t.durationMs,delayMs:t.delayMs,easing:t.easing,spring:t.spring});
1183
+ }
1184
+ }
1185
+ return out;
1186
+ }
1187
+ _applyOverrides(baseLanes,po){
1188
+ return baseLanes.map(t=>{const o=po[t.laneId];return {...t,durationMs:o?.durationMs??t.durationMs,delayMs:o?.delayMs??t.delayMs,easing:o?.easing??t.easing,spring:o?.spring??t.spring};});
1189
+ }
1122
1190
  _notify(){
1123
- this.snapshot=Array.from(this.entries.values());
1124
1191
  this.effectiveCache.clear();
1192
+ const items=[];
1193
+ // 1) agent groups → one selectable item per phase
1194
+ for(const g of this.groups){
1195
+ for(const ph of (g.phases||[])){
1196
+ const id=ph.id||(g.id+":"+(ph.phase||ph.label||"phase"));
1197
+ const po=this.laneOverrides.get(id)||{};
1198
+ const baseLanes=this._baseLanesPhase(ph);
1199
+ const effTimings=this._applyOverrides(baseLanes,po);
1200
+ const repDur=effTimings.reduce((mx,t)=>Math.max(mx,(t.durationMs||0)+(t.delayMs||0)),0);
1201
+ const members=(ph.members||[]).map(m=>{
1202
+ const mb=baseLanes.filter(l=>l.memberId===m.id);
1203
+ return {memberId:m.id,label:m.label||m.id,selector:m.selector,toState:m.toState,
1204
+ lanes:this._applyOverrides(mb,po),baseLanes:mb};
1205
+ });
1206
+ const item={id,kind:"phase",groupId:g.id,groupLabel:g.label||g.id,component:g.component||null,
1207
+ phase:ph.phase||null,phaseLabel:ph.label||ph.phase||"Phase",
1208
+ label:(g.label||g.id)+" · "+(ph.label||ph.phase||"Phase"),durationMs:repDur,
1209
+ members,effectiveTimings:effTimings,baseLanes};
1210
+ this.effectiveCache.set(id,item);
1211
+ items.push(item);
1212
+ }
1213
+ }
1214
+ // 2) flat DOM entries not claimed by a group → "Ungrouped"
1125
1215
  for(const[id,entry]of this.entries){
1126
- const po = this.propOverrides.get(id) || {};
1127
- const timings = (entry.propertyTimings || entry.properties.map(p=>({property:p,durationMs:entry.durationMs,delayMs:entry.delayMs,easing:entry.easing})));
1128
- const effTimings = timings.map(t => {
1129
- const o = po[t.property];
1130
- return { property:t.property, durationMs:o?.durationMs??t.durationMs, delayMs:o?.delayMs??t.delayMs, easing:o?.easing??t.easing, spring:o?.spring??t.spring };
1131
- });
1132
- this.effectiveCache.set(id, { ...entry, effectiveTimings: effTimings });
1216
+ if(this.claimedEntryIds.has(id))continue;
1217
+ const po=this.laneOverrides.get(id)||{};
1218
+ const baseLanes=this._baseLanesFlat(entry);
1219
+ const effTimings=this._applyOverrides(baseLanes,po);
1220
+ const item={...entry,kind:"flat",groupId:null,groupLabel:null,effectiveTimings:effTimings,baseLanes};
1221
+ this.effectiveCache.set(id,item);
1222
+ items.push(item);
1133
1223
  }
1224
+ this.snapshot=items;
1134
1225
  for(const fn of this.listeners) fn(this.snapshot);
1135
1226
  }
1136
1227
  }
@@ -1169,6 +1260,109 @@
1169
1260
  // capture phase + the bubbling transitionrun event reaches document
1170
1261
  document.addEventListener("transitionrun",_txOnRun,true);
1171
1262
  }
1263
+ // from/to recovered from a previously observed real run
1264
+ function _txCaptured(el,et){
1265
+ const rec=_txCapture.get(el);if(!rec)return null;
1266
+ const from={},to={};let n=0;
1267
+ for(const t of et){const c=rec.get(t.property);if(c){from[t.property]=c.from;to[t.property]=c.to;n++;}}
1268
+ return n?{from,to}:null;
1269
+ }
1270
+ // Discover the transition's end-state WITHOUT interaction by reading the
1271
+ // stylesheets: find rules that set a transitioning prop and represent a
1272
+ // *state* of the element (a :hover/:focus pseudo, or a toggled class/attr
1273
+ // like `.modal.open`). from = current computed value, to = the rule's value.
1274
+ 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;
1275
+ function _txReduceLast(el,sel){
1276
+ const parts=sel.split(/(\s*[>+~]\s*|\s+)/);
1277
+ let i=parts.length-1;while(i>=0&&/^\s*[>+~]?\s*$/.test(parts[i]))i--;
1278
+ if(i<0)return null;
1279
+ const toks=parts[i].match(/[.#]?[\w-]+|\[[^\]]*\]|::?[\w-]+(?:\([^)]*\))?|\*/g)||[];
1280
+ let dropped=false,hasId=false;
1281
+ const kept=toks.filter(tk=>{
1282
+ if(tk[0]==="."){const ok=el.classList.contains(tk.slice(1));if(ok)hasId=true;else dropped=true;return ok;}
1283
+ if(tk[0]==="["){let ok;try{ok=el.matches(tk);}catch{ok=false;}if(ok)hasId=true;else dropped=true;return ok;}
1284
+ if(tk[0]==="#"){const ok=el.id&&("#"+el.id)===tk;if(ok)hasId=true;else dropped=true;return ok;}
1285
+ if(tk[0]===":"){dropped=true;return false;} // pseudo = the state delta
1286
+ return true; // tag / *
1287
+ });
1288
+ // Only a real *variant of this element*: we must have removed a state token
1289
+ // AND kept a class/id/attr that ties the rule to el. Dropping every class to
1290
+ // land on a bare tag/`*` would falsely match unrelated rules.
1291
+ if(!dropped||!hasId)return null;
1292
+ parts[i]=kept.join("");
1293
+ return parts.join("");
1294
+ }
1295
+ function _txIsStateOf(el,sel){
1296
+ try{if(el.matches(sel))return false;}catch{return false;}
1297
+ const noP=sel.replace(_TX_STATE_PSEUDO,"").trim();
1298
+ if(noP&&noP!==sel){try{if(el.matches(noP))return true;}catch{}}
1299
+ const red=_txReduceLast(el,sel);
1300
+ if(red&&red!==sel){try{if(el.matches(red))return true;}catch{}}
1301
+ return false;
1302
+ }
1303
+ function _txScanRules(rules,el,props,to){
1304
+ for(const rule of rules){
1305
+ // Read style rules directly. NOTE: in browsers with CSS Nesting a plain
1306
+ // CSSStyleRule also has a (usually empty) .cssRules, so test selectorText
1307
+ // first — don't treat every style rule as a grouping rule.
1308
+ const decl=rule.style;
1309
+ if(decl&&rule.selectorText){
1310
+ let sets=false;for(const p of props){if(decl.getPropertyValue(p)){sets=true;break;}}
1311
+ if(sets){
1312
+ for(const sub of rule.selectorText.split(",")){
1313
+ const s=sub.trim();if(!s)continue;
1314
+ if(_txIsStateOf(el,s)){for(const p of props){const v=decl.getPropertyValue(p);if(v&&!(p in to))to[p]=v.trim();}}
1315
+ }
1316
+ }
1317
+ }
1318
+ // Recurse into @media / @supports / @layer and any nested rules.
1319
+ if(rule.cssRules&&rule.cssRules.length){
1320
+ try{if(rule.media&&!window.matchMedia(rule.media.mediaText).matches)continue;}catch{}
1321
+ _txScanRules(rule.cssRules,el,props,to);
1322
+ }
1323
+ }
1324
+ }
1325
+ function _txDiscover(el,et){
1326
+ if(typeof document==="undefined"||!el.matches)return null;
1327
+ const props=et.map(t=>t.property).filter(p=>p&&p!=="all");
1328
+ if(!props.length)return null;
1329
+ const to={};
1330
+ for(const sheet of Array.from(document.styleSheets||[])){
1331
+ let rules;try{rules=sheet.cssRules;}catch{continue;} // cross-origin sheet
1332
+ if(rules)_txScanRules(rules,el,props,to);
1333
+ }
1334
+ const keys=Object.keys(to);if(!keys.length)return null;
1335
+ const cs=getComputedStyle(el);const from={};
1336
+ for(const p of keys)from[p]=cs.getPropertyValue(p)||cs[_txCamel(p)]||"";
1337
+ return {from,to};
1338
+ }
1339
+ // Toggle a phase's state class/attribute (e.g. ".is-open" or "[data-open]")
1340
+ // and read the resulting computed values, so a phase plays its real end-state
1341
+ // even when the source uses a toggled class the DOM isn't currently in.
1342
+ function _txToState(el,et,toState){
1343
+ if(typeof document==="undefined"||!el.matches||!toState)return null;
1344
+ const props=et.map(t=>t.property).filter(p=>p&&p!=="all");
1345
+ if(!props.length)return null;
1346
+ const s=String(toState).trim();
1347
+ let applied=null; // {undo}
1348
+ try{
1349
+ if(s[0]==="."){const c=s.slice(1);if(c&&!el.classList.contains(c)){el.classList.add(c);applied=()=>el.classList.remove(c);}}
1350
+ else if(s[0]==="["){
1351
+ const m=s.match(/^\[\s*([\w-]+)\s*(?:([~|^$*]?=)\s*"?([^"\]]*)"?\s*)?\]$/);
1352
+ if(m){const name=m[1],val=m[3]!=null?m[3]:"";if(el.getAttribute(name)!==val){el.setAttribute(name,val);applied=()=>el.removeAttribute(name);}}
1353
+ }
1354
+ }catch{}
1355
+ if(!applied)return null; // already in that state, or unparseable
1356
+ const cs=getComputedStyle(el);const to={};
1357
+ for(const p of props)to[p]=cs.getPropertyValue(p)||cs[_txCamel(p)]||"";
1358
+ try{applied();}catch{}
1359
+ const cs0=getComputedStyle(el);const from={};
1360
+ for(const p of props)from[p]=cs0.getPropertyValue(p)||cs0[_txCamel(p)]||"";
1361
+ const keys=Object.keys(to).filter(p=>to[p]!=null&&to[p]!==""&&to[p]!==from[p]);
1362
+ if(!keys.length)return null;
1363
+ const f={},t={};for(const p of keys){f[p]=from[p];t[p]=to[p];}
1364
+ return {from:f,to:t};
1365
+ }
1172
1366
 
1173
1367
  class PreviewController {
1174
1368
  state="idle"; listeners=new Set(); cleanups=[]; animations=[]; progressListeners=new Set(); _rafId=null; scanner=null; _gen=0;
@@ -1179,7 +1373,25 @@
1179
1373
  subscribe(fn){this.listeners.add(fn);return()=>this.listeners.delete(fn);}
1180
1374
  onProgress(fn){this.progressListeners.add(fn);return()=>this.progressListeners.delete(fn);}
1181
1375
  play(entry){this.stop();this._gen++;this._current=entry;
1182
- if(this.scanner)this.scanner.pause();if(entry.bindings.type==="css")this._playCss(entry,this._gen);this._setState("playing");}
1376
+ if(this.scanner)this.scanner.pause();this._playCss(entry,this._gen);this._setState("playing");}
1377
+ // Resolve a selectable item to a flat list of {el, et, toState} arm targets.
1378
+ // Phase items fan out to their members (selector may match several elements,
1379
+ // e.g. staggered items); flat items use their bound DOM elements.
1380
+ _targets(entry){
1381
+ const out=[];
1382
+ if(entry.kind==="phase"){
1383
+ for(const m of (entry.members||[])){
1384
+ if(!m.selector||!m.lanes||!m.lanes.length)continue;
1385
+ let els=[];try{els=Array.from(document.querySelectorAll(m.selector));}catch{}
1386
+ for(const el of els)out.push({el,et:m.lanes,toState:m.toState});
1387
+ }
1388
+ return out;
1389
+ }
1390
+ if(!entry.bindings||entry.bindings.type!=="css")return out;
1391
+ const et=entry.effectiveTimings||entry.properties.map(p=>({property:p,durationMs:entry.durationMs,delayMs:entry.delayMs,easing:entry.easing}));
1392
+ for(const wr of entry.bindings.elements){const el=wr.deref&&wr.deref();if(el)out.push({el,et,toState:null});}
1393
+ return out;
1394
+ }
1183
1395
  pause(){if(this.state!=="playing")return;for(const a of this.animations){try{a.pause();}catch{}}this._stopPL();this._setState("paused");}
1184
1396
  resume(){if(this.state!=="paused")return;for(const a of this.animations){try{a.play();}catch{}}this._startPL();this._setState("playing");}
1185
1397
  stop(){this._stopPL();for(const a of this.animations){try{a.cancel();}catch{}}this.animations=[];for(const c of this.cleanups)c();this.cleanups=[];this._ep(0);if(this.scanner)this.scanner.unpause();this._setState("idle");}
@@ -1188,11 +1400,11 @@
1188
1400
  this.stop();this._gen++;const gen=this._gen;
1189
1401
  this._pendingSeek=seekMs;
1190
1402
  if(this.scanner)this.scanner.pause();
1191
- if(entry.bindings.type!=="css")return;
1403
+ const targets=this._targets(entry);
1404
+ if(!targets.length)return;
1192
1405
  this.animations=[];
1193
- const et=entry.effectiveTimings||entry.properties.map(p=>({property:p,durationMs:entry.durationMs,delayMs:entry.delayMs,easing:entry.easing}));
1194
- for(const wr of entry.bindings.elements){const el=wr.deref();if(!el)continue;
1195
- const restore=this._arm(el,et);
1406
+ for(const {el,et,toState} of targets){
1407
+ const restore=this._arm(el,et,toState);
1196
1408
  requestAnimationFrame(()=>{if(this._gen!==gen)return;const running=el.getAnimations();
1197
1409
  for(const a of running){a.pause();a.playbackRate=this.rate;this.animations.push(a);}
1198
1410
  const t=this._pendingSeek??seekMs;
@@ -1210,31 +1422,54 @@
1210
1422
  this._pendingSeek=timeMs;
1211
1423
  }
1212
1424
  _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.
1216
- _arm(el,et){
1425
+ // Arm an element so its transition runs now, without needing the user to
1426
+ // trigger it first. Priority:
1427
+ // 1. a previously observed real run (exact),
1428
+ // 2. end-state discovered from the stylesheets (hover/focus/toggled class),
1429
+ // 3. a synthetic opacity/transform pulse to preview the timing,
1430
+ // 4. click the element (last resort, may have side effects).
1431
+ // Returns a restore fn.
1432
+ _arm(el,et,toState){
1217
1433
  const saved=el.style.cssText;
1218
1434
  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();
1435
+ // toState (a phase's driving class/attr) is the most reliable source of
1436
+ // the real end-state; fall back to a captured run, then CSSOM discovery.
1437
+ const states=(toState&&_txToState(el,et,toState))||_txCaptured(el,et)||_txDiscover(el,et);
1438
+ if(states){
1439
+ const props=et.map(t=>t.property).filter(p=>states.from[p]!=null&&states.to[p]!=null);
1440
+ if(props.length){
1441
+ el.style.transition="none";
1442
+ for(const p of props)el.style.setProperty(p,states.from[p]);
1443
+ void el.offsetWidth; // commit the from-state before transitioning
1444
+ el.style.transition=tv;
1445
+ for(const p of props)el.style.setProperty(p,states.to[p]);
1446
+ return ()=>{try{el.style.cssText=saved;}catch{}};
1447
+ }
1229
1448
  }
1449
+ const synth=et.filter(t=>t.property==="opacity"||t.property==="transform"||t.property==="all");
1450
+ if(synth.length){
1451
+ const dur=Math.max(...et.map(t=>(t.durationMs||0)+(t.delayMs||0)))||et[0].durationMs||300;
1452
+ const ease=et[0].easing||"ease";
1453
+ const hasOp=synth.some(t=>t.property!=="transform"),hasTr=synth.some(t=>t.property!=="opacity");
1454
+ const k0={},k1={},k2={};
1455
+ if(hasOp){k0.opacity=1;k1.opacity=0.35;k2.opacity=1;}
1456
+ if(hasTr){k0.transform="none";k1.transform="translateY(8px)";k2.transform="none";}
1457
+ try{el.animate([k0,k1,k2],{duration:dur,easing:ease,fill:"none"});return ()=>{};}catch{}
1458
+ }
1459
+ el.style.transition=tv;el.click();
1230
1460
  return ()=>{try{el.style.cssText=saved;}catch{}};
1231
1461
  }
1232
- _playCss(entry,gen){if(entry.bindings.type!=="css")return;this.animations=[];
1233
- const et = entry.effectiveTimings || entry.properties.map(p=>({property:p,durationMs:entry.durationMs,delayMs:entry.delayMs,easing:entry.easing}));
1234
- for(const wr of entry.bindings.elements){const el=wr.deref();if(!el)continue;
1235
- const restore=this._arm(el,et);
1462
+ _playCss(entry,gen){
1463
+ const targets=this._targets(entry);
1464
+ this.animations=[];
1465
+ if(!targets.length){this._finish();return;}
1466
+ let pending=targets.length;
1467
+ for(const {el,et,toState} of targets){
1468
+ const restore=this._arm(el,et,toState);
1236
1469
  requestAnimationFrame(()=>{if(this._gen!==gen)return;const running=el.getAnimations();for(const a of running){a.playbackRate=this.rate;this.animations.push(a);}this._startPL();
1237
- if(running.length>0){Promise.allSettled(running.map(a=>a.finished)).then(()=>{if(this._gen!==gen||this.state!=="playing")return;if(this.loop&&this._current){this.play(this._current);}else{this._finish();}});}else{this._finish();}});
1470
+ // resolve the whole group when every target's animations have settled
1471
+ const onDone=()=>{if(this._gen!==gen||this.state!=="playing")return;pending--;if(pending>0)return;if(this.loop&&this._current){this.play(this._current);}else{this._finish();}};
1472
+ if(running.length>0){Promise.allSettled(running.map(a=>a.finished)).then(onDone);}else{onDone();}});
1238
1473
  this.cleanups.push(restore);}}
1239
1474
  _startPL(){this._stopPL();const tick=()=>{if(this.animations.length>0){let cur=0;for(const a of this.animations){const c=Number(a.currentTime)||0;if(c>cur)cur=c;}this._ep(cur);}this._rafId=requestAnimationFrame(tick);};this._rafId=requestAnimationFrame(tick);}
1240
1475
  _stopPL(){if(this._rafId!==null){cancelAnimationFrame(this._rafId);this._rafId=null;}}
@@ -1642,10 +1877,46 @@
1642
1877
  h("div",{className:"tl-refine-foot"},foot)));
1643
1878
  }
1644
1879
 
1645
- function Header({entries,active,onSelect,onReset,onCopy,copied,loop,setLoop,snap,setSnap,onMinimize,onRefine,refineActive}){
1880
+ // Diff the active item's effective (edited/refined) lanes against their
1881
+ // originally-scanned values — the set of changes Accept writes to source.
1882
+ // Each change carries member/selector/phase context so the agent can target
1883
+ // the right state rule (e.g. .is-open vs .is-closing) for a grouped phase.
1884
+ function computeChanges(active){
1885
+ if(!active)return[];
1886
+ const base=active.baseLanes||[];
1887
+ const bmap=new Map(base.map(t=>[t.laneId,t]));
1888
+ const out=[];
1889
+ for(const t of (active.effectiveTimings||[])){
1890
+ const b=bmap.get(t.laneId);if(!b)continue;
1891
+ if(t.durationMs!==b.durationMs||t.delayMs!==b.delayMs||(t.easing||"")!==(b.easing||"")){
1892
+ out.push({property:t.property,member:t.member||null,selector:t.selector||null,
1893
+ from:{durationMs:b.durationMs,delayMs:b.delayMs,easing:b.easing},
1894
+ to:{durationMs:t.durationMs,delayMs:t.delayMs,easing:t.easing}});
1895
+ }
1896
+ }
1897
+ return out;
1898
+ }
1899
+
1900
+ function Header({entries,active,onSelect,onReset,onCopy,copied,loop,setLoop,snap,setSnap,onMinimize,onRefine,refineActive,onAccept,acceptState,acceptDisabled,acceptError,scanning}){
1646
1901
  const[pick,setPick]=useState(false);
1647
1902
  const[setg,setSetg]=useState(false);
1648
1903
  const pickRef=useRef(null), gearRef=useRef(null);
1904
+ // Split selectable items into agent groups (component → phases) and the
1905
+ // flat, ungrouped DOM transitions, preserving order.
1906
+ const groupList=[]; const groupMap=new Map(); const flatItems=[];
1907
+ for(const e of entries){
1908
+ if(e.kind==="phase"){
1909
+ let g=groupMap.get(e.groupId);
1910
+ if(!g){g={id:e.groupId,label:e.groupLabel,phases:[]};groupMap.set(e.groupId,g);groupList.push(g);}
1911
+ g.phases.push(e);
1912
+ }else flatItems.push(e);
1913
+ }
1914
+ const phaseItem=(e)=>h(MenuItem,{key:e.id,active:active&&e.id===active.id,onClick:()=>{onSelect(e.id);setPick(false);},
1915
+ right:active&&e.id===active.id&&h("span",{className:"tl-menu-check"},h(Ic,{name:"check"}))},
1916
+ h("span",null,e.phaseLabel,h("span",{className:"tl-menu-dim"}," "+e.durationMs+"ms")));
1917
+ const flatItem=(e)=>h(MenuItem,{key:e.id,active:active&&e.id===active.id,onClick:()=>{onSelect(e.id);setPick(false);},
1918
+ right:active&&e.id===active.id&&h("span",{className:"tl-menu-check"},h(Ic,{name:"check"}))},
1919
+ h("span",null,e.label,h("span",{className:"tl-menu-dim"}," "+e.durationMs+"ms")));
1649
1920
  return h("div",{className:"tl-header"},
1650
1921
  h("span",{className:"tl-header-label"},"Selected"),
1651
1922
  h("button",{ref:pickRef,className:cx("tl-ghost-btn",pick&&"is-active"),disabled:!active,onClick:()=>setPick(v=>!v)},
@@ -1655,10 +1926,14 @@
1655
1926
  h(Dropdown,{open:pick,onClose:()=>setPick(false),triggerRef:pickRef,width:Math.max(240,(pickRef.current&&pickRef.current.offsetWidth)||240),align:"left"},
1656
1927
  entries.length===0
1657
1928
  ? h("div",{className:"tl-menu-empty"},"No transitions found")
1658
- : entries.map(e=>h(MenuItem,{key:e.id,active:active&&e.id===active.id,onClick:()=>{onSelect(e.id);setPick(false);},
1659
- right:active&&e.id===active.id&&h("span",{className:"tl-menu-check"},h(Ic,{name:"check"}))},
1660
- h("span",null,e.label,h("span",{className:"tl-menu-dim"}," "+e.durationMs+"ms")))) ),
1661
- h("span",{className:"tl-header-count"},entries.length+" transition"+(entries.length===1?"":"s")+" found"),
1929
+ : h(React.Fragment,null,
1930
+ ...groupList.map(g=>h(React.Fragment,{key:g.id},
1931
+ h("div",{className:"tl-menu-section"},g.label),
1932
+ ...g.phases.map(phaseItem))),
1933
+ flatItems.length>0&&groupList.length>0&&h("div",{className:"tl-menu-section"},"Ungrouped"),
1934
+ ...flatItems.map(flatItem))),
1935
+ h("span",{className:"tl-header-count"},
1936
+ scanning?"Grouping…":entries.length+" transition"+(entries.length===1?"":"s")+" found"),
1662
1937
  h("button",{ref:gearRef,className:cx("tl-icon-btn",setg&&"is-active"),title:"Settings",onClick:()=>setSetg(v=>!v)},h(Ic,{name:"gear"})),
1663
1938
  h(Dropdown,{open:setg,onClose:()=>setSetg(false),triggerRef:gearRef,width:210,align:"right"},
1664
1939
  h(MenuItem,{onClick:()=>setLoop(v=>!v),right:loop&&h("span",{className:"tl-menu-check"},h(Ic,{name:"check"}))},"Loop playback"),
@@ -1672,6 +1947,18 @@
1672
1947
  h("span",{className:"t-icon","data-icon":"a"},h(Ic,{name:"copy"})),
1673
1948
  h("span",{className:"t-icon","data-icon":"b"},h(Ic,{name:"check"})))),
1674
1949
  h("span",{className:"t-tt tl-tt-below",role:"tooltip"},copied?"Copied":"Copy values")),
1950
+ h("span",{className:"t-tt-wrap"},
1951
+ h("button",{className:cx("tl-accept-btn",acceptState==="saving"&&"is-saving",acceptState==="done"&&"is-done"),
1952
+ disabled:!active||acceptDisabled||acceptState==="saving"||acceptState==="done",onClick:onAccept,"aria-label":"Accept changes to your code"},
1953
+ acceptState==="saving"
1954
+ ? h("span",{className:"tl-accept-spin","aria-hidden":"true"})
1955
+ : h(Ic,{name:"check"}),
1956
+ h("span",null,acceptState==="done"?"Done":"Accept")),
1957
+ h("span",{className:"t-tt tl-tt-below",role:"tooltip"},
1958
+ acceptState==="error"&&acceptError?acceptError
1959
+ :acceptState==="done"?"Saved to your code"
1960
+ :acceptDisabled?"No changes to save"
1961
+ :"Save changes to your codebase")),
1675
1962
  h("button",{className:cx("tl-refine-btn",refineActive&&"is-active"),disabled:!active,onClick:onRefine},
1676
1963
  h(Ic,{name:"wand"}),
1677
1964
  h("span",{className:"tl-refine-sparks","aria-hidden":"true"},
@@ -1690,7 +1977,7 @@
1690
1977
  );
1691
1978
  }
1692
1979
 
1693
- function PropTrack({property, delayMs, durationMs, selected, dragging, onSelect, onReorder, onDelayChange, onDurationChange, snap, scaleMs, lockDuration}){
1980
+ function PropTrack({property, member, delayMs, durationMs, selected, dragging, onSelect, onReorder, onDelayChange, onDurationChange, snap, scaleMs, lockDuration}){
1694
1981
  const trackRef=useRef(null);
1695
1982
  const grid = snap ? 25 : 1;
1696
1983
  const pxToMs=useCallback(px=>{if(!trackRef.current)return 0;const w=trackRef.current.getBoundingClientRect().width;return Math.round((px/w)*scaleMs/grid)*grid;},[grid,scaleMs]);
@@ -1720,7 +2007,9 @@
1720
2007
  return h("div",{className:cx("tl-prop-row",selected&&"selected",dragging&&"reordering",lockDuration&&"is-spring"),onClick:onSelect},
1721
2008
  h("div",{className:"tl-prop-head"},
1722
2009
  h("span",{className:"tl-prop-grip",title:"Drag to reorder",onMouseDown:onReorder},h(Ic,{name:"dots"})),
1723
- h("span",{className:"tl-prop-label"},property)),
2010
+ h("span",{className:"tl-prop-label"},
2011
+ member&&h("span",{className:"tl-prop-member"},member),
2012
+ h("span",{className:"tl-prop-prop"},property))),
1724
2013
  h("div",{className:"tl-prop-track",ref:trackRef},
1725
2014
  h("div",{className:"tl-bar",style:{left:delPct+"%",width:durPct+"%"},onMouseDown:e=>startDrag("move",e),
1726
2015
  title:lockDuration?"Spring duration is derived \u2014 drag to move, resize is locked":undefined},
@@ -2136,44 +2425,44 @@
2136
2425
  return h("div",{className:"tl-scrub-zone",ref:areaRef,onMouseDown:startScrub});
2137
2426
  }
2138
2427
 
2139
- function Tracks({et, selProp, setSelProp, onPropChange, snap, scaleMs}){
2428
+ function Tracks({et, selLane, setSelLane, onPropChange, snap, scaleMs}){
2140
2429
  const t = usePreviewTime();
2141
2430
  const ratio = Math.min(t / scaleMs, 1);
2142
2431
  const ROW_H = 48;
2143
2432
  const rowsRef = useRef(null);
2144
2433
  const [order, setOrder] = useState([]);
2145
- const [dragProp, setDragProp] = useState(null);
2146
- const propKey = et.map(x=>x.property).join("|");
2434
+ const [dragLane, setDragLane] = useState(null);
2435
+ const laneKey = et.map(x=>x.laneId).join("|");
2147
2436
  useEffect(()=>{
2148
- const props = et.map(x=>x.property);
2437
+ const ids = et.map(x=>x.laneId);
2149
2438
  setOrder(prev=>{
2150
- const kept = prev.filter(p=>props.includes(p));
2151
- const added = props.filter(p=>!kept.includes(p));
2439
+ const kept = prev.filter(p=>ids.includes(p));
2440
+ const added = ids.filter(p=>!kept.includes(p));
2152
2441
  return [...kept, ...added];
2153
2442
  });
2154
- },[propKey]);
2155
- const orderedRows = order.map(p=>et.find(x=>x.property===p)).filter(Boolean);
2156
- const startReorder = useCallback((property,e)=>{
2443
+ },[laneKey]);
2444
+ const orderedRows = order.map(p=>et.find(x=>x.laneId===p)).filter(Boolean);
2445
+ const startReorder = useCallback((laneId,e)=>{
2157
2446
  e.preventDefault(); e.stopPropagation();
2158
- setSelProp(property);
2159
- setDragProp(property);
2447
+ setSelLane(laneId);
2448
+ setDragLane(laneId);
2160
2449
  const top = rowsRef.current ? rowsRef.current.getBoundingClientRect().top : 0;
2161
2450
  const onMove = e2=>{
2162
2451
  const idx = Math.floor((e2.clientY - top) / ROW_H);
2163
2452
  setOrder(prev=>{
2164
- const cur = prev.indexOf(property);
2453
+ const cur = prev.indexOf(laneId);
2165
2454
  if(cur<0) return prev;
2166
2455
  const target = Math.max(0, Math.min(prev.length-1, idx));
2167
2456
  if(target===cur) return prev;
2168
2457
  const next = prev.slice();
2169
2458
  next.splice(cur,1);
2170
- next.splice(target,0,property);
2459
+ next.splice(target,0,laneId);
2171
2460
  return next;
2172
2461
  });
2173
2462
  };
2174
- const onUp = ()=>{ setDragProp(null); window.removeEventListener("mousemove",onMove); window.removeEventListener("mouseup",onUp); };
2463
+ const onUp = ()=>{ setDragLane(null); window.removeEventListener("mousemove",onMove); window.removeEventListener("mouseup",onUp); };
2175
2464
  window.addEventListener("mousemove",onMove); window.addEventListener("mouseup",onUp);
2176
- },[setSelProp]);
2465
+ },[setSelLane]);
2177
2466
  const majorStep = scaleMs <= 10000 ? 1000 : scaleMs <= 25000 ? 5000 : 10000;
2178
2467
  const minorStep = majorStep / 4;
2179
2468
  const ruler=[];
@@ -2191,13 +2480,13 @@
2191
2480
  h("div",{className:"tl-ruler"},...ruler)),
2192
2481
  h("div",{className:"tl-rows",ref:rowsRef},
2193
2482
  ...orderedRows.map(row=>h(PropTrack,{
2194
- key:row.property, property:row.property,
2483
+ key:row.laneId, property:row.property, member:row.member,
2195
2484
  delayMs:row.delayMs, durationMs:row.durationMs, lockDuration:!!row.spring,
2196
- selected:row.property===selProp, dragging:row.property===dragProp, snap, scaleMs,
2197
- onSelect:()=>setSelProp(row.property),
2198
- onReorder:e=>startReorder(row.property,e),
2199
- onDelayChange:ms=>onPropChange(row.property,{delayMs:ms}),
2200
- onDurationChange:ms=>onPropChange(row.property,{durationMs:ms}),
2485
+ selected:row.laneId===selLane, dragging:row.laneId===dragLane, snap, scaleMs,
2486
+ onSelect:()=>setSelLane(row.laneId),
2487
+ onReorder:e=>startReorder(row.laneId,e),
2488
+ onDelayChange:ms=>onPropChange(row.laneId,{delayMs:ms}),
2489
+ onDurationChange:ms=>onPropChange(row.laneId,{durationMs:ms}),
2201
2490
  }))),
2202
2491
  h(ScrubZone,{scaleMs}),
2203
2492
  h("div",{className:"tl-playhead-layer"},
@@ -2208,40 +2497,42 @@
2208
2497
  );
2209
2498
  }
2210
2499
 
2211
- function Inspector({entry, selProp, onPropChange, snap}){
2500
+ function Inspector({entry, selLane, onPropChange, snap}){
2212
2501
  const et = entry.effectiveTimings || [];
2213
- const sel = et.find(t=>t.property===selProp) || et[0];
2502
+ const sel = et.find(t=>t.laneId===selLane) || et[0];
2214
2503
  if(!sel) return h("div",{className:"tl-inspector"});
2215
2504
  const cubic = easingToCubic(sel.easing);
2216
2505
  const isSpring = !!sel.spring;
2217
2506
  return h("div",{className:"tl-inspector"},
2218
- h("div",{className:"tl-insp-title"},sel.property),
2507
+ h("div",{className:"tl-insp-title"},
2508
+ sel.member&&h("span",{className:"tl-insp-member"},sel.member),
2509
+ h("span",null,sel.property)),
2219
2510
  h(ValueField,{label:"Duration",value:sel.durationMs,min:0,max:5000,step:25,tokens:DURATION_TOKENS,snap,
2220
2511
  readOnly:isSpring,
2221
2512
  readOnlyHint:"Duration is set by the spring. A spring's settle time is derived from its stiffness, damping and mass \u2014 so it can't be edited directly. Switch to the Easing tab to set a fixed duration.",
2222
- onChange:v=>onPropChange(sel.property,{durationMs:v})}),
2513
+ onChange:v=>onPropChange(sel.laneId,{durationMs:v})}),
2223
2514
  h(ValueField,{label:"Delay",value:sel.delayMs,min:0,max:5000,step:25,tokens:DELAY_TOKENS,snap,
2224
- onChange:v=>onPropChange(sel.property,{delayMs:v})}),
2225
- h(EasingEditor,{easing:sel.easing, cubic, spring:sel.spring, durationMs:sel.durationMs, propKey:sel.property,
2226
- apply:partial=>onPropChange(sel.property,partial)}),
2515
+ onChange:v=>onPropChange(sel.laneId,{delayMs:v})}),
2516
+ h(EasingEditor,{easing:sel.easing, cubic, spring:sel.spring, durationMs:sel.durationMs, propKey:sel.laneId,
2517
+ apply:partial=>onPropChange(sel.laneId,partial)}),
2227
2518
  );
2228
2519
  }
2229
2520
 
2230
2521
  function Body({entry, onPropChange, state, play, pause, resume, restart, stop, speed, setSpeed, snap}){
2231
2522
  const et = entry.effectiveTimings || [];
2232
- const [selProp, setSelProp] = useState(et[0]?.property ?? null);
2523
+ const [selLane, setSelLane] = useState(et[0]?.laneId ?? null);
2233
2524
  const [zoom, setZoom] = useState(ZOOM_DEFAULT);
2234
2525
  const scaleMs = scaleFromZoom(zoom);
2235
2526
  useEffect(()=>{
2236
- if(et.length && !et.find(t=>t.property===selProp)) setSelProp(et[0]?.property);
2237
- },[et,selProp]);
2527
+ if(et.length && !et.find(t=>t.laneId===selLane)) setSelLane(et[0]?.laneId);
2528
+ },[et,selLane]);
2238
2529
 
2239
2530
  return h("div",{className:"tl-body"},
2240
2531
  h("div",{className:"tl-main"},
2241
2532
  h(Transport,{state,disabled:!entry,onPlay:play,onPause:pause,onResume:resume,onRestart:restart,onStop:stop,speed,setSpeed,zoom,setZoom}),
2242
- h(Tracks,{et,selProp,setSelProp,onPropChange,snap,scaleMs}),
2533
+ h(Tracks,{et,selLane,setSelLane,onPropChange,snap,scaleMs}),
2243
2534
  ),
2244
- h(Inspector,{entry,selProp,onPropChange,snap}),
2535
+ h(Inspector,{entry,selLane,onPropChange,snap}),
2245
2536
  );
2246
2537
  }
2247
2538
 
@@ -2295,6 +2586,12 @@
2295
2586
  const[refineSuggestions,setRefineSuggestions]=useState([]);
2296
2587
  const[refineSummary,setRefineSummary]=useState(null);
2297
2588
  const[refineError,setRefineError]=useState(null);
2589
+ // ── accept (write to source) ──
2590
+ const[acceptState,setAcceptState]=useState("idle"); // idle | saving | done | error
2591
+ const[acceptError,setAcceptError]=useState(null);
2592
+ // ── grouped scan (agent reads source → Open/Close phases) ──
2593
+ const[groupScanState,setGroupScanState]=useState("idle"); // idle | scanning | done | error
2594
+ const didGroupScanRef=useRef(false);
2298
2595
  const[refineLabel,setRefineLabel]=useState(null);
2299
2596
  const[appliedIds,setAppliedIds]=useState({});
2300
2597
  const[refineMode,setRefineMode]=useState("llm"); // llm (Agent) | deterministic
@@ -2328,8 +2625,11 @@
2328
2625
  const mode=(refineMode==="llm"&&!avail)?"deterministic":refineMode;
2329
2626
  if(mode!==refineMode)setRefineMode(mode);
2330
2627
  try{
2331
- const timings=(active.effectiveTimings||[]).map(t=>({property:t.property,durationMs:t.durationMs,delayMs:t.delayMs,easing:t.easing}));
2332
- const{id}=await relayCreateJob({transitionId:active.id,label:active.label,selector:active.bindings&&active.bindings.selector,timings,mode,refineType});
2628
+ const et=active.effectiveTimings||[];
2629
+ const timings=et.map(t=>({property:t.property,durationMs:t.durationMs,delayMs:t.delayMs,easing:t.easing}));
2630
+ const selector=(active.bindings&&active.bindings.selector)||(et[0]&&et[0].selector)||null;
2631
+ const{id}=await relayCreateJob({transitionId:active.id,label:active.label,selector,
2632
+ phase:active.phase||null,group:active.groupLabel||null,timings,mode,refineType});
2333
2633
  setRefineJobId(id);
2334
2634
  }catch(e){
2335
2635
  setRefinePhase("error");
@@ -2361,9 +2661,15 @@
2361
2661
  if(p.durationMs!=null)o.durationMs=p.durationMs;
2362
2662
  if(p.delayMs!=null)o.delayMs=p.delayMs;
2363
2663
  if(p.easing!=null)o.easing=p.easing;
2364
- setPropOverride(p.property,o);
2664
+ // map a property patch onto the matching lane(s) — for a grouped phase the
2665
+ // same property can appear on several members, so apply to each.
2666
+ const et=(active&&active.effectiveTimings)||[];
2667
+ let lanes=p.property==="all"?et:et.filter(t=>t.property===p.property);
2668
+ if(p.member)lanes=lanes.filter(t=>(t.member===p.member||t.memberId===p.member));
2669
+ const ids=lanes.length?lanes.map(t=>t.laneId):[p.property];
2670
+ for(const id of ids)setPropOverride(id,o);
2365
2671
  setAppliedIds(prev=>({...prev,[s.id]:true}));
2366
- },[setPropOverride]);
2672
+ },[setPropOverride,active]);
2367
2673
  const applyAllSuggestions=useCallback(()=>{
2368
2674
  for(const s of refineSuggestions){if(!appliedIds[s.id])applySuggestion(s);}
2369
2675
  },[refineSuggestions,appliedIds,applySuggestion]);
@@ -2385,12 +2691,79 @@
2385
2691
  const copyValues=useCallback(()=>{
2386
2692
  if(!active)return;
2387
2693
  const et=active.effectiveTimings||[];
2388
- const css=et.map(t=>
2389
- `${t.property} ${formatCssTime(t.durationMs)} ${t.easing} ${formatCssTime(t.delayMs)}`
2390
- ).join(",\n ");
2391
- navigator.clipboard.writeText("transition: "+css+";").then(()=>{setCopied(true);setTimeout(()=>setCopied(false),1500);});
2694
+ const decl=t=>`${t.property} ${formatCssTime(t.durationMs)} ${t.easing} ${formatCssTime(t.delayMs)}`;
2695
+ let text;
2696
+ if(et.some(t=>t.member)){
2697
+ // grouped phase: one transition block per member, labelled
2698
+ const byMember=new Map();
2699
+ for(const t of et){const k=t.memberId||t.member||"";if(!byMember.has(k))byMember.set(k,{member:t.member,selector:t.selector,lanes:[]});byMember.get(k).lanes.push(t);}
2700
+ text=[...byMember.values()].map(m=>
2701
+ `/* ${m.member||"element"}${m.selector?" — "+m.selector:""} */\ntransition: ${m.lanes.map(decl).join(",\n ")};`
2702
+ ).join("\n\n");
2703
+ }else{
2704
+ text="transition: "+et.map(decl).join(",\n ")+";";
2705
+ }
2706
+ navigator.clipboard.writeText(text).then(()=>{setCopied(true);setTimeout(()=>setCopied(false),1500);});
2392
2707
  },[active]);
2393
2708
  const resetOverrides=useCallback(()=>{if(active)registry.clearOverride(active.id);},[registry,active]);
2709
+ // Accept → send an "apply" job so the agent writes the edited timings into
2710
+ // the user's source, then reflect saving / done / error on the button.
2711
+ const onAccept=useCallback(async()=>{
2712
+ const changes=computeChanges(active);
2713
+ if(!active||!changes.length)return;
2714
+ setAcceptState("saving");setAcceptError(null);
2715
+ try{
2716
+ const{id}=await relayCreateJob({kind:"apply",transitionId:active.id,label:active.label,
2717
+ selector:active.bindings&&active.bindings.selector,
2718
+ group:active.groupLabel||null,phase:active.phase||null,component:active.component||null,
2719
+ changes});
2720
+ let settled=false;
2721
+ for(let i=0;i<240&&!settled;i++){
2722
+ await new Promise(r=>setTimeout(r,500));
2723
+ const job=await relayGetJob(id);
2724
+ if(job.status==="done"){settled=true;
2725
+ if(job.result&&job.result.applied===false){
2726
+ setAcceptState("error");setAcceptError((job.result&&job.result.summary)||"The agent couldn't find this transition in your source.");
2727
+ }else{
2728
+ setAcceptState("done");setTimeout(()=>setAcceptState("idle"),2500);
2729
+ }
2730
+ }else if(job.status==="error"){settled=true;setAcceptState("error");setAcceptError(job.error||"The agent reported an error.");}
2731
+ }
2732
+ if(!settled){setAcceptState("error");setAcceptError("Timed out waiting for the agent.");}
2733
+ }catch(e){
2734
+ setAcceptState("error");
2735
+ setAcceptError("Couldn't reach the relay. Run: npx transitions-refine live");
2736
+ }
2737
+ },[active]);
2738
+ // reset Accept feedback when switching transitions
2739
+ useEffect(()=>{setAcceptState("idle");setAcceptError(null);},[active&&active.id]);
2740
+ // Auto-run the grouped scan once, after the flat DOM scan has populated.
2741
+ // The agent reads the source and returns Open/Close phases; if no agent is
2742
+ // live (or the relay is down) we silently keep the flat DOM scan.
2743
+ useEffect(()=>{
2744
+ if(didGroupScanRef.current||!entries.length)return;
2745
+ didGroupScanRef.current=true;
2746
+ let cancelled=false;
2747
+ (async()=>{
2748
+ setGroupScanState("scanning");
2749
+ const flat=registry.getAll().filter(e=>e.kind!=="phase");
2750
+ const raw=flat.map(e=>({label:e.label,selector:e.bindings&&e.bindings.selector,
2751
+ properties:e.properties,
2752
+ timings:(e.baseLanes||[]).map(l=>({property:l.property,durationMs:l.durationMs,delayMs:l.delayMs,easing:l.easing}))}));
2753
+ try{
2754
+ const{id}=await relayCreateJob({kind:"scan",url:location.href,raw});
2755
+ for(let i=0;i<240&&!cancelled;i++){
2756
+ await new Promise(r=>setTimeout(r,500));
2757
+ const job=await relayGetJob(id);
2758
+ if(cancelled)return;
2759
+ if(job.status==="done"){const groups=(job.result&&job.result.groups)||[];if(groups.length)registry.setGroups(groups);setGroupScanState("done");return;}
2760
+ if(job.status==="error"){setGroupScanState("error");return;}
2761
+ }
2762
+ if(!cancelled)setGroupScanState("error");
2763
+ }catch(e){ if(!cancelled)setGroupScanState("error"); /* relay down → stay flat */ }
2764
+ })();
2765
+ return ()=>{cancelled=true;};
2766
+ },[entries.length,registry]);
2394
2767
 
2395
2768
  // whole-component open/close uses the transitions.dev panel reveal:
2396
2769
  // keep the panel mounted while it animates, flip data-open on the next
@@ -2439,7 +2812,9 @@
2439
2812
  h("div",{className:"tl-panel-body"},
2440
2813
  h("div",{className:"tl-panel-main"},
2441
2814
  h(Header,{entries,active,onSelect:setActiveId,onReset:resetOverrides,onCopy:copyValues,copied,
2442
- loop,setLoop,snap,setSnap,onMinimize:()=>setMinimized(true),onRefine:openRefine,refineActive:refineOpen}),
2815
+ loop,setLoop,snap,setSnap,onMinimize:()=>setMinimized(true),onRefine:openRefine,refineActive:refineOpen,
2816
+ onAccept,acceptState,acceptDisabled:computeChanges(active).length===0,acceptError,
2817
+ scanning:groupScanState==="scanning"}),
2443
2818
  active
2444
2819
  ?h(Body,{entry:active,onPropChange:(prop,o)=>setPropOverride(prop,o),
2445
2820
  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.2.0",
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,43 @@ 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
+ // Scan jobs ask the agent to read the source and group related transitions into
172
+ // components with open/close phases and member elements.
173
+ function parseScanOutput(stdout) {
174
+ const obj = parseJsonish(stdout);
175
+ if (!obj || !Array.isArray(obj.groups)) throw new Error("agent output missing groups[]");
176
+ return { groups: obj.groups, summary: obj.summary ?? null };
177
+ }
178
+
179
+ function runAgentCmd(cmd, prompt, parse = parseAgentOutput) {
161
180
  return new Promise((resolve, reject) => {
162
181
  const child = spawn("sh", ["-c", cmd], { stdio: ["pipe", "pipe", "pipe"] });
163
182
  let out = "";
@@ -176,7 +195,7 @@ function runAgentCmd(cmd, prompt) {
176
195
  clearTimeout(timer);
177
196
  if (code !== 0) return reject(new Error(`agent exited ${code}: ${err.slice(0, 300)}`));
178
197
  try {
179
- resolve(parseAgentOutput(out));
198
+ resolve(parse(out));
180
199
  } catch (e) {
181
200
  reject(new Error(`${e.message} — got: ${out.slice(0, 200)}`));
182
201
  }
@@ -186,6 +205,50 @@ function runAgentCmd(cmd, prompt) {
186
205
  });
187
206
  }
188
207
 
208
+ // Prompt for an "apply" job: the agent edits the user's source so the selected
209
+ // transition uses the approved timings.
210
+ function buildApplyPrompt(job) {
211
+ const r = job.request || {};
212
+ return [
213
+ "You are APPLYING an approved transition change to the user's SOURCE CODE. Edit files; do not just suggest.",
214
+ "",
215
+ "Change context (JSON):",
216
+ JSON.stringify({ label: r.label, selector: r.selector, component: r.component, group: r.group, phase: r.phase, changes: r.changes }, null, 2),
217
+ "",
218
+ "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.",
219
+ "",
220
+ "Steps:",
221
+ "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.",
222
+ "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.",
223
+ "3. Make the minimal edit. Do not reformat or change unrelated code.",
224
+ "",
225
+ 'Output ONLY a JSON object — no prose, no markdown fences — shaped exactly like:',
226
+ '{"applied":true,"summary":"Set .modal transition to 250ms ease-out","files":["src/Modal.css:42"]}',
227
+ 'If you cannot confidently locate the declaration, output {"applied":false,"summary":"<what you looked for and why it was not found>"}.',
228
+ ].join("\n");
229
+ }
230
+
231
+ // Prompt for a "scan" job: the agent reads the source and groups the raw,
232
+ // DOM-detected transitions into components with open/close phases and members.
233
+ function buildScanPrompt(job) {
234
+ const r = job.request || {};
235
+ return [
236
+ "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.",
237
+ "",
238
+ "Raw DOM-detected transitions (JSON) — use as hints to locate the components, not as the final answer:",
239
+ JSON.stringify({ url: r.url, raw: r.raw }, null, 2),
240
+ "",
241
+ "Steps:",
242
+ "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).",
243
+ "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.",
244
+ "3. For each phase, list its MEMBER elements (panel, backdrop, the staggered items…). Give each member a stable `id`, a human `label`, a CSS `selector` that resolves in the live DOM, an optional `toState` hint (the class/attribute that drives that phase, e.g. `.is-open`), and its real `propertyTimings` (durationMs, delayMs, easing per animated property). Quote the real timings from source — never invent.",
245
+ "",
246
+ "Output ONLY a JSON object — no prose, no markdown fences — shaped exactly like:",
247
+ '{"summary":"Grouped 3 components.","groups":[{"id":"dropdown","label":"Dropdown","component":"src/Dropdown.tsx","phases":[{"id":"dropdown:open","phase":"open","label":"Open","members":[{"id":"panel","label":"Panel","selector":".dropdown-panel","toState":".is-open","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","members":[{"id":"panel","label":"Panel","selector":".dropdown-panel","toState":".is-closing","propertyTimings":[{"property":"opacity","durationMs":150,"delayMs":0,"easing":"ease-in"}]}]}]}]}',
248
+ "If you cannot confidently group anything, return an empty groups array with a short summary; the panel keeps its flat DOM scan.",
249
+ ].join("\n");
250
+ }
251
+
189
252
  function refineDeterministic(job) {
190
253
  // Whole-transition replacement needs usage inference + recipe selection, which
191
254
  // only the agent (LLM) path can do. Deterministic can only snap to tokens.
@@ -207,13 +270,50 @@ function refineDeterministic(job) {
207
270
  async function answerJob(job) {
208
271
  job.status = "working";
209
272
  job.updatedAt = now();
273
+ const isApply = job.request?.kind === "apply";
274
+ const isScan = job.request?.kind === "scan";
210
275
  const label = job.request?.label || job.request?.selector || "transition";
211
276
  // The browser picks the mode per job via the LLM / Deterministic tabs.
212
277
  // Default: LLM when a command is configured, otherwise deterministic.
213
278
  const mode = job.request?.mode || (AGENT_CMD ? "llm" : "deterministic");
214
- job.statusLog.push({ message: `Scanning "${label}"…`, at: now() });
279
+ job.statusLog.push({
280
+ message: isApply ? `Writing "${label}" to your code…` : isScan ? "Grouping transitions from your source…" : `Scanning "${label}"…`,
281
+ at: now(),
282
+ });
215
283
  try {
216
284
  let result;
285
+ if (isApply) {
286
+ // Editing source can only be done by the agent.
287
+ if (!AGENT_CMD) {
288
+ throw new Error(
289
+ "Saving to your code needs the agent. Run `/refine live` in your editor, " +
290
+ "or start the relay with REFINE_AGENT_CMD set."
291
+ );
292
+ }
293
+ job.statusLog.push({ message: "Editing source files…", at: now() });
294
+ result = await runAgentCmd(AGENT_CMD, buildApplyPrompt(job), parseApplyOutput);
295
+ job.result = { applied: result.applied, summary: result.summary, files: result.files };
296
+ job.status = "done";
297
+ job.updatedAt = now();
298
+ console.log(` ✓ apply ${job.id.slice(0, 8)} — applied=${result.applied}`);
299
+ return;
300
+ }
301
+ if (isScan) {
302
+ // Reading source to group transitions can only be done by the agent.
303
+ if (!AGENT_CMD) {
304
+ throw new Error(
305
+ "Grouping transitions needs the agent. Run `/refine live` in your editor, " +
306
+ "or start the relay with REFINE_AGENT_CMD set."
307
+ );
308
+ }
309
+ job.statusLog.push({ message: "Reading components from source…", at: now() });
310
+ result = await runAgentCmd(AGENT_CMD, buildScanPrompt(job), parseScanOutput);
311
+ job.result = { groups: result.groups, summary: result.summary };
312
+ job.status = "done";
313
+ job.updatedAt = now();
314
+ console.log(` ✓ scan ${job.id.slice(0, 8)} — ${result.groups.length} group(s)`);
315
+ return;
316
+ }
217
317
  if (mode === "llm") {
218
318
  if (!AGENT_CMD) {
219
319
  throw new Error(
@@ -313,7 +413,10 @@ const server = createServer(async (req, res) => {
313
413
  return send(res, 400, { error: "Body must be { request: {...} }" });
314
414
  }
315
415
  const job = createJob(body.request);
316
- const mode = job.request.mode || (llmAvailable() ? "llm" : "deterministic");
416
+ // Apply and scan jobs read/edit source agent only, never deterministic.
417
+ const mode = (job.request.kind === "apply" || job.request.kind === "scan")
418
+ ? "llm"
419
+ : (job.request.mode || (llmAvailable() ? "llm" : "deterministic"));
317
420
  job.request.mode = mode;
318
421
 
319
422
  if (!AUTO) {
@@ -386,9 +489,17 @@ const server = createServer(async (req, res) => {
386
489
 
387
490
  if (method === "POST" && sub === "result") {
388
491
  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 };
492
+ if (body && Array.isArray(body.suggestions)) {
493
+ job.result = { suggestions: body.suggestions, summary: body.summary ?? null };
494
+ } else if (body && Array.isArray(body.groups)) {
495
+ // scan-job result from a `/refine live` agent
496
+ job.result = { groups: body.groups, summary: body.summary ?? null };
497
+ } else if (body && typeof body.applied !== "undefined") {
498
+ // apply-job result from a `/refine live` agent
499
+ job.result = { applied: Boolean(body.applied), summary: body.summary ?? null, files: Array.isArray(body.files) ? body.files : null };
500
+ } else {
501
+ return send(res, 400, { error: "Body must be { suggestions: [...] }, { groups: [...] }, or { applied, summary }" });
502
+ }
392
503
  job.status = "done";
393
504
  job.updatedAt = now();
394
505
  return send(res, 200, { ok: true });