transitions-refine 0.1.3 → 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) 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.
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,10 @@ 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.
57
61
  - **If `request.kind === "apply"`** this is not a suggestion job — the user
58
62
  pressed **Accept** to write changes to their code. Jump to
59
63
  [`## Apply jobs`](#apply-jobs-write-to-source) and edit the source instead of
@@ -176,6 +180,78 @@ never has to re-run `/refine live`.
176
180
  stop, tell them the LLM tab will go unavailable and how to restart
177
181
  (`/refine live`).
178
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
+
179
255
  ## Apply jobs (write to source)
180
256
 
181
257
  When a claimed job has `request.kind === "apply"`, the user accepted their current
@@ -186,10 +262,14 @@ timeline values and wants them written to the codebase. The request looks like:
186
262
  "id": "uuid",
187
263
  "request": {
188
264
  "kind": "apply",
189
- "label": "div.modal.t-modal",
190
- "selector": "div.modal > button.close",
265
+ "label": "Dropdown · Close",
266
+ "selector": ".dropdown-panel",
267
+ "component": "src/Dropdown.tsx",
268
+ "group": "Dropdown",
269
+ "phase": "close",
191
270
  "changes": [
192
- { "property": "opacity", "from": { "durationMs": 300, "delayMs": 0, "easing": "ease" },
271
+ { "property": "opacity", "member": "Panel", "selector": ".dropdown-panel",
272
+ "from": { "durationMs": 300, "delayMs": 0, "easing": "ease" },
193
273
  "to": { "durationMs": 150, "delayMs": 0, "easing": "cubic-bezier(0.4, 0, 1, 1)" } }
194
274
  ]
195
275
  }
@@ -199,15 +279,19 @@ timeline values and wants them written to the codebase. The request looks like:
199
279
  Do this:
200
280
 
201
281
  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.
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.
207
291
  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.
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.
211
295
  3. **Minimal edit** — no reformatting or unrelated changes.
212
296
  4. **Post the outcome** (this completes the job):
213
297
 
package/README.md CHANGED
@@ -4,6 +4,8 @@ A live, agent-driven **Refine** panel for CSS and [Motion](https://motion.dev) t
4
4
 
5
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
+ 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.
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
 
9
11
  ```
@@ -54,6 +56,12 @@ 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.
@@ -86,7 +94,7 @@ Like Replace, Accept needs the agent — run `/refine live` (or `--llm` / `REFIN
86
94
  | `REFINE_AUTO=0` | — | disable auto-answer and wait for an external poller |
87
95
  | `window.REFINE_RELAY_URL` | injected origin | browser override for the relay URL |
88
96
 
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}`.
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`).
90
98
 
91
99
  Refine suggestions stay as live overrides until you press **Accept**, which is the explicit step that writes them into your source.
92
100
 
package/demo.html CHANGED
@@ -323,8 +323,15 @@
323
323
  position: relative; z-index: 5; cursor: grabbing; }
324
324
  .tl-prop-row.reordering .tl-prop-grip { color: var(--c-ruler); cursor: grabbing; }
325
325
  .tl-prop-label { font-size: 13px; font-weight: 500; line-height: 18px; color: var(--c-text-mut2); white-space: nowrap; overflow: hidden;
326
- text-overflow: ellipsis; text-transform: capitalize; }
326
+ text-overflow: ellipsis; text-transform: capitalize; display: flex; align-items: center; gap: 6px; min-width: 0; }
327
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; }
328
335
  /* 16px left inset of the plot area (kept in sync with ruler/playhead/scrub) */
329
336
  .tl-prop-track { position: relative; flex: 1; user-select: none; margin-left: 16px; }
330
337
  /* timeline line — capsule (fully rounded), Figma #eeeeef default / #e9e9e9 hover */
@@ -548,6 +555,9 @@
548
555
  .tl-menu-help svg { display: block; }
549
556
  .tl-menu-check { display: flex; color: var(--c-text-strong); flex: none; }
550
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; }
551
561
 
552
562
  /* ═════ transitions.dev — menu dropdown (verbatim) ═════ */
553
563
  .t-dropdown {
@@ -1115,36 +1125,103 @@
1115
1125
  return out;
1116
1126
  }
1117
1127
 
1118
- // ── 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.
1119
1134
  class TransitionRegistry {
1120
- entries=new Map(); listeners=new Set(); propOverrides=new Map(); snapshot=[]; effectiveCache=new Map();
1121
- register(e){ this.entries.set(e.id,e); this._notify(); }
1122
- unregister(id){ this.entries.delete(id); this.propOverrides.delete(id); this._notify(); }
1123
- 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(); }
1124
1144
  get(id){ return this.entries.get(id); }
1125
1145
  getAll(){ return this.snapshot; }
1126
1146
  getEffective(id){ return this.effectiveCache.get(id); }
1127
- setPropOverride(id, prop, o){
1128
- const m = this.propOverrides.get(id) || {};
1129
- m[prop] = { ...m[prop], ...o };
1130
- 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);
1131
1152
  this._notify();
1132
1153
  }
1133
- clearOverride(id){ this.propOverrides.delete(id); this._notify(); }
1134
- getPropOverrides(id){ return this.propOverrides.get(id); }
1154
+ clearOverride(id){ this.laneOverrides.delete(id); this._notify(); }
1155
+ getPropOverrides(id){ return this.laneOverrides.get(id); }
1135
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
+ }
1136
1190
  _notify(){
1137
- this.snapshot=Array.from(this.entries.values());
1138
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"
1139
1215
  for(const[id,entry]of this.entries){
1140
- const po = this.propOverrides.get(id) || {};
1141
- const timings = (entry.propertyTimings || entry.properties.map(p=>({property:p,durationMs:entry.durationMs,delayMs:entry.delayMs,easing:entry.easing})));
1142
- const effTimings = timings.map(t => {
1143
- const o = po[t.property];
1144
- return { property:t.property, durationMs:o?.durationMs??t.durationMs, delayMs:o?.delayMs??t.delayMs, easing:o?.easing??t.easing, spring:o?.spring??t.spring };
1145
- });
1146
- 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);
1147
1223
  }
1224
+ this.snapshot=items;
1148
1225
  for(const fn of this.listeners) fn(this.snapshot);
1149
1226
  }
1150
1227
  }
@@ -1259,6 +1336,33 @@
1259
1336
  for(const p of keys)from[p]=cs.getPropertyValue(p)||cs[_txCamel(p)]||"";
1260
1337
  return {from,to};
1261
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
+ }
1262
1366
 
1263
1367
  class PreviewController {
1264
1368
  state="idle"; listeners=new Set(); cleanups=[]; animations=[]; progressListeners=new Set(); _rafId=null; scanner=null; _gen=0;
@@ -1269,7 +1373,25 @@
1269
1373
  subscribe(fn){this.listeners.add(fn);return()=>this.listeners.delete(fn);}
1270
1374
  onProgress(fn){this.progressListeners.add(fn);return()=>this.progressListeners.delete(fn);}
1271
1375
  play(entry){this.stop();this._gen++;this._current=entry;
1272
- 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
+ }
1273
1395
  pause(){if(this.state!=="playing")return;for(const a of this.animations){try{a.pause();}catch{}}this._stopPL();this._setState("paused");}
1274
1396
  resume(){if(this.state!=="paused")return;for(const a of this.animations){try{a.play();}catch{}}this._startPL();this._setState("playing");}
1275
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");}
@@ -1278,11 +1400,11 @@
1278
1400
  this.stop();this._gen++;const gen=this._gen;
1279
1401
  this._pendingSeek=seekMs;
1280
1402
  if(this.scanner)this.scanner.pause();
1281
- if(entry.bindings.type!=="css")return;
1403
+ const targets=this._targets(entry);
1404
+ if(!targets.length)return;
1282
1405
  this.animations=[];
1283
- const et=entry.effectiveTimings||entry.properties.map(p=>({property:p,durationMs:entry.durationMs,delayMs:entry.delayMs,easing:entry.easing}));
1284
- for(const wr of entry.bindings.elements){const el=wr.deref();if(!el)continue;
1285
- const restore=this._arm(el,et);
1406
+ for(const {el,et,toState} of targets){
1407
+ const restore=this._arm(el,et,toState);
1286
1408
  requestAnimationFrame(()=>{if(this._gen!==gen)return;const running=el.getAnimations();
1287
1409
  for(const a of running){a.pause();a.playbackRate=this.rate;this.animations.push(a);}
1288
1410
  const t=this._pendingSeek??seekMs;
@@ -1307,10 +1429,12 @@
1307
1429
  // 3. a synthetic opacity/transform pulse to preview the timing,
1308
1430
  // 4. click the element (last resort, may have side effects).
1309
1431
  // Returns a restore fn.
1310
- _arm(el,et){
1432
+ _arm(el,et,toState){
1311
1433
  const saved=el.style.cssText;
1312
1434
  const tv=et.map(t=>`${t.property} ${formatCssTime(t.durationMs)} ${t.easing} ${formatCssTime(t.delayMs)}`).join(", ");
1313
- const states=_txCaptured(el,et)||_txDiscover(el,et);
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);
1314
1438
  if(states){
1315
1439
  const props=et.map(t=>t.property).filter(p=>states.from[p]!=null&&states.to[p]!=null);
1316
1440
  if(props.length){
@@ -1335,12 +1459,17 @@
1335
1459
  el.style.transition=tv;el.click();
1336
1460
  return ()=>{try{el.style.cssText=saved;}catch{}};
1337
1461
  }
1338
- _playCss(entry,gen){if(entry.bindings.type!=="css")return;this.animations=[];
1339
- const et = entry.effectiveTimings || entry.properties.map(p=>({property:p,durationMs:entry.durationMs,delayMs:entry.delayMs,easing:entry.easing}));
1340
- for(const wr of entry.bindings.elements){const el=wr.deref();if(!el)continue;
1341
- 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);
1342
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();
1343
- 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();}});
1344
1473
  this.cleanups.push(restore);}}
1345
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);}
1346
1475
  _stopPL(){if(this._rafId!==null){cancelAnimationFrame(this._rafId);this._rafId=null;}}
@@ -1748,17 +1877,19 @@
1748
1877
  h("div",{className:"tl-refine-foot"},foot)));
1749
1878
  }
1750
1879
 
1751
- // Diff the active transition's effective (edited/refined) timings against its
1880
+ // Diff the active item's effective (edited/refined) lanes against their
1752
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.
1753
1884
  function computeChanges(active){
1754
1885
  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]));
1886
+ const base=active.baseLanes||[];
1887
+ const bmap=new Map(base.map(t=>[t.laneId,t]));
1757
1888
  const out=[];
1758
1889
  for(const t of (active.effectiveTimings||[])){
1759
- const b=bmap.get(t.property);if(!b)continue;
1890
+ const b=bmap.get(t.laneId);if(!b)continue;
1760
1891
  if(t.durationMs!==b.durationMs||t.delayMs!==b.delayMs||(t.easing||"")!==(b.easing||"")){
1761
- out.push({property:t.property,
1892
+ out.push({property:t.property,member:t.member||null,selector:t.selector||null,
1762
1893
  from:{durationMs:b.durationMs,delayMs:b.delayMs,easing:b.easing},
1763
1894
  to:{durationMs:t.durationMs,delayMs:t.delayMs,easing:t.easing}});
1764
1895
  }
@@ -1766,10 +1897,26 @@
1766
1897
  return out;
1767
1898
  }
1768
1899
 
1769
- function Header({entries,active,onSelect,onReset,onCopy,copied,loop,setLoop,snap,setSnap,onMinimize,onRefine,refineActive,onAccept,acceptState,acceptDisabled,acceptError}){
1900
+ function Header({entries,active,onSelect,onReset,onCopy,copied,loop,setLoop,snap,setSnap,onMinimize,onRefine,refineActive,onAccept,acceptState,acceptDisabled,acceptError,scanning}){
1770
1901
  const[pick,setPick]=useState(false);
1771
1902
  const[setg,setSetg]=useState(false);
1772
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")));
1773
1920
  return h("div",{className:"tl-header"},
1774
1921
  h("span",{className:"tl-header-label"},"Selected"),
1775
1922
  h("button",{ref:pickRef,className:cx("tl-ghost-btn",pick&&"is-active"),disabled:!active,onClick:()=>setPick(v=>!v)},
@@ -1779,10 +1926,14 @@
1779
1926
  h(Dropdown,{open:pick,onClose:()=>setPick(false),triggerRef:pickRef,width:Math.max(240,(pickRef.current&&pickRef.current.offsetWidth)||240),align:"left"},
1780
1927
  entries.length===0
1781
1928
  ? h("div",{className:"tl-menu-empty"},"No transitions found")
1782
- : entries.map(e=>h(MenuItem,{key:e.id,active:active&&e.id===active.id,onClick:()=>{onSelect(e.id);setPick(false);},
1783
- right:active&&e.id===active.id&&h("span",{className:"tl-menu-check"},h(Ic,{name:"check"}))},
1784
- h("span",null,e.label,h("span",{className:"tl-menu-dim"}," "+e.durationMs+"ms")))) ),
1785
- 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"),
1786
1937
  h("button",{ref:gearRef,className:cx("tl-icon-btn",setg&&"is-active"),title:"Settings",onClick:()=>setSetg(v=>!v)},h(Ic,{name:"gear"})),
1787
1938
  h(Dropdown,{open:setg,onClose:()=>setSetg(false),triggerRef:gearRef,width:210,align:"right"},
1788
1939
  h(MenuItem,{onClick:()=>setLoop(v=>!v),right:loop&&h("span",{className:"tl-menu-check"},h(Ic,{name:"check"}))},"Loop playback"),
@@ -1826,7 +1977,7 @@
1826
1977
  );
1827
1978
  }
1828
1979
 
1829
- 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}){
1830
1981
  const trackRef=useRef(null);
1831
1982
  const grid = snap ? 25 : 1;
1832
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]);
@@ -1856,7 +2007,9 @@
1856
2007
  return h("div",{className:cx("tl-prop-row",selected&&"selected",dragging&&"reordering",lockDuration&&"is-spring"),onClick:onSelect},
1857
2008
  h("div",{className:"tl-prop-head"},
1858
2009
  h("span",{className:"tl-prop-grip",title:"Drag to reorder",onMouseDown:onReorder},h(Ic,{name:"dots"})),
1859
- 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))),
1860
2013
  h("div",{className:"tl-prop-track",ref:trackRef},
1861
2014
  h("div",{className:"tl-bar",style:{left:delPct+"%",width:durPct+"%"},onMouseDown:e=>startDrag("move",e),
1862
2015
  title:lockDuration?"Spring duration is derived \u2014 drag to move, resize is locked":undefined},
@@ -2272,44 +2425,44 @@
2272
2425
  return h("div",{className:"tl-scrub-zone",ref:areaRef,onMouseDown:startScrub});
2273
2426
  }
2274
2427
 
2275
- function Tracks({et, selProp, setSelProp, onPropChange, snap, scaleMs}){
2428
+ function Tracks({et, selLane, setSelLane, onPropChange, snap, scaleMs}){
2276
2429
  const t = usePreviewTime();
2277
2430
  const ratio = Math.min(t / scaleMs, 1);
2278
2431
  const ROW_H = 48;
2279
2432
  const rowsRef = useRef(null);
2280
2433
  const [order, setOrder] = useState([]);
2281
- const [dragProp, setDragProp] = useState(null);
2282
- const propKey = et.map(x=>x.property).join("|");
2434
+ const [dragLane, setDragLane] = useState(null);
2435
+ const laneKey = et.map(x=>x.laneId).join("|");
2283
2436
  useEffect(()=>{
2284
- const props = et.map(x=>x.property);
2437
+ const ids = et.map(x=>x.laneId);
2285
2438
  setOrder(prev=>{
2286
- const kept = prev.filter(p=>props.includes(p));
2287
- 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));
2288
2441
  return [...kept, ...added];
2289
2442
  });
2290
- },[propKey]);
2291
- const orderedRows = order.map(p=>et.find(x=>x.property===p)).filter(Boolean);
2292
- 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)=>{
2293
2446
  e.preventDefault(); e.stopPropagation();
2294
- setSelProp(property);
2295
- setDragProp(property);
2447
+ setSelLane(laneId);
2448
+ setDragLane(laneId);
2296
2449
  const top = rowsRef.current ? rowsRef.current.getBoundingClientRect().top : 0;
2297
2450
  const onMove = e2=>{
2298
2451
  const idx = Math.floor((e2.clientY - top) / ROW_H);
2299
2452
  setOrder(prev=>{
2300
- const cur = prev.indexOf(property);
2453
+ const cur = prev.indexOf(laneId);
2301
2454
  if(cur<0) return prev;
2302
2455
  const target = Math.max(0, Math.min(prev.length-1, idx));
2303
2456
  if(target===cur) return prev;
2304
2457
  const next = prev.slice();
2305
2458
  next.splice(cur,1);
2306
- next.splice(target,0,property);
2459
+ next.splice(target,0,laneId);
2307
2460
  return next;
2308
2461
  });
2309
2462
  };
2310
- 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); };
2311
2464
  window.addEventListener("mousemove",onMove); window.addEventListener("mouseup",onUp);
2312
- },[setSelProp]);
2465
+ },[setSelLane]);
2313
2466
  const majorStep = scaleMs <= 10000 ? 1000 : scaleMs <= 25000 ? 5000 : 10000;
2314
2467
  const minorStep = majorStep / 4;
2315
2468
  const ruler=[];
@@ -2327,13 +2480,13 @@
2327
2480
  h("div",{className:"tl-ruler"},...ruler)),
2328
2481
  h("div",{className:"tl-rows",ref:rowsRef},
2329
2482
  ...orderedRows.map(row=>h(PropTrack,{
2330
- key:row.property, property:row.property,
2483
+ key:row.laneId, property:row.property, member:row.member,
2331
2484
  delayMs:row.delayMs, durationMs:row.durationMs, lockDuration:!!row.spring,
2332
- selected:row.property===selProp, dragging:row.property===dragProp, snap, scaleMs,
2333
- onSelect:()=>setSelProp(row.property),
2334
- onReorder:e=>startReorder(row.property,e),
2335
- onDelayChange:ms=>onPropChange(row.property,{delayMs:ms}),
2336
- 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}),
2337
2490
  }))),
2338
2491
  h(ScrubZone,{scaleMs}),
2339
2492
  h("div",{className:"tl-playhead-layer"},
@@ -2344,40 +2497,42 @@
2344
2497
  );
2345
2498
  }
2346
2499
 
2347
- function Inspector({entry, selProp, onPropChange, snap}){
2500
+ function Inspector({entry, selLane, onPropChange, snap}){
2348
2501
  const et = entry.effectiveTimings || [];
2349
- const sel = et.find(t=>t.property===selProp) || et[0];
2502
+ const sel = et.find(t=>t.laneId===selLane) || et[0];
2350
2503
  if(!sel) return h("div",{className:"tl-inspector"});
2351
2504
  const cubic = easingToCubic(sel.easing);
2352
2505
  const isSpring = !!sel.spring;
2353
2506
  return h("div",{className:"tl-inspector"},
2354
- 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)),
2355
2510
  h(ValueField,{label:"Duration",value:sel.durationMs,min:0,max:5000,step:25,tokens:DURATION_TOKENS,snap,
2356
2511
  readOnly:isSpring,
2357
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.",
2358
- onChange:v=>onPropChange(sel.property,{durationMs:v})}),
2513
+ onChange:v=>onPropChange(sel.laneId,{durationMs:v})}),
2359
2514
  h(ValueField,{label:"Delay",value:sel.delayMs,min:0,max:5000,step:25,tokens:DELAY_TOKENS,snap,
2360
- onChange:v=>onPropChange(sel.property,{delayMs:v})}),
2361
- h(EasingEditor,{easing:sel.easing, cubic, spring:sel.spring, durationMs:sel.durationMs, propKey:sel.property,
2362
- 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)}),
2363
2518
  );
2364
2519
  }
2365
2520
 
2366
2521
  function Body({entry, onPropChange, state, play, pause, resume, restart, stop, speed, setSpeed, snap}){
2367
2522
  const et = entry.effectiveTimings || [];
2368
- const [selProp, setSelProp] = useState(et[0]?.property ?? null);
2523
+ const [selLane, setSelLane] = useState(et[0]?.laneId ?? null);
2369
2524
  const [zoom, setZoom] = useState(ZOOM_DEFAULT);
2370
2525
  const scaleMs = scaleFromZoom(zoom);
2371
2526
  useEffect(()=>{
2372
- if(et.length && !et.find(t=>t.property===selProp)) setSelProp(et[0]?.property);
2373
- },[et,selProp]);
2527
+ if(et.length && !et.find(t=>t.laneId===selLane)) setSelLane(et[0]?.laneId);
2528
+ },[et,selLane]);
2374
2529
 
2375
2530
  return h("div",{className:"tl-body"},
2376
2531
  h("div",{className:"tl-main"},
2377
2532
  h(Transport,{state,disabled:!entry,onPlay:play,onPause:pause,onResume:resume,onRestart:restart,onStop:stop,speed,setSpeed,zoom,setZoom}),
2378
- h(Tracks,{et,selProp,setSelProp,onPropChange,snap,scaleMs}),
2533
+ h(Tracks,{et,selLane,setSelLane,onPropChange,snap,scaleMs}),
2379
2534
  ),
2380
- h(Inspector,{entry,selProp,onPropChange,snap}),
2535
+ h(Inspector,{entry,selLane,onPropChange,snap}),
2381
2536
  );
2382
2537
  }
2383
2538
 
@@ -2434,6 +2589,9 @@
2434
2589
  // ── accept (write to source) ──
2435
2590
  const[acceptState,setAcceptState]=useState("idle"); // idle | saving | done | error
2436
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);
2437
2595
  const[refineLabel,setRefineLabel]=useState(null);
2438
2596
  const[appliedIds,setAppliedIds]=useState({});
2439
2597
  const[refineMode,setRefineMode]=useState("llm"); // llm (Agent) | deterministic
@@ -2467,8 +2625,11 @@
2467
2625
  const mode=(refineMode==="llm"&&!avail)?"deterministic":refineMode;
2468
2626
  if(mode!==refineMode)setRefineMode(mode);
2469
2627
  try{
2470
- const timings=(active.effectiveTimings||[]).map(t=>({property:t.property,durationMs:t.durationMs,delayMs:t.delayMs,easing:t.easing}));
2471
- 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});
2472
2633
  setRefineJobId(id);
2473
2634
  }catch(e){
2474
2635
  setRefinePhase("error");
@@ -2500,9 +2661,15 @@
2500
2661
  if(p.durationMs!=null)o.durationMs=p.durationMs;
2501
2662
  if(p.delayMs!=null)o.delayMs=p.delayMs;
2502
2663
  if(p.easing!=null)o.easing=p.easing;
2503
- 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);
2504
2671
  setAppliedIds(prev=>({...prev,[s.id]:true}));
2505
- },[setPropOverride]);
2672
+ },[setPropOverride,active]);
2506
2673
  const applyAllSuggestions=useCallback(()=>{
2507
2674
  for(const s of refineSuggestions){if(!appliedIds[s.id])applySuggestion(s);}
2508
2675
  },[refineSuggestions,appliedIds,applySuggestion]);
@@ -2524,10 +2691,19 @@
2524
2691
  const copyValues=useCallback(()=>{
2525
2692
  if(!active)return;
2526
2693
  const et=active.effectiveTimings||[];
2527
- const css=et.map(t=>
2528
- `${t.property} ${formatCssTime(t.durationMs)} ${t.easing} ${formatCssTime(t.delayMs)}`
2529
- ).join(",\n ");
2530
- 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);});
2531
2707
  },[active]);
2532
2708
  const resetOverrides=useCallback(()=>{if(active)registry.clearOverride(active.id);},[registry,active]);
2533
2709
  // Accept → send an "apply" job so the agent writes the edited timings into
@@ -2538,7 +2714,9 @@
2538
2714
  setAcceptState("saving");setAcceptError(null);
2539
2715
  try{
2540
2716
  const{id}=await relayCreateJob({kind:"apply",transitionId:active.id,label:active.label,
2541
- selector:active.bindings&&active.bindings.selector,changes});
2717
+ selector:active.bindings&&active.bindings.selector,
2718
+ group:active.groupLabel||null,phase:active.phase||null,component:active.component||null,
2719
+ changes});
2542
2720
  let settled=false;
2543
2721
  for(let i=0;i<240&&!settled;i++){
2544
2722
  await new Promise(r=>setTimeout(r,500));
@@ -2559,6 +2737,33 @@
2559
2737
  },[active]);
2560
2738
  // reset Accept feedback when switching transitions
2561
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]);
2562
2767
 
2563
2768
  // whole-component open/close uses the transitions.dev panel reveal:
2564
2769
  // keep the panel mounted while it animates, flip data-open on the next
@@ -2608,7 +2813,8 @@
2608
2813
  h("div",{className:"tl-panel-main"},
2609
2814
  h(Header,{entries,active,onSelect:setActiveId,onReset:resetOverrides,onCopy:copyValues,copied,
2610
2815
  loop,setLoop,snap,setSnap,onMinimize:()=>setMinimized(true),onRefine:openRefine,refineActive:refineOpen,
2611
- onAccept,acceptState,acceptDisabled:computeChanges(active).length===0,acceptError}),
2816
+ onAccept,acceptState,acceptDisabled:computeChanges(active).length===0,acceptError,
2817
+ scanning:groupScanState==="scanning"}),
2612
2818
  active
2613
2819
  ?h(Body,{entry:active,onPropChange:(prop,o)=>setPropOverride(prop,o),
2614
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.3",
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
@@ -168,6 +168,14 @@ function parseApplyOutput(stdout) {
168
168
  return { applied: Boolean(obj.applied), summary: obj.summary ?? null, files: Array.isArray(obj.files) ? obj.files : null };
169
169
  }
170
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
+
171
179
  function runAgentCmd(cmd, prompt, parse = parseAgentOutput) {
172
180
  return new Promise((resolve, reject) => {
173
181
  const child = spawn("sh", ["-c", cmd], { stdio: ["pipe", "pipe", "pipe"] });
@@ -205,11 +213,13 @@ function buildApplyPrompt(job) {
205
213
  "You are APPLYING an approved transition change to the user's SOURCE CODE. Edit files; do not just suggest.",
206
214
  "",
207
215
  "Change context (JSON):",
208
- JSON.stringify({ label: r.label, selector: r.selector, changes: r.changes }, null, 2),
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.",
209
219
  "",
210
220
  "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.",
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.",
213
223
  "3. Make the minimal edit. Do not reformat or change unrelated code.",
214
224
  "",
215
225
  'Output ONLY a JSON object — no prose, no markdown fences — shaped exactly like:',
@@ -218,6 +228,27 @@ function buildApplyPrompt(job) {
218
228
  ].join("\n");
219
229
  }
220
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
+
221
252
  function refineDeterministic(job) {
222
253
  // Whole-transition replacement needs usage inference + recipe selection, which
223
254
  // only the agent (LLM) path can do. Deterministic can only snap to tokens.
@@ -240,11 +271,15 @@ async function answerJob(job) {
240
271
  job.status = "working";
241
272
  job.updatedAt = now();
242
273
  const isApply = job.request?.kind === "apply";
274
+ const isScan = job.request?.kind === "scan";
243
275
  const label = job.request?.label || job.request?.selector || "transition";
244
276
  // The browser picks the mode per job via the LLM / Deterministic tabs.
245
277
  // Default: LLM when a command is configured, otherwise deterministic.
246
278
  const mode = job.request?.mode || (AGENT_CMD ? "llm" : "deterministic");
247
- job.statusLog.push({ message: isApply ? `Writing "${label}" to your code…` : `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
+ });
248
283
  try {
249
284
  let result;
250
285
  if (isApply) {
@@ -263,6 +298,22 @@ async function answerJob(job) {
263
298
  console.log(` ✓ apply ${job.id.slice(0, 8)} — applied=${result.applied}`);
264
299
  return;
265
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
+ }
266
317
  if (mode === "llm") {
267
318
  if (!AGENT_CMD) {
268
319
  throw new Error(
@@ -362,8 +413,8 @@ const server = createServer(async (req, res) => {
362
413
  return send(res, 400, { error: "Body must be { request: {...} }" });
363
414
  }
364
415
  const job = createJob(body.request);
365
- // Apply jobs edit source — agent only, never deterministic.
366
- const mode = job.request.kind === "apply"
416
+ // Apply and scan jobs read/edit source — agent only, never deterministic.
417
+ const mode = (job.request.kind === "apply" || job.request.kind === "scan")
367
418
  ? "llm"
368
419
  : (job.request.mode || (llmAvailable() ? "llm" : "deterministic"));
369
420
  job.request.mode = mode;
@@ -440,11 +491,14 @@ const server = createServer(async (req, res) => {
440
491
  const body = await readJson(req);
441
492
  if (body && Array.isArray(body.suggestions)) {
442
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 };
443
497
  } else if (body && typeof body.applied !== "undefined") {
444
498
  // apply-job result from a `/refine live` agent
445
499
  job.result = { applied: Boolean(body.applied), summary: body.summary ?? null, files: Array.isArray(body.files) ? body.files : null };
446
500
  } else {
447
- return send(res, 400, { error: "Body must be { suggestions: [...] } or { applied, summary }" });
501
+ return send(res, 400, { error: "Body must be { suggestions: [...] }, { groups: [...] }, or { applied, summary }" });
448
502
  }
449
503
  job.status = "done";
450
504
  job.updatedAt = now();