transitions-refine 0.3.26 → 0.3.27

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
@@ -2443,11 +2443,25 @@
2443
2443
  }
2444
2444
  return out;
2445
2445
  }
2446
- _applyOverrides(baseLanes,po){
2447
- 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,
2446
+ _applyOverrides(baseLanes,po,memberFilter){
2447
+ const out=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,
2448
2448
  // value overrides (scale/blur) come only from applied refine suggestions;
2449
2449
  // they carry their own from-value + backing var so Accept + live preview work.
2450
- scale:o.scale??t.scale,blur:o.blur??t.blur,varName:o.varName??t.varName,scaleFrom:o.scaleFrom??t.scaleFrom,blurFrom:o.blurFrom??t.blurFrom};});
2450
+ scale:o.scale??t.scale,blur:o.blur??t.blur,varName:o.varName??t.varName,scaleFrom:o.scaleFrom??t.scaleFrom,blurFrom:o.blurFrom??t.blurFrom,
2451
+ translate:o.translate??t.translate,translateFrom:o.translateFrom??t.translateFrom,translateVarName:o.translateVarName??t.translateVarName};});
2452
+ // Synthetic lanes: a refine recipe can ADD a lane the source never
2453
+ // animated (e.g. blur on a transition with no `filter`). Overrides that
2454
+ // carry their own `property` and match no base lane become new lanes so
2455
+ // the transition string, live preview and Accept all see them.
2456
+ // memberFilter: undefined → include all; otherwise only lanes whose
2457
+ // memberId matches (null for flat entries).
2458
+ const have=new Set(baseLanes.map(t=>t.laneId));
2459
+ for(const[laneId,o]of Object.entries(po||{})){
2460
+ if(have.has(laneId)||!o||!o.property)continue;
2461
+ if(memberFilter!==undefined&&(o.memberId??null)!==memberFilter)continue;
2462
+ out.push({laneId,synthetic:true,...o});
2463
+ }
2464
+ return out;
2451
2465
  }
2452
2466
  _notify(){
2453
2467
  this.effectiveCache.clear();
@@ -2463,7 +2477,7 @@
2463
2477
  const members=(ph.members||[]).map(m=>{
2464
2478
  const mb=baseLanes.filter(l=>l.memberId===m.id);
2465
2479
  return {memberId:m.id,label:m.label||m.id,selector:m.selector,toState:m.toState,
2466
- lanes:this._applyOverrides(mb,po),baseLanes:mb};
2480
+ lanes:this._applyOverrides(mb,po,m.id),baseLanes:mb};
2467
2481
  });
2468
2482
  const item={id,kind:"phase",groupId:g.id,groupLabel:g.label||g.id,component:g.component||null,
2469
2483
  phase:ph.phase||null,phaseLabel:ph.label||ph.phase||"Phase",
@@ -2581,9 +2595,12 @@
2581
2595
  }
2582
2596
 
2583
2597
  // Read the non-resting ("pre") VALUE a lane animates from the live stylesheets:
2584
- // transform-scale (≠1) and filter-blur (≠0), plus the CSS custom property
2585
- // backing it (var-backed values are the ones we can live-preview). Returns
2586
- // {scale?|blur?, varName?}. Only meaningful for transform/filter lanes.
2598
+ // transform-scale (≠1), transform-translate distance (≠0) and filter-blur,
2599
+ // plus the CSS custom property backing each (var-backed values are the ones we
2600
+ // can live-preview). Returns {scale?, blur?, varName?, translate?,
2601
+ // translateVarName?}. A var-backed blur is reported even at 0px — the var is
2602
+ // how an ADDED blur (recipe lane the source lacks) goes live. Only meaningful
2603
+ // for transform/filter lanes.
2587
2604
  function readElValueHint(el, property){
2588
2605
  const out={};
2589
2606
  try{
@@ -2593,8 +2610,24 @@
2593
2610
  const esc=s=>s.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");
2594
2611
  const tokRes=own.map(c=>new RegExp("\\."+esc(c)+"(?![\\w-])"));
2595
2612
  const matchTok=sel=>!own.length||tokRes.some(re=>re.test(sel));
2596
- let bestScale=null,bestBlur=null;
2597
- const consider=txt=>{
2613
+ let bestScale=null,bestBlur=null,bestTranslate=null;
2614
+ // custom-property definitions seen on matching rules — lets a var chain
2615
+ // resolve to its token var (--t-page-from-x: calc(var(--page-slide-distance)
2616
+ // * -1) → --page-slide-distance), which is the var shared across members.
2617
+ const varDefs=new Map();
2618
+ // walk nested var() definitions to the innermost ("token") var.
2619
+ const rootVar=(name)=>{let n=name,guard=0;
2620
+ while(guard++<4){const def=varDefs.get(n);const nm=def&&def.match(/var\(\s*(--[\w-]+)/);if(!nm)break;n=nm[1];}
2621
+ return n;};
2622
+ const texts=[];
2623
+ const collect=list=>{for(const rule of Array.from(list||[])){
2624
+ if(rule.cssRules&&rule.selectorText===undefined){collect(rule.cssRules);continue;}
2625
+ const sel=rule.selectorText;if(!sel||!matchTok(sel))continue;
2626
+ if(rule.cssText)texts.push(rule.cssText);}};
2627
+ for(const sheet of Array.from(document.styleSheets||[])){let r;try{r=sheet.cssRules;}catch{continue;}collect(r);}
2628
+ for(const txt of texts){const defRe=/(--[\w-]+)\s*:\s*([^;}]+)/g;let dm;
2629
+ while((dm=defRe.exec(txt)))if(!varDefs.has(dm[1]))varDefs.set(dm[1],dm[2].trim());}
2630
+ for(const txt of texts){
2598
2631
  if(property==="transform"){
2599
2632
  const tm=txt.match(/transform\s*:\s*([^;}]+)/i);
2600
2633
  if(tm){const sc=tm[1].match(/scale\(\s*([^),]+)/i);
@@ -2602,23 +2635,27 @@
2602
2635
  const num=vm?parseFloat(cs.getPropertyValue(vm[1])):parseFloat(inner);
2603
2636
  // pick the value furthest from the resting 1 (the real pre-scale).
2604
2637
  if(Number.isFinite(num)&&Math.abs(num-1)>1e-4&&(!bestScale||Math.abs(num-1)>Math.abs(bestScale.scale-1)))
2605
- bestScale={scale:num,...(vm?{varName:vm[1]}:{})};}}
2638
+ bestScale={scale:num,...(vm?{varName:vm[1]}:{})};}
2639
+ const tr=tm[1].match(/translate(?:X|Y|3d)?\(\s*([^),]+)/i);
2640
+ if(tr){const inner=tr[1].trim();const vm=inner.match(/var\(\s*(--[\w-]+)/);
2641
+ const vn=vm?rootVar(vm[1]):null;
2642
+ // distance is a magnitude — direction lives in the CSS (± calc).
2643
+ const num=Math.abs(vn?parseFloat(cs.getPropertyValue(vn)):parseFloat(inner));
2644
+ if(Number.isFinite(num)&&num>1e-4&&(!bestTranslate||num>bestTranslate.translate))
2645
+ bestTranslate={translate:num,...(vn?{translateVarName:vn}:{})};}}
2606
2646
  }else{
2607
2647
  const fm=txt.match(/filter\s*:\s*([^;}]+)/i);
2608
2648
  if(fm){const bl=fm[1].match(/blur\(\s*([^)]+)\)/i);
2609
2649
  if(bl){const inner=bl[1].trim();const vm=inner.match(/var\(\s*(--[\w-]+)/);
2610
2650
  const num=vm?parseFloat(cs.getPropertyValue(vm[1])):parseFloat(inner);
2611
- if(Number.isFinite(num)&&num>1e-4&&(!bestBlur||num>bestBlur.blur))
2651
+ // var-backed 0px still reports the var (the hook for ADDING blur).
2652
+ if(Number.isFinite(num)&&(num>1e-4||vm)&&(!bestBlur||num>bestBlur.blur))
2612
2653
  bestBlur={blur:num,...(vm?{varName:vm[1]}:{})};}}
2613
2654
  }
2614
- };
2615
- const collect=list=>{for(const rule of Array.from(list||[])){
2616
- if(rule.cssRules&&rule.selectorText===undefined){collect(rule.cssRules);continue;}
2617
- const sel=rule.selectorText;if(!sel||!matchTok(sel))continue;
2618
- if(rule.cssText)consider(rule.cssText);}};
2619
- for(const sheet of Array.from(document.styleSheets||[])){let r;try{r=sheet.cssRules;}catch{continue;}collect(r);}
2655
+ }
2620
2656
  if(bestScale)Object.assign(out,bestScale);
2621
2657
  if(bestBlur)Object.assign(out,bestBlur);
2658
+ if(bestTranslate)Object.assign(out,bestTranslate);
2622
2659
  }catch{}
2623
2660
  return out;
2624
2661
  }
@@ -3240,7 +3277,10 @@
3240
3277
  "Updates ",
3241
3278
  s.patches.map((pp,pi)=>h("span",{className:"tl-sug-phase",key:pi},
3242
3279
  (cap(pp.phase)||pp.phase||"Phase"),
3243
- pp.durationMs!=null&&h("span",{className:"tl-sug-phase-dur"},pp.durationMs+"ms")))))
3280
+ pp.durationMs!=null&&h("span",{className:"tl-sug-phase-dur"},pp.durationMs+"ms"),
3281
+ pp.scale!=null&&h("span",{className:"tl-sug-phase-dur"},"scale "+pp.scale),
3282
+ pp.blur!=null&&h("span",{className:"tl-sug-phase-dur"},"blur "+pp.blur+"px"),
3283
+ pp.translate!=null&&h("span",{className:"tl-sug-phase-dur"},"slide "+pp.translate+"px")))))
3244
3284
  : h(React.Fragment,null,
3245
3285
  s.title&&h("div",{className:"tl-sug-title"},s.title),
3246
3286
  (s.from||s.to)&&h("div",{className:"tl-sug-delta"},
@@ -3532,14 +3572,21 @@
3532
3572
  const base=tgt.baseLanes||[];
3533
3573
  const bmap=new Map(base.map(t=>[t.laneId,t]));
3534
3574
  for(const t of (tgt.effectiveTimings||[])){
3535
- const b=bmap.get(t.laneId);if(!b)continue;
3536
- if(t.durationMs!==b.durationMs||t.delayMs!==b.delayMs||(t.easing||"")!==(b.easing||"")){
3575
+ const b=bmap.get(t.laneId);
3576
+ // a SYNTHETIC lane (recipe added a property the source never animated,
3577
+ // e.g. blur on a filter-less transition) has no base — write it as an
3578
+ // addition: the lane's timing plus its value(s) below.
3579
+ if(!b&&!t.synthetic)continue;
3580
+ if(!b){
3581
+ out.push({property:t.property,member:t.member||null,selector:t.selector||null,phase:tgt.phase||null,added:true,
3582
+ from:null,to:{durationMs:t.durationMs,delayMs:t.delayMs,easing:t.easing}});
3583
+ }else if(t.durationMs!==b.durationMs||t.delayMs!==b.delayMs||(t.easing||"")!==(b.easing||"")){
3537
3584
  out.push({property:t.property,member:t.member||null,selector:t.selector||null,phase:tgt.phase||null,
3538
3585
  from:{durationMs:b.durationMs,delayMs:b.delayMs,easing:b.easing},
3539
3586
  to:{durationMs:t.durationMs,delayMs:t.delayMs,easing:t.easing}});
3540
3587
  }
3541
- // value edits (scale/blur) live as overrides on the effective lane and
3542
- // carry their own from-value + backing var; emit one change each.
3588
+ // value edits (scale/blur/translate) live as overrides on the effective
3589
+ // lane and carry their own from-value + backing var; emit one change each.
3543
3590
  if(t.scale!=null){
3544
3591
  out.push({property:t.property,member:t.member||null,selector:t.selector||null,phase:tgt.phase||null,varName:t.varName||null,
3545
3592
  from:{scale:t.scaleFrom},to:{scale:t.scale}});
@@ -3548,6 +3595,10 @@
3548
3595
  out.push({property:t.property,member:t.member||null,selector:t.selector||null,phase:tgt.phase||null,varName:t.varName||null,
3549
3596
  from:{blur:t.blurFrom},to:{blur:t.blur}});
3550
3597
  }
3598
+ if(t.translate!=null){
3599
+ out.push({property:t.property,member:t.member||null,selector:t.selector||null,phase:tgt.phase||null,varName:t.translateVarName||null,
3600
+ from:{translate:t.translateFrom},to:{translate:t.translate}});
3601
+ }
3551
3602
  }
3552
3603
  }
3553
3604
  return out;
@@ -4602,7 +4653,10 @@
4602
4653
  const[refineLabel,setRefineLabel]=useState(null);
4603
4654
  const[appliedIds,setAppliedIds]=useState({});
4604
4655
  const[refineMode,setRefineMode]=useState("llm"); // llm (Agent) | deterministic
4605
- const[refineType,setRefineType]=useState("small"); // small | replace
4656
+ // small | replace — last selected tab persists (same pattern as tl-theme-pref)
4657
+ const[refineType,setRefineType]=useState(()=>{
4658
+ try{const v=localStorage.getItem("tl-refine-tab-pref");return v==="small"||v==="replace"?v:"small";}catch{return "small";}
4659
+ });
4606
4660
  const[llmAvailable,setLlmAvailable]=useState(null); // null=unknown, true/false from /health
4607
4661
  const[cliInstalled,setCliInstalled]=useState(null); // null=unknown, true/false from /health
4608
4662
  const[agentReason,setAgentReason]=useState(null); // why no CLI is wired (/health agentReason)
@@ -4741,7 +4795,8 @@
4741
4795
  // attach the lane's non-resting scale/blur value (+ backing var) so the
4742
4796
  // agent + deterministic engine can refine transform/filter VALUES too.
4743
4797
  const withVal=(t)=>{const v=laneValueHint(t.selector,t.property);return {property:t.property,member:t.member||null,durationMs:t.durationMs,delayMs:t.delayMs,easing:t.easing,
4744
- ...(v.scale!=null?{scale:v.scale}:{}),...(v.blur!=null?{blur:v.blur}:{}),...(v.varName?{varName:v.varName}:{})};};
4798
+ ...(v.scale!=null?{scale:v.scale}:{}),...(v.blur!=null?{blur:v.blur}:{}),...(v.varName?{varName:v.varName}:{}),
4799
+ ...(v.translate!=null?{translate:v.translate}:{}),...(v.translateVarName?{translateVarName:v.translateVarName}:{})};};
4745
4800
  const timings=et.map(withVal);
4746
4801
  const selector=(active.bindings&&active.bindings.selector)||(et[0]&&et[0].selector)||null;
4747
4802
  // A recipe swap (replace/both) on a grouped phase should update its related
@@ -4769,6 +4824,7 @@
4769
4824
  // (idle/error), tabs just preview copy and never auto-scan.
4770
4825
  const changeRefineType=useCallback((t)=>{
4771
4826
  setRefineType(t);
4827
+ try{localStorage.setItem("tl-refine-tab-pref",t);}catch{}
4772
4828
  if(!active)return;
4773
4829
  if(scanModeRef.current==="deterministic")return;
4774
4830
  if(refinePhase!=="done"&&refinePhase!=="scanning")return;
@@ -4835,6 +4891,7 @@
4835
4891
  // map the property patch onto the matching lane(s) — a property can span
4836
4892
  // several members in a grouped phase, so apply to each.
4837
4893
  const et=target.effectiveTimings||[];
4894
+ const memberLanes=p.member?et.filter(t=>(t.member===p.member||t.memberId===p.member)):et;
4838
4895
  let lanes=p.property==="all"?et:et.filter(t=>t.property===p.property);
4839
4896
  if(p.member)lanes=lanes.filter(t=>(t.member===p.member||t.memberId===p.member));
4840
4897
  const ids=lanes.length?lanes.map(t=>t.laneId):[p.property];
@@ -4842,31 +4899,68 @@
4842
4899
  for(const id of ids)registry.setPropOverride(target.id,id,timingOverride);
4843
4900
  }
4844
4901
 
4845
- // Value edits (scale/blur) must land on concrete lanes so live preview
4846
- // can write the right backing var. For coarse recipe patches that use
4847
- // property:"all" and omit varName, fan out by lane type.
4848
- if(p.scale!=null){
4849
- let scaleLanes=lanes.filter(t=>t.property==="transform"||t.scale!=null);
4850
- if(!scaleLanes.length&&p.property==="all")scaleLanes=et.filter(t=>t.property==="transform"||t.scale!=null);
4851
- const scaleFrom=(s.from!=null?parseFloat(s.from):undefined);
4852
- for(const lane of scaleLanes){
4853
- const ov={scale:p.scale,scaleFrom};
4854
- const vn=p.varName??lane.varName;
4855
- if(vn!=null)ov.varName=vn;
4856
- registry.setPropOverride(target.id,lane.laneId,ov);
4857
- }
4858
- }
4859
- if(p.blur!=null){
4860
- let blurLanes=lanes.filter(t=>t.property==="filter"||t.blur!=null);
4861
- if(!blurLanes.length&&p.property==="all")blurLanes=et.filter(t=>t.property==="filter"||t.blur!=null);
4862
- const blurFrom=(s.from!=null?parseFloat(s.from):undefined);
4863
- for(const lane of blurLanes){
4864
- const ov={blur:p.blur,blurFrom};
4865
- const vn=p.varName??lane.varName;
4866
- if(vn!=null)ov.varName=vn;
4867
- registry.setPropOverride(target.id,lane.laneId,ov);
4902
+ // Value edits (scale/blur/translate) must land on concrete lanes so live
4903
+ // preview can write the right backing var. For coarse recipe patches that
4904
+ // use property:"all" and omit varName, fan out by lane type. When a
4905
+ // recipe prescribes a value the source has NO lane for (e.g. blur on a
4906
+ // transition that never animated `filter`), SYNTHESIZE the lane: an
4907
+ // override keyed by a fresh laneId that carries its own property, member
4908
+ // and timing, which the registry surfaces as a new effective lane.
4909
+ // The backing var is probed from the live stylesheets at apply time
4910
+ // (readElValueHint reports var-backed values even at their resting 0),
4911
+ // so the patch doesn't need to know the page's CSS custom properties.
4912
+ const sibling=(prop)=>memberLanes.find(t=>t.property===prop)||memberLanes[0]||et[0]||null;
4913
+ const synthTiming=(base)=>({
4914
+ durationMs:p.durationMs??(base&&base.durationMs)??250,
4915
+ delayMs:p.delayMs??(base&&base.delayMs)??0,
4916
+ easing:p.easing??(base&&base.easing)??"ease",
4917
+ });
4918
+ const applyValue=(prop,mk)=>{
4919
+ // mk(lane, hint) → override fields for one lane, or null to skip.
4920
+ let vLanes=lanes.filter(t=>t.property===prop);
4921
+ if(!vLanes.length&&p.property==="all")vLanes=et.filter(t=>t.property===prop&&(!p.member||t.member===p.member||t.memberId===p.member));
4922
+ if(vLanes.length){
4923
+ for(const lane of vLanes){
4924
+ const hint=laneValueHint(lane.selector,prop);
4925
+ const ov=mk(lane,hint);
4926
+ if(ov)registry.setPropOverride(target.id,lane.laneId,ov);
4927
+ }
4928
+ return;
4868
4929
  }
4869
- }
4930
+ // no such lane on this member → synthesize one next to its siblings.
4931
+ // A new filter lane rides the transform (slide) timing per the skill.
4932
+ const sib=sibling(prop==="filter"?"transform":"opacity");
4933
+ if(!sib)return;
4934
+ const hint=laneValueHint(sib.selector,prop);
4935
+ const ov=mk(null,hint);
4936
+ if(!ov)return;
4937
+ const laneId=(sib.memberId?sib.memberId+"::":"")+prop;
4938
+ registry.setPropOverride(target.id,laneId,{
4939
+ property:prop,memberId:sib.memberId??null,member:sib.member??null,selector:sib.selector??null,
4940
+ ...synthTiming(sib),...ov,
4941
+ });
4942
+ };
4943
+ if(p.scale!=null)applyValue("transform",(lane,hint)=>{
4944
+ const ov={scale:p.scale,scaleFrom:(lane&&lane.scale)??hint.scale??(s.from!=null?parseFloat(s.from):undefined)};
4945
+ const vn=p.varName??(lane&&lane.varName)??hint.varName;
4946
+ if(vn!=null)ov.varName=vn;
4947
+ return ov;
4948
+ });
4949
+ if(p.blur!=null)applyValue("filter",(lane,hint)=>{
4950
+ const ov={blur:p.blur,blurFrom:(lane&&lane.blur)??hint.blur??0};
4951
+ const vn=p.varName??(lane&&lane.varName)??hint.varName;
4952
+ if(vn!=null)ov.varName=vn;
4953
+ return ov;
4954
+ });
4955
+ if(p.translate!=null)applyValue("transform",(lane,hint)=>{
4956
+ const ov={translate:p.translate,translateFrom:(lane&&lane.translate)??hint.translate??0};
4957
+ const vn=p.translateVarName??p.varName??(lane&&lane.translateVarName)??hint.translateVarName;
4958
+ // a translate distance can only go live through a backing var — the
4959
+ // inline transition string can't express keyframe positions. Still
4960
+ // record the edit (Accept writes it) even when no var exists.
4961
+ if(vn!=null)ov.translateVarName=vn;
4962
+ return ov;
4963
+ });
4870
4964
  }
4871
4965
  setAppliedIds(prev=>({...prev,[s.id]:true}));
4872
4966
  },[registry,active,entries]);
@@ -4892,11 +4986,13 @@
4892
4986
  if(typeof document==="undefined")return;
4893
4987
  const applied=liveAppliedRef.current;
4894
4988
  const desired=new Map(); // el → {css, vars:{name:value}}
4895
- // collect the var-backed scale/blur overrides on a set of lanes into a
4896
- // {varName: cssValue} map (scale → unitless, blur → px) for live preview.
4897
- const lanesToVars=(lanes)=>{const v={};for(const l of (lanes||[])){if(!l.varName)continue;
4898
- if(l.scale!=null)v[l.varName]=String(l.scale);
4899
- if(l.blur!=null)v[l.varName]=l.blur+"px";}return v;};
4989
+ // collect the var-backed scale/blur/translate overrides on a set of lanes
4990
+ // into a {varName: cssValue} map (scale → unitless, blur/translate → px).
4991
+ const lanesToVars=(lanes)=>{const v={};for(const l of (lanes||[])){
4992
+ if(l.varName){
4993
+ if(l.scale!=null)v[l.varName]=String(l.scale);
4994
+ if(l.blur!=null)v[l.varName]=l.blur+"px";}
4995
+ if(l.translate!=null&&l.translateVarName)v[l.translateVarName]=l.translate+"px";}return v;};
4900
4996
  const add=(els,css,vars)=>{ for(const el of els){ if(!el||(el.closest&&el.closest("[data-timeline-panel],[data-tl-ui]")))continue; desired.set(el,{css,vars:vars||{}}); } };
4901
4997
  for(const item of entries){
4902
4998
  if(item.kind==="phase")continue; // phases handled by the state-scoped effect
@@ -4962,9 +5058,11 @@
4962
5058
  if(livePhaseRef.current) txTeardownPhase(livePhaseRef.current);
4963
5059
  const store={observers:[],ptr:null,applied:new Map(),pending:false};
4964
5060
  livePhaseRef.current=store;
4965
- const lanesToVars=(lanes)=>{const v={};for(const l of (lanes||[])){if(!l.varName)continue;
4966
- if(l.scale!=null)v[l.varName]=String(l.scale);
4967
- if(l.blur!=null)v[l.varName]=l.blur+"px";}return v;};
5061
+ const lanesToVars=(lanes)=>{const v={};for(const l of (lanes||[])){
5062
+ if(l.varName){
5063
+ if(l.scale!=null)v[l.varName]=String(l.scale);
5064
+ if(l.blur!=null)v[l.varName]=l.blur+"px";}
5065
+ if(l.translate!=null&&l.translateVarName)v[l.translateVarName]=l.translate+"px";}return v;};
4968
5066
  const inUI=el=>el&&el.closest&&el.closest("[data-timeline-panel],[data-tl-ui]");
4969
5067
  const safeMatches=(el,sel)=>{ if(!sel)return false; try{return el.matches(sel);}catch{return false;} };
4970
5068
  const fresh=sel=>{ if(!sel)return []; try{return Array.from(document.querySelectorAll(sel)).filter(e=>!inUI(e));}catch{return [];} };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "transitions-refine",
3
- "version": "0.3.26",
3
+ "version": "0.3.27",
4
4
  "description": "Live, agent-driven Refine panel for CSS/Motion transitions — injects a timeline + Refine UI and runs transitions.dev suggestions via your coding agent.",
5
5
  "type": "module",
6
6
  "bin": {
package/server/relay.mjs CHANGED
@@ -265,9 +265,9 @@ const RECIPES_BLOCK = [
265
265
  "- Card hover tilt: 3D tilt toward the pointer (19-card-tilt.md)",
266
266
  "- Plus to menu morph: a circular trigger becomes the surface it opens (20-plus-menu-morph.md)",
267
267
  "- Accordion expand: a collapsible body grows/shrinks in height (21-accordion.md)",
268
- "Recipe non-resting values — the pre-blur/pre-scale the recipe animates FROM (settling to blur 0 / scale 1). When you pick one of these recipes you MUST also emit a lane-level value patch for it, EVEN IF the input has no such lane (add the lane so the element gains the recipe's blur/scale):",
268
+ "Recipe non-resting values — the pre-blur/pre-scale/slide-distance the recipe animates FROM (settling to blur 0 / scale 1 / translate 0). When you pick one of these recipes you MUST also emit a lane-level value patch for EACH of its values, EVEN IF the input has no such lane (add the lane so the element gains the recipe's blur/scale/distance):",
269
269
  " - Panel reveal → filter blur 2px (Small)",
270
- " - Page side-by-side → filter blur 3px (Medium) on the sliding page",
270
+ " - Page side-by-side → filter blur 3px (Medium) AND transform translate distance 8px, both on the sliding page",
271
271
  " - Icon swap → filter blur 2px (Small)",
272
272
  " - Skeleton loader and reveal → filter blur 2px (Small)",
273
273
  " - Texts reveal → filter blur 3px (Medium)",
@@ -305,7 +305,7 @@ function buildPrompt(job) {
305
305
  "",
306
306
  "Infer each declaration's USAGE (modal close, dropdown open, tooltip, badge, resize, color/theme change…) from the label/selector. Match on usage intent, not the nearest number.",
307
307
  "",
308
- "Some timings carry VALUES too: a `transform` lane may include `scale` (its non-resting pre-scale, e.g. 0.8) and a `filter` lane may include `blur` (its non-resting pre-blur in px). When present, also check these against the Scales/Blur tokens by USAGE (a dropdown-open surface should pre-scale to 0.97, not whatever number it has) and propose a fix when they differ. A lane may also carry `varName` (the CSS custom property backing the value) — pass it straight through in the patch so the edit targets that variable.",
308
+ "Some timings carry VALUES too: a `transform` lane may include `scale` (its non-resting pre-scale, e.g. 0.8) and/or `translate` (its non-resting slide distance in px, e.g. 30), and a `filter` lane may include `blur` (its non-resting pre-blur in px). When present, also check these against the Scales/Blur tokens and the recipe values by USAGE (a dropdown-open surface should pre-scale to 0.97, not whatever number it has; a page slide should travel the recipe's distance) and propose a fix when they differ. A lane may also carry `varName` / `translateVarName` (the CSS custom property backing each value) — pass them straight through in the patch so the edit targets that variable.",
309
309
  "",
310
310
  ];
311
311
  if (needsRecipe) {
@@ -317,7 +317,7 @@ function buildPrompt(job) {
317
317
  );
318
318
  if (multiPhase) {
319
319
  lines.push(
320
- "RELATED PHASES: `phases` lists this transition's related states (e.g. open AND close). They are ONE motion — a recipe swap must update them TOGETHER. For the replace suggestion emit a `patches` array with ONE timing entry per phase in `phases`, shaped `{\"phase\":<phase>,\"property\":\"all\",\"durationMs\":...,\"easing\":...}`. If the recipe uses transform pre-scale or filter pre-blur (see \"Recipe non-resting values\" above), add EXTRA per-lane value patches for those lanes (not property:\"all\"), e.g. `{\"phase\":\"open\",\"property\":\"transform\",\"member\":\"Panel\",\"varName\":\"--dropdown-pre-scale\",\"scale\":0.97}` and `{\"phase\":\"open\",\"property\":\"filter\",\"member\":\"Panel\",\"varName\":\"--panel-blur\",\"blur\":2}`. Copy each lane's `member` + `varName` from the input timings when that lane exists; when the recipe adds a lane the input LACKS (e.g. Page side-by-side prescribes a 3px blur but the captured page has no `filter` lane), STILL emit the blur patch — reuse the sibling lane's `member` so it targets the right element and omit `varName`. Take each phase duration from MOTION TOKENS (open usually slower than close, e.g. 250ms/150ms). Also include a single `patch` for live preview (first timing patch is fine). Apply will write every phase from `patches`.",
320
+ "RELATED PHASES: `phases` lists this transition's related states (e.g. open AND close). They are ONE motion — a recipe swap must update them TOGETHER. For the replace suggestion emit a `patches` array with ONE timing entry per phase in `phases`, shaped `{\"phase\":<phase>,\"property\":\"all\",\"durationMs\":...,\"easing\":...}`. If the recipe uses transform pre-scale, filter pre-blur or a slide distance (see \"Recipe non-resting values\" above), add EXTRA per-lane value patches for those lanes (not property:\"all\"), e.g. `{\"phase\":\"open\",\"property\":\"transform\",\"member\":\"Panel\",\"varName\":\"--dropdown-pre-scale\",\"scale\":0.97}`, `{\"phase\":\"open\",\"property\":\"filter\",\"member\":\"Panel\",\"varName\":\"--panel-blur\",\"blur\":2}` or `{\"phase\":\"forward\",\"property\":\"transform\",\"member\":\"Page panel\",\"translateVarName\":\"--page-slide-distance\",\"translate\":8}`. Copy each lane's `member` + `varName`/`translateVarName` from the input timings when that lane exists; when the recipe adds a lane the input LACKS (e.g. Page side-by-side prescribes a 3px blur but the captured page has no `filter` lane), STILL emit the value patch — reuse the sibling lane's `member` so it targets the right element and omit the var name (Apply discovers the page's backing variable itself). Take each phase duration from MOTION TOKENS (open usually slower than close, e.g. 250ms/150ms). Also include a single `patch` for live preview (first timing patch is fine). Apply will write every phase from `patches`.",
321
321
  "",
322
322
  );
323
323
  }
@@ -327,7 +327,7 @@ function buildPrompt(job) {
327
327
  "refineType is \"replace\": suggest a WHOLE-TRANSITION replacement ONLY — do NOT propose motion-token tweaks (no kind \"duration\"/\"delay\"/\"easing\").",
328
328
  multiPhase
329
329
  ? "Pick the SINGLE best-fit recipe from the list above. Emit ONE suggestion with kind \"replace\": include the `patches` array (one entry per related phase, as described above) AND a single `patch` (the first phase) for the live preview, add a `reference` field with the reference filename, and name the recipe in `title`/`reason`. If no recipe genuinely fits the usage, return an empty suggestions array."
330
- : "Pick the SINGLE best-fit recipe from the list above. Emit ONE suggestion with kind \"replace\": set its `patch` to the motion-token duration/easing for the recipe's phase on the property that already transitions (or \"all\") so Apply works live, add a `reference` field with the reference filename, and name the recipe in `title`/`reason`. If the recipe prescribes a non-resting pre-blur/pre-scale (see \"Recipe non-resting values\" above), put that value on the `patch` too (add `blur` for a filter-blur recipe like Page side-by-side → 3px, `scale` for a scale recipe) so the sliding/scaling element gains it even when the captured transition had no such lane. If no recipe genuinely fits the usage, return an empty suggestions array.",
330
+ : "Pick the SINGLE best-fit recipe from the list above. Emit ONE suggestion with kind \"replace\": set its `patch` to the motion-token duration/easing for the recipe's phase on the property that already transitions (or \"all\") so Apply works live, add a `reference` field with the reference filename, and name the recipe in `title`/`reason`. If the recipe prescribes a non-resting pre-blur/pre-scale/slide-distance (see \"Recipe non-resting values\" above), put those values on the `patch` too (add `blur` for a filter-blur recipe like Page side-by-side → 3px, `scale` for a scale recipe, `translate` for a slide distance like Page side-by-side → 8px) so the sliding/scaling element gains them even when the captured transition had no such lane. If no recipe genuinely fits the usage, return an empty suggestions array.",
331
331
  "Answer in ONE response — do NOT read or search files.",
332
332
  );
333
333
  } else if (refineType === "both") {
@@ -337,7 +337,7 @@ function buildPrompt(job) {
337
337
  MISSING_BLUR_NOTE,
338
338
  multiPhase
339
339
  ? "(2) Whole-transition replacement (kind \"replace\"): ALWAYS evaluate one — pick the SINGLE best-fit recipe. Emit at most ONE \"replace\" suggestion with the `patches` array (one entry per related phase) AND a single `patch` (the first phase), a `reference` field, and the recipe named in `title`/`reason`. If no recipe genuinely fits, omit the replace suggestion."
340
- : "(2) Whole-transition replacement (kind \"replace\"): ALWAYS evaluate one — pick the SINGLE best-fit recipe from the list above. Emit at most ONE \"replace\" suggestion: set its `patch` to the motion-token duration/easing for the recipe's phase on the property that already transitions (or \"all\"); if the recipe prescribes a non-resting pre-blur/pre-scale (see \"Recipe non-resting values\" above) put that `blur`/`scale` on the `patch` too so e.g. Page side-by-side gains its 3px blur. Add a `reference` field with the reference filename, and name the recipe in `title`/`reason`. If no recipe genuinely fits, omit the replace suggestion.",
340
+ : "(2) Whole-transition replacement (kind \"replace\"): ALWAYS evaluate one — pick the SINGLE best-fit recipe from the list above. Emit at most ONE \"replace\" suggestion: set its `patch` to the motion-token duration/easing for the recipe's phase on the property that already transitions (or \"all\"); if the recipe prescribes a non-resting pre-blur/pre-scale/slide-distance (see \"Recipe non-resting values\" above) put those `blur`/`scale`/`translate` values on the `patch` too so e.g. Page side-by-side gains its 3px blur and 8px distance. Add a `reference` field with the reference filename, and name the recipe in `title`/`reason`. If no recipe genuinely fits, omit the replace suggestion.",
341
341
  "Answer in ONE response — do NOT read or search files.",
342
342
  );
343
343
  } else {
@@ -354,8 +354,8 @@ function buildPrompt(job) {
354
354
  'A scale tweak looks like {"id":"transform-scale","kind":"scale","property":"transform","member":"Menu","title":"Scale → Medium","from":"0.8","to":"0.97","patch":{"property":"transform","member":"Menu","scale":0.97},"reason":"…"}; a blur tweak like {"id":"filter-blur","kind":"blur","property":"filter","member":"Panel","title":"Blur → Small","from":"8px","to":"2px","patch":{"property":"filter","member":"Panel","blur":2},"reason":"…"}.',
355
355
  "CRITICAL — `member`: every suggestion and its `patch` MUST echo the `member` of the input lane it came from, copied VERBATIM from that lane in the data above. This is REQUIRED whenever any input lane carries a `member` — most importantly when several lanes share the same `property` (e.g. a dropdown where a caret does `transform: rotate` AND a panel does `transform: scale`): the `member` is the ONLY way to tell which lane a `transform` tweak targets. Omitting it mislabels the suggestion onto the wrong element. Omit `member` only for lanes that genuinely have none.",
356
356
  multiPhase
357
- ? 'A multi-phase replace suggestion ALSO carries a `patches` array. Put timing in phase-level entries (`property:"all"`, duration/easing), and put scale/blur in lane-level entries (`property:"transform"`/`"filter"` plus the lane `member` and `varName` from input timings). In each `patch`/`patches` entry include only changed fields (durationMs, delayMs, easing, scale, blur — plus `member` copied from input lane and `varName` when present); `property` must match an input property or "all".'
358
- : 'In each `patch` include only the changed fields (durationMs, delayMs, easing, scale, blur — plus `member` copied from the input lane, and `varName` if the input lane had one); `property` must match an input property or "all". If nothing should change, return an empty suggestions array.',
357
+ ? 'A multi-phase replace suggestion ALSO carries a `patches` array. Put timing in phase-level entries (`property:"all"`, duration/easing), and put scale/blur/translate in lane-level entries (`property:"transform"`/`"filter"` plus the lane `member` and `varName`/`translateVarName` from input timings). In each `patch`/`patches` entry include only changed fields (durationMs, delayMs, easing, scale, blur, translate — plus `member` copied from input lane and `varName`/`translateVarName` when present); `property` must match an input property or "all".'
358
+ : 'In each `patch` include only the changed fields (durationMs, delayMs, easing, scale, blur, translate — plus `member` copied from the input lane, and `varName`/`translateVarName` if the input lane had one); `property` must match an input property or "all". If nothing should change, return an empty suggestions array.',
359
359
  );
360
360
  return lines.join("\n");
361
361
  }
@@ -503,7 +503,7 @@ function buildApplyPrompt(job) {
503
503
  "",
504
504
  "Steps:",
505
505
  "1. Find where this transition is defined in the source. Search by the per-change `selector`/`member`, the `component` hint, and class names. Handle plain CSS, CSS Modules, styled-components/emotion template literals, Tailwind utilities/config, inline style objects, and Motion/Framer variants — the browser selector is a hint, the real declaration may live in any of these.",
506
- "2. For each change, edit the source so that property's transition uses the `to` values. A change's `to` may include timing (durationMs in ms, easing, delayMs in ms) AND/OR values: `scale` (the non-resting transform pre-scale, e.g. set `transform: scale(0.97)` on the pre-open/closed state — never the resting scale(1)) and `blur` (the non-resting filter pre-blur in px, e.g. `filter: blur(2px)` on that same state). If the change carries `varName`, the value is backed by that CSS custom property — update the variable's value at its definition instead of the inline declaration. Keep the file's existing unit/format conventions (e.g. `0.25s` vs `250ms`) and only touch the named property on the right member + phase.",
506
+ "2. For each change, edit the source so that property's transition uses the `to` values. A change's `to` may include timing (durationMs in ms, easing, delayMs in ms) AND/OR values: `scale` (the non-resting transform pre-scale, e.g. set `transform: scale(0.97)` on the pre-open/closed state — never the resting scale(1)), `blur` (the non-resting filter pre-blur in px, e.g. `filter: blur(2px)` on that same state) and `translate` (the non-resting slide distance in px — the offset the element travels from, keeping the existing direction/sign convention). A change with `added:true` introduces a lane the source never animated — add that property to the transition (with the `to` timing) AND give the pre-state its non-resting value from the paired value change. If the change carries `varName`, the value is backed by that CSS custom property — update the variable's value at its definition instead of the inline declaration. Keep the file's existing unit/format conventions (e.g. `0.25s` vs `250ms`) and only touch the named property on the right member + phase.",
507
507
  "3. Make the minimal edit. Do not reformat or change unrelated code.",
508
508
  "",
509
509
  'Output ONLY a JSON object — no prose, no markdown fences — shaped exactly like:',