transitions-refine 0.3.25 → 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.
Files changed (3) hide show
  1. package/demo.html +154 -56
  2. package/package.json +1 -1
  3. package/server/relay.mjs +26 -7
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.25",
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,26 @@ 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/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
+ " - Panel reveal → filter blur 2px (Small)",
270
+ " - Page side-by-side → filter blur 3px (Medium) AND transform translate distance 8px, both on the sliding page",
271
+ " - Icon swap → filter blur 2px (Small)",
272
+ " - Skeleton loader and reveal → filter blur 2px (Small)",
273
+ " - Texts reveal → filter blur 3px (Medium)",
274
+ " - Success check → filter blur 8px (Large)",
275
+ " - Modal open/close → transform scale 0.96 (Large)",
276
+ " - Menu dropdown → transform scale 0.97 (Medium) open, 0.99 (Tiny) close",
277
+ " - Tooltip open/close → transform scale 0.98 (Small)",
268
278
  "Tie-break: prefer the lower-overhead recipe (card resize over panel reveal, dropdown over modal). If no recipe genuinely fits, return an empty suggestions array.",
269
279
  ].join("\n");
270
280
 
281
+ // Shared guidance for the token-tweak passes (small + both). Mirrors the
282
+ // deterministic engine's "missing-blur" path: some usages animate a non-resting
283
+ // pre-blur that a bare opacity/transform capture won't show, so the agent must
284
+ // ADD it rather than only tweak blur lanes that already exist.
285
+ const MISSING_BLUR_NOTE =
286
+ "MISSING BLUR: blur can be ABSENT, not just off-token. When the inferred usage clearly implies a non-resting pre-blur per the Blur tokens above (e.g. a page slide / page side-by-side panel → 3px Medium; an icon swap, text swap, panel reveal, skeleton reveal or number pop-in → 2px Small; a text reveal → 3px Medium; a success check → 8px Large) but the input timings carry NO `filter` lane, ADD one: emit a `blur` suggestion with `property:\"filter\"`, `from:\"0px\"`, `to` the usage token (e.g. \"3px\"), echoing the `member` (and `varName` if present) of the sibling lanes so the sliding/swapping element gains the recipe's blur. Only add blur when the usage genuinely calls for it — never on motion that is intentionally opacity-only (a plain fade, a colour/theme change).";
287
+
271
288
  function buildPrompt(job) {
272
289
  const r = job.request || {};
273
290
  const rawType = r.refineType || "small";
@@ -288,7 +305,7 @@ function buildPrompt(job) {
288
305
  "",
289
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.",
290
307
  "",
291
- "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.",
292
309
  "",
293
310
  ];
294
311
  if (needsRecipe) {
@@ -300,7 +317,7 @@ function buildPrompt(job) {
300
317
  );
301
318
  if (multiPhase) {
302
319
  lines.push(
303
- "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 also uses transform pre-scale or filter pre-blur, add EXTRA per-lane value patches for those lanes (not property:\"all\") and include that lane's `member` + `varName` from input timings, 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}`. 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`.",
304
321
  "",
305
322
  );
306
323
  }
@@ -310,21 +327,23 @@ function buildPrompt(job) {
310
327
  "refineType is \"replace\": suggest a WHOLE-TRANSITION replacement ONLY — do NOT propose motion-token tweaks (no kind \"duration\"/\"delay\"/\"easing\").",
311
328
  multiPhase
312
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."
313
- : "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 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.",
314
331
  "Answer in ONE response — do NOT read or search files.",
315
332
  );
316
333
  } else if (refineType === "both") {
317
334
  lines.push(
318
335
  "refineType is \"both\": produce TWO independent groups in the SAME suggestions array — the UI shows them in separate tabs, so include each group whenever it applies.",
319
336
  "(1) Motion-token tweaks (kind \"duration\"/\"delay\"/\"easing\"/\"scale\"/\"blur\"): for each declaration, propose the token value only where it DIFFERS from the current one. Use \"scale\" for an off-token transform pre-scale and \"blur\" for an off-token filter pre-blur, picked by usage.",
337
+ MISSING_BLUR_NOTE,
320
338
  multiPhase
321
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."
322
- : "(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\"), 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.",
323
341
  "Answer in ONE response — do NOT read or search files.",
324
342
  );
325
343
  } else {
326
344
  lines.push(
327
345
  "refineType is \"small\": suggest motion-token tweaks ONLY — for each declaration, propose the token value only where it DIFFERS from the current one (kind \"duration\"/\"delay\"/\"easing\"/\"scale\"/\"blur\"). Use \"scale\" for an off-token transform pre-scale and \"blur\" for an off-token filter pre-blur, picked by usage. Do NOT propose a whole-transition replacement (no kind \"replace\") — the Replace tab requests that separately.",
346
+ MISSING_BLUR_NOTE,
328
347
  "Answer in ONE response using ONLY the data above. Do NOT read or search files, run tools, spawn subagents, or explore the codebase — the motion tokens contain everything you need. This is a quick judgement, not a coding task.",
329
348
  );
330
349
  }
@@ -335,8 +354,8 @@ function buildPrompt(job) {
335
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":"…"}.',
336
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.",
337
356
  multiPhase
338
- ? '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".'
339
- : '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.',
340
359
  );
341
360
  return lines.join("\n");
342
361
  }
@@ -484,7 +503,7 @@ function buildApplyPrompt(job) {
484
503
  "",
485
504
  "Steps:",
486
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.",
487
- "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.",
488
507
  "3. Make the minimal edit. Do not reformat or change unrelated code.",
489
508
  "",
490
509
  'Output ONLY a JSON object — no prose, no markdown fences — shaped exactly like:',