transitions-refine 0.1.3 → 0.3.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.
package/demo.html CHANGED
@@ -249,50 +249,6 @@
249
249
  @media (prefers-reduced-motion: reduce) {
250
250
  .tl-refine-btn:hover:not(:disabled) .tl-refine-sparks i { animation: none; } }
251
251
 
252
- /* ── transport row ── */
253
- .tl-transport { display: flex; align-items: center; gap: 8px; padding: 10px 16px; border-bottom: 1px solid var(--c-line); }
254
- .tl-transport-left { flex: 1; display: flex; align-items: center; gap: 10px; min-width: 0; }
255
- .tl-transport-center { flex: 0 0 auto; display: flex; justify-content: center; }
256
- .tl-transport-right { flex: 1; display: flex; align-items: center; justify-content: flex-end; gap: 12px; }
257
- .tl-transport .tl-ghost-btn,
258
- .tl-transport .tl-icon-btn,
259
- .tl-transport .tl-play-btn { border-radius: 60px; }
260
- .tl-timecode { font-variant-numeric: tabular-nums; font-size: 13px; font-weight: 500; color: var(--c-text); min-width: 70px; }
261
- .tl-speed { font-variant-numeric: tabular-nums; }
262
- .tl-zoom { position: relative; width: 72px; height: 15px; flex: none; }
263
- /* slider — Logram design system (node 13064:2539): 15px frame, track at y=5, 15px knob at y=0 */
264
- .tl-zoom-track { position: absolute; left: 0; right: 0; top: 5px; height: 5px; pointer-events: none;
265
- background: rgba(115,115,115,0.1); border: 1px solid rgba(0,0,0,0.08); border-radius: 7px; box-sizing: border-box; }
266
- .tl-zoom input[type="range"] { position: relative; z-index: 1; width: 100%; height: 15px; margin: 0;
267
- -webkit-appearance: none; appearance: none; background: transparent; outline: none; cursor: pointer; }
268
- .tl-zoom input[type="range"]::-webkit-slider-runnable-track { height: 15px; background: transparent; border: none; }
269
- .tl-zoom input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none;
270
- width: 15px; height: 15px; margin-top: 0; border-radius: 50%; background: #fff; cursor: pointer;
271
- box-shadow: 0 1px 3px rgba(0,0,0,0.08), inset 0 0 0 1px rgba(126,126,126,0.1), inset 0 -1px 0 rgba(0,0,0,0.1); }
272
- .tl-zoom input[type="range"]::-moz-range-track { height: 15px; background: transparent; border: none; }
273
- .tl-zoom input[type="range"]::-moz-range-thumb { width: 15px; height: 15px; border: none; border-radius: 50%;
274
- background: #fff; cursor: pointer;
275
- box-shadow: 0 1px 3px rgba(0,0,0,0.08), inset 0 0 0 1px rgba(126,126,126,0.1), inset 0 -1px 0 rgba(0,0,0,0.1); }
276
- /* play/pause circle — Figma: drop shadow + inset ring overlay */
277
- .tl-play-btn { position: relative; isolation: isolate; overflow: hidden;
278
- display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px;
279
- border: none; border-radius: 50%; background: var(--c-circle); color: #17181c;
280
- box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.04); cursor: pointer;
281
- transition: background 0.12s ease, scale 0.12s ease; }
282
- .tl-play-btn::after { content: ""; position: absolute; inset: 0; border-radius: inherit;
283
- box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.06), inset 0 -1px 0 0 rgba(0, 0, 0, 0.06),
284
- inset 0 0 0 1px rgba(196, 196, 196, 0.10); pointer-events: none; }
285
- .tl-play-btn > * { position: relative; z-index: 1; }
286
- .tl-play-btn:hover:not(:disabled) { background: var(--c-circle-h); }
287
- .tl-play-btn:active:not(:disabled) { background: var(--c-circle-a); scale: 0.96; }
288
- .tl-play-btn:disabled { opacity: 0.5; cursor: default; }
289
- /* center both icon layers in the swap cell so the differently-sized play (10×12)
290
- and pause (16²) svgs share one center; nudge ONLY the play triangle right for
291
- optical centering (skill: play-button triangles). left/position (not transform)
292
- so it doesn't clash with the icon-swap scale animation; pause stays dead-center. */
293
- .tl-play-btn .t-icon { place-self: center; }
294
- .tl-play-btn .t-icon[data-icon="a"] { position: relative; left: 1px; }
295
-
296
252
  /* ── body / floating cards row ── */
297
253
  .tl-body { display: flex; flex: 1; min-height: 0; gap: 0; }
298
254
  .tl-main { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden; }
@@ -323,9 +279,16 @@
323
279
  position: relative; z-index: 5; cursor: grabbing; }
324
280
  .tl-prop-row.reordering .tl-prop-grip { color: var(--c-ruler); cursor: grabbing; }
325
281
  .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; }
282
+ text-overflow: ellipsis; text-transform: capitalize; display: flex; align-items: center; gap: 6px; min-width: 0; }
327
283
  .tl-prop-row.selected .tl-prop-label { color: #171717; }
328
- /* 16px left inset of the plot area (kept in sync with ruler/playhead/scrub) */
284
+ .tl-prop-member { flex: none; font-size: 11px; font-weight: 600; line-height: 14px; padding: 1px 6px; border-radius: 60px;
285
+ background: var(--c-sec, rgba(0,0,0,0.05)); color: var(--c-text-faint); text-transform: none; max-width: 96px;
286
+ overflow: hidden; text-overflow: ellipsis; }
287
+ .tl-prop-row.selected .tl-prop-member { color: var(--c-text-mut2); }
288
+ .tl-prop-prop { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
289
+ .tl-insp-member { display: inline-block; font-size: 11px; font-weight: 600; line-height: 14px; padding: 1px 6px; border-radius: 60px;
290
+ background: var(--c-sec, rgba(0,0,0,0.05)); color: var(--c-text-faint); text-transform: none; margin-right: 6px; vertical-align: middle; }
291
+ /* 16px left inset of the plot area (kept in sync with the ruler) */
329
292
  .tl-prop-track { position: relative; flex: 1; user-select: none; margin-left: 16px; }
330
293
  /* timeline line — capsule (fully rounded), Figma #eeeeef default / #e9e9e9 hover */
331
294
  .tl-bar { position: absolute; top: 50%; height: 24px; transform: translateY(-50%); background: var(--c-track);
@@ -345,13 +308,16 @@
345
308
  .tl-bar-grip { position: absolute; top: 0; bottom: 0; width: 10px; cursor: ew-resize; z-index: 2; }
346
309
  .tl-bar-grip.left { left: -5px; } .tl-bar-grip.right { right: -5px; }
347
310
 
348
- .tl-playhead-layer { position: absolute; top: 14px; bottom: 0; left: 166px; right: 0;
349
- pointer-events: none; z-index: 4; }
350
- .tl-playhead { position: absolute; top: 0; bottom: 0; width: 2px; background: #1A7AFF; transform: translateX(-1px); }
351
- .tl-playhead-head { position: absolute; top: -6px; left: 50%; transform: translateX(-50%); width: 16.666px; height: 24px;
352
- overflow: visible; filter: drop-shadow(0 1px 3px rgba(0,0,0,.25)); }
353
- .tl-scrub-zone { position: absolute; top: 0; height: 30px; left: 166px; right: 0;
354
- z-index: 6; cursor: ew-resize; }
311
+ /* draggable divider between the label column and the bars column. The hit
312
+ area is wide for easy grabbing; the visible line is thin and only accents
313
+ on hover / while dragging (matches the panel's 0.12s hover house style). */
314
+ .tl-col-resizer { position: absolute; top: 0; bottom: 0; width: 11px; transform: translateX(-50%);
315
+ cursor: col-resize; z-index: 6; display: flex; justify-content: center; }
316
+ .tl-col-resizer-line { width: 1px; height: 100%; background: transparent;
317
+ transition: background 0.12s ease, width 0.12s ease; }
318
+ .tl-col-resizer:hover .tl-col-resizer-line,
319
+ .tl-col-resizer.dragging .tl-col-resizer-line { width: 2px; background: #1A7AFF; }
320
+ @media (prefers-reduced-motion: reduce) { .tl-col-resizer-line { transition: none; } }
355
321
 
356
322
  /* ── value field (slider + input) — exact Figma "Value slider and input" ── */
357
323
  .tl-field-wrap { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
@@ -548,6 +514,9 @@
548
514
  .tl-menu-help svg { display: block; }
549
515
  .tl-menu-check { display: flex; color: var(--c-text-strong); flex: none; }
550
516
  .tl-menu-empty { padding: 10px; color: var(--c-disabled); font-size: 13px; }
517
+ .tl-menu-section { padding: 8px 10px 4px; font-size: 11px; font-weight: 600; line-height: 14px;
518
+ letter-spacing: 0.02em; text-transform: uppercase; color: var(--c-text-faint); }
519
+ .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
520
 
552
521
  /* ═════ transitions.dev — menu dropdown (verbatim) ═════ */
553
522
  .t-dropdown {
@@ -1009,6 +978,13 @@
1009
978
  if(cur)parts.push(cur); return parts;
1010
979
  }
1011
980
  function transitionSignature(props,dur,del,ease) { return [...props].sort().join(",")+"|"+dur+"|"+del+"|"+ease; }
981
+ // Serialise a set of effective lanes back into a CSS `transition` shorthand.
982
+ function transitionLanesToCss(lanes){ return (lanes||[]).map(t=>`${t.property} ${formatCssTime(t.durationMs)} ${t.easing} ${formatCssTime(t.delayMs)}`).join(", "); }
983
+ // Elements we've live-applied edited timings to. Maps element → its ORIGINAL
984
+ // source lanes ({z}), so the DomScanner keeps the entry pinned to the source
985
+ // signature/timings even while our inline `transition` overrides the DOM —
986
+ // otherwise the timing-based id would churn and orphan the user's edits.
987
+ const _TX_LIVE_BASE = new WeakMap();
1012
988
 
1013
989
  const EASING_PRESETS = [
1014
990
  { keyword:"linear", cubic:[0,0,1,1] }, { keyword:"ease", cubic:[0.25,0.1,0.25,1] },
@@ -1115,36 +1091,104 @@
1115
1091
  return out;
1116
1092
  }
1117
1093
 
1118
- // ── registry (per-property overrides) ──
1094
+ // ── registry (per-lane overrides) ──
1095
+ // A "lane" is one animated property of one element. For flat DOM transitions
1096
+ // a lane's id is just the property; for agent-grouped phases it is
1097
+ // `memberId::property`, so two members can each animate e.g. `opacity`.
1098
+ // Selectable items are either flat DOM entries (kind "flat") or one phase of
1099
+ // an agent group (kind "phase"); a phase fans out into per-member lanes.
1119
1100
  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(); }
1101
+ entries=new Map(); // flat DOM entries by id
1102
+ groups=[]; // agent groups: [{id,label,component,phases:[{id,phase,label,members:[...]}]}]
1103
+ claimedEntryIds=new Set();// flat entries covered by a group member (hidden from "Ungrouped")
1104
+ listeners=new Set(); laneOverrides=new Map(); snapshot=[]; effectiveCache=new Map();
1105
+ register(e){ this.entries.set(e.id,e); this._recomputeClaimed(); this._notify(); }
1106
+ unregister(id){ this.entries.delete(id); this.laneOverrides.delete(id); this._recomputeClaimed(); this._notify(); }
1107
+ replaceAll(list){ this.entries.clear(); for(const e of list) this.entries.set(e.id,e); this._recomputeClaimed(); this._notify(); }
1108
+ setGroups(groups){ this.groups = Array.isArray(groups)?groups:[]; this._recomputeClaimed(); this._notify(); }
1109
+ clearGroups(){ this.groups=[]; this.claimedEntryIds.clear(); this._notify(); }
1124
1110
  get(id){ return this.entries.get(id); }
1125
1111
  getAll(){ return this.snapshot; }
1126
1112
  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);
1113
+ // overrides are keyed by selectable-item id, then by laneId
1114
+ setPropOverride(id, laneId, o){
1115
+ const m = this.laneOverrides.get(id) || {};
1116
+ m[laneId] = { ...m[laneId], ...o };
1117
+ this.laneOverrides.set(id, m);
1131
1118
  this._notify();
1132
1119
  }
1133
- clearOverride(id){ this.propOverrides.delete(id); this._notify(); }
1134
- getPropOverrides(id){ return this.propOverrides.get(id); }
1120
+ clearOverride(id){ this.laneOverrides.delete(id); this._notify(); }
1121
+ getPropOverrides(id){ return this.laneOverrides.get(id); }
1135
1122
  subscribe(fn){ this.listeners.add(fn); return ()=>this.listeners.delete(fn); }
1123
+ // A flat entry is "claimed" when every live element it is bound to is also
1124
+ // matched by some group member's selector, so we don't list it twice.
1125
+ _recomputeClaimed(){
1126
+ this.claimedEntryIds.clear();
1127
+ if(!this.groups.length||typeof document==="undefined")return;
1128
+ const memberEls=new Set();
1129
+ for(const g of this.groups)for(const ph of (g.phases||[]))for(const m of (ph.members||[])){
1130
+ if(!m.selector)continue;
1131
+ let els=[];try{els=Array.from(document.querySelectorAll(m.selector));}catch{}
1132
+ for(const el of els)memberEls.add(el);
1133
+ }
1134
+ if(!memberEls.size)return;
1135
+ for(const[id,entry]of this.entries){
1136
+ const els=((entry.bindings&&entry.bindings.elements)||[]).map(w=>w.deref&&w.deref()).filter(Boolean);
1137
+ if(els.length&&els.every(el=>memberEls.has(el)))this.claimedEntryIds.add(id);
1138
+ }
1139
+ }
1140
+ _baseLanesFlat(entry){
1141
+ const timings=(entry.propertyTimings||entry.properties.map(p=>({property:p,durationMs:entry.durationMs,delayMs:entry.delayMs,easing:entry.easing})));
1142
+ 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}));
1143
+ }
1144
+ _baseLanesPhase(phase){
1145
+ const out=[];
1146
+ for(const m of (phase.members||[])){
1147
+ for(const t of (m.propertyTimings||[])){
1148
+ 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});
1149
+ }
1150
+ }
1151
+ return out;
1152
+ }
1153
+ _applyOverrides(baseLanes,po){
1154
+ 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};});
1155
+ }
1136
1156
  _notify(){
1137
- this.snapshot=Array.from(this.entries.values());
1138
1157
  this.effectiveCache.clear();
1158
+ const items=[];
1159
+ // 1) agent groups → one selectable item per phase
1160
+ for(const g of this.groups){
1161
+ for(const ph of (g.phases||[])){
1162
+ const id=ph.id||(g.id+":"+(ph.phase||ph.label||"phase"));
1163
+ const po=this.laneOverrides.get(id)||{};
1164
+ const baseLanes=this._baseLanesPhase(ph);
1165
+ const effTimings=this._applyOverrides(baseLanes,po);
1166
+ const repDur=effTimings.reduce((mx,t)=>Math.max(mx,(t.durationMs||0)+(t.delayMs||0)),0);
1167
+ const members=(ph.members||[]).map(m=>{
1168
+ const mb=baseLanes.filter(l=>l.memberId===m.id);
1169
+ return {memberId:m.id,label:m.label||m.id,selector:m.selector,toState:m.toState,
1170
+ lanes:this._applyOverrides(mb,po),baseLanes:mb};
1171
+ });
1172
+ const item={id,kind:"phase",groupId:g.id,groupLabel:g.label||g.id,component:g.component||null,
1173
+ phase:ph.phase||null,phaseLabel:ph.label||ph.phase||"Phase",
1174
+ stateTarget:ph.stateTarget||null,fromState:(ph.fromState??null),toState:(ph.toState??null),
1175
+ label:(g.label||g.id)+" · "+(ph.label||ph.phase||"Phase"),durationMs:repDur,
1176
+ members,effectiveTimings:effTimings,baseLanes};
1177
+ this.effectiveCache.set(id,item);
1178
+ items.push(item);
1179
+ }
1180
+ }
1181
+ // 2) flat DOM entries not claimed by a group → "Ungrouped"
1139
1182
  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 });
1183
+ if(this.claimedEntryIds.has(id))continue;
1184
+ const po=this.laneOverrides.get(id)||{};
1185
+ const baseLanes=this._baseLanesFlat(entry);
1186
+ const effTimings=this._applyOverrides(baseLanes,po);
1187
+ const item={...entry,kind:"flat",groupId:null,groupLabel:null,effectiveTimings:effTimings,baseLanes};
1188
+ this.effectiveCache.set(id,item);
1189
+ items.push(item);
1147
1190
  }
1191
+ this.snapshot=items;
1148
1192
  for(const fn of this.listeners) fn(this.snapshot);
1149
1193
  }
1150
1194
  }
@@ -1259,6 +1303,53 @@
1259
1303
  for(const p of keys)from[p]=cs.getPropertyValue(p)||cs[_txCamel(p)]||"";
1260
1304
  return {from,to};
1261
1305
  }
1306
+ // Toggle a phase's state class/attribute (e.g. ".is-open" or "[data-open]")
1307
+ // and read the resulting computed values, so a phase plays its real end-state
1308
+ // even when the source uses a toggled class the DOM isn't currently in.
1309
+ function _txToState(el,et,toState){
1310
+ if(typeof document==="undefined"||!el.matches||!toState)return null;
1311
+ const props=et.map(t=>t.property).filter(p=>p&&p!=="all");
1312
+ if(!props.length)return null;
1313
+ const s=String(toState).trim();
1314
+ let applied=null; // {undo}
1315
+ try{
1316
+ if(s[0]==="."){const c=s.slice(1);if(c&&!el.classList.contains(c)){el.classList.add(c);applied=()=>el.classList.remove(c);}}
1317
+ else if(s[0]==="["){
1318
+ const m=s.match(/^\[\s*([\w-]+)\s*(?:([~|^$*]?=)\s*"?([^"\]]*)"?\s*)?\]$/);
1319
+ 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);}}
1320
+ }
1321
+ }catch{}
1322
+ if(!applied)return null; // already in that state, or unparseable
1323
+ const cs=getComputedStyle(el);const to={};
1324
+ for(const p of props)to[p]=cs.getPropertyValue(p)||cs[_txCamel(p)]||"";
1325
+ try{applied();}catch{}
1326
+ const cs0=getComputedStyle(el);const from={};
1327
+ for(const p of props)from[p]=cs0.getPropertyValue(p)||cs0[_txCamel(p)]||"";
1328
+ const keys=Object.keys(to).filter(p=>to[p]!=null&&to[p]!==""&&to[p]!==from[p]);
1329
+ if(!keys.length)return null;
1330
+ const f={},t={};for(const p of keys){f[p]=from[p];t[p]=to[p];}
1331
+ return {from:f,to:t};
1332
+ }
1333
+
1334
+ // Add/remove a single state token (".class" or "[attr=val]"/"[attr]") on an
1335
+ // element. Returns false for tokens we can't toggle (e.g. pseudo ":hover").
1336
+ function _applyStateToken(el,token,add){
1337
+ const s=String(token||"").trim();
1338
+ if(!s)return true;
1339
+ if(s[0]==="."){const c=s.slice(1);if(!c)return false;if(add)el.classList.add(c);else el.classList.remove(c);return true;}
1340
+ if(s[0]==="["){
1341
+ const m=s.match(/^\[\s*([\w-]+)\s*(?:[~|^$*]?=\s*"?([^"\]]*)"?\s*)?\]$/);
1342
+ if(!m)return false;
1343
+ const name=m[1],val=m[2]!=null?m[2]:"";
1344
+ if(add)el.setAttribute(name,val);else el.removeAttribute(name);
1345
+ return true;
1346
+ }
1347
+ return false; // pseudo (:hover/:focus) or unrecognised → not class-toggleable
1348
+ }
1349
+ function _tokenToggleable(token){
1350
+ const s=String(token||"").trim();
1351
+ return !s || s[0]==="."||s[0]==="[";
1352
+ }
1262
1353
 
1263
1354
  class PreviewController {
1264
1355
  state="idle"; listeners=new Set(); cleanups=[]; animations=[]; progressListeners=new Set(); _rafId=null; scanner=null; _gen=0;
@@ -1269,7 +1360,25 @@
1269
1360
  subscribe(fn){this.listeners.add(fn);return()=>this.listeners.delete(fn);}
1270
1361
  onProgress(fn){this.progressListeners.add(fn);return()=>this.progressListeners.delete(fn);}
1271
1362
  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");}
1363
+ if(this.scanner)this.scanner.pause();this._playCss(entry,this._gen);this._setState("playing");}
1364
+ // Resolve a selectable item to a flat list of {el, et, toState} arm targets.
1365
+ // Phase items fan out to their members (selector may match several elements,
1366
+ // e.g. staggered items); flat items use their bound DOM elements.
1367
+ _targets(entry){
1368
+ const out=[];
1369
+ if(entry.kind==="phase"){
1370
+ for(const m of (entry.members||[])){
1371
+ if(!m.selector||!m.lanes||!m.lanes.length)continue;
1372
+ let els=[];try{els=Array.from(document.querySelectorAll(m.selector));}catch{}
1373
+ for(const el of els)out.push({el,et:m.lanes,toState:m.toState});
1374
+ }
1375
+ return out;
1376
+ }
1377
+ if(!entry.bindings||entry.bindings.type!=="css")return out;
1378
+ const et=entry.effectiveTimings||entry.properties.map(p=>({property:p,durationMs:entry.durationMs,delayMs:entry.delayMs,easing:entry.easing}));
1379
+ for(const wr of entry.bindings.elements){const el=wr.deref&&wr.deref();if(el)out.push({el,et,toState:null});}
1380
+ return out;
1381
+ }
1273
1382
  pause(){if(this.state!=="playing")return;for(const a of this.animations){try{a.pause();}catch{}}this._stopPL();this._setState("paused");}
1274
1383
  resume(){if(this.state!=="paused")return;for(const a of this.animations){try{a.play();}catch{}}this._startPL();this._setState("playing");}
1275
1384
  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,20 +1387,81 @@
1278
1387
  this.stop();this._gen++;const gen=this._gen;
1279
1388
  this._pendingSeek=seekMs;
1280
1389
  if(this.scanner)this.scanner.pause();
1281
- if(entry.bindings.type!=="css")return;
1390
+ const armed=this._armAll(entry);
1391
+ const els=armed.els;
1392
+ if(!els.length){this._setState("paused");if(seekMs!=null)this._ep(seekMs);return;}
1393
+ this.cleanups.push(armed.restore);
1282
1394
  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);
1286
- requestAnimationFrame(()=>{if(this._gen!==gen)return;const running=el.getAnimations();
1287
- for(const a of running){a.pause();a.playbackRate=this.rate;this.animations.push(a);}
1288
- const t=this._pendingSeek??seekMs;
1289
- if(t!=null){for(const a of this.animations){try{a.currentTime=t;}catch{}}this._ep(t);}
1290
- });
1291
- this.cleanups.push(restore);}
1395
+ requestAnimationFrame(()=>{if(this._gen!==gen)return;
1396
+ const running=[];for(const el of els){for(const a of el.getAnimations()){a.pause();a.playbackRate=this.rate;running.push(a);}}
1397
+ this.animations=running;
1398
+ const t=this._pendingSeek??seekMs;
1399
+ if(t!=null){for(const a of this.animations){try{a.currentTime=t;}catch{}}this._ep(t);}
1400
+ });
1292
1401
  this._setState("paused");
1293
1402
  if(seekMs!=null)this._ep(seekMs);
1294
1403
  }
1404
+ // Snapshot/restore the toggled state on a root element.
1405
+ _snapshotState(el,toggleTokens){
1406
+ const attrs={},classes={};
1407
+ for(const t of toggleTokens){const s=String(t).trim();
1408
+ if(s[0]===".")classes[s.slice(1)]=el.classList.contains(s.slice(1));
1409
+ else if(s[0]==="["){const m=s.match(/^\[\s*([\w-]+)/);if(m)attrs[m[1]]=el.getAttribute(m[1]);}}
1410
+ return {attrs,classes};
1411
+ }
1412
+ _restoreState(el,snap){
1413
+ for(const c in snap.classes){if(snap.classes[c])el.classList.add(c);else el.classList.remove(c);}
1414
+ for(const n in snap.attrs){const v=snap.attrs[n];if(v==null)el.removeAttribute(n);else el.setAttribute(n,v);}
1415
+ }
1416
+ _setRootState(el,target,toggleTokens){
1417
+ for(const t of toggleTokens)_applyStateToken(el,t,false);
1418
+ if(target)_applyStateToken(el,target,true);
1419
+ }
1420
+ // Arm a whole phase by toggling the REAL driving state on its stateTarget,
1421
+ // so the actual CSS animates every member in the correct direction (open
1422
+ // base→state, close state→base). Edited timings are applied inline on the
1423
+ // member elements so they override the source. Returns {els, restore} or
1424
+ // null to fall back to per-member arming.
1425
+ _armPhase(entry,root){
1426
+ const fromState=entry.fromState??null, toState=entry.toState??null;
1427
+ if(![fromState,toState].every(_tokenToggleable))return null; // pseudo states → legacy
1428
+ const toggleTokens=[fromState,toState].filter(Boolean);
1429
+ if(!toggleTokens.length)return null;
1430
+ const memberEls=[];
1431
+ for(const m of (entry.members||[])){
1432
+ if(!m.selector||!m.lanes||!m.lanes.length)continue;
1433
+ let els=[];try{els=Array.from(document.querySelectorAll(m.selector));}catch{}
1434
+ for(const el of els)memberEls.push({el,lanes:m.lanes});
1435
+ }
1436
+ if(!memberEls.length)return null;
1437
+ const savedRoot=this._snapshotState(root,toggleTokens);
1438
+ const savedStyles=memberEls.map(({el})=>el.style.cssText);
1439
+ // 1. suppress transitions and commit the FROM state
1440
+ for(const {el} of memberEls)el.style.transition="none";
1441
+ this._setRootState(root,fromState,toggleTokens);
1442
+ void root.offsetWidth;for(const {el} of memberEls)void el.offsetWidth;
1443
+ // 2. apply the (possibly edited) per-member timings inline
1444
+ for(const {el,lanes} of memberEls){
1445
+ el.style.transition=lanes.map(t=>`${t.property} ${formatCssTime(t.durationMs)} ${t.easing} ${formatCssTime(t.delayMs)}`).join(", ");
1446
+ }
1447
+ // 3. toggle to the TO state → real CSS transitions fire on every member
1448
+ this._setRootState(root,toState,toggleTokens);
1449
+ const restore=()=>{try{this._restoreState(root,savedRoot);}catch{}
1450
+ memberEls.forEach(({el},i)=>{try{el.style.cssText=savedStyles[i];}catch{}});};
1451
+ return {els:memberEls.map(m=>m.el),restore};
1452
+ }
1453
+ // Resolve an entry to armed element(s) + a restore fn. Phase items with a
1454
+ // toggleable stateTarget use _armPhase; everything else uses per-target arm.
1455
+ _armAll(entry){
1456
+ if(entry&&entry.kind==="phase"&&entry.stateTarget&&typeof document!=="undefined"){
1457
+ let root=null;try{root=document.querySelector(entry.stateTarget);}catch{}
1458
+ if(root){const r=this._armPhase(entry,root);if(r)return r;}
1459
+ }
1460
+ const targets=this._targets(entry);
1461
+ const els=[],restores=[];
1462
+ for(const {el,et,toState} of targets){restores.push(this._arm(el,et,toState));els.push(el);}
1463
+ return {els,restore:()=>{for(const c of restores){try{c();}catch{}}}};
1464
+ }
1295
1465
  seek(timeMs){
1296
1466
  if(this.state==="idle")return;
1297
1467
  if(this.state==="playing"){for(const a of this.animations){try{a.pause();}catch{}}this._stopPL();this._setState("paused");}
@@ -1307,10 +1477,12 @@
1307
1477
  // 3. a synthetic opacity/transform pulse to preview the timing,
1308
1478
  // 4. click the element (last resort, may have side effects).
1309
1479
  // Returns a restore fn.
1310
- _arm(el,et){
1480
+ _arm(el,et,toState){
1311
1481
  const saved=el.style.cssText;
1312
1482
  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);
1483
+ // toState (a phase's driving class/attr) is the most reliable source of
1484
+ // the real end-state; fall back to a captured run, then CSSOM discovery.
1485
+ const states=(toState&&_txToState(el,et,toState))||_txCaptured(el,et)||_txDiscover(el,et);
1314
1486
  if(states){
1315
1487
  const props=et.map(t=>t.property).filter(p=>states.from[p]!=null&&states.to[p]!=null);
1316
1488
  if(props.length){
@@ -1335,13 +1507,18 @@
1335
1507
  el.style.transition=tv;el.click();
1336
1508
  return ()=>{try{el.style.cssText=saved;}catch{}};
1337
1509
  }
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);
1342
- 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();}});
1344
- this.cleanups.push(restore);}}
1510
+ _playCss(entry,gen){
1511
+ const armed=this._armAll(entry);
1512
+ const els=armed.els;
1513
+ this.animations=[];
1514
+ if(!els.length){this._finish();return;}
1515
+ this.cleanups.push(armed.restore);
1516
+ requestAnimationFrame(()=>{if(this._gen!==gen)return;
1517
+ const running=[];for(const el of els){for(const a of el.getAnimations()){a.playbackRate=this.rate;running.push(a);}}
1518
+ this.animations=running;this._startPL();
1519
+ const onDone=()=>{if(this._gen!==gen||this.state!=="playing")return;if(this.loop&&this._current){this.play(this._current);}else{this._finish();}};
1520
+ if(running.length>0){Promise.allSettled(running.map(a=>a.finished)).then(onDone);}else{onDone();}
1521
+ });}
1345
1522
  _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
1523
  _stopPL(){if(this._rafId!==null){cancelAnimationFrame(this._rafId);this._rafId=null;}}
1347
1524
  _ep(p){for(const fn of this.progressListeners)fn(p);}
@@ -1353,7 +1530,7 @@
1353
1530
  observer=null;rafId=null;running=false;paused=false;
1354
1531
  constructor(root,reg){this.root=root;this.registry=reg;}
1355
1532
  pause(){this.paused=true;} unpause(){this.paused=false;this._sched();}
1356
- start(){if(this.running)return;this.running=true;_txInstallCapture();this.scan();this.observer=new MutationObserver(()=>this._sched());this.observer.observe(this.root,{childList:true,subtree:true,attributes:true,attributeFilter:["class","style"]});}
1533
+ start(){if(this.running)return;this.running=true;this.scan();this.observer=new MutationObserver(()=>this._sched());this.observer.observe(this.root,{childList:true,subtree:true,attributes:true,attributeFilter:["class","style"]});}
1357
1534
  stop(){this.running=false;this.observer?.disconnect();this.observer=null;if(this.rafId!==null){cancelAnimationFrame(this.rafId);this.rafId=null;}}
1358
1535
  _sched(){if(this.rafId!==null)return;this.rafId=requestAnimationFrame(()=>{this.rafId=null;if(this.running&&!this.paused)this.scan();});}
1359
1536
  scan(){const seen=new Map();const w=document.createTreeWalker(this.root,NodeFilter.SHOW_ELEMENT);let n=w.currentNode;while(n){if(n instanceof HTMLElement)this._proc(n,seen);n=w.nextNode();}this.registry.replaceAll(Array.from(seen.values()));}
@@ -1365,7 +1542,11 @@
1365
1542
  const durs=(s.transitionDuration||"0s").split(",");
1366
1543
  const dels=(s.transitionDelay||"0s").split(",");
1367
1544
  const eass=splitCssValues(s.transitionTimingFunction||"ease");
1368
- const z=zipTransitionLists(props,durs,dels,eass);
1545
+ let z=zipTransitionLists(props,durs,dels,eass);
1546
+ // If we've live-applied edited timings here, keep the entry pinned to the
1547
+ // ORIGINAL source lanes so its id/overrides stay stable across rescans.
1548
+ const _lb=_TX_LIVE_BASE.get(el);
1549
+ if(_lb){ if(_lb.z&&_lb.z.length) z=_lb.z; else return; }
1369
1550
  if(!z.length||z.every(x=>x.durationMs===0&&x.delayMs===0))return;
1370
1551
  const pDur=z[0].durationMs,pDel=z[0].delayMs,pEase=z[0].easing;
1371
1552
  const allP=z.map(x=>x.property);
@@ -1383,19 +1564,11 @@
1383
1564
  const TimelineCtx = createContext(null);
1384
1565
  function useReg(){const{registry}=useContext(TimelineCtx);return useSyncExternalStore(useCallback(cb=>registry.subscribe(cb),[registry]),useCallback(()=>registry.getAll(),[registry]),useCallback(()=>registry.getAll(),[registry]));}
1385
1566
  function useActive(){const{activeId,setActiveId,registry}=useContext(TimelineCtx);const active=useSyncExternalStore(useCallback(cb=>registry.subscribe(cb),[registry]),useCallback(()=>activeId?registry.getEffective(activeId):undefined,[registry,activeId]),useCallback(()=>activeId?registry.getEffective(activeId):undefined,[registry,activeId]));return{active,setActiveId};}
1386
- function usePlayback(){const{preview,registry,activeId}=useContext(TimelineCtx);const state=useSyncExternalStore(useCallback(cb=>preview.subscribe(cb),[preview]),useCallback(()=>preview.getState(),[preview]),useCallback(()=>preview.getState(),[preview]));
1387
- return{state,play:useCallback(()=>{if(!activeId)return;const e=registry.getEffective(activeId);if(e)preview.play(e);},[preview,registry,activeId]),pause:useCallback(()=>preview.pause(),[preview]),resume:useCallback(()=>preview.resume(),[preview]),restart:useCallback(()=>{if(!activeId)return;const e=registry.getEffective(activeId);if(e)preview.restart(e);},[preview,registry,activeId]),stop:useCallback(()=>preview.stop(),[preview])};}
1388
1567
  function usePropOverride(){const{registry,activeId}=useContext(TimelineCtx);return{setPropOverride:useCallback((prop,o)=>{if(activeId)registry.setPropOverride(activeId,prop,o);},[registry,activeId])};}
1389
1568
 
1390
1569
  // ── components ──
1391
- const BASE_SCALE_MS = 5000;
1392
- const ZOOM_MIN = 25;
1393
- const ZOOM_MAX = 400;
1394
- const ZOOM_DEFAULT = 100;
1395
- const scaleFromZoom = zoom => Math.round(BASE_SCALE_MS * 100 / zoom);
1396
1570
  const LABEL_W = 150;
1397
1571
  const CLOSE_MS = 150;
1398
- const SPEEDS = [0.25, 0.5, 1, 2];
1399
1572
  const DURATION_TOKENS = [
1400
1573
  {label:"Duration-fast", ms:150, usage:"Quick state changes — hovers, toggles, button presses, dropdown & modal close, text swaps."},
1401
1574
  {label:"Duration-medium", ms:250, usage:"Standard UI motion — icon swaps, dropdown & modal open, sliding tabs, page slides."},
@@ -1409,8 +1582,6 @@
1409
1582
  {label:"Long", ms:300, usage:"Pronounced wait — use sparingly to draw attention."},
1410
1583
  ];
1411
1584
  function cx(...a){ return a.filter(Boolean).join(" "); }
1412
- function fmtTimecode(ms){ const m=Math.floor(ms/60000); const s=Math.floor((ms%60000)/1000); const c=Math.floor((ms%1000)/10); const p=n=>String(n).padStart(2,"0"); return p(m)+":"+p(s)+":"+p(c); }
1413
- function fmtSpeed(s){ return s+"x"; }
1414
1585
 
1415
1586
  // ── icons ──
1416
1587
  // Exact SVGs exported from the Logram ❖ Design System Figma file (no recreations).
@@ -1445,13 +1616,6 @@
1445
1616
  style:{display:"block"},dangerouslySetInnerHTML:{__html:ic.svg}});
1446
1617
  }
1447
1618
 
1448
- function usePreviewTime(){
1449
- const{preview}=useContext(TimelineCtx);
1450
- const[ms,setMs]=useState(0);
1451
- useEffect(()=>preview.onProgress(setMs),[preview]);
1452
- return ms;
1453
- }
1454
-
1455
1619
  // portaled, origin-aware dropdown surface (transitions.dev menu-dropdown)
1456
1620
  function Dropdown({open,onClose,triggerRef,width,align,children}){
1457
1621
  const ref=useRef(null);
@@ -1748,17 +1912,19 @@
1748
1912
  h("div",{className:"tl-refine-foot"},foot)));
1749
1913
  }
1750
1914
 
1751
- // Diff the active transition's effective (edited/refined) timings against its
1915
+ // Diff the active item's effective (edited/refined) lanes against their
1752
1916
  // originally-scanned values — the set of changes Accept writes to source.
1917
+ // Each change carries member/selector/phase context so the agent can target
1918
+ // the right state rule (e.g. .is-open vs .is-closing) for a grouped phase.
1753
1919
  function computeChanges(active){
1754
1920
  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]));
1921
+ const base=active.baseLanes||[];
1922
+ const bmap=new Map(base.map(t=>[t.laneId,t]));
1757
1923
  const out=[];
1758
1924
  for(const t of (active.effectiveTimings||[])){
1759
- const b=bmap.get(t.property);if(!b)continue;
1925
+ const b=bmap.get(t.laneId);if(!b)continue;
1760
1926
  if(t.durationMs!==b.durationMs||t.delayMs!==b.delayMs||(t.easing||"")!==(b.easing||"")){
1761
- out.push({property:t.property,
1927
+ out.push({property:t.property,member:t.member||null,selector:t.selector||null,
1762
1928
  from:{durationMs:b.durationMs,delayMs:b.delayMs,easing:b.easing},
1763
1929
  to:{durationMs:t.durationMs,delayMs:t.delayMs,easing:t.easing}});
1764
1930
  }
@@ -1766,10 +1932,26 @@
1766
1932
  return out;
1767
1933
  }
1768
1934
 
1769
- function Header({entries,active,onSelect,onReset,onCopy,copied,loop,setLoop,snap,setSnap,onMinimize,onRefine,refineActive,onAccept,acceptState,acceptDisabled,acceptError}){
1935
+ function Header({entries,active,onSelect,onReset,onCopy,copied,snap,setSnap,onMinimize,onRefine,refineActive,onAccept,acceptState,acceptDisabled,acceptError,scanning}){
1770
1936
  const[pick,setPick]=useState(false);
1771
1937
  const[setg,setSetg]=useState(false);
1772
1938
  const pickRef=useRef(null), gearRef=useRef(null);
1939
+ // Split selectable items into agent groups (component → phases) and the
1940
+ // flat, ungrouped DOM transitions, preserving order.
1941
+ const groupList=[]; const groupMap=new Map(); const flatItems=[];
1942
+ for(const e of entries){
1943
+ if(e.kind==="phase"){
1944
+ let g=groupMap.get(e.groupId);
1945
+ if(!g){g={id:e.groupId,label:e.groupLabel,phases:[]};groupMap.set(e.groupId,g);groupList.push(g);}
1946
+ g.phases.push(e);
1947
+ }else flatItems.push(e);
1948
+ }
1949
+ const phaseItem=(e)=>h(MenuItem,{key:e.id,active:active&&e.id===active.id,onClick:()=>{onSelect(e.id);setPick(false);},
1950
+ right:active&&e.id===active.id&&h("span",{className:"tl-menu-check"},h(Ic,{name:"check"}))},
1951
+ h("span",null,e.phaseLabel,h("span",{className:"tl-menu-dim"}," "+e.durationMs+"ms")));
1952
+ const flatItem=(e)=>h(MenuItem,{key:e.id,active:active&&e.id===active.id,onClick:()=>{onSelect(e.id);setPick(false);},
1953
+ right:active&&e.id===active.id&&h("span",{className:"tl-menu-check"},h(Ic,{name:"check"}))},
1954
+ h("span",null,e.label,h("span",{className:"tl-menu-dim"}," "+e.durationMs+"ms")));
1773
1955
  return h("div",{className:"tl-header"},
1774
1956
  h("span",{className:"tl-header-label"},"Selected"),
1775
1957
  h("button",{ref:pickRef,className:cx("tl-ghost-btn",pick&&"is-active"),disabled:!active,onClick:()=>setPick(v=>!v)},
@@ -1779,13 +1961,16 @@
1779
1961
  h(Dropdown,{open:pick,onClose:()=>setPick(false),triggerRef:pickRef,width:Math.max(240,(pickRef.current&&pickRef.current.offsetWidth)||240),align:"left"},
1780
1962
  entries.length===0
1781
1963
  ? 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"),
1964
+ : h(React.Fragment,null,
1965
+ ...groupList.map(g=>h(React.Fragment,{key:g.id},
1966
+ h("div",{className:"tl-menu-section"},g.label),
1967
+ ...g.phases.map(phaseItem))),
1968
+ flatItems.length>0&&groupList.length>0&&h("div",{className:"tl-menu-section"},"Ungrouped"),
1969
+ ...flatItems.map(flatItem))),
1970
+ h("span",{className:"tl-header-count"},
1971
+ scanning?"Grouping…":entries.length+" transition"+(entries.length===1?"":"s")+" found"),
1786
1972
  h("button",{ref:gearRef,className:cx("tl-icon-btn",setg&&"is-active"),title:"Settings",onClick:()=>setSetg(v=>!v)},h(Ic,{name:"gear"})),
1787
1973
  h(Dropdown,{open:setg,onClose:()=>setSetg(false),triggerRef:gearRef,width:210,align:"right"},
1788
- h(MenuItem,{onClick:()=>setLoop(v=>!v),right:loop&&h("span",{className:"tl-menu-check"},h(Ic,{name:"check"}))},"Loop playback"),
1789
1974
  h(MenuItem,{onClick:()=>setSnap(v=>!v),right:snap&&h("span",{className:"tl-menu-check"},h(Ic,{name:"check"}))},"Snap to grid")),
1790
1975
  h("span",{className:"t-tt-wrap"},
1791
1976
  h("button",{className:"tl-sec-btn t-tt-trigger",disabled:!active,onClick:onReset},"Reset"),
@@ -1826,7 +2011,7 @@
1826
2011
  );
1827
2012
  }
1828
2013
 
1829
- function PropTrack({property, delayMs, durationMs, selected, dragging, onSelect, onReorder, onDelayChange, onDurationChange, snap, scaleMs, lockDuration}){
2014
+ function PropTrack({property, member, delayMs, durationMs, selected, dragging, onSelect, onReorder, onDelayChange, onDurationChange, snap, scaleMs, lockDuration, labelW}){
1830
2015
  const trackRef=useRef(null);
1831
2016
  const grid = snap ? 25 : 1;
1832
2017
  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]);
@@ -1854,9 +2039,11 @@
1854
2039
 
1855
2040
  const delPct=(delayMs/scaleMs)*100; const durPct=(durationMs/scaleMs)*100;
1856
2041
  return h("div",{className:cx("tl-prop-row",selected&&"selected",dragging&&"reordering",lockDuration&&"is-spring"),onClick:onSelect},
1857
- h("div",{className:"tl-prop-head"},
2042
+ h("div",{className:"tl-prop-head",style:labelW!=null?{flexBasis:labelW+"px"}:undefined},
1858
2043
  h("span",{className:"tl-prop-grip",title:"Drag to reorder",onMouseDown:onReorder},h(Ic,{name:"dots"})),
1859
- h("span",{className:"tl-prop-label"},property)),
2044
+ h("span",{className:"tl-prop-label"},
2045
+ member&&h("span",{className:"tl-prop-member"},member),
2046
+ h("span",{className:"tl-prop-prop"},property))),
1860
2047
  h("div",{className:"tl-prop-track",ref:trackRef},
1861
2048
  h("div",{className:"tl-bar",style:{left:delPct+"%",width:durPct+"%"},onMouseDown:e=>startDrag("move",e),
1862
2049
  title:lockDuration?"Spring duration is derived \u2014 drag to move, resize is locked":undefined},
@@ -2235,82 +2422,63 @@
2235
2422
  );
2236
2423
  }
2237
2424
 
2238
- function ScrubZone({scaleMs}){
2239
- const { preview, registry, activeId } = useContext(TimelineCtx);
2240
- const areaRef = useRef(null);
2241
- const startedRef = useRef(false);
2242
-
2243
- const pxToMs = useCallback(clientX=>{
2244
- if(!areaRef.current) return 0;
2245
- const rect = areaRef.current.getBoundingClientRect();
2246
- const ratio = Math.max(0, Math.min((clientX - rect.left) / rect.width, 1));
2247
- return ratio * scaleMs;
2248
- },[scaleMs]);
2249
-
2250
- const doSeek = useCallback(ms=>{
2251
- if(preview.getState()==="idle" && !startedRef.current){
2252
- if(!activeId) return;
2253
- const entry = registry.getEffective(activeId);
2254
- if(!entry) return;
2255
- startedRef.current = true;
2256
- preview.playPaused(entry, ms);
2257
- } else {
2258
- preview.seek(ms);
2259
- }
2260
- },[preview,registry,activeId]);
2261
-
2262
- const startScrub = useCallback(e=>{
2263
- e.preventDefault();
2264
- startedRef.current = false;
2265
- doSeek(pxToMs(e.clientX));
2266
- const onMove = e2 => doSeek(pxToMs(e2.clientX));
2267
- const onUp = () => { startedRef.current = false; window.removeEventListener("mousemove",onMove); window.removeEventListener("mouseup",onUp); };
2268
- window.addEventListener("mousemove",onMove);
2269
- window.addEventListener("mouseup",onUp);
2270
- },[doSeek,pxToMs]);
2271
-
2272
- return h("div",{className:"tl-scrub-zone",ref:areaRef,onMouseDown:startScrub});
2273
- }
2274
-
2275
- function Tracks({et, selProp, setSelProp, onPropChange, snap, scaleMs}){
2276
- const t = usePreviewTime();
2277
- const ratio = Math.min(t / scaleMs, 1);
2425
+ function Tracks({et, selLane, setSelLane, onPropChange, snap, scaleMs}){
2278
2426
  const ROW_H = 48;
2279
2427
  const rowsRef = useRef(null);
2428
+ const tracksRef = useRef(null);
2280
2429
  const [order, setOrder] = useState([]);
2281
- const [dragProp, setDragProp] = useState(null);
2282
- const propKey = et.map(x=>x.property).join("|");
2430
+ const [dragLane, setDragLane] = useState(null);
2431
+ // Width of the label column (shared by the ruler spacer + every prop-row
2432
+ // head). Draggable via the divider; the bars column is flex:1 so it just
2433
+ // takes the remaining space and the bars stay proportional to scaleMs.
2434
+ const LABEL_MIN = 96, LABEL_TRACK_MIN = 160;
2435
+ const [labelW, setLabelW] = useState(LABEL_W);
2436
+ const [colDragging, setColDragging] = useState(false);
2437
+ const startColResize = useCallback(e=>{
2438
+ e.preventDefault(); e.stopPropagation();
2439
+ const startX = e.clientX, startW = labelW;
2440
+ const maxW = tracksRef.current
2441
+ ? Math.max(LABEL_MIN, tracksRef.current.getBoundingClientRect().width - LABEL_TRACK_MIN)
2442
+ : 360;
2443
+ setColDragging(true);
2444
+ document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none";
2445
+ const onMove = e2 => setLabelW(Math.max(LABEL_MIN, Math.min(maxW, startW + (e2.clientX - startX))));
2446
+ const onUp = () => { setColDragging(false); document.body.style.cursor=""; document.body.style.userSelect="";
2447
+ window.removeEventListener("mousemove",onMove); window.removeEventListener("mouseup",onUp); };
2448
+ window.addEventListener("mousemove",onMove); window.addEventListener("mouseup",onUp);
2449
+ },[labelW]);
2450
+ const laneKey = et.map(x=>x.laneId).join("|");
2283
2451
  useEffect(()=>{
2284
- const props = et.map(x=>x.property);
2452
+ const ids = et.map(x=>x.laneId);
2285
2453
  setOrder(prev=>{
2286
- const kept = prev.filter(p=>props.includes(p));
2287
- const added = props.filter(p=>!kept.includes(p));
2454
+ const kept = prev.filter(p=>ids.includes(p));
2455
+ const added = ids.filter(p=>!kept.includes(p));
2288
2456
  return [...kept, ...added];
2289
2457
  });
2290
- },[propKey]);
2291
- const orderedRows = order.map(p=>et.find(x=>x.property===p)).filter(Boolean);
2292
- const startReorder = useCallback((property,e)=>{
2458
+ },[laneKey]);
2459
+ const orderedRows = order.map(p=>et.find(x=>x.laneId===p)).filter(Boolean);
2460
+ const startReorder = useCallback((laneId,e)=>{
2293
2461
  e.preventDefault(); e.stopPropagation();
2294
- setSelProp(property);
2295
- setDragProp(property);
2462
+ setSelLane(laneId);
2463
+ setDragLane(laneId);
2296
2464
  const top = rowsRef.current ? rowsRef.current.getBoundingClientRect().top : 0;
2297
2465
  const onMove = e2=>{
2298
2466
  const idx = Math.floor((e2.clientY - top) / ROW_H);
2299
2467
  setOrder(prev=>{
2300
- const cur = prev.indexOf(property);
2468
+ const cur = prev.indexOf(laneId);
2301
2469
  if(cur<0) return prev;
2302
2470
  const target = Math.max(0, Math.min(prev.length-1, idx));
2303
2471
  if(target===cur) return prev;
2304
2472
  const next = prev.slice();
2305
2473
  next.splice(cur,1);
2306
- next.splice(target,0,property);
2474
+ next.splice(target,0,laneId);
2307
2475
  return next;
2308
2476
  });
2309
2477
  };
2310
- const onUp = ()=>{ setDragProp(null); window.removeEventListener("mousemove",onMove); window.removeEventListener("mouseup",onUp); };
2478
+ const onUp = ()=>{ setDragLane(null); window.removeEventListener("mousemove",onMove); window.removeEventListener("mouseup",onUp); };
2311
2479
  window.addEventListener("mousemove",onMove); window.addEventListener("mouseup",onUp);
2312
- },[setSelProp]);
2313
- const majorStep = scaleMs <= 10000 ? 1000 : scaleMs <= 25000 ? 5000 : 10000;
2480
+ },[setSelLane]);
2481
+ const majorStep = scaleMs <= 2000 ? 500 : scaleMs <= 10000 ? 1000 : scaleMs <= 25000 ? 5000 : 10000;
2314
2482
  const minorStep = majorStep / 4;
2315
2483
  const ruler=[];
2316
2484
  for(let ms=0; ms<=scaleMs; ms+=majorStep){
@@ -2321,107 +2489,75 @@
2321
2489
  if(ms%majorStep===0) continue;
2322
2490
  ruler.push(h("span",{key:"t"+ms,className:"tick",style:{left:((ms/scaleMs)*100)+"%"}}));
2323
2491
  }
2324
- return h("div",{className:"tl-tracks"},
2492
+ return h("div",{className:"tl-tracks",ref:tracksRef},
2325
2493
  h("div",{className:"tl-ruler-row"},
2326
- h("div",{className:"tl-ruler-spacer"}),
2494
+ h("div",{className:"tl-ruler-spacer",style:{flexBasis:labelW+"px"}}),
2327
2495
  h("div",{className:"tl-ruler"},...ruler)),
2328
2496
  h("div",{className:"tl-rows",ref:rowsRef},
2329
2497
  ...orderedRows.map(row=>h(PropTrack,{
2330
- key:row.property, property:row.property,
2498
+ key:row.laneId, property:row.property, member:row.member,
2331
2499
  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}),
2500
+ selected:row.laneId===selLane, dragging:row.laneId===dragLane, snap, scaleMs, labelW,
2501
+ onSelect:()=>setSelLane(row.laneId),
2502
+ onReorder:e=>startReorder(row.laneId,e),
2503
+ onDelayChange:ms=>onPropChange(row.laneId,{delayMs:ms}),
2504
+ onDurationChange:ms=>onPropChange(row.laneId,{durationMs:ms}),
2337
2505
  }))),
2338
- h(ScrubZone,{scaleMs}),
2339
- h("div",{className:"tl-playhead-layer"},
2340
- h("div",{className:"tl-playhead",style:{left:(ratio*100)+"%"}},
2341
- h("svg",{className:"tl-playhead-head",viewBox:"0 0 16.666 24",width:"16.666",height:"24",fill:"none"},
2342
- h("path",{d:"M13.666 7.333C13.666 11.609 9.333 15.666 9.333 19.942L9.333 24L7.333 24L7.333 19.942C7.333 15.666 3 11.609 3 7.333C3 4.387 5.387 2 8.333 2C11.279 2 13.666 4.387 13.666 7.333Z",fill:"#1A7AFF"}))),
2343
- ),
2506
+ h("div",{className:cx("tl-col-resizer",colDragging&&"dragging"),style:{left:labelW+"px"},
2507
+ onMouseDown:startColResize,title:"Drag to resize columns","aria-label":"Resize label column",role:"separator"},
2508
+ h("span",{className:"tl-col-resizer-line"})),
2344
2509
  );
2345
2510
  }
2346
2511
 
2347
- function Inspector({entry, selProp, onPropChange, snap}){
2512
+ function Inspector({entry, selLane, onPropChange, snap}){
2348
2513
  const et = entry.effectiveTimings || [];
2349
- const sel = et.find(t=>t.property===selProp) || et[0];
2514
+ const sel = et.find(t=>t.laneId===selLane) || et[0];
2350
2515
  if(!sel) return h("div",{className:"tl-inspector"});
2351
2516
  const cubic = easingToCubic(sel.easing);
2352
2517
  const isSpring = !!sel.spring;
2353
2518
  return h("div",{className:"tl-inspector"},
2354
- h("div",{className:"tl-insp-title"},sel.property),
2519
+ h("div",{className:"tl-insp-title"},
2520
+ sel.member&&h("span",{className:"tl-insp-member"},sel.member),
2521
+ h("span",null,sel.property)),
2355
2522
  h(ValueField,{label:"Duration",value:sel.durationMs,min:0,max:5000,step:25,tokens:DURATION_TOKENS,snap,
2356
2523
  readOnly:isSpring,
2357
2524
  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})}),
2525
+ onChange:v=>onPropChange(sel.laneId,{durationMs:v})}),
2359
2526
  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)}),
2527
+ onChange:v=>onPropChange(sel.laneId,{delayMs:v})}),
2528
+ h(EasingEditor,{easing:sel.easing, cubic, spring:sel.spring, durationMs:sel.durationMs, propKey:sel.laneId,
2529
+ apply:partial=>onPropChange(sel.laneId,partial)}),
2363
2530
  );
2364
2531
  }
2365
2532
 
2366
- function Body({entry, onPropChange, state, play, pause, resume, restart, stop, speed, setSpeed, snap}){
2533
+ function Body({entry, onPropChange, snap}){
2367
2534
  const et = entry.effectiveTimings || [];
2368
- const [selProp, setSelProp] = useState(et[0]?.property ?? null);
2369
- const [zoom, setZoom] = useState(ZOOM_DEFAULT);
2370
- const scaleMs = scaleFromZoom(zoom);
2535
+ const [selLane, setSelLane] = useState(et[0]?.laneId ?? null);
2536
+ // No playback: the real component is the preview. The track scale just
2537
+ // auto-fits the longest lane (duration+delay) with a little headroom so
2538
+ // the static bars stay readable across short and long transitions.
2539
+ const maxEnd = et.reduce((m,t)=>Math.max(m,(t.durationMs||0)+(t.delayMs||0)),0);
2540
+ const scaleMs = Math.max(500, Math.ceil((maxEnd*1.15)/250)*250);
2371
2541
  useEffect(()=>{
2372
- if(et.length && !et.find(t=>t.property===selProp)) setSelProp(et[0]?.property);
2373
- },[et,selProp]);
2542
+ if(et.length && !et.find(t=>t.laneId===selLane)) setSelLane(et[0]?.laneId);
2543
+ },[et,selLane]);
2374
2544
 
2375
2545
  return h("div",{className:"tl-body"},
2376
2546
  h("div",{className:"tl-main"},
2377
- 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}),
2379
- ),
2380
- h(Inspector,{entry,selProp,onPropChange,snap}),
2381
- );
2382
- }
2383
-
2384
- function Transport({state,disabled,onPlay,onPause,onResume,onRestart,onStop,speed,setSpeed,zoom,setZoom}){
2385
- const t = usePreviewTime();
2386
- const spRef = useRef(null);
2387
- const [sp,setSp] = useState(false);
2388
- const playing = state==="playing";
2389
- const onMain = () => playing ? onPause() : state==="paused" ? onResume() : onPlay();
2390
- return h("div",{className:"tl-transport"},
2391
- h("div",{className:"tl-transport-left"},
2392
- h("span",{className:"tl-timecode"}, fmtTimecode(t)),
2393
- h("button",{ref:spRef,className:cx("tl-ghost-btn","tl-speed",sp&&"is-active"),onClick:()=>setSp(v=>!v)},
2394
- fmtSpeed(speed), h("span",{className:"tl-ghost-chev"},h(Ic,{name:"chevron"}))),
2395
- h(Dropdown,{open:sp,onClose:()=>setSp(false),triggerRef:spRef,width:120,align:"left"},
2396
- SPEEDS.map(s=>h(MenuItem,{key:s,active:s===speed,onClick:()=>{setSpeed(s);setSp(false);},
2397
- right:s===speed&&h("span",{className:"tl-menu-check"},h(Ic,{name:"check"}))},fmtSpeed(s)))) ),
2398
- h("div",{className:"tl-transport-center"},
2399
- h("button",{className:"tl-play-btn",disabled,onClick:onMain,title:playing?"Pause":"Play"},
2400
- h("span",{className:"t-icon-swap","data-state":playing?"b":"a"},
2401
- h("span",{className:"t-icon","data-icon":"a"},h(Ic,{name:"play",size:12})),
2402
- h("span",{className:"t-icon","data-icon":"b"},h(Ic,{name:"pause",size:16}))))),
2403
- h("div",{className:"tl-transport-right"},
2404
- h("div",{className:"tl-zoom",title:"Timeline zoom"},
2405
- h("div",{className:"tl-zoom-track","aria-hidden":true}),
2406
- h("input",{type:"range",min:ZOOM_MIN,max:ZOOM_MAX,value:zoom,
2407
- onChange:e=>setZoom(Number(e.target.value))})),
2408
- h("span",{className:"t-tt-wrap"},
2409
- h("button",{className:"tl-icon-btn ghost t-tt-trigger",disabled,"aria-label":"Replay",onClick:onRestart},h(Ic,{name:"restart"})),
2410
- h("span",{className:"t-tt tl-tt-below",role:"tooltip"},"Replay")),
2547
+ h(Tracks,{et,selLane,setSelLane,onPropChange,snap,scaleMs}),
2411
2548
  ),
2549
+ h(Inspector,{entry,selLane,onPropChange,snap}),
2412
2550
  );
2413
2551
  }
2414
2552
 
2415
2553
  function TimelinePanel(){
2416
2554
  const entries=useReg(); const{active,setActiveId}=useActive();
2417
- const{state,play,pause,resume,restart,stop}=usePlayback(); const{setPropOverride}=usePropOverride();
2418
- const{registry,preview}=useContext(TimelineCtx);
2555
+ const{setPropOverride}=usePropOverride();
2556
+ const{registry}=useContext(TimelineCtx);
2419
2557
  const[copied,setCopied]=useState(false);
2420
2558
  const[minimized,setMinimized]=useState(false);
2421
2559
  const[panelHeight,setPanelHeight]=useState(440);
2422
2560
  const[resizing,setResizing]=useState(false);
2423
- const[speed,setSpeed]=useState(1);
2424
- const[loop,setLoop]=useState(false);
2425
2561
  const[snap,setSnap]=useState(true);
2426
2562
  // ── refine ──
2427
2563
  const[refineOpen,setRefineOpen]=useState(false);
@@ -2434,6 +2570,9 @@
2434
2570
  // ── accept (write to source) ──
2435
2571
  const[acceptState,setAcceptState]=useState("idle"); // idle | saving | done | error
2436
2572
  const[acceptError,setAcceptError]=useState(null);
2573
+ // ── grouped scan (agent reads source → Open/Close phases) ──
2574
+ const[groupScanState,setGroupScanState]=useState("idle"); // idle | scanning | done | error
2575
+ const didGroupScanRef=useRef(false);
2437
2576
  const[refineLabel,setRefineLabel]=useState(null);
2438
2577
  const[appliedIds,setAppliedIds]=useState({});
2439
2578
  const[refineMode,setRefineMode]=useState("llm"); // llm (Agent) | deterministic
@@ -2467,8 +2606,11 @@
2467
2606
  const mode=(refineMode==="llm"&&!avail)?"deterministic":refineMode;
2468
2607
  if(mode!==refineMode)setRefineMode(mode);
2469
2608
  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});
2609
+ const et=active.effectiveTimings||[];
2610
+ const timings=et.map(t=>({property:t.property,durationMs:t.durationMs,delayMs:t.delayMs,easing:t.easing}));
2611
+ const selector=(active.bindings&&active.bindings.selector)||(et[0]&&et[0].selector)||null;
2612
+ const{id}=await relayCreateJob({transitionId:active.id,label:active.label,selector,
2613
+ phase:active.phase||null,group:active.groupLabel||null,timings,mode,refineType});
2472
2614
  setRefineJobId(id);
2473
2615
  }catch(e){
2474
2616
  setRefinePhase("error");
@@ -2500,17 +2642,74 @@
2500
2642
  if(p.durationMs!=null)o.durationMs=p.durationMs;
2501
2643
  if(p.delayMs!=null)o.delayMs=p.delayMs;
2502
2644
  if(p.easing!=null)o.easing=p.easing;
2503
- setPropOverride(p.property,o);
2645
+ // map a property patch onto the matching lane(s) — for a grouped phase the
2646
+ // same property can appear on several members, so apply to each.
2647
+ const et=(active&&active.effectiveTimings)||[];
2648
+ let lanes=p.property==="all"?et:et.filter(t=>t.property===p.property);
2649
+ if(p.member)lanes=lanes.filter(t=>(t.member===p.member||t.memberId===p.member));
2650
+ const ids=lanes.length?lanes.map(t=>t.laneId):[p.property];
2651
+ for(const id of ids)setPropOverride(id,o);
2504
2652
  setAppliedIds(prev=>({...prev,[s.id]:true}));
2505
- },[setPropOverride]);
2653
+ },[setPropOverride,active]);
2506
2654
  const applyAllSuggestions=useCallback(()=>{
2507
2655
  for(const s of refineSuggestions){if(!appliedIds[s.id])applySuggestion(s);}
2508
2656
  },[refineSuggestions,appliedIds,applySuggestion]);
2509
2657
  const panelMinH=200;
2510
2658
  const panelMaxH=useCallback(()=>Math.round(window.innerHeight*0.92),[]);
2511
2659
  useEffect(()=>{if(!active&&entries.length>0)setActiveId(entries[0].id);},[active,entries,setActiveId]);
2512
- useEffect(()=>{preview.setRate(speed);},[preview,speed]);
2513
- useEffect(()=>{preview.setLoop(loop);},[preview,loop]);
2660
+ // ── live styling ──────────────────────────────────────────────────────
2661
+ // With playback gone, the real component IS the preview. So whenever a
2662
+ // transition has edits, mirror its effective timings onto the live
2663
+ // element(s) as an inline `transition`, and revert when edits are cleared.
2664
+ // Interacting with the component then animates with the edited values.
2665
+ const liveAppliedRef=useRef(new Map()); // el → {prevInline, css}
2666
+ useEffect(()=>{
2667
+ if(typeof document==="undefined")return;
2668
+ const applied=liveAppliedRef.current;
2669
+ const desired=new Map(); // el → css
2670
+ const add=(els,css)=>{ for(const el of els){ if(!el||(el.closest&&el.closest("[data-timeline-panel]")))continue; desired.set(el,css); } };
2671
+ for(const item of entries){
2672
+ const ov=registry.getPropOverrides(item.id);
2673
+ if(!ov||!Object.keys(ov).length)continue; // only edited transitions go live
2674
+ if(item.kind==="phase"){
2675
+ for(const m of (item.members||[])){
2676
+ if(!m.selector||!m.lanes||!m.lanes.length)continue;
2677
+ let els=[];try{els=Array.from(document.querySelectorAll(m.selector));}catch{}
2678
+ add(els,transitionLanesToCss(m.lanes));
2679
+ }
2680
+ }else{
2681
+ const lanes=item.effectiveTimings||[];
2682
+ if(!lanes.length)continue;
2683
+ const els=((item.bindings&&item.bindings.elements)||[]).map(w=>w.deref&&w.deref()).filter(Boolean);
2684
+ add(els,transitionLanesToCss(lanes));
2685
+ }
2686
+ }
2687
+ // apply new / changed
2688
+ for(const [el,css] of desired){
2689
+ const cur=applied.get(el);
2690
+ if(cur&&cur.css===css)continue;
2691
+ if(!cur){
2692
+ const s=getComputedStyle(el);
2693
+ const z=zipTransitionLists((s.transitionProperty||"").split(","),(s.transitionDuration||"0s").split(","),(s.transitionDelay||"0s").split(","),splitCssValues(s.transitionTimingFunction||"ease"));
2694
+ _TX_LIVE_BASE.set(el,{z});
2695
+ applied.set(el,{prevInline:el.style.transition||"",css});
2696
+ }else applied.set(el,{prevInline:cur.prevInline,css});
2697
+ try{el.style.transition=css;}catch{}
2698
+ }
2699
+ // revert elements that no longer have edits
2700
+ for(const [el,rec] of Array.from(applied)){
2701
+ if(desired.has(el))continue;
2702
+ try{el.style.transition=rec.prevInline;}catch{}
2703
+ _TX_LIVE_BASE.delete(el);
2704
+ applied.delete(el);
2705
+ }
2706
+ },[entries,registry]);
2707
+ // restore everything on unmount
2708
+ useEffect(()=>()=>{
2709
+ const applied=liveAppliedRef.current;
2710
+ for(const [el,rec] of applied){ try{el.style.transition=rec.prevInline;}catch{} _TX_LIVE_BASE.delete(el); }
2711
+ applied.clear();
2712
+ },[]);
2514
2713
  const startResize=useCallback(e=>{
2515
2714
  e.preventDefault();
2516
2715
  const startY=e.clientY; const startH=panelHeight;
@@ -2524,10 +2723,19 @@
2524
2723
  const copyValues=useCallback(()=>{
2525
2724
  if(!active)return;
2526
2725
  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);});
2726
+ const decl=t=>`${t.property} ${formatCssTime(t.durationMs)} ${t.easing} ${formatCssTime(t.delayMs)}`;
2727
+ let text;
2728
+ if(et.some(t=>t.member)){
2729
+ // grouped phase: one transition block per member, labelled
2730
+ const byMember=new Map();
2731
+ 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);}
2732
+ text=[...byMember.values()].map(m=>
2733
+ `/* ${m.member||"element"}${m.selector?" — "+m.selector:""} */\ntransition: ${m.lanes.map(decl).join(",\n ")};`
2734
+ ).join("\n\n");
2735
+ }else{
2736
+ text="transition: "+et.map(decl).join(",\n ")+";";
2737
+ }
2738
+ navigator.clipboard.writeText(text).then(()=>{setCopied(true);setTimeout(()=>setCopied(false),1500);});
2531
2739
  },[active]);
2532
2740
  const resetOverrides=useCallback(()=>{if(active)registry.clearOverride(active.id);},[registry,active]);
2533
2741
  // Accept → send an "apply" job so the agent writes the edited timings into
@@ -2538,7 +2746,9 @@
2538
2746
  setAcceptState("saving");setAcceptError(null);
2539
2747
  try{
2540
2748
  const{id}=await relayCreateJob({kind:"apply",transitionId:active.id,label:active.label,
2541
- selector:active.bindings&&active.bindings.selector,changes});
2749
+ selector:active.bindings&&active.bindings.selector,
2750
+ group:active.groupLabel||null,phase:active.phase||null,component:active.component||null,
2751
+ changes});
2542
2752
  let settled=false;
2543
2753
  for(let i=0;i<240&&!settled;i++){
2544
2754
  await new Promise(r=>setTimeout(r,500));
@@ -2559,6 +2769,47 @@
2559
2769
  },[active]);
2560
2770
  // reset Accept feedback when switching transitions
2561
2771
  useEffect(()=>{setAcceptState("idle");setAcceptError(null);},[active&&active.id]);
2772
+ // Auto-run the grouped scan once, after the flat DOM scan has populated.
2773
+ // The agent reads the source and returns Open/Close phases; if no agent is
2774
+ // live (or the relay is down) we silently keep the flat DOM scan.
2775
+ // Lifecycle is tied to `registry` only (a stable ref), never to
2776
+ // `entries.length`. On dynamic pages the DOM scanner makes entries.length
2777
+ // fluctuate; keying the effect on it tore down the in-flight poll before
2778
+ // the agent answered and the once-guard blocked a restart → permanent
2779
+ // "Grouping…". Here a single run waits for flat entries to appear, fires
2780
+ // one scan job, then polls to completion regardless of DOM churn; only an
2781
+ // actual unmount cancels it.
2782
+ useEffect(()=>{
2783
+ if(didGroupScanRef.current)return;
2784
+ let stopped=false;
2785
+ const run=async()=>{
2786
+ while(!stopped){
2787
+ const flat0=registry.getAll().filter(e=>e.kind!=="phase");
2788
+ if(flat0.length)break;
2789
+ await new Promise(r=>setTimeout(r,300));
2790
+ }
2791
+ if(stopped||didGroupScanRef.current)return;
2792
+ didGroupScanRef.current=true;
2793
+ setGroupScanState("scanning");
2794
+ const flat=registry.getAll().filter(e=>e.kind!=="phase");
2795
+ const raw=flat.map(e=>({label:e.label,selector:e.bindings&&e.bindings.selector,
2796
+ properties:e.properties,
2797
+ timings:(e.baseLanes||[]).map(l=>({property:l.property,durationMs:l.durationMs,delayMs:l.delayMs,easing:l.easing}))}));
2798
+ try{
2799
+ const{id}=await relayCreateJob({kind:"scan",url:location.href,raw});
2800
+ for(let i=0;i<520&&!stopped;i++){
2801
+ await new Promise(r=>setTimeout(r,500));
2802
+ const job=await relayGetJob(id);
2803
+ if(stopped)return;
2804
+ if(job.status==="done"){const groups=(job.result&&job.result.groups)||[];if(groups.length)registry.setGroups(groups);setGroupScanState("done");return;}
2805
+ if(job.status==="error"){setGroupScanState("error");return;}
2806
+ }
2807
+ if(!stopped)setGroupScanState("error");
2808
+ }catch(e){ if(!stopped)setGroupScanState("error"); /* relay down → stay flat */ }
2809
+ };
2810
+ run();
2811
+ return ()=>{stopped=true;};
2812
+ },[registry]);
2562
2813
 
2563
2814
  // whole-component open/close uses the transitions.dev panel reveal:
2564
2815
  // keep the panel mounted while it animates, flip data-open on the next
@@ -2607,11 +2858,11 @@
2607
2858
  h("div",{className:"tl-panel-body"},
2608
2859
  h("div",{className:"tl-panel-main"},
2609
2860
  h(Header,{entries,active,onSelect:setActiveId,onReset:resetOverrides,onCopy:copyValues,copied,
2610
- loop,setLoop,snap,setSnap,onMinimize:()=>setMinimized(true),onRefine:openRefine,refineActive:refineOpen,
2611
- onAccept,acceptState,acceptDisabled:computeChanges(active).length===0,acceptError}),
2861
+ snap,setSnap,onMinimize:()=>setMinimized(true),onRefine:openRefine,refineActive:refineOpen,
2862
+ onAccept,acceptState,acceptDisabled:computeChanges(active).length===0,acceptError,
2863
+ scanning:groupScanState==="scanning"}),
2612
2864
  active
2613
- ?h(Body,{entry:active,onPropChange:(prop,o)=>setPropOverride(prop,o),
2614
- state,play,pause,resume,restart,stop,speed,setSpeed,snap})
2865
+ ?h(Body,{entry:active,onPropChange:(prop,o)=>setPropOverride(prop,o),snap})
2615
2866
  :h("div",{className:"tl-empty"},"Select a transition to inspect and edit it")),
2616
2867
  h(RefinePanel,{open:refineOpen,onClose:()=>setRefineOpen(false),phase:refinePhase,label:refineLabel,
2617
2868
  refineType,onType:changeRefineType,suggestions:refineSuggestions,summary:refineSummary,error:refineError,
@@ -2731,16 +2982,16 @@
2731
2982
  }
2732
2983
 
2733
2984
  function App(){
2734
- const rootRef=useRef(null);const registry=useMemo(()=>new TransitionRegistry(),[]);const preview=useMemo(()=>new PreviewController(),[]);const[activeId,setActiveId]=useState(null);
2735
- useEffect(()=>{const root=rootRef.current??document.body;const scanner=new DomScanner(root,registry);preview.setScanner(scanner);scanner.start();return()=>{scanner.stop();preview.setScanner(null);};},[registry,preview]);
2736
- const ctx=useMemo(()=>({registry,preview,activeId,setActiveId}),[registry,preview,activeId]);
2985
+ const rootRef=useRef(null);const registry=useMemo(()=>new TransitionRegistry(),[]);const[activeId,setActiveId]=useState(null);
2986
+ useEffect(()=>{const root=rootRef.current??document.body;const scanner=new DomScanner(root,registry);scanner.start();return()=>{scanner.stop();};},[registry]);
2987
+ const ctx=useMemo(()=>({registry,activeId,setActiveId}),[registry,activeId]);
2737
2988
  // Demo-only tweak controls: hidden by default. Append ?controls to the URL
2738
2989
  // to show them for testing. (This whole block is below the inject CUT_MARKER,
2739
2990
  // so it never ships in the injected build.)
2740
2991
  const showControls=(()=>{try{return new URLSearchParams(location.search).has("controls");}catch(e){return false;}})();
2741
2992
  return h(TimelineCtx.Provider,{value:ctx},
2742
2993
  showControls&&h(PanelControls),
2743
- h("div",{ref:rootRef,className:"demo-root"},h("div",{className:"demo-header"},h("h1",null,"Timeline Inspector \u2014 Demo"),h("p",null,"CSS transitions are automatically detected. Select one below, drag the bars to edit timing, then Play.")),
2994
+ h("div",{ref:rootRef,className:"demo-root"},h("div",{className:"demo-header"},h("h1",null,"Timeline Inspector \u2014 Demo"),h("p",null,"CSS transitions are automatically detected. Select one below, drag the bars to edit timing, then interact with the component itself to preview it.")),
2744
2995
  h("div",{className:"demo-grid"},h(BoxResize),h(BoxOpacity),h(BoxSlide),h(BoxColor))),
2745
2996
  h(TimelinePanel));
2746
2997
  }