transitions-refine 0.2.0 → 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/README.md CHANGED
@@ -4,7 +4,9 @@ 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.
7
+ There's **no play button or scrubber** the running component *is* the preview. Any edit (a dragged bar, an inspector tweak, or an applied suggestion) is written straight onto the live element as an inline `transition`, so you see it the next time you trigger the transition (open the dropdown, hover the card, ). Reset reverts the element to its source.
8
+
9
+ 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. If no agent is live, the panel falls back to the flat DOM scan with no regression.
8
10
 
9
11
  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.
10
12
 
@@ -58,7 +60,7 @@ The CLI must have the `transitions-dev` skill available (the prompt tells it to
58
60
 
59
61
  ## Grouped scan — Open / Close phases
60
62
 
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.
63
+ 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. Editing any lane applies **live** as an inline `transition` on that member element, so triggering the component yourself (open/close it) previews the whole phase with your values; **Accept** writes back to the correct state rule (`.is-open` vs `.is-closing`) per member.
62
64
 
63
65
  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
66
 
@@ -71,7 +73,7 @@ Grouping needs the agent (`/refine live`, `--llm`, or `REFINE_AGENT_CMD`); with
71
73
 
72
74
  The **Accept** button (next to Refine) is enabled whenever the selected transition has unsaved changes — whether you edited the bars/easing by hand or applied a Refine suggestion. Pressing it sends an **apply job** to the relay: the agent finds where that transition is declared in your source (plain CSS, CSS Modules, styled-components/emotion, Tailwind, or inline styles), edits only the changed timings, and reports back. The button shows a spinner while saving and flips to **Done** on success.
73
75
 
74
- Like Replace, Accept needs the agent — run `/refine live` (or `--llm` / `REFINE_AGENT_CMD`). The deterministic answerer can't edit files. Play preview also no longer needs you to trigger the transition first: it recovers the end-state from your stylesheets (hover/focus pseudo-states and toggled classes like `.modal.open`), so opening the panel and pressing Play just works.
76
+ Like Replace, Accept needs the agent — run `/refine live` (or `--llm` / `REFINE_AGENT_CMD`). The deterministic answerer can't edit files.
75
77
 
76
78
  ## Pieces
77
79
 
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; }
@@ -332,7 +288,7 @@
332
288
  .tl-prop-prop { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
333
289
  .tl-insp-member { display: inline-block; font-size: 11px; font-weight: 600; line-height: 14px; padding: 1px 6px; border-radius: 60px;
334
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; }
335
- /* 16px left inset of the plot area (kept in sync with ruler/playhead/scrub) */
291
+ /* 16px left inset of the plot area (kept in sync with the ruler) */
336
292
  .tl-prop-track { position: relative; flex: 1; user-select: none; margin-left: 16px; }
337
293
  /* timeline line — capsule (fully rounded), Figma #eeeeef default / #e9e9e9 hover */
338
294
  .tl-bar { position: absolute; top: 50%; height: 24px; transform: translateY(-50%); background: var(--c-track);
@@ -352,13 +308,16 @@
352
308
  .tl-bar-grip { position: absolute; top: 0; bottom: 0; width: 10px; cursor: ew-resize; z-index: 2; }
353
309
  .tl-bar-grip.left { left: -5px; } .tl-bar-grip.right { right: -5px; }
354
310
 
355
- .tl-playhead-layer { position: absolute; top: 14px; bottom: 0; left: 166px; right: 0;
356
- pointer-events: none; z-index: 4; }
357
- .tl-playhead { position: absolute; top: 0; bottom: 0; width: 2px; background: #1A7AFF; transform: translateX(-1px); }
358
- .tl-playhead-head { position: absolute; top: -6px; left: 50%; transform: translateX(-50%); width: 16.666px; height: 24px;
359
- overflow: visible; filter: drop-shadow(0 1px 3px rgba(0,0,0,.25)); }
360
- .tl-scrub-zone { position: absolute; top: 0; height: 30px; left: 166px; right: 0;
361
- 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; } }
362
321
 
363
322
  /* ── value field (slider + input) — exact Figma "Value slider and input" ── */
364
323
  .tl-field-wrap { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
@@ -1019,6 +978,13 @@
1019
978
  if(cur)parts.push(cur); return parts;
1020
979
  }
1021
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();
1022
988
 
1023
989
  const EASING_PRESETS = [
1024
990
  { keyword:"linear", cubic:[0,0,1,1] }, { keyword:"ease", cubic:[0.25,0.1,0.25,1] },
@@ -1205,6 +1171,7 @@
1205
1171
  });
1206
1172
  const item={id,kind:"phase",groupId:g.id,groupLabel:g.label||g.id,component:g.component||null,
1207
1173
  phase:ph.phase||null,phaseLabel:ph.label||ph.phase||"Phase",
1174
+ stateTarget:ph.stateTarget||null,fromState:(ph.fromState??null),toState:(ph.toState??null),
1208
1175
  label:(g.label||g.id)+" · "+(ph.label||ph.phase||"Phase"),durationMs:repDur,
1209
1176
  members,effectiveTimings:effTimings,baseLanes};
1210
1177
  this.effectiveCache.set(id,item);
@@ -1364,6 +1331,26 @@
1364
1331
  return {from:f,to:t};
1365
1332
  }
1366
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
+ }
1353
+
1367
1354
  class PreviewController {
1368
1355
  state="idle"; listeners=new Set(); cleanups=[]; animations=[]; progressListeners=new Set(); _rafId=null; scanner=null; _gen=0;
1369
1356
  rate=1; loop=false; _current=null;
@@ -1400,20 +1387,81 @@
1400
1387
  this.stop();this._gen++;const gen=this._gen;
1401
1388
  this._pendingSeek=seekMs;
1402
1389
  if(this.scanner)this.scanner.pause();
1403
- const targets=this._targets(entry);
1404
- if(!targets.length)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);
1405
1394
  this.animations=[];
1406
- for(const {el,et,toState} of targets){
1407
- const restore=this._arm(el,et,toState);
1408
- requestAnimationFrame(()=>{if(this._gen!==gen)return;const running=el.getAnimations();
1409
- for(const a of running){a.pause();a.playbackRate=this.rate;this.animations.push(a);}
1410
- const t=this._pendingSeek??seekMs;
1411
- if(t!=null){for(const a of this.animations){try{a.currentTime=t;}catch{}}this._ep(t);}
1412
- });
1413
- 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
+ });
1414
1401
  this._setState("paused");
1415
1402
  if(seekMs!=null)this._ep(seekMs);
1416
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
+ }
1417
1465
  seek(timeMs){
1418
1466
  if(this.state==="idle")return;
1419
1467
  if(this.state==="playing"){for(const a of this.animations){try{a.pause();}catch{}}this._stopPL();this._setState("paused");}
@@ -1460,17 +1508,17 @@
1460
1508
  return ()=>{try{el.style.cssText=saved;}catch{}};
1461
1509
  }
1462
1510
  _playCss(entry,gen){
1463
- const targets=this._targets(entry);
1511
+ const armed=this._armAll(entry);
1512
+ const els=armed.els;
1464
1513
  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);
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();
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();}});
1473
- this.cleanups.push(restore);}}
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
+ });}
1474
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);}
1475
1523
  _stopPL(){if(this._rafId!==null){cancelAnimationFrame(this._rafId);this._rafId=null;}}
1476
1524
  _ep(p){for(const fn of this.progressListeners)fn(p);}
@@ -1482,7 +1530,7 @@
1482
1530
  observer=null;rafId=null;running=false;paused=false;
1483
1531
  constructor(root,reg){this.root=root;this.registry=reg;}
1484
1532
  pause(){this.paused=true;} unpause(){this.paused=false;this._sched();}
1485
- 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"]});}
1486
1534
  stop(){this.running=false;this.observer?.disconnect();this.observer=null;if(this.rafId!==null){cancelAnimationFrame(this.rafId);this.rafId=null;}}
1487
1535
  _sched(){if(this.rafId!==null)return;this.rafId=requestAnimationFrame(()=>{this.rafId=null;if(this.running&&!this.paused)this.scan();});}
1488
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()));}
@@ -1494,7 +1542,11 @@
1494
1542
  const durs=(s.transitionDuration||"0s").split(",");
1495
1543
  const dels=(s.transitionDelay||"0s").split(",");
1496
1544
  const eass=splitCssValues(s.transitionTimingFunction||"ease");
1497
- 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; }
1498
1550
  if(!z.length||z.every(x=>x.durationMs===0&&x.delayMs===0))return;
1499
1551
  const pDur=z[0].durationMs,pDel=z[0].delayMs,pEase=z[0].easing;
1500
1552
  const allP=z.map(x=>x.property);
@@ -1512,19 +1564,11 @@
1512
1564
  const TimelineCtx = createContext(null);
1513
1565
  function useReg(){const{registry}=useContext(TimelineCtx);return useSyncExternalStore(useCallback(cb=>registry.subscribe(cb),[registry]),useCallback(()=>registry.getAll(),[registry]),useCallback(()=>registry.getAll(),[registry]));}
1514
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};}
1515
- 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]));
1516
- 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])};}
1517
1567
  function usePropOverride(){const{registry,activeId}=useContext(TimelineCtx);return{setPropOverride:useCallback((prop,o)=>{if(activeId)registry.setPropOverride(activeId,prop,o);},[registry,activeId])};}
1518
1568
 
1519
1569
  // ── components ──
1520
- const BASE_SCALE_MS = 5000;
1521
- const ZOOM_MIN = 25;
1522
- const ZOOM_MAX = 400;
1523
- const ZOOM_DEFAULT = 100;
1524
- const scaleFromZoom = zoom => Math.round(BASE_SCALE_MS * 100 / zoom);
1525
1570
  const LABEL_W = 150;
1526
1571
  const CLOSE_MS = 150;
1527
- const SPEEDS = [0.25, 0.5, 1, 2];
1528
1572
  const DURATION_TOKENS = [
1529
1573
  {label:"Duration-fast", ms:150, usage:"Quick state changes — hovers, toggles, button presses, dropdown & modal close, text swaps."},
1530
1574
  {label:"Duration-medium", ms:250, usage:"Standard UI motion — icon swaps, dropdown & modal open, sliding tabs, page slides."},
@@ -1538,8 +1582,6 @@
1538
1582
  {label:"Long", ms:300, usage:"Pronounced wait — use sparingly to draw attention."},
1539
1583
  ];
1540
1584
  function cx(...a){ return a.filter(Boolean).join(" "); }
1541
- 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); }
1542
- function fmtSpeed(s){ return s+"x"; }
1543
1585
 
1544
1586
  // ── icons ──
1545
1587
  // Exact SVGs exported from the Logram ❖ Design System Figma file (no recreations).
@@ -1574,13 +1616,6 @@
1574
1616
  style:{display:"block"},dangerouslySetInnerHTML:{__html:ic.svg}});
1575
1617
  }
1576
1618
 
1577
- function usePreviewTime(){
1578
- const{preview}=useContext(TimelineCtx);
1579
- const[ms,setMs]=useState(0);
1580
- useEffect(()=>preview.onProgress(setMs),[preview]);
1581
- return ms;
1582
- }
1583
-
1584
1619
  // portaled, origin-aware dropdown surface (transitions.dev menu-dropdown)
1585
1620
  function Dropdown({open,onClose,triggerRef,width,align,children}){
1586
1621
  const ref=useRef(null);
@@ -1897,7 +1932,7 @@
1897
1932
  return out;
1898
1933
  }
1899
1934
 
1900
- function Header({entries,active,onSelect,onReset,onCopy,copied,loop,setLoop,snap,setSnap,onMinimize,onRefine,refineActive,onAccept,acceptState,acceptDisabled,acceptError,scanning}){
1935
+ function Header({entries,active,onSelect,onReset,onCopy,copied,snap,setSnap,onMinimize,onRefine,refineActive,onAccept,acceptState,acceptDisabled,acceptError,scanning}){
1901
1936
  const[pick,setPick]=useState(false);
1902
1937
  const[setg,setSetg]=useState(false);
1903
1938
  const pickRef=useRef(null), gearRef=useRef(null);
@@ -1936,7 +1971,6 @@
1936
1971
  scanning?"Grouping…":entries.length+" transition"+(entries.length===1?"":"s")+" found"),
1937
1972
  h("button",{ref:gearRef,className:cx("tl-icon-btn",setg&&"is-active"),title:"Settings",onClick:()=>setSetg(v=>!v)},h(Ic,{name:"gear"})),
1938
1973
  h(Dropdown,{open:setg,onClose:()=>setSetg(false),triggerRef:gearRef,width:210,align:"right"},
1939
- h(MenuItem,{onClick:()=>setLoop(v=>!v),right:loop&&h("span",{className:"tl-menu-check"},h(Ic,{name:"check"}))},"Loop playback"),
1940
1974
  h(MenuItem,{onClick:()=>setSnap(v=>!v),right:snap&&h("span",{className:"tl-menu-check"},h(Ic,{name:"check"}))},"Snap to grid")),
1941
1975
  h("span",{className:"t-tt-wrap"},
1942
1976
  h("button",{className:"tl-sec-btn t-tt-trigger",disabled:!active,onClick:onReset},"Reset"),
@@ -1977,7 +2011,7 @@
1977
2011
  );
1978
2012
  }
1979
2013
 
1980
- function PropTrack({property, member, 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}){
1981
2015
  const trackRef=useRef(null);
1982
2016
  const grid = snap ? 25 : 1;
1983
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]);
@@ -2005,7 +2039,7 @@
2005
2039
 
2006
2040
  const delPct=(delayMs/scaleMs)*100; const durPct=(durationMs/scaleMs)*100;
2007
2041
  return h("div",{className:cx("tl-prop-row",selected&&"selected",dragging&&"reordering",lockDuration&&"is-spring"),onClick:onSelect},
2008
- h("div",{className:"tl-prop-head"},
2042
+ h("div",{className:"tl-prop-head",style:labelW!=null?{flexBasis:labelW+"px"}:undefined},
2009
2043
  h("span",{className:"tl-prop-grip",title:"Drag to reorder",onMouseDown:onReorder},h(Ic,{name:"dots"})),
2010
2044
  h("span",{className:"tl-prop-label"},
2011
2045
  member&&h("span",{className:"tl-prop-member"},member),
@@ -2388,50 +2422,31 @@
2388
2422
  );
2389
2423
  }
2390
2424
 
2391
- function ScrubZone({scaleMs}){
2392
- const { preview, registry, activeId } = useContext(TimelineCtx);
2393
- const areaRef = useRef(null);
2394
- const startedRef = useRef(false);
2395
-
2396
- const pxToMs = useCallback(clientX=>{
2397
- if(!areaRef.current) return 0;
2398
- const rect = areaRef.current.getBoundingClientRect();
2399
- const ratio = Math.max(0, Math.min((clientX - rect.left) / rect.width, 1));
2400
- return ratio * scaleMs;
2401
- },[scaleMs]);
2402
-
2403
- const doSeek = useCallback(ms=>{
2404
- if(preview.getState()==="idle" && !startedRef.current){
2405
- if(!activeId) return;
2406
- const entry = registry.getEffective(activeId);
2407
- if(!entry) return;
2408
- startedRef.current = true;
2409
- preview.playPaused(entry, ms);
2410
- } else {
2411
- preview.seek(ms);
2412
- }
2413
- },[preview,registry,activeId]);
2414
-
2415
- const startScrub = useCallback(e=>{
2416
- e.preventDefault();
2417
- startedRef.current = false;
2418
- doSeek(pxToMs(e.clientX));
2419
- const onMove = e2 => doSeek(pxToMs(e2.clientX));
2420
- const onUp = () => { startedRef.current = false; window.removeEventListener("mousemove",onMove); window.removeEventListener("mouseup",onUp); };
2421
- window.addEventListener("mousemove",onMove);
2422
- window.addEventListener("mouseup",onUp);
2423
- },[doSeek,pxToMs]);
2424
-
2425
- return h("div",{className:"tl-scrub-zone",ref:areaRef,onMouseDown:startScrub});
2426
- }
2427
-
2428
2425
  function Tracks({et, selLane, setSelLane, onPropChange, snap, scaleMs}){
2429
- const t = usePreviewTime();
2430
- const ratio = Math.min(t / scaleMs, 1);
2431
2426
  const ROW_H = 48;
2432
2427
  const rowsRef = useRef(null);
2428
+ const tracksRef = useRef(null);
2433
2429
  const [order, setOrder] = useState([]);
2434
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]);
2435
2450
  const laneKey = et.map(x=>x.laneId).join("|");
2436
2451
  useEffect(()=>{
2437
2452
  const ids = et.map(x=>x.laneId);
@@ -2463,7 +2478,7 @@
2463
2478
  const onUp = ()=>{ setDragLane(null); window.removeEventListener("mousemove",onMove); window.removeEventListener("mouseup",onUp); };
2464
2479
  window.addEventListener("mousemove",onMove); window.addEventListener("mouseup",onUp);
2465
2480
  },[setSelLane]);
2466
- const majorStep = scaleMs <= 10000 ? 1000 : scaleMs <= 25000 ? 5000 : 10000;
2481
+ const majorStep = scaleMs <= 2000 ? 500 : scaleMs <= 10000 ? 1000 : scaleMs <= 25000 ? 5000 : 10000;
2467
2482
  const minorStep = majorStep / 4;
2468
2483
  const ruler=[];
2469
2484
  for(let ms=0; ms<=scaleMs; ms+=majorStep){
@@ -2474,26 +2489,23 @@
2474
2489
  if(ms%majorStep===0) continue;
2475
2490
  ruler.push(h("span",{key:"t"+ms,className:"tick",style:{left:((ms/scaleMs)*100)+"%"}}));
2476
2491
  }
2477
- return h("div",{className:"tl-tracks"},
2492
+ return h("div",{className:"tl-tracks",ref:tracksRef},
2478
2493
  h("div",{className:"tl-ruler-row"},
2479
- h("div",{className:"tl-ruler-spacer"}),
2494
+ h("div",{className:"tl-ruler-spacer",style:{flexBasis:labelW+"px"}}),
2480
2495
  h("div",{className:"tl-ruler"},...ruler)),
2481
2496
  h("div",{className:"tl-rows",ref:rowsRef},
2482
2497
  ...orderedRows.map(row=>h(PropTrack,{
2483
2498
  key:row.laneId, property:row.property, member:row.member,
2484
2499
  delayMs:row.delayMs, durationMs:row.durationMs, lockDuration:!!row.spring,
2485
- selected:row.laneId===selLane, dragging:row.laneId===dragLane, snap, scaleMs,
2500
+ selected:row.laneId===selLane, dragging:row.laneId===dragLane, snap, scaleMs, labelW,
2486
2501
  onSelect:()=>setSelLane(row.laneId),
2487
2502
  onReorder:e=>startReorder(row.laneId,e),
2488
2503
  onDelayChange:ms=>onPropChange(row.laneId,{delayMs:ms}),
2489
2504
  onDurationChange:ms=>onPropChange(row.laneId,{durationMs:ms}),
2490
2505
  }))),
2491
- h(ScrubZone,{scaleMs}),
2492
- h("div",{className:"tl-playhead-layer"},
2493
- h("div",{className:"tl-playhead",style:{left:(ratio*100)+"%"}},
2494
- h("svg",{className:"tl-playhead-head",viewBox:"0 0 16.666 24",width:"16.666",height:"24",fill:"none"},
2495
- 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"}))),
2496
- ),
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"})),
2497
2509
  );
2498
2510
  }
2499
2511
 
@@ -2518,65 +2530,34 @@
2518
2530
  );
2519
2531
  }
2520
2532
 
2521
- function Body({entry, onPropChange, state, play, pause, resume, restart, stop, speed, setSpeed, snap}){
2533
+ function Body({entry, onPropChange, snap}){
2522
2534
  const et = entry.effectiveTimings || [];
2523
2535
  const [selLane, setSelLane] = useState(et[0]?.laneId ?? null);
2524
- const [zoom, setZoom] = useState(ZOOM_DEFAULT);
2525
- const scaleMs = scaleFromZoom(zoom);
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);
2526
2541
  useEffect(()=>{
2527
2542
  if(et.length && !et.find(t=>t.laneId===selLane)) setSelLane(et[0]?.laneId);
2528
2543
  },[et,selLane]);
2529
2544
 
2530
2545
  return h("div",{className:"tl-body"},
2531
2546
  h("div",{className:"tl-main"},
2532
- h(Transport,{state,disabled:!entry,onPlay:play,onPause:pause,onResume:resume,onRestart:restart,onStop:stop,speed,setSpeed,zoom,setZoom}),
2533
2547
  h(Tracks,{et,selLane,setSelLane,onPropChange,snap,scaleMs}),
2534
2548
  ),
2535
2549
  h(Inspector,{entry,selLane,onPropChange,snap}),
2536
2550
  );
2537
2551
  }
2538
2552
 
2539
- function Transport({state,disabled,onPlay,onPause,onResume,onRestart,onStop,speed,setSpeed,zoom,setZoom}){
2540
- const t = usePreviewTime();
2541
- const spRef = useRef(null);
2542
- const [sp,setSp] = useState(false);
2543
- const playing = state==="playing";
2544
- const onMain = () => playing ? onPause() : state==="paused" ? onResume() : onPlay();
2545
- return h("div",{className:"tl-transport"},
2546
- h("div",{className:"tl-transport-left"},
2547
- h("span",{className:"tl-timecode"}, fmtTimecode(t)),
2548
- h("button",{ref:spRef,className:cx("tl-ghost-btn","tl-speed",sp&&"is-active"),onClick:()=>setSp(v=>!v)},
2549
- fmtSpeed(speed), h("span",{className:"tl-ghost-chev"},h(Ic,{name:"chevron"}))),
2550
- h(Dropdown,{open:sp,onClose:()=>setSp(false),triggerRef:spRef,width:120,align:"left"},
2551
- SPEEDS.map(s=>h(MenuItem,{key:s,active:s===speed,onClick:()=>{setSpeed(s);setSp(false);},
2552
- right:s===speed&&h("span",{className:"tl-menu-check"},h(Ic,{name:"check"}))},fmtSpeed(s)))) ),
2553
- h("div",{className:"tl-transport-center"},
2554
- h("button",{className:"tl-play-btn",disabled,onClick:onMain,title:playing?"Pause":"Play"},
2555
- h("span",{className:"t-icon-swap","data-state":playing?"b":"a"},
2556
- h("span",{className:"t-icon","data-icon":"a"},h(Ic,{name:"play",size:12})),
2557
- h("span",{className:"t-icon","data-icon":"b"},h(Ic,{name:"pause",size:16}))))),
2558
- h("div",{className:"tl-transport-right"},
2559
- h("div",{className:"tl-zoom",title:"Timeline zoom"},
2560
- h("div",{className:"tl-zoom-track","aria-hidden":true}),
2561
- h("input",{type:"range",min:ZOOM_MIN,max:ZOOM_MAX,value:zoom,
2562
- onChange:e=>setZoom(Number(e.target.value))})),
2563
- h("span",{className:"t-tt-wrap"},
2564
- h("button",{className:"tl-icon-btn ghost t-tt-trigger",disabled,"aria-label":"Replay",onClick:onRestart},h(Ic,{name:"restart"})),
2565
- h("span",{className:"t-tt tl-tt-below",role:"tooltip"},"Replay")),
2566
- ),
2567
- );
2568
- }
2569
-
2570
2553
  function TimelinePanel(){
2571
2554
  const entries=useReg(); const{active,setActiveId}=useActive();
2572
- const{state,play,pause,resume,restart,stop}=usePlayback(); const{setPropOverride}=usePropOverride();
2573
- const{registry,preview}=useContext(TimelineCtx);
2555
+ const{setPropOverride}=usePropOverride();
2556
+ const{registry}=useContext(TimelineCtx);
2574
2557
  const[copied,setCopied]=useState(false);
2575
2558
  const[minimized,setMinimized]=useState(false);
2576
2559
  const[panelHeight,setPanelHeight]=useState(440);
2577
2560
  const[resizing,setResizing]=useState(false);
2578
- const[speed,setSpeed]=useState(1);
2579
- const[loop,setLoop]=useState(false);
2580
2561
  const[snap,setSnap]=useState(true);
2581
2562
  // ── refine ──
2582
2563
  const[refineOpen,setRefineOpen]=useState(false);
@@ -2676,8 +2657,59 @@
2676
2657
  const panelMinH=200;
2677
2658
  const panelMaxH=useCallback(()=>Math.round(window.innerHeight*0.92),[]);
2678
2659
  useEffect(()=>{if(!active&&entries.length>0)setActiveId(entries[0].id);},[active,entries,setActiveId]);
2679
- useEffect(()=>{preview.setRate(speed);},[preview,speed]);
2680
- 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
+ },[]);
2681
2713
  const startResize=useCallback(e=>{
2682
2714
  e.preventDefault();
2683
2715
  const startY=e.clientY; const startH=panelHeight;
@@ -2740,11 +2772,24 @@
2740
2772
  // Auto-run the grouped scan once, after the flat DOM scan has populated.
2741
2773
  // The agent reads the source and returns Open/Close phases; if no agent is
2742
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.
2743
2782
  useEffect(()=>{
2744
- if(didGroupScanRef.current||!entries.length)return;
2745
- didGroupScanRef.current=true;
2746
- let cancelled=false;
2747
- (async()=>{
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;
2748
2793
  setGroupScanState("scanning");
2749
2794
  const flat=registry.getAll().filter(e=>e.kind!=="phase");
2750
2795
  const raw=flat.map(e=>({label:e.label,selector:e.bindings&&e.bindings.selector,
@@ -2752,18 +2797,19 @@
2752
2797
  timings:(e.baseLanes||[]).map(l=>({property:l.property,durationMs:l.durationMs,delayMs:l.delayMs,easing:l.easing}))}));
2753
2798
  try{
2754
2799
  const{id}=await relayCreateJob({kind:"scan",url:location.href,raw});
2755
- for(let i=0;i<240&&!cancelled;i++){
2800
+ for(let i=0;i<520&&!stopped;i++){
2756
2801
  await new Promise(r=>setTimeout(r,500));
2757
2802
  const job=await relayGetJob(id);
2758
- if(cancelled)return;
2803
+ if(stopped)return;
2759
2804
  if(job.status==="done"){const groups=(job.result&&job.result.groups)||[];if(groups.length)registry.setGroups(groups);setGroupScanState("done");return;}
2760
2805
  if(job.status==="error"){setGroupScanState("error");return;}
2761
2806
  }
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]);
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]);
2767
2813
 
2768
2814
  // whole-component open/close uses the transitions.dev panel reveal:
2769
2815
  // keep the panel mounted while it animates, flip data-open on the next
@@ -2812,12 +2858,11 @@
2812
2858
  h("div",{className:"tl-panel-body"},
2813
2859
  h("div",{className:"tl-panel-main"},
2814
2860
  h(Header,{entries,active,onSelect:setActiveId,onReset:resetOverrides,onCopy:copyValues,copied,
2815
- loop,setLoop,snap,setSnap,onMinimize:()=>setMinimized(true),onRefine:openRefine,refineActive:refineOpen,
2861
+ snap,setSnap,onMinimize:()=>setMinimized(true),onRefine:openRefine,refineActive:refineOpen,
2816
2862
  onAccept,acceptState,acceptDisabled:computeChanges(active).length===0,acceptError,
2817
2863
  scanning:groupScanState==="scanning"}),
2818
2864
  active
2819
- ?h(Body,{entry:active,onPropChange:(prop,o)=>setPropOverride(prop,o),
2820
- state,play,pause,resume,restart,stop,speed,setSpeed,snap})
2865
+ ?h(Body,{entry:active,onPropChange:(prop,o)=>setPropOverride(prop,o),snap})
2821
2866
  :h("div",{className:"tl-empty"},"Select a transition to inspect and edit it")),
2822
2867
  h(RefinePanel,{open:refineOpen,onClose:()=>setRefineOpen(false),phase:refinePhase,label:refineLabel,
2823
2868
  refineType,onType:changeRefineType,suggestions:refineSuggestions,summary:refineSummary,error:refineError,
@@ -2937,16 +2982,16 @@
2937
2982
  }
2938
2983
 
2939
2984
  function App(){
2940
- const rootRef=useRef(null);const registry=useMemo(()=>new TransitionRegistry(),[]);const preview=useMemo(()=>new PreviewController(),[]);const[activeId,setActiveId]=useState(null);
2941
- 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]);
2942
- 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]);
2943
2988
  // Demo-only tweak controls: hidden by default. Append ?controls to the URL
2944
2989
  // to show them for testing. (This whole block is below the inject CUT_MARKER,
2945
2990
  // so it never ships in the injected build.)
2946
2991
  const showControls=(()=>{try{return new URLSearchParams(location.search).has("controls");}catch(e){return false;}})();
2947
2992
  return h(TimelineCtx.Provider,{value:ctx},
2948
2993
  showControls&&h(PanelControls),
2949
- 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.")),
2950
2995
  h("div",{className:"demo-grid"},h(BoxResize),h(BoxOpacity),h(BoxSlide),h(BoxColor))),
2951
2996
  h(TimelinePanel));
2952
2997
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "transitions-refine",
3
- "version": "0.2.0",
3
+ "version": "0.3.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/inject.mjs CHANGED
@@ -86,15 +86,13 @@ function buildEpilogue(css) {
86
86
 
87
87
  function InjectedRoot(){
88
88
  const registry = useMemo(() => new TransitionRegistry(), []);
89
- const preview = useMemo(() => new PreviewController(), []);
90
89
  const [activeId, setActiveId] = useState(null);
91
90
  useEffect(() => {
92
91
  const scanner = new DomScanner(document.body, registry);
93
- preview.setScanner(scanner);
94
92
  scanner.start();
95
- return () => { scanner.stop(); preview.setScanner(null); };
96
- }, [registry, preview]);
97
- const ctx = useMemo(() => ({ registry, preview, activeId, setActiveId }), [registry, preview, activeId]);
93
+ return () => { scanner.stop(); };
94
+ }, [registry]);
95
+ const ctx = useMemo(() => ({ registry, activeId, setActiveId }), [registry, activeId]);
98
96
  return h(TimelineCtx.Provider, { value: ctx }, h(TimelinePanel));
99
97
  }
100
98
 
package/server/relay.mjs CHANGED
@@ -241,10 +241,18 @@ function buildScanPrompt(job) {
241
241
  "Steps:",
242
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
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.",
244
+ "3. PHASE STATE how the phase is driven (REQUIRED for playback to work). For each phase provide:",
245
+ " - `stateTarget`: a CSS selector for the ONE element whose class/attribute is toggled to drive the whole phase (e.g. the dropdown root, the `.modal`, the element with `[data-open]`). It MUST resolve in the live DOM RIGHT NOW, in any state — so it must NOT itself contain the toggled state (write `.t-morph`, never `.t-morph[data-open=\"true\"]`).",
246
+ " - `fromState` and `toState`: the class/attribute on `stateTarget` at the START and END of this phase, as a token: a class `\".is-open\"`, an attribute `\"[data-open=\\\"true\\\"]\"`, or `null`/`\"\"` for the base/no-class state. OPEN usually goes base→open (`fromState:null`, `toState:\".is-open\"`); CLOSE goes open→base (`fromState:\".is-open\"`, `toState:null`). Get the DIRECTION right — open must animate into the open look, close must animate back out.",
247
+ "4. For each phase, list its MEMBER elements (panel, backdrop, the staggered items…). Give each member a stable `id`, a human `label`, a CSS `selector`, and its real `propertyTimings`. The member `selector` MUST resolve in the live DOM RIGHT NOW regardless of phase — use the BASE element selector and do NOT bake the phase's toggled class/attribute into it (write `.t-morph .t-morph-plus`, never `.t-morph[data-open=\"true\"] .t-morph-plus`). The toggled state belongs only in the phase's `stateTarget`/`toState`.",
248
+ "5. TIMINGS MUST BE EXACT AND PER-PROPERTY. This is the most common mistake — do not make it:",
249
+ " - List one `propertyTimings` entry per animated property. Read EACH property's own duration/delay/easing from the shorthand `transition:` list (or the property-specific longhand). Do NOT copy one property's duration onto the others, and do NOT use the phase's longest/representative duration for every lane.",
250
+ " - Resolve CSS custom properties (e.g. `var(--morph-fade-dur)`) to concrete numbers by following the `:root`/scope where they're defined; convert `s`→ms (`0.25s`→250). Never emit a `var(...)` or a guess.",
251
+ " - It is normal and expected for properties within one phase to differ (e.g. opacity/filter 200ms but transform 350ms). If every property in a phase ends up identical, re-read the source — you probably collapsed them by mistake.",
252
+ " - Open and close usually have DIFFERENT durations/easings; report each from its own rule.",
245
253
  "",
246
254
  "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"}]}]}]}]}',
255
+ '{"summary":"Grouped 3 components.","groups":[{"id":"dropdown","label":"Dropdown","component":"src/Dropdown.tsx","phases":[{"id":"dropdown:open","phase":"open","label":"Open","stateTarget":".dropdown","fromState":null,"toState":".is-open","members":[{"id":"panel","label":"Panel","selector":".dropdown .dropdown-panel","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","stateTarget":".dropdown","fromState":".is-open","toState":null,"members":[{"id":"panel","label":"Panel","selector":".dropdown .dropdown-panel","propertyTimings":[{"property":"opacity","durationMs":150,"delayMs":0,"easing":"ease-in"},{"property":"transform","durationMs":150,"delayMs":0,"easing":"ease-in"}]}]}]}]}',
248
256
  "If you cannot confidently group anything, return an empty groups array with a short summary; the panel keeps its flat DOM scan.",
249
257
  ].join("\n");
250
258
  }