transitions-refine 0.3.31 → 0.3.32

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 +135 -81
  2. package/package.json +1 -1
  3. package/server/relay.mjs +41 -0
package/demo.html CHANGED
@@ -1266,7 +1266,7 @@
1266
1266
  background: #fff; color: var(--c-text);
1267
1267
  font-family: "Inter", system-ui, -apple-system, sans-serif;
1268
1268
  }
1269
- /* header: title block (left) + Agent/Deterministic mode dropdown (right) */
1269
+ /* header: title block (left) + scan-scope dropdown (right, Replace tab only) */
1270
1270
  /* match the timeline top bar exactly: 7.5px/14px padding, centered, 52px tall (36px row + 15 + 1px border) */
1271
1271
  .tl-refine-head { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between;
1272
1272
  gap: 8px; box-sizing: border-box; padding: 7.5px 7.5px 7.5px 14px;
@@ -1635,7 +1635,7 @@
1635
1635
  filter: blur(var(--text-swap-blur)); opacity: 0; }
1636
1636
  .t-text-swap.is-enter-start { transform: translateY(var(--text-swap-translate-y));
1637
1637
  filter: blur(var(--text-swap-blur)); opacity: 0; transition: none; }
1638
- /* mode dropdown rows (Agent / Deterministic) */
1638
+ /* scope dropdown rows (Selected transition / All transitions) */
1639
1639
  .tl-mode-row { display: flex; align-items: center; gap: 12px; width: 100%; padding: 8px 12px;
1640
1640
  border: none; background: transparent; border-radius: 8px; cursor: pointer; text-align: left;
1641
1641
  font: inherit; transition: background 0.12s ease; }
@@ -3109,9 +3109,11 @@
3109
3109
  return r.json();
3110
3110
  }
3111
3111
 
3112
- const REFINE_MODES=[
3113
- {key:"llm",label:"Agent",desc:"Suggested edits from the agent using the Transitions.dev skill."},
3114
- {key:"deterministic",label:"Deterministic",desc:"Suggests edits only mathematically, with no credit usage."},
3112
+ // Scan SCOPE (Replace tab only): scan just the selected transition, or every
3113
+ // detected transition and propose a replacement for each good recipe match.
3114
+ const REFINE_SCOPES=[
3115
+ {key:"selected",label:"Selected transition",desc:"Scan only the transition you have selected."},
3116
+ {key:"all",label:"All transitions",desc:"Scan every detected transition and suggest a replacement for each good recipe match."},
3115
3117
  ];
3116
3118
  const REFINE_TYPES=[
3117
3119
  {key:"small",label:"Small refinements",desc:"The agent will scan the transition and suggest small refinements by adjusting motion tokens using the Transitions.dev skill."},
@@ -3268,7 +3270,12 @@
3268
3270
  // top row: member chip + property, or a single heading for replace cards
3269
3271
  h("div",{className:"tl-sug-head"},
3270
3272
  isReplace
3271
- ? h("span",{className:"tl-sug-prop"},s.title||"Transition replacement")
3273
+ ? h(React.Fragment,null,
3274
+ // "All transitions" scope tags each replace card with the
3275
+ // transition/component it targets so a multi-suggestion list
3276
+ // is legible; single-scope replaces omit it.
3277
+ s._targetLabel&&h("span",{className:"tl-sug-member"},s._targetLabel),
3278
+ h("span",{className:"tl-sug-prop"},s.title||"Transition replacement"))
3272
3279
  : h(React.Fragment,null,
3273
3280
  member&&h("span",{className:"tl-sug-member"},member),
3274
3281
  prop&&h("span",{className:"tl-sug-prop"},prop))),
@@ -3333,14 +3340,15 @@
3333
3340
  onReconnect&&h("button",{className:"tl-gate-recheck",disabled:rechecking,onClick:recheck},
3334
3341
  rechecking?(relayDown?"Retrying…":"Reconnecting…"):(relayDown?"Retry connection":"Reconnect agent")));
3335
3342
  }
3336
- function RefinePanel({open,onClose,phase,label,refineType,onType,suggestions,summary,error,appliedIds,onApply,onApplyAll,mode,onMode,llmAvailable,cliInstalled,relayDown,agentReason,onReconnect,onStart,lanes,resolvedTheme}){
3343
+ function RefinePanel({open,onClose,phase,label,refineType,onType,suggestions,summary,error,appliedIds,onApply,onApplyAll,scope,onScope,llmAvailable,cliInstalled,relayDown,agentReason,onReconnect,onStart,lanes,resolvedTheme}){
3337
3344
  // mount-on-open; keep mounted through the panel-reveal slide-out, then unmount
3338
3345
  const[render,setRender]=useState(open);
3339
3346
  const[panelOpen,setPanelOpen]=useState(false);
3340
3347
  const[slidePhase,setSlidePhase]=useState("opening");
3341
- const[modeOpen,setModeOpen]=useState(false);
3348
+ // scope selector dropdown (Replace tab only)
3349
+ const[scopeOpen,setScopeOpen]=useState(false);
3342
3350
  const[statusIx,setStatusIx]=useState(0);
3343
- const modeRef=useRef(null);
3351
+ const scopeRef=useRef(null);
3344
3352
  // The results reveal should play once per scan, not on every tab switch.
3345
3353
  // This persists across RefineResults remounts; reset it when a new scan
3346
3354
  // starts (or the panel returns to idle) so fresh results animate again.
@@ -3365,10 +3373,10 @@
3365
3373
  // close on Escape — dismiss the mode dropdown first if it's open
3366
3374
  useEffect(()=>{
3367
3375
  if(!open)return;
3368
- const onKey=e=>{if(e.key!=="Escape")return;if(modeOpen){setModeOpen(false);return;}onClose();};
3376
+ const onKey=e=>{if(e.key!=="Escape")return;if(scopeOpen){setScopeOpen(false);return;}onClose();};
3369
3377
  window.addEventListener("keydown",onKey);
3370
3378
  return()=>window.removeEventListener("keydown",onKey);
3371
- },[open,onClose,modeOpen]);
3379
+ },[open,onClose,scopeOpen]);
3372
3380
  // cycle the in-progress status copy while scanning (matches the design's
3373
3381
  // "Scanning… → Reading the Transitions.dev skill → Suggesting edits").
3374
3382
  useEffect(()=>{
@@ -3402,15 +3410,11 @@
3402
3410
  // Replace transition → kind "replace"; Small refinements → token tweaks.
3403
3411
  const visible = suggestions.filter(s=> refineType==="replace" ? s.kind==="replace" : s.kind!=="replace");
3404
3412
  const pending = visible.filter(s=>!appliedIds[s.id]);
3405
- const agentMode = mode==="llm";
3406
3413
  // null (unknown / still probing) is treated as ready so the panel doesn't
3407
- // flash the "unavailable" copy before /health resolves.
3408
- const agentReady = !agentMode || llmAvailable!==false;
3409
- const modeLabel = (REFINE_MODES.find(m=>m.key===mode)||REFINE_MODES[0]).label;
3410
- // Idle empty-state copy is mode-aware (computed per type in bodyFor):
3411
- // Agent (llm) keeps the type's agent-oriented desc; Deterministic has no
3412
- // agent, so it describes the math-snap behavior (and that "replace" needs
3413
- // Agent mode).
3414
+ // flash the "unavailable" copy before /health resolves. All scanning goes
3415
+ // through the agent now, so readiness is purely "is a live agent wired".
3416
+ const agentReady = llmAvailable!==false;
3417
+ const scopeLabel = (REFINE_SCOPES.find(s=>s.key===scope)||REFINE_SCOPES[0]).label;
3414
3418
  // One persistent control for the whole foot: in idle/done/error it's the
3415
3419
  // pill button; while scanning it carries `.is-scanning` and the same DOM
3416
3420
  // node morphs (card resize) into the loading rectangle with the border-beam.
@@ -3467,9 +3471,7 @@
3467
3471
  h("div",{className:"tl-refine-unavail-title"},"Connect with your agent"),
3468
3472
  h(AgentConnectBody,{cliMissing:cliInstalled===false,relayDown,reason:agentReason,onReconnect}));
3469
3473
  }
3470
- const idleDescR = agentMode ? (REFINE_TYPES.find(t=>t.key===rt)||REFINE_TYPES[0]).desc : (rt==="replace"
3471
- ? "Deterministic mode can't pick a recipe to replace the transition — switch to Agent mode for replacements."
3472
- : "Deterministic mode snaps your transition's timing to the nearest Transitions.dev motion tokens — mathematically, with no agent or credits.");
3474
+ const idleDescR = (REFINE_TYPES.find(t=>t.key===rt)||REFINE_TYPES[0]).desc;
3473
3475
  return h("div",{className:"tl-refine-center"},
3474
3476
  h("p",{className:"tl-refine-idle-text"},idleDescR));
3475
3477
  };
@@ -3534,19 +3536,22 @@
3534
3536
  h("h3",null,"Refine"),
3535
3537
  h("p",null,label||"Transitions review")),
3536
3538
  h("div",{className:"tl-refine-actions"},
3537
- h("button",{ref:modeRef,className:cx("tl-refine-mode",modeOpen&&"is-open"),
3538
- "aria-haspopup":"menu","aria-expanded":modeOpen?"true":"false",onClick:()=>setModeOpen(v=>!v)},
3539
- h("span",null,modeLabel),
3539
+ // Scan-scope selector — Replace tab only (Small refinements always
3540
+ // scan just the selected transition). Same control the Agent/
3541
+ // Deterministic mode dropdown used to be.
3542
+ refineType==="replace"&&h("button",{ref:scopeRef,className:cx("tl-refine-mode",scopeOpen&&"is-open"),
3543
+ "aria-haspopup":"menu","aria-expanded":scopeOpen?"true":"false",onClick:()=>setScopeOpen(v=>!v)},
3544
+ h("span",null,scopeLabel),
3540
3545
  h(Ic,{name:"chevron",size:16})),
3541
3546
  h("button",{className:"tl-refine-close","aria-label":"Close refine panel",onClick:onClose},
3542
3547
  h(Ic,{name:"close",size:16})))),
3543
- h(Dropdown,{open:modeOpen,onClose:()=>setModeOpen(false),triggerRef:modeRef,width:276,align:"right"},
3544
- REFINE_MODES.map(m=>h("button",{key:m.key,className:"tl-mode-row",
3545
- onClick:()=>{if(m.key!==mode)onMode(m.key);setModeOpen(false);}},
3548
+ refineType==="replace"&&h(Dropdown,{open:scopeOpen,onClose:()=>setScopeOpen(false),triggerRef:scopeRef,width:276,align:"right"},
3549
+ REFINE_SCOPES.map(sc=>h("button",{key:sc.key,className:"tl-mode-row",
3550
+ onClick:()=>{if(sc.key!==scope)onScope(sc.key);setScopeOpen(false);}},
3546
3551
  h("div",{className:"tl-mode-row-main"},
3547
- h("div",{className:"tl-mode-row-title"},m.label),
3548
- h("div",{className:"tl-mode-row-desc"},m.desc)),
3549
- (mode===m.key)&&h("span",{className:"tl-mode-row-check"},h(Ic,{name:"accept",size:16}))))),
3552
+ h("div",{className:"tl-mode-row-title"},sc.label),
3553
+ h("div",{className:"tl-mode-row-desc"},sc.desc)),
3554
+ (scope===sc.key)&&h("span",{className:"tl-mode-row-check"},h(Ic,{name:"accept",size:16}))))),
3550
3555
  h("div",{className:"tl-refine-tabs",role:"tablist","aria-label":"Refinement type"},
3551
3556
  REFINE_TYPES.map(t=>h("button",{key:t.key,className:"tl-refine-tab",role:"tab",
3552
3557
  "aria-selected":refineType===t.key?"true":"false",
@@ -4625,10 +4630,7 @@
4625
4630
  const[refineSuggestions,setRefineSuggestions]=useState([]);
4626
4631
  const[refineSummary,setRefineSummary]=useState(null);
4627
4632
  const[refineError,setRefineError]=useState(null);
4628
- // deterministic scans answer almost instantly, so enforce a 2s floor on the
4629
- // "scanning" phase for that mode only (LLM scans stay immediate).
4630
4633
  const scanStartedAtRef=useRef(0);
4631
- const scanModeRef=useRef("llm");
4632
4634
  // ── accept (write to source) ──
4633
4635
  const[acceptState,setAcceptState]=useState("idle"); // idle | saving | done | error
4634
4636
  const[acceptError,setAcceptError]=useState(null);
@@ -4659,7 +4661,12 @@
4659
4661
  const GROUP_SCAN_MAX_RETRIES=2;
4660
4662
  const[refineLabel,setRefineLabel]=useState(null);
4661
4663
  const[appliedIds,setAppliedIds]=useState({});
4662
- const[refineMode,setRefineMode]=useState("llm"); // llm (Agent) | deterministic
4664
+ // scan SCOPE for the Replace tab — "selected" (only the active transition)
4665
+ // or "all" (every detected transition). Persisted like the tab/theme prefs;
4666
+ // defaults to "selected". Small refinements always scan just the selection.
4667
+ const[refineScope,setRefineScope]=useState(()=>{
4668
+ try{const v=localStorage.getItem("tl-refine-scope-pref");return v==="all"?"all":"selected";}catch{return "selected";}
4669
+ });
4663
4670
  // small | replace — last selected tab persists (same pattern as tl-theme-pref)
4664
4671
  const[refineType,setRefineType]=useState(()=>{
4665
4672
  try{const v=localStorage.getItem("tl-refine-tab-pref");return v==="small"||v==="replace"?v:"small";}catch{return "small";}
@@ -4754,10 +4761,9 @@
4754
4761
  },[active,refineOpen,refreshHealth]);
4755
4762
  // "Start scanning" resolves availability first, then posts the job.
4756
4763
  // per-(transition,type) result cache so switching tabs reuses a prior scan
4757
- // instead of re-asking the agent. In llm mode each scan is scoped to the
4758
- // active tab (Small = token tweaks, Replace = recipe) so the common Small
4759
- // view skips the recipe selection + reference-file read. Deterministic stays
4760
- // a single instant "both" pass that feeds both tabs.
4764
+ // instead of re-asking the agent. Each scan is scoped to the active tab
4765
+ // (Small = token tweaks, Replace = recipe) so the common Small view skips
4766
+ // the recipe selection + reference-file read.
4761
4767
  const refineCacheRef=useRef(new Map()); // `${id}::${type}` -> {suggestions,summary}
4762
4768
  const refineScanTypeRef=useRef("small"); // which type the in-flight job targets
4763
4769
  // results are not comparable across transitions — drop the cache when the
@@ -4776,64 +4782,96 @@
4776
4782
  const startScan=useCallback(async(typeArg,opts)=>{
4777
4783
  if(!active)return;
4778
4784
  opts=opts||{};
4779
- // Resolve availability from the continuously-polled /health (2s cadence)
4780
- // rather than a serial probe on the click path. Unknown (null) counts as
4781
- // available so the first click isn't blocked; a dead relay surfaces via
4782
- // the POST below (→ error state).
4783
- const avail=llmAvailable!==false;
4784
- const mode=(refineMode==="llm"&&!avail)?"deterministic":refineMode;
4785
- if(mode!==refineMode)setRefineMode(mode);
4786
- scanModeRef.current=mode;
4787
- // deterministic → one "both" pass (instant, free). llm → scope to the
4788
- // active tab so Small skips recipe work; Replace asks for it on demand.
4789
- // NOTE: this is also wired as a button onClick, so typeArg may be a click
4790
- // event — only honour it when it's an explicit "small"/"replace" string.
4785
+ // All scanning goes through the agent now (the deterministic path is gone).
4786
+ // Availability is resolved from the continuously-polled /health; unknown
4787
+ // (null) counts as available so the first click isn't blocked, and a dead
4788
+ // relay surfaces via the POST below (→ error state).
4789
+ const mode="llm";
4790
+ // NOTE: also wired as a button onClick, so typeArg may be a click event —
4791
+ // only honour it when it's an explicit "small"/"replace" string.
4791
4792
  const reqType=(typeArg==="small"||typeArg==="replace")?typeArg:refineType;
4792
- const type=mode==="deterministic"?"both":reqType;
4793
+ const type=reqType;
4794
+ // Scope only applies to the Replace tab — Small always scans the selection.
4795
+ const scope=(reqType==="replace")?refineScope:"selected";
4793
4796
  refineScanTypeRef.current=type;
4794
4797
  scanStartedAtRef.current=Date.now();
4795
4798
  setRefinePhase("scanning");setRefineLog([]);setRefineError(null);
4796
- setRefineLabel(active.label);setRefineJobId(null);
4799
+ setRefineLabel(scope==="all"?"All transitions":active.label);setRefineJobId(null);
4797
4800
  if(!opts.keepApplied)setAppliedIds({});
4798
4801
  // re-scan only this type; keep the other tab's cached result for the merge.
4799
4802
  refineCacheRef.current.delete(active.id+"::"+type);
4803
+ // attach the lane's non-resting scale/blur/translate value (+ backing var)
4804
+ // so the agent can refine transform/filter VALUES too.
4805
+ 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,
4806
+ ...(v.scale!=null?{scale:v.scale}:{}),...(v.blur!=null?{blur:v.blur}:{}),...(v.varName?{varName:v.varName}:{}),
4807
+ ...(v.translate!=null?{translate:v.translate}:{}),...(v.translateVarName?{translateVarName:v.translateVarName}:{})};};
4800
4808
  try{
4809
+ if(scope==="all"){
4810
+ // Bundle EVERY detected transition — one per agent group (with its
4811
+ // related open/close phases) plus each ungrouped flat entry — so the
4812
+ // agent can return a LIST of replace suggestions, one per transition
4813
+ // it finds a good recipe for. Each suggestion echoes its transitionId
4814
+ // so Apply targets the right transition.
4815
+ const seenGroup=new Set();
4816
+ const transitions=[];
4817
+ for(const e of entries){
4818
+ if(e.kind==="phase"){
4819
+ if(!e.groupId||seenGroup.has(e.groupId))continue;
4820
+ seenGroup.add(e.groupId);
4821
+ const gp=entries.filter(x=>x.kind==="phase"&&x.groupId===e.groupId);
4822
+ const rep=gp[0]||e;
4823
+ const ret=rep.effectiveTimings||[];
4824
+ const sel=(ret[0]&&ret[0].selector)||null;
4825
+ const ph=gp.length>1?gp.map(x=>({phase:x.phase||x.phaseLabel||"phase",label:x.phaseLabel||x.phase||"Phase",
4826
+ timings:(x.effectiveTimings||[]).map(withVal)})):undefined;
4827
+ transitions.push({transitionId:rep.id,label:rep.groupLabel||rep.label,selector:sel,timings:ret.map(withVal),phases:ph});
4828
+ }else{
4829
+ const fet=e.effectiveTimings||[];
4830
+ const sel=(e.bindings&&e.bindings.selector)||(fet[0]&&fet[0].selector)||null;
4831
+ transitions.push({transitionId:e.id,label:e.label,selector:sel,timings:fet.map(withVal)});
4832
+ }
4833
+ }
4834
+ const{id}=await relayCreateJob({scope:"all",label:"All transitions",mode,refineType:"replace",transitions});
4835
+ setRefineJobId(id);
4836
+ return;
4837
+ }
4801
4838
  const et=active.effectiveTimings||[];
4802
- // attach the lane's non-resting scale/blur value (+ backing var) so the
4803
- // agent + deterministic engine can refine transform/filter VALUES too.
4804
- 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,
4805
- ...(v.scale!=null?{scale:v.scale}:{}),...(v.blur!=null?{blur:v.blur}:{}),...(v.varName?{varName:v.varName}:{}),
4806
- ...(v.translate!=null?{translate:v.translate}:{}),...(v.translateVarName?{translateVarName:v.translateVarName}:{})};};
4807
4839
  const timings=et.map(withVal);
4808
4840
  const selector=(active.bindings&&active.bindings.selector)||(et[0]&&et[0].selector)||null;
4809
- // A recipe swap (replace/both) on a grouped phase should update its related
4841
+ // A recipe swap (replace) on a grouped phase should update its related
4810
4842
  // phases (open + close) together — they're one motion. Send the whole
4811
4843
  // group's phases so the agent can return a per-phase `patches` array.
4812
4844
  let phases;
4813
- if((type==="replace"||type==="both")&&active.kind==="phase"&&active.groupId){
4845
+ if(type==="replace"&&active.kind==="phase"&&active.groupId){
4814
4846
  const gp=entries.filter(e=>e.kind==="phase"&&e.groupId===active.groupId);
4815
4847
  if(gp.length>1)phases=gp.map(e=>({phase:e.phase||e.phaseLabel||"phase",label:e.phaseLabel||e.phase||"Phase",
4816
4848
  timings:(e.effectiveTimings||[]).map(withVal)}));
4817
4849
  }
4818
4850
  const{id}=await relayCreateJob({transitionId:active.id,label:active.label,selector,
4819
- phase:active.phase||null,group:active.groupLabel||null,timings,mode,refineType:type,phases});
4851
+ phase:active.phase||null,group:active.groupLabel||null,timings,mode,refineType:type,scope:"selected",phases});
4820
4852
  setRefineJobId(id);
4821
4853
  }catch(e){
4822
4854
  setRefinePhase("error");
4823
4855
  setRefineError(h(React.Fragment,null,"Couldn't reach the refine relay. Start it with ",h("code",{className:"tl-code"},"npx transitions-refine live"),"."));
4824
4856
  }
4825
- },[active,refineMode,refineType,llmAvailable,entries]);
4826
- const changeRefineMode=useCallback((mode)=>{setRefineMode(mode);setRefinePhase("idle");refineCacheRef.current.clear();refreshHealth();},[refreshHealth]);
4827
- // Switching tabs reuses a cached scan when present; otherwise (llm only) the
4828
- // tab drops back to its idle state and waits for an explicit Start-scanning
4829
- // click — switching never auto-runs a scan. Deterministic feeds both tabs
4830
- // from one "both" pass, so its tab switch is a pure filter. Before any scan
4831
- // (idle/error), tabs just preview copy and never auto-scan.
4857
+ },[active,refineScope,refineType,llmAvailable,entries]);
4858
+ // Switching scope changes the Replace scan's shape (one transition vs all),
4859
+ // so drop cached results and fall back to idle the next scan uses the new
4860
+ // scope. Persist the choice like the tab/theme prefs.
4861
+ const changeRefineScope=useCallback((sc)=>{
4862
+ setRefineScope(sc);
4863
+ try{localStorage.setItem("tl-refine-scope-pref",sc);}catch{}
4864
+ refineCacheRef.current.clear();
4865
+ setRefinePhase(p=>(p==="done"||p==="error")?"idle":p);
4866
+ },[]);
4867
+ // Switching tabs reuses a cached scan when present; otherwise the tab drops
4868
+ // back to its idle state and waits for an explicit Start-scanning click —
4869
+ // switching never auto-runs a scan. Before any scan (idle/error), tabs just
4870
+ // preview copy and never auto-scan.
4832
4871
  const changeRefineType=useCallback((t)=>{
4833
4872
  setRefineType(t);
4834
4873
  try{localStorage.setItem("tl-refine-tab-pref",t);}catch{}
4835
4874
  if(!active)return;
4836
- if(scanModeRef.current==="deterministic")return;
4837
4875
  if(refinePhase!=="done"&&refinePhase!=="scanning")return;
4838
4876
  if(refineCacheRef.current.has(active.id+"::"+t)){applyMerged(active.id);setRefinePhase("done");return;}
4839
4877
  // No cached result for this tab → show its idle scan button and wait for
@@ -4856,13 +4894,23 @@
4856
4894
  // (so the other tab's cached suggestions stay visible).
4857
4895
  const type=refineScanTypeRef.current;
4858
4896
  const id=active?active.id:"";
4859
- refineCacheRef.current.set(id+"::"+type,{suggestions:(job.result&&job.result.suggestions)||[],summary:(job.result&&job.result.summary)||null});
4860
- if(active)applyMerged(active.id);else{setRefineSuggestions((job.result&&job.result.suggestions)||[]);setRefineSummary(job.result&&job.result.summary);}
4897
+ // "All transitions" scope returns MANY replace suggestions, each
4898
+ // carrying the `transitionId` it targets. Tag each with the target
4899
+ // transition's label (for the card) and namespace its id by that
4900
+ // transitionId so multiple replaces stay uniquely keyed for Apply.
4901
+ const rawSugs=(job.result&&job.result.suggestions)||[];
4902
+ const sugs=rawSugs.map((s,i)=>{
4903
+ if(s&&s.transitionId){
4904
+ const ent=entries.find(e=>e.id===s.transitionId);
4905
+ return {...s,id:s.transitionId+"::"+(s.id||s.kind||("sug"+i)),
4906
+ _targetLabel:ent?(ent.groupLabel||ent.label):null};
4907
+ }
4908
+ return s;
4909
+ });
4910
+ refineCacheRef.current.set(id+"::"+type,{suggestions:sugs,summary:(job.result&&job.result.summary)||null});
4911
+ if(active)applyMerged(active.id);else{setRefineSuggestions(sugs);setRefineSummary(job.result&&job.result.summary);}
4861
4912
  setRefinePhase("done");};
4862
- // deterministic mode floors the scanning phase at 2s so it doesn't flash by.
4863
- const minMs=scanModeRef.current==="deterministic"?2000:0;
4864
- const wait=Math.max(0,minMs-(Date.now()-scanStartedAtRef.current));
4865
- if(wait>0){to=setTimeout(finish,wait);}else{finish();}
4913
+ finish();
4866
4914
  return;
4867
4915
  }
4868
4916
  if(job.status==="error"){setRefineError(job.error||"The agent reported an error.");setRefinePhase("error");return;}
@@ -4873,14 +4921,20 @@
4873
4921
  return()=>{live=false;if(to)clearTimeout(to);};
4874
4922
  },[refineJobId,refinePhase]);
4875
4923
  const applySuggestion=useCallback((s)=>{
4924
+ // Resolve which transition this suggestion targets. In "All transitions"
4925
+ // scope every suggestion carries its own `transitionId`; otherwise it
4926
+ // applies to the currently-active transition. Everything below writes to
4927
+ // `tgt` (and its sibling phases), so multi-transition Apply just works.
4928
+ const tgt=(s&&s.transitionId?entries.find(e=>e.id===s.transitionId):null)||active;
4929
+ if(!tgt)return;
4876
4930
  // A replace suggestion may carry `patches` (one per related phase, e.g.
4877
4931
  // open + close) — a recipe swap is one motion, so applying it writes BOTH.
4878
- // Otherwise fall back to the single `patch` on the active phase.
4932
+ // Otherwise fall back to the single `patch` on the target phase.
4879
4933
  let patches=Array.isArray(s.patches)&&s.patches.length
4880
4934
  ? s.patches
4881
- : (s.patch&&s.patch.property?[{...s.patch,phase:(active&&active.phase)||null}]:[]);
4935
+ : (s.patch&&s.patch.property?[{...s.patch,phase:(tgt&&tgt.phase)||null}]:[]);
4882
4936
  if(!patches.length)return;
4883
- const groupId=active&&active.groupId;
4937
+ const groupId=tgt&&tgt.groupId;
4884
4938
  // Same-slot swap recipes (icon/text swaps) use two stacked members in one
4885
4939
  // group. The agent can sometimes return the recipe's blur/scale value for
4886
4940
  // only the currently selected member; expand those value patches to every
@@ -4891,7 +4945,7 @@
4891
4945
  const extra=[]; const has=new Set(patches.map(p=>[p.phase||"",p.property||"",p.member||"",p.blur!=null?"blur":"",p.scale!=null?"scale":"",p.translate!=null?"translate":""].join("|")));
4892
4946
  const valuePatches=patches.filter(p=>p.member&&(p.blur!=null||p.scale!=null)&&(p.property==="filter"||p.property==="transform"));
4893
4947
  for(const p of valuePatches){
4894
- const target=entries.find(e=>e.kind==="phase"&&e.groupId===groupId&&(!p.phase||e.phase===p.phase))||active;
4948
+ const target=entries.find(e=>e.kind==="phase"&&e.groupId===groupId&&(!p.phase||e.phase===p.phase))||tgt;
4895
4949
  const members=new Map();
4896
4950
  for(const t of (target&&target.effectiveTimings)||[]){
4897
4951
  const m=t.member||t.memberId;if(m&&!members.has(m))members.set(m,m);
@@ -4933,7 +4987,7 @@
4933
4987
  if(p.easing!=null)timingOverride.easing=p.easing;
4934
4988
  // resolve the phase entry this patch targets (its sibling open/close
4935
4989
  // lives in the same group); default to the active entry.
4936
- let target=active;
4990
+ let target=tgt;
4937
4991
  if(p.phase&&groupId){
4938
4992
  const found=entries.find(e=>e.kind==="phase"&&e.groupId===groupId&&e.phase===p.phase);
4939
4993
  if(found)target=found;
@@ -5619,7 +5673,7 @@
5619
5673
  h(RefinePanel,{open:refineOpen,onClose:()=>setRefineOpen(false),phase:refinePhase,label:refineLabel,
5620
5674
  refineType,onType:changeRefineType,suggestions:refineSuggestions,summary:refineSummary,error:refineError,
5621
5675
  appliedIds,onApply:applySuggestion,onApplyAll:applyAllSuggestions,
5622
- mode:refineMode,onMode:changeRefineMode,llmAvailable,cliInstalled,relayDown,agentReason,onReconnect:reconnectAgent,onStart:startScan,
5676
+ scope:refineScope,onScope:changeRefineScope,llmAvailable,cliInstalled,relayDown,agentReason,onReconnect:reconnectAgent,onStart:startScan,
5623
5677
  lanes:active?.effectiveTimings||[],resolvedTheme}))
5624
5678
  : h("div",{className:"tl-panel-main"},
5625
5679
  gate==="blocked" && h("div",{className:"tl-gate"},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "transitions-refine",
3
- "version": "0.3.31",
3
+ "version": "0.3.32",
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
@@ -293,6 +293,47 @@ const MISSING_BLUR_NOTE =
293
293
 
294
294
  function buildPrompt(job) {
295
295
  const r = job.request || {};
296
+ // "All transitions" replace scope: the panel sends EVERY detected transition
297
+ // and wants a LIST of whole-transition replacements — one per transition the
298
+ // agent finds a good transitions.dev recipe for. Each suggestion echoes its
299
+ // `transitionId` so the panel applies it to the right transition, and carries
300
+ // the SAME patch/patches contract as a single replace so Apply is unchanged.
301
+ const transitions = Array.isArray(r.transitions) ? r.transitions.filter((t) => t && t.transitionId) : [];
302
+ if (r.scope === "all" && transitions.length) {
303
+ return [
304
+ "You are refining MULTIPLE CSS transitions against the transitions.dev library and motion tokens. Evaluate EVERY transition below; for each one where a transitions.dev recipe is a genuinely good fit, emit ONE whole-transition replacement. Transitions with no good recipe match are simply OMITTED — never force a swap.",
305
+ MOTION_TOKENS_BLOCK,
306
+ "",
307
+ "Transitions (JSON array). Each has a stable `transitionId` you MUST echo VERBATIM in that transition's suggestion, its current `timings`, and — when it has related states — a `phases` array (e.g. open + close) that is ONE motion.",
308
+ JSON.stringify(
309
+ transitions.map((t) => ({
310
+ transitionId: t.transitionId,
311
+ label: t.label,
312
+ selector: t.selector,
313
+ timings: t.timings,
314
+ phases: Array.isArray(t.phases) && t.phases.length ? t.phases : undefined,
315
+ })),
316
+ null,
317
+ 2
318
+ ),
319
+ "",
320
+ "For EACH transition infer its USAGE (modal close, dropdown open, tooltip, badge, resize, page slide, icon/text swap…) from its label/selector. Match on usage intent, not the nearest number. A lane may carry `scale`/`translate` (transform) or `blur` (filter) plus a `varName`/`translateVarName` — check those against the Scales/Blur/recipe values too and pass any var names straight through in the patch.",
321
+ "",
322
+ "To pick a whole-transition replacement, match the inferred USAGE against the recipe list below (the skill's decision rules are summarised here — do NOT read SKILL.md or any reference file). Put the recipe's reference filename in `reference`.",
323
+ "",
324
+ RECIPES_BLOCK,
325
+ "",
326
+ "PER-TRANSITION OUTPUT — for each transition you replace, emit ONE suggestion with kind \"replace\" and `transitionId` set to that transition's id:",
327
+ " - If the transition has a `phases` array, emit a `patches` array with ONE phase-level timing entry per phase (`{\"phase\":<phase>,\"property\":\"all\",\"durationMs\":...,\"easing\":...}`) PLUS extra per-lane value patches for any recipe non-resting scale/blur/translate (`property:\"transform\"`/`\"filter\"` with the lane `member` and `varName`/`translateVarName` copied from that phase's input timings; when the recipe adds a lane the input lacks, still emit it and reuse a sibling lane's `member`, omitting the var name). Also include a single `patch` (the first phase) for the live preview. Set each phase's durationMs AND easing from \"Recipe timing & easing\" (SYMMETRIC recipes use the SAME duration+easing for every phase; ASYMMETRIC use open 250ms / close 150ms).",
328
+ " - Otherwise (single phase): set its `patch` to the recipe's documented duration AND easing on the property that already transitions (or \"all\"); if the recipe prescribes a non-resting pre-blur/pre-scale/slide-distance, put those `blur`/`scale`/`translate` values on the `patch` too so the element gains them even when the captured transition had no such lane.",
329
+ "Copy each suggestion's and patch's `member` VERBATIM from the input lane it came from — REQUIRED whenever an input lane carries a member (it is the only way to tell which lane a transform/filter tweak targets).",
330
+ "Return ALL such suggestions in ONE array (order doesn't matter). If NO transition has a good recipe match, return an empty suggestions array.",
331
+ "Answer in ONE response — do NOT read or search files.",
332
+ "",
333
+ "Output ONLY a JSON object — no prose, no markdown fences — shaped exactly like:",
334
+ '{"summary":"…","suggestions":[{"id":"dropdown-replace","transitionId":"<echo an id from the input>","kind":"replace","title":"Menu dropdown","reference":"05-menu-dropdown.md","patch":{"property":"all","member":"Panel","durationMs":250,"easing":"cubic-bezier(0.22, 1, 0.36, 1)","scale":0.97},"patches":[{"phase":"open","property":"all","durationMs":250,"easing":"cubic-bezier(0.22, 1, 0.36, 1)"},{"phase":"open","property":"transform","member":"Panel","scale":0.97},{"phase":"close","property":"all","durationMs":150,"easing":"cubic-bezier(0.22, 1, 0.36, 1)"}],"reason":"…"}]}',
335
+ ].join("\n");
336
+ }
296
337
  const rawType = r.refineType || "small";
297
338
  const refineType = rawType === "replace" ? "replace" : rawType === "both" ? "both" : "small";
298
339
  const needsRecipe = refineType === "replace" || refineType === "both";