transitions-refine 0.3.16 → 0.3.17

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 (2) hide show
  1. package/demo.html +162 -16
  2. package/package.json +1 -1
package/demo.html CHANGED
@@ -1799,6 +1799,63 @@
1799
1799
  return out;
1800
1800
  }
1801
1801
 
1802
+ // Resolve a grouped phase MEMBER selector to live element(s), SCOPED to the
1803
+ // component instance(s) named by the phase's `stateTarget`. Agent-produced
1804
+ // member selectors are often broad (e.g. `.panel`, a utility class) and, when
1805
+ // queried globally, match elements in OTHER components — so editing one
1806
+ // group's timing leaked onto unrelated transitions, and claiming hid the
1807
+ // wrong flat entries. Restricting the query to each stateTarget subtree keeps
1808
+ // the match inside the intended component (all instances of it, which is
1809
+ // correct) without touching look-alikes elsewhere. Falls back to a global
1810
+ // query only when scoping yields nothing (no/again-unresolvable stateTarget),
1811
+ // so this can only ever NARROW matches — never regress to "applies nothing".
1812
+ function txMemberEls(memberSel, stateTargetSel){
1813
+ if(!memberSel) return [];
1814
+ const out=[];
1815
+ if(stateTargetSel){
1816
+ let roots=[];try{roots=Array.from(document.querySelectorAll(stateTargetSel));}catch{}
1817
+ for(const root of roots){
1818
+ try{ if(root.matches&&root.matches(memberSel)) out.push(root); }catch{}
1819
+ try{ for(const el of root.querySelectorAll(memberSel)) out.push(el); }catch{}
1820
+ }
1821
+ }
1822
+ const scoped=Array.from(new Set(out));
1823
+ if(scoped.length) return scoped;
1824
+ let all=[];try{all=Array.from(document.querySelectorAll(memberSel));}catch{}
1825
+ return all;
1826
+ }
1827
+
1828
+ // Normalize a phase state token (the class/attribute toggled on `stateTarget`)
1829
+ // into a CSS selector fragment that can be concatenated onto a selector:
1830
+ // ".is-open" → ".is-open" (class, already a fragment)
1831
+ // "[data-open=\"true\"]"→ "[data-open=\"true\"]" (attribute, already a fragment)
1832
+ // "is-open" → ".is-open" (bare class name)
1833
+ // "data-open" → "[data-open]" (bare attribute name)
1834
+ // null / "" / "base" → "" (the no-class base state)
1835
+ function stateSuffix(tok){
1836
+ if(tok==null) return "";
1837
+ tok=String(tok).trim();
1838
+ if(!tok||/^base$/i.test(tok)) return "";
1839
+ // already a selector fragment: .class, [attr], :pseudo (e.g. :hover)
1840
+ if(tok[0]==="."||tok[0]==="["||tok[0]===":") return tok;
1841
+ if(/^[\w-]+$/.test(tok)) return "."+tok;
1842
+ return "["+tok+"]";
1843
+ }
1844
+
1845
+ // Tear down a state-gated phase live-preview store: stop observers/listeners
1846
+ // and restore every element's original inline transition + custom props.
1847
+ function txTeardownPhase(store){
1848
+ if(!store) return;
1849
+ if(store) store.pending=false;
1850
+ for(const mo of store.observers||[]){try{mo.disconnect();}catch{}}
1851
+ if(store.ptr){try{document.removeEventListener("pointerover",store.ptr,true);document.removeEventListener("pointerout",store.ptr,true);}catch{}}
1852
+ for(const [el,rec] of store.applied||new Map()){
1853
+ try{el.style.transition=rec.prevInline;}catch{}
1854
+ for(const n of Object.keys(rec.prevVars||{})){try{const pv=rec.prevVars[n];if(pv)el.style.setProperty(n,pv);else el.style.removeProperty(n);}catch{}}
1855
+ }
1856
+ store.applied&&store.applied.clear();
1857
+ }
1858
+
1802
1859
  // ── registry (per-lane overrides) ──
1803
1860
  // A "lane" is one animated property of one element. For flat DOM transitions
1804
1861
  // a lane's id is just the property; for agent-grouped phases it is
@@ -1836,8 +1893,7 @@
1836
1893
  const memberEls=new Set();
1837
1894
  for(const g of this.groups)for(const ph of (g.phases||[]))for(const m of (ph.members||[])){
1838
1895
  if(!m.selector)continue;
1839
- let els=[];try{els=Array.from(document.querySelectorAll(m.selector));}catch{}
1840
- for(const el of els)memberEls.add(el);
1896
+ for(const el of txMemberEls(m.selector,ph.stateTarget))memberEls.add(el);
1841
1897
  }
1842
1898
  if(!memberEls.size)return;
1843
1899
  for(const[id,entry]of this.entries){
@@ -3955,8 +4011,14 @@
3955
4011
  // ── live styling ──────────────────────────────────────────────────────
3956
4012
  // With playback gone, the real component IS the preview. So whenever a
3957
4013
  // transition has edits, mirror its effective timings onto the live
3958
- // element(s) as an inline `transition`, and revert when edits are cleared.
3959
- // Interacting with the component then animates with the edited values.
4014
+ // element(s) and revert when edits are cleared. Interacting with the
4015
+ // component then animates with the edited values.
4016
+ //
4017
+ // FLAT entries (single-state DOM transitions) are mirrored as an inline
4018
+ // `transition` on their exact bound elements — precise and simple. GROUPED
4019
+ // phases are NOT handled here: open and close share the same base element,
4020
+ // so a single inline slot can't tell them apart (editing open would also
4021
+ // change close). Phases use a state-scoped <style> instead (effect below).
3960
4022
  const liveAppliedRef=useRef(new Map()); // el → {prevInline, css}
3961
4023
  useEffect(()=>{
3962
4024
  if(typeof document==="undefined")return;
@@ -3969,20 +4031,13 @@
3969
4031
  if(l.blur!=null)v[l.varName]=l.blur+"px";}return v;};
3970
4032
  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||{}}); } };
3971
4033
  for(const item of entries){
4034
+ if(item.kind==="phase")continue; // phases handled by the state-scoped effect
3972
4035
  const ov=registry.getPropOverrides(item.id);
3973
4036
  if(!ov||!Object.keys(ov).length)continue; // only edited transitions go live
3974
- if(item.kind==="phase"){
3975
- for(const m of (item.members||[])){
3976
- if(!m.selector||!m.lanes||!m.lanes.length)continue;
3977
- let els=[];try{els=Array.from(document.querySelectorAll(m.selector));}catch{}
3978
- add(els,transitionLanesToCss(m.lanes),lanesToVars(m.lanes));
3979
- }
3980
- }else{
3981
- const lanes=item.effectiveTimings||[];
3982
- if(!lanes.length)continue;
3983
- const els=((item.bindings&&item.bindings.elements)||[]).map(w=>w.deref&&w.deref()).filter(Boolean);
3984
- add(els,transitionLanesToCss(lanes),lanesToVars(lanes));
3985
- }
4037
+ const lanes=item.effectiveTimings||[];
4038
+ if(!lanes.length)continue;
4039
+ const els=((item.bindings&&item.bindings.elements)||[]).map(w=>w.deref&&w.deref()).filter(Boolean);
4040
+ add(els,transitionLanesToCss(lanes),lanesToVars(lanes));
3986
4041
  }
3987
4042
  const varsKey=v=>Object.keys(v).sort().map(k=>k+":"+v[k]).join(";");
3988
4043
  // apply new / changed
@@ -4019,6 +4074,97 @@
4019
4074
  for(const [el,rec] of applied){ try{el.style.transition=rec.prevInline;}catch{} for(const n of Object.keys(rec.vars||{})){try{const pv=(rec.prevVars||{})[n];if(pv)el.style.setProperty(n,pv);else el.style.removeProperty(n);}catch{}} _TX_LIVE_BASE.delete(el); }
4020
4075
  applied.clear();
4021
4076
  },[]);
4077
+ // ── live styling: grouped phases (state-gated) ──────────────────────────
4078
+ // Open and close of one component share the SAME element(s), so a single
4079
+ // inline `transition` can't separate them (editing open would bend close
4080
+ // too). And a member often lives OUTSIDE its stateTarget (a caret in the
4081
+ // trigger, a backdrop portal'd to <body>), so a CSS descendant rule can't
4082
+ // reach it. So we GATE on state instead: watch each phase's `stateTarget`
4083
+ // for its destination state and apply the member's edited timing inline
4084
+ // ONLY while that state is active, reverting otherwise:
4085
+ // • OPEN (base→toState): apply while stateTarget IS in toState.
4086
+ // • CLOSE (fromState→base): apply while stateTarget is NOT in fromState.
4087
+ // This works regardless of where the member sits in the DOM and keeps the
4088
+ // two directions independent. A MutationObserver on each stateTarget (class
4089
+ // + state data-attrs, never `style`, so no feedback) re-runs the gate;
4090
+ // hover phases also watch pointer events. Everything reverts on teardown.
4091
+ const livePhaseRef=useRef(null);
4092
+ useEffect(()=>{
4093
+ if(typeof document==="undefined")return;
4094
+ if(livePhaseRef.current) txTeardownPhase(livePhaseRef.current);
4095
+ const store={observers:[],ptr:null,applied:new Map(),pending:false};
4096
+ livePhaseRef.current=store;
4097
+ const lanesToVars=(lanes)=>{const v={};for(const l of (lanes||[])){if(!l.varName)continue;
4098
+ if(l.scale!=null)v[l.varName]=String(l.scale);
4099
+ if(l.blur!=null)v[l.varName]=l.blur+"px";}return v;};
4100
+ const inUI=el=>el&&el.closest&&el.closest("[data-timeline-panel],[data-tl-ui]");
4101
+ const safeMatches=(el,sel)=>{ if(!sel)return false; try{return el.matches(sel);}catch{return false;} };
4102
+ const fresh=sel=>{ if(!sel)return []; try{return Array.from(document.querySelectorAll(sel)).filter(e=>!inUI(e));}catch{return [];} };
4103
+ // Specs hold SELECTORS, not resolved elements: the stateTarget panel and
4104
+ // members frequently unmount/remount on toggle, so we re-resolve them on
4105
+ // every recompute to stay correct across that churn.
4106
+ const specs=[]; const attrNames=new Set(["class"]); let anyHover=false;
4107
+ for(const item of entries){
4108
+ if(item.kind!=="phase")continue;
4109
+ const ov=registry.getPropOverrides(item.id);
4110
+ if(!ov||!Object.keys(ov).length)continue; // only edited phases go live
4111
+ const toSuf=stateSuffix(item.toState), fromSuf=stateSuffix(item.fromState);
4112
+ // collect attribute names referenced by the state tokens so the observer
4113
+ // fires on e.g. data-open / aria-expanded toggles, not just class.
4114
+ for(const suf of [toSuf,fromSuf]){const am=suf&&suf.match(/^\[([\w-]+)/);if(am)attrNames.add(am[1]);}
4115
+ if(/:hover/.test(toSuf)||/:hover/.test(fromSuf))anyHover=true;
4116
+ const members=[];
4117
+ for(const m of (item.members||[])){
4118
+ if(!m.selector||!m.lanes||!m.lanes.length)continue;
4119
+ members.push({selector:m.selector,css:transitionLanesToCss(m.lanes),vars:lanesToVars(m.lanes)});
4120
+ }
4121
+ if(!members.length)continue;
4122
+ specs.push({stateTarget:item.stateTarget||null,toSuf,fromSuf,members});
4123
+ }
4124
+ if(!specs.length) return ()=>txTeardownPhase(store);
4125
+ const varsKey=v=>Object.keys(v).sort().map(k=>k+":"+v[k]).join(";");
4126
+ const recompute=()=>{
4127
+ const desired=new Map(); // el → {css,vars}
4128
+ for(const sp of specs){
4129
+ const stEls=fresh(sp.stateTarget);
4130
+ // is THIS phase's destination state active on the stateTarget RIGHT NOW?
4131
+ let active;
4132
+ if(sp.toSuf) active=stEls.length?stEls.some(e=>safeMatches(e,sp.toSuf)):true; // open
4133
+ else if(sp.fromSuf) active=stEls.length?stEls.some(e=>!safeMatches(e,sp.fromSuf)):true; // close = not in fromState
4134
+ else active=true; // no state info → always reflect (degrades to old behavior)
4135
+ if(!active)continue;
4136
+ for(const mm of sp.members) for(const el of txMemberEls(mm.selector,sp.stateTarget)){ if(inUI(el))continue; desired.set(el,{css:mm.css,vars:mm.vars}); }
4137
+ }
4138
+ for(const [el,d] of desired){
4139
+ const vars=d.vars||{},vk=varsKey(vars),cur=store.applied.get(el);
4140
+ if(cur&&cur.css===d.css&&cur.vk===vk)continue;
4141
+ if(!cur) store.applied.set(el,{prevInline:el.style.transition||"",prevVars:Object.fromEntries(Object.keys(vars).map(n=>[n,el.style.getPropertyValue(n)])),css:d.css,vk});
4142
+ else store.applied.set(el,{prevInline:cur.prevInline,prevVars:cur.prevVars,css:d.css,vk});
4143
+ try{el.style.transition=d.css;}catch{}
4144
+ for(const n of Object.keys(vars)){try{el.style.setProperty(n,vars[n]);}catch{}}
4145
+ }
4146
+ for(const [el,rec] of Array.from(store.applied)){
4147
+ if(desired.has(el))continue;
4148
+ try{el.style.transition=rec.prevInline;}catch{}
4149
+ for(const n of Object.keys(rec.prevVars||{})){try{const pv=rec.prevVars[n];if(pv)el.style.setProperty(n,pv);else el.style.removeProperty(n);}catch{}}
4150
+ store.applied.delete(el);
4151
+ }
4152
+ };
4153
+ // Coalesce via microtask (NOT rAF): the gate must update the moment the
4154
+ // state class/attr flips — before the browser styles the next frame — so
4155
+ // the very first interaction animates with the edited timing, and reverts
4156
+ // are instant even when the tab is backgrounded (rAF pauses there).
4157
+ const queueMt=typeof queueMicrotask==="function"?queueMicrotask:(f)=>Promise.resolve().then(f);
4158
+ const schedule=()=>{ if(store.pending)return; store.pending=true; queueMt(()=>{store.pending=false;recompute();}); };
4159
+ // ONE observer on the document subtree: state lives on elements that may
4160
+ // mount/unmount (panels) or just toggle a class/attr, so we watch class +
4161
+ // the state attrs across the tree, plus childList for remounts. Never
4162
+ // `style` → our own inline writes can't feed back. rAF-coalesced.
4163
+ try{ const mo=new MutationObserver(schedule); mo.observe(document.body,{attributes:true,attributeFilter:Array.from(attrNames),subtree:true,childList:true}); store.observers.push(mo); }catch{}
4164
+ if(anyHover){ const onPtr=()=>schedule(); document.addEventListener("pointerover",onPtr,true); document.addEventListener("pointerout",onPtr,true); store.ptr=onPtr; }
4165
+ recompute(); // initial pass
4166
+ return ()=>txTeardownPhase(store);
4167
+ },[entries,registry]);
4022
4168
  const startResize=useCallback(e=>{
4023
4169
  e.preventDefault();
4024
4170
  const startY=e.clientY; const startH=panelHeight;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "transitions-refine",
3
- "version": "0.3.16",
3
+ "version": "0.3.17",
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": {