transitions-refine 0.3.31 → 0.3.33
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 +205 -81
- package/package.json +1 -1
- 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) +
|
|
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
|
-
/*
|
|
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; }
|
|
@@ -2383,6 +2383,7 @@
|
|
|
2383
2383
|
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{}}
|
|
2384
2384
|
}
|
|
2385
2385
|
store.applied&&store.applied.clear();
|
|
2386
|
+
try{tlSyncNoneGuard(TL_LP_GUARD_PHASE_ID,null);}catch{}
|
|
2386
2387
|
}
|
|
2387
2388
|
|
|
2388
2389
|
// ── registry (per-lane overrides) ──
|
|
@@ -2666,6 +2667,66 @@
|
|
|
2666
2667
|
}catch{}
|
|
2667
2668
|
return out;
|
|
2668
2669
|
}
|
|
2670
|
+
// ── FLIP "jump-to-start" guard ──────────────────────────────────────────
|
|
2671
|
+
// Many transitions teleport to a start offset before animating, using a
|
|
2672
|
+
// state class that sets `transition: none` (the FLIP pattern), e.g.
|
|
2673
|
+
// `.t-text-swap.is-enter-start { transform: translateY(...); transition: none }`.
|
|
2674
|
+
// Our live-preview mirrors edited timings as an INLINE `transition` on the
|
|
2675
|
+
// element; inline wins (specificity 1,0,0,0) over that class rule, so the
|
|
2676
|
+
// instant teleport becomes an animation — the entering content then eases
|
|
2677
|
+
// in from the EXIT position instead of its start offset, flipping the
|
|
2678
|
+
// direction. Fix generally: find those page-authored `transition: none`
|
|
2679
|
+
// start-states and re-assert them with `!important` so the teleport stays
|
|
2680
|
+
// instant regardless of our inline transition. We only match STATE variants
|
|
2681
|
+
// (a selector whose subject compound carries a class the element is NOT
|
|
2682
|
+
// currently wearing) so we never freeze an element's BASE transition — the
|
|
2683
|
+
// base is what carries the lanes we add. @media blocks (e.g. reduced-motion)
|
|
2684
|
+
// are skipped so we don't promote their `transition: none` to always-on.
|
|
2685
|
+
const _TL_NONE_CACHE=new WeakMap();
|
|
2686
|
+
function tlCollectNoneSelectors(el){
|
|
2687
|
+
let cached=_TL_NONE_CACHE.get(el); if(cached)return cached;
|
|
2688
|
+
const out=new Set();
|
|
2689
|
+
try{
|
|
2690
|
+
const own=(typeof el.className==="string"?el.className.trim().split(/\s+/):Array.from(el.classList||[])).filter(Boolean);
|
|
2691
|
+
if(own.length){
|
|
2692
|
+
const ownSet=new Set(own);
|
|
2693
|
+
const esc=s=>s.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");
|
|
2694
|
+
const tokRes=own.map(c=>new RegExp("\\."+esc(c)+"(?![\\w-])"));
|
|
2695
|
+
for(const sheet of Array.from(document.styleSheets||[])){
|
|
2696
|
+
let rules;try{rules=sheet.cssRules;}catch{continue;}
|
|
2697
|
+
for(const rule of Array.from(rules||[])){
|
|
2698
|
+
if(rule.selectorText===undefined)continue; // skip @media / @supports etc.
|
|
2699
|
+
const tv=rule.style&&rule.style.getPropertyValue("transition");
|
|
2700
|
+
if(!tv||!/^\s*none\s*$/i.test(tv))continue;
|
|
2701
|
+
for(const part of rule.selectorText.split(",")){
|
|
2702
|
+
const s=part.trim(); if(!s)continue;
|
|
2703
|
+
// subject = rightmost compound that carries one of the element's tokens.
|
|
2704
|
+
const compounds=s.split(/\s*[>+~]\s*|\s+/).filter(Boolean);
|
|
2705
|
+
let subject=null;
|
|
2706
|
+
for(let i=compounds.length-1;i>=0;i--){ if(tokRes.some(re=>re.test(compounds[i]))){subject=compounds[i];break;} }
|
|
2707
|
+
if(!subject)continue;
|
|
2708
|
+
const subjClasses=(subject.match(/\.[-\w]+/g)||[]).map(c=>c.slice(1));
|
|
2709
|
+
// require a state class the element isn't currently wearing.
|
|
2710
|
+
if(subjClasses.some(c=>!ownSet.has(c)))out.add(s);
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2714
|
+
}
|
|
2715
|
+
}catch{}
|
|
2716
|
+
_TL_NONE_CACHE.set(el,out);
|
|
2717
|
+
return out;
|
|
2718
|
+
}
|
|
2719
|
+
function tlSyncNoneGuard(id,selectors){
|
|
2720
|
+
try{
|
|
2721
|
+
let st=document.getElementById(id);
|
|
2722
|
+
if(!selectors||!selectors.size){ if(st)st.remove(); return; }
|
|
2723
|
+
if(!st){ st=document.createElement("style"); st.id=id; (document.head||document.documentElement).appendChild(st); }
|
|
2724
|
+
const css=Array.from(selectors).map(s=>s+"{transition:none !important;}").join("\n");
|
|
2725
|
+
if(st.textContent!==css)st.textContent=css;
|
|
2726
|
+
}catch{}
|
|
2727
|
+
}
|
|
2728
|
+
const TL_LP_GUARD_FLAT_ID="tl-live-none-guard-flat";
|
|
2729
|
+
const TL_LP_GUARD_PHASE_ID="tl-live-none-guard-phase";
|
|
2669
2730
|
// Same, resolving the element from a CSS selector (used when only a lane's
|
|
2670
2731
|
// selector is known, e.g. a grouped phase member).
|
|
2671
2732
|
function laneValueHint(selector, property){
|
|
@@ -3109,9 +3170,11 @@
|
|
|
3109
3170
|
return r.json();
|
|
3110
3171
|
}
|
|
3111
3172
|
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3173
|
+
// Scan SCOPE (Replace tab only): scan just the selected transition, or every
|
|
3174
|
+
// detected transition and propose a replacement for each good recipe match.
|
|
3175
|
+
const REFINE_SCOPES=[
|
|
3176
|
+
{key:"selected",label:"Selected transition",desc:"Scan only the transition you have selected."},
|
|
3177
|
+
{key:"all",label:"All transitions",desc:"Scan every detected transition and suggest a replacement for each good recipe match."},
|
|
3115
3178
|
];
|
|
3116
3179
|
const REFINE_TYPES=[
|
|
3117
3180
|
{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 +3331,12 @@
|
|
|
3268
3331
|
// top row: member chip + property, or a single heading for replace cards
|
|
3269
3332
|
h("div",{className:"tl-sug-head"},
|
|
3270
3333
|
isReplace
|
|
3271
|
-
? h(
|
|
3334
|
+
? h(React.Fragment,null,
|
|
3335
|
+
// "All transitions" scope tags each replace card with the
|
|
3336
|
+
// transition/component it targets so a multi-suggestion list
|
|
3337
|
+
// is legible; single-scope replaces omit it.
|
|
3338
|
+
s._targetLabel&&h("span",{className:"tl-sug-member"},s._targetLabel),
|
|
3339
|
+
h("span",{className:"tl-sug-prop"},s.title||"Transition replacement"))
|
|
3272
3340
|
: h(React.Fragment,null,
|
|
3273
3341
|
member&&h("span",{className:"tl-sug-member"},member),
|
|
3274
3342
|
prop&&h("span",{className:"tl-sug-prop"},prop))),
|
|
@@ -3333,14 +3401,15 @@
|
|
|
3333
3401
|
onReconnect&&h("button",{className:"tl-gate-recheck",disabled:rechecking,onClick:recheck},
|
|
3334
3402
|
rechecking?(relayDown?"Retrying…":"Reconnecting…"):(relayDown?"Retry connection":"Reconnect agent")));
|
|
3335
3403
|
}
|
|
3336
|
-
function RefinePanel({open,onClose,phase,label,refineType,onType,suggestions,summary,error,appliedIds,onApply,onApplyAll,
|
|
3404
|
+
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
3405
|
// mount-on-open; keep mounted through the panel-reveal slide-out, then unmount
|
|
3338
3406
|
const[render,setRender]=useState(open);
|
|
3339
3407
|
const[panelOpen,setPanelOpen]=useState(false);
|
|
3340
3408
|
const[slidePhase,setSlidePhase]=useState("opening");
|
|
3341
|
-
|
|
3409
|
+
// scope selector dropdown (Replace tab only)
|
|
3410
|
+
const[scopeOpen,setScopeOpen]=useState(false);
|
|
3342
3411
|
const[statusIx,setStatusIx]=useState(0);
|
|
3343
|
-
const
|
|
3412
|
+
const scopeRef=useRef(null);
|
|
3344
3413
|
// The results reveal should play once per scan, not on every tab switch.
|
|
3345
3414
|
// This persists across RefineResults remounts; reset it when a new scan
|
|
3346
3415
|
// starts (or the panel returns to idle) so fresh results animate again.
|
|
@@ -3365,10 +3434,10 @@
|
|
|
3365
3434
|
// close on Escape — dismiss the mode dropdown first if it's open
|
|
3366
3435
|
useEffect(()=>{
|
|
3367
3436
|
if(!open)return;
|
|
3368
|
-
const onKey=e=>{if(e.key!=="Escape")return;if(
|
|
3437
|
+
const onKey=e=>{if(e.key!=="Escape")return;if(scopeOpen){setScopeOpen(false);return;}onClose();};
|
|
3369
3438
|
window.addEventListener("keydown",onKey);
|
|
3370
3439
|
return()=>window.removeEventListener("keydown",onKey);
|
|
3371
|
-
},[open,onClose,
|
|
3440
|
+
},[open,onClose,scopeOpen]);
|
|
3372
3441
|
// cycle the in-progress status copy while scanning (matches the design's
|
|
3373
3442
|
// "Scanning… → Reading the Transitions.dev skill → Suggesting edits").
|
|
3374
3443
|
useEffect(()=>{
|
|
@@ -3402,15 +3471,11 @@
|
|
|
3402
3471
|
// Replace transition → kind "replace"; Small refinements → token tweaks.
|
|
3403
3472
|
const visible = suggestions.filter(s=> refineType==="replace" ? s.kind==="replace" : s.kind!=="replace");
|
|
3404
3473
|
const pending = visible.filter(s=>!appliedIds[s.id]);
|
|
3405
|
-
const agentMode = mode==="llm";
|
|
3406
3474
|
// null (unknown / still probing) is treated as ready so the panel doesn't
|
|
3407
|
-
// flash the "unavailable" copy before /health resolves.
|
|
3408
|
-
|
|
3409
|
-
const
|
|
3410
|
-
|
|
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).
|
|
3475
|
+
// flash the "unavailable" copy before /health resolves. All scanning goes
|
|
3476
|
+
// through the agent now, so readiness is purely "is a live agent wired".
|
|
3477
|
+
const agentReady = llmAvailable!==false;
|
|
3478
|
+
const scopeLabel = (REFINE_SCOPES.find(s=>s.key===scope)||REFINE_SCOPES[0]).label;
|
|
3414
3479
|
// One persistent control for the whole foot: in idle/done/error it's the
|
|
3415
3480
|
// pill button; while scanning it carries `.is-scanning` and the same DOM
|
|
3416
3481
|
// node morphs (card resize) into the loading rectangle with the border-beam.
|
|
@@ -3467,9 +3532,7 @@
|
|
|
3467
3532
|
h("div",{className:"tl-refine-unavail-title"},"Connect with your agent"),
|
|
3468
3533
|
h(AgentConnectBody,{cliMissing:cliInstalled===false,relayDown,reason:agentReason,onReconnect}));
|
|
3469
3534
|
}
|
|
3470
|
-
const idleDescR =
|
|
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.");
|
|
3535
|
+
const idleDescR = (REFINE_TYPES.find(t=>t.key===rt)||REFINE_TYPES[0]).desc;
|
|
3473
3536
|
return h("div",{className:"tl-refine-center"},
|
|
3474
3537
|
h("p",{className:"tl-refine-idle-text"},idleDescR));
|
|
3475
3538
|
};
|
|
@@ -3534,19 +3597,22 @@
|
|
|
3534
3597
|
h("h3",null,"Refine"),
|
|
3535
3598
|
h("p",null,label||"Transitions review")),
|
|
3536
3599
|
h("div",{className:"tl-refine-actions"},
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3600
|
+
// Scan-scope selector — Replace tab only (Small refinements always
|
|
3601
|
+
// scan just the selected transition). Same control the Agent/
|
|
3602
|
+
// Deterministic mode dropdown used to be.
|
|
3603
|
+
refineType==="replace"&&h("button",{ref:scopeRef,className:cx("tl-refine-mode",scopeOpen&&"is-open"),
|
|
3604
|
+
"aria-haspopup":"menu","aria-expanded":scopeOpen?"true":"false",onClick:()=>setScopeOpen(v=>!v)},
|
|
3605
|
+
h("span",null,scopeLabel),
|
|
3540
3606
|
h(Ic,{name:"chevron",size:16})),
|
|
3541
3607
|
h("button",{className:"tl-refine-close","aria-label":"Close refine panel",onClick:onClose},
|
|
3542
3608
|
h(Ic,{name:"close",size:16})))),
|
|
3543
|
-
h(Dropdown,{open:
|
|
3544
|
-
|
|
3545
|
-
onClick:()=>{if(
|
|
3609
|
+
refineType==="replace"&&h(Dropdown,{open:scopeOpen,onClose:()=>setScopeOpen(false),triggerRef:scopeRef,width:276,align:"right"},
|
|
3610
|
+
REFINE_SCOPES.map(sc=>h("button",{key:sc.key,className:"tl-mode-row",
|
|
3611
|
+
onClick:()=>{if(sc.key!==scope)onScope(sc.key);setScopeOpen(false);}},
|
|
3546
3612
|
h("div",{className:"tl-mode-row-main"},
|
|
3547
|
-
h("div",{className:"tl-mode-row-title"},
|
|
3548
|
-
h("div",{className:"tl-mode-row-desc"},
|
|
3549
|
-
(
|
|
3613
|
+
h("div",{className:"tl-mode-row-title"},sc.label),
|
|
3614
|
+
h("div",{className:"tl-mode-row-desc"},sc.desc)),
|
|
3615
|
+
(scope===sc.key)&&h("span",{className:"tl-mode-row-check"},h(Ic,{name:"accept",size:16}))))),
|
|
3550
3616
|
h("div",{className:"tl-refine-tabs",role:"tablist","aria-label":"Refinement type"},
|
|
3551
3617
|
REFINE_TYPES.map(t=>h("button",{key:t.key,className:"tl-refine-tab",role:"tab",
|
|
3552
3618
|
"aria-selected":refineType===t.key?"true":"false",
|
|
@@ -4625,10 +4691,7 @@
|
|
|
4625
4691
|
const[refineSuggestions,setRefineSuggestions]=useState([]);
|
|
4626
4692
|
const[refineSummary,setRefineSummary]=useState(null);
|
|
4627
4693
|
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
4694
|
const scanStartedAtRef=useRef(0);
|
|
4631
|
-
const scanModeRef=useRef("llm");
|
|
4632
4695
|
// ── accept (write to source) ──
|
|
4633
4696
|
const[acceptState,setAcceptState]=useState("idle"); // idle | saving | done | error
|
|
4634
4697
|
const[acceptError,setAcceptError]=useState(null);
|
|
@@ -4659,7 +4722,12 @@
|
|
|
4659
4722
|
const GROUP_SCAN_MAX_RETRIES=2;
|
|
4660
4723
|
const[refineLabel,setRefineLabel]=useState(null);
|
|
4661
4724
|
const[appliedIds,setAppliedIds]=useState({});
|
|
4662
|
-
|
|
4725
|
+
// scan SCOPE for the Replace tab — "selected" (only the active transition)
|
|
4726
|
+
// or "all" (every detected transition). Persisted like the tab/theme prefs;
|
|
4727
|
+
// defaults to "selected". Small refinements always scan just the selection.
|
|
4728
|
+
const[refineScope,setRefineScope]=useState(()=>{
|
|
4729
|
+
try{const v=localStorage.getItem("tl-refine-scope-pref");return v==="all"?"all":"selected";}catch{return "selected";}
|
|
4730
|
+
});
|
|
4663
4731
|
// small | replace — last selected tab persists (same pattern as tl-theme-pref)
|
|
4664
4732
|
const[refineType,setRefineType]=useState(()=>{
|
|
4665
4733
|
try{const v=localStorage.getItem("tl-refine-tab-pref");return v==="small"||v==="replace"?v:"small";}catch{return "small";}
|
|
@@ -4754,10 +4822,9 @@
|
|
|
4754
4822
|
},[active,refineOpen,refreshHealth]);
|
|
4755
4823
|
// "Start scanning" resolves availability first, then posts the job.
|
|
4756
4824
|
// per-(transition,type) result cache so switching tabs reuses a prior scan
|
|
4757
|
-
// instead of re-asking the agent.
|
|
4758
|
-
//
|
|
4759
|
-
//
|
|
4760
|
-
// a single instant "both" pass that feeds both tabs.
|
|
4825
|
+
// instead of re-asking the agent. Each scan is scoped to the active tab
|
|
4826
|
+
// (Small = token tweaks, Replace = recipe) so the common Small view skips
|
|
4827
|
+
// the recipe selection + reference-file read.
|
|
4761
4828
|
const refineCacheRef=useRef(new Map()); // `${id}::${type}` -> {suggestions,summary}
|
|
4762
4829
|
const refineScanTypeRef=useRef("small"); // which type the in-flight job targets
|
|
4763
4830
|
// results are not comparable across transitions — drop the cache when the
|
|
@@ -4776,64 +4843,96 @@
|
|
|
4776
4843
|
const startScan=useCallback(async(typeArg,opts)=>{
|
|
4777
4844
|
if(!active)return;
|
|
4778
4845
|
opts=opts||{};
|
|
4779
|
-
//
|
|
4780
|
-
//
|
|
4781
|
-
// available so the first click isn't blocked
|
|
4782
|
-
// the POST below (→ error state).
|
|
4783
|
-
const
|
|
4784
|
-
|
|
4785
|
-
|
|
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.
|
|
4846
|
+
// All scanning goes through the agent now (the deterministic path is gone).
|
|
4847
|
+
// Availability is resolved from the continuously-polled /health; unknown
|
|
4848
|
+
// (null) counts as available so the first click isn't blocked, and a dead
|
|
4849
|
+
// relay surfaces via the POST below (→ error state).
|
|
4850
|
+
const mode="llm";
|
|
4851
|
+
// NOTE: also wired as a button onClick, so typeArg may be a click event —
|
|
4852
|
+
// only honour it when it's an explicit "small"/"replace" string.
|
|
4791
4853
|
const reqType=(typeArg==="small"||typeArg==="replace")?typeArg:refineType;
|
|
4792
|
-
const type=
|
|
4854
|
+
const type=reqType;
|
|
4855
|
+
// Scope only applies to the Replace tab — Small always scans the selection.
|
|
4856
|
+
const scope=(reqType==="replace")?refineScope:"selected";
|
|
4793
4857
|
refineScanTypeRef.current=type;
|
|
4794
4858
|
scanStartedAtRef.current=Date.now();
|
|
4795
4859
|
setRefinePhase("scanning");setRefineLog([]);setRefineError(null);
|
|
4796
|
-
setRefineLabel(active.label);setRefineJobId(null);
|
|
4860
|
+
setRefineLabel(scope==="all"?"All transitions":active.label);setRefineJobId(null);
|
|
4797
4861
|
if(!opts.keepApplied)setAppliedIds({});
|
|
4798
4862
|
// re-scan only this type; keep the other tab's cached result for the merge.
|
|
4799
4863
|
refineCacheRef.current.delete(active.id+"::"+type);
|
|
4864
|
+
// attach the lane's non-resting scale/blur/translate value (+ backing var)
|
|
4865
|
+
// so the agent can refine transform/filter VALUES too.
|
|
4866
|
+
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,
|
|
4867
|
+
...(v.scale!=null?{scale:v.scale}:{}),...(v.blur!=null?{blur:v.blur}:{}),...(v.varName?{varName:v.varName}:{}),
|
|
4868
|
+
...(v.translate!=null?{translate:v.translate}:{}),...(v.translateVarName?{translateVarName:v.translateVarName}:{})};};
|
|
4800
4869
|
try{
|
|
4870
|
+
if(scope==="all"){
|
|
4871
|
+
// Bundle EVERY detected transition — one per agent group (with its
|
|
4872
|
+
// related open/close phases) plus each ungrouped flat entry — so the
|
|
4873
|
+
// agent can return a LIST of replace suggestions, one per transition
|
|
4874
|
+
// it finds a good recipe for. Each suggestion echoes its transitionId
|
|
4875
|
+
// so Apply targets the right transition.
|
|
4876
|
+
const seenGroup=new Set();
|
|
4877
|
+
const transitions=[];
|
|
4878
|
+
for(const e of entries){
|
|
4879
|
+
if(e.kind==="phase"){
|
|
4880
|
+
if(!e.groupId||seenGroup.has(e.groupId))continue;
|
|
4881
|
+
seenGroup.add(e.groupId);
|
|
4882
|
+
const gp=entries.filter(x=>x.kind==="phase"&&x.groupId===e.groupId);
|
|
4883
|
+
const rep=gp[0]||e;
|
|
4884
|
+
const ret=rep.effectiveTimings||[];
|
|
4885
|
+
const sel=(ret[0]&&ret[0].selector)||null;
|
|
4886
|
+
const ph=gp.length>1?gp.map(x=>({phase:x.phase||x.phaseLabel||"phase",label:x.phaseLabel||x.phase||"Phase",
|
|
4887
|
+
timings:(x.effectiveTimings||[]).map(withVal)})):undefined;
|
|
4888
|
+
transitions.push({transitionId:rep.id,label:rep.groupLabel||rep.label,selector:sel,timings:ret.map(withVal),phases:ph});
|
|
4889
|
+
}else{
|
|
4890
|
+
const fet=e.effectiveTimings||[];
|
|
4891
|
+
const sel=(e.bindings&&e.bindings.selector)||(fet[0]&&fet[0].selector)||null;
|
|
4892
|
+
transitions.push({transitionId:e.id,label:e.label,selector:sel,timings:fet.map(withVal)});
|
|
4893
|
+
}
|
|
4894
|
+
}
|
|
4895
|
+
const{id}=await relayCreateJob({scope:"all",label:"All transitions",mode,refineType:"replace",transitions});
|
|
4896
|
+
setRefineJobId(id);
|
|
4897
|
+
return;
|
|
4898
|
+
}
|
|
4801
4899
|
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
4900
|
const timings=et.map(withVal);
|
|
4808
4901
|
const selector=(active.bindings&&active.bindings.selector)||(et[0]&&et[0].selector)||null;
|
|
4809
|
-
// A recipe swap (replace
|
|
4902
|
+
// A recipe swap (replace) on a grouped phase should update its related
|
|
4810
4903
|
// phases (open + close) together — they're one motion. Send the whole
|
|
4811
4904
|
// group's phases so the agent can return a per-phase `patches` array.
|
|
4812
4905
|
let phases;
|
|
4813
|
-
if(
|
|
4906
|
+
if(type==="replace"&&active.kind==="phase"&&active.groupId){
|
|
4814
4907
|
const gp=entries.filter(e=>e.kind==="phase"&&e.groupId===active.groupId);
|
|
4815
4908
|
if(gp.length>1)phases=gp.map(e=>({phase:e.phase||e.phaseLabel||"phase",label:e.phaseLabel||e.phase||"Phase",
|
|
4816
4909
|
timings:(e.effectiveTimings||[]).map(withVal)}));
|
|
4817
4910
|
}
|
|
4818
4911
|
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});
|
|
4912
|
+
phase:active.phase||null,group:active.groupLabel||null,timings,mode,refineType:type,scope:"selected",phases});
|
|
4820
4913
|
setRefineJobId(id);
|
|
4821
4914
|
}catch(e){
|
|
4822
4915
|
setRefinePhase("error");
|
|
4823
4916
|
setRefineError(h(React.Fragment,null,"Couldn't reach the refine relay. Start it with ",h("code",{className:"tl-code"},"npx transitions-refine live"),"."));
|
|
4824
4917
|
}
|
|
4825
|
-
},[active,
|
|
4826
|
-
|
|
4827
|
-
//
|
|
4828
|
-
//
|
|
4829
|
-
|
|
4830
|
-
|
|
4831
|
-
|
|
4918
|
+
},[active,refineScope,refineType,llmAvailable,entries]);
|
|
4919
|
+
// Switching scope changes the Replace scan's shape (one transition vs all),
|
|
4920
|
+
// so drop cached results and fall back to idle — the next scan uses the new
|
|
4921
|
+
// scope. Persist the choice like the tab/theme prefs.
|
|
4922
|
+
const changeRefineScope=useCallback((sc)=>{
|
|
4923
|
+
setRefineScope(sc);
|
|
4924
|
+
try{localStorage.setItem("tl-refine-scope-pref",sc);}catch{}
|
|
4925
|
+
refineCacheRef.current.clear();
|
|
4926
|
+
setRefinePhase(p=>(p==="done"||p==="error")?"idle":p);
|
|
4927
|
+
},[]);
|
|
4928
|
+
// Switching tabs reuses a cached scan when present; otherwise the tab drops
|
|
4929
|
+
// back to its idle state and waits for an explicit Start-scanning click —
|
|
4930
|
+
// switching never auto-runs a scan. Before any scan (idle/error), tabs just
|
|
4931
|
+
// preview copy and never auto-scan.
|
|
4832
4932
|
const changeRefineType=useCallback((t)=>{
|
|
4833
4933
|
setRefineType(t);
|
|
4834
4934
|
try{localStorage.setItem("tl-refine-tab-pref",t);}catch{}
|
|
4835
4935
|
if(!active)return;
|
|
4836
|
-
if(scanModeRef.current==="deterministic")return;
|
|
4837
4936
|
if(refinePhase!=="done"&&refinePhase!=="scanning")return;
|
|
4838
4937
|
if(refineCacheRef.current.has(active.id+"::"+t)){applyMerged(active.id);setRefinePhase("done");return;}
|
|
4839
4938
|
// No cached result for this tab → show its idle scan button and wait for
|
|
@@ -4856,13 +4955,23 @@
|
|
|
4856
4955
|
// (so the other tab's cached suggestions stay visible).
|
|
4857
4956
|
const type=refineScanTypeRef.current;
|
|
4858
4957
|
const id=active?active.id:"";
|
|
4859
|
-
|
|
4860
|
-
|
|
4958
|
+
// "All transitions" scope returns MANY replace suggestions, each
|
|
4959
|
+
// carrying the `transitionId` it targets. Tag each with the target
|
|
4960
|
+
// transition's label (for the card) and namespace its id by that
|
|
4961
|
+
// transitionId so multiple replaces stay uniquely keyed for Apply.
|
|
4962
|
+
const rawSugs=(job.result&&job.result.suggestions)||[];
|
|
4963
|
+
const sugs=rawSugs.map((s,i)=>{
|
|
4964
|
+
if(s&&s.transitionId){
|
|
4965
|
+
const ent=entries.find(e=>e.id===s.transitionId);
|
|
4966
|
+
return {...s,id:s.transitionId+"::"+(s.id||s.kind||("sug"+i)),
|
|
4967
|
+
_targetLabel:ent?(ent.groupLabel||ent.label):null};
|
|
4968
|
+
}
|
|
4969
|
+
return s;
|
|
4970
|
+
});
|
|
4971
|
+
refineCacheRef.current.set(id+"::"+type,{suggestions:sugs,summary:(job.result&&job.result.summary)||null});
|
|
4972
|
+
if(active)applyMerged(active.id);else{setRefineSuggestions(sugs);setRefineSummary(job.result&&job.result.summary);}
|
|
4861
4973
|
setRefinePhase("done");};
|
|
4862
|
-
|
|
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();}
|
|
4974
|
+
finish();
|
|
4866
4975
|
return;
|
|
4867
4976
|
}
|
|
4868
4977
|
if(job.status==="error"){setRefineError(job.error||"The agent reported an error.");setRefinePhase("error");return;}
|
|
@@ -4873,14 +4982,20 @@
|
|
|
4873
4982
|
return()=>{live=false;if(to)clearTimeout(to);};
|
|
4874
4983
|
},[refineJobId,refinePhase]);
|
|
4875
4984
|
const applySuggestion=useCallback((s)=>{
|
|
4985
|
+
// Resolve which transition this suggestion targets. In "All transitions"
|
|
4986
|
+
// scope every suggestion carries its own `transitionId`; otherwise it
|
|
4987
|
+
// applies to the currently-active transition. Everything below writes to
|
|
4988
|
+
// `tgt` (and its sibling phases), so multi-transition Apply just works.
|
|
4989
|
+
const tgt=(s&&s.transitionId?entries.find(e=>e.id===s.transitionId):null)||active;
|
|
4990
|
+
if(!tgt)return;
|
|
4876
4991
|
// A replace suggestion may carry `patches` (one per related phase, e.g.
|
|
4877
4992
|
// open + close) — a recipe swap is one motion, so applying it writes BOTH.
|
|
4878
|
-
// Otherwise fall back to the single `patch` on the
|
|
4993
|
+
// Otherwise fall back to the single `patch` on the target phase.
|
|
4879
4994
|
let patches=Array.isArray(s.patches)&&s.patches.length
|
|
4880
4995
|
? s.patches
|
|
4881
|
-
: (s.patch&&s.patch.property?[{...s.patch,phase:(
|
|
4996
|
+
: (s.patch&&s.patch.property?[{...s.patch,phase:(tgt&&tgt.phase)||null}]:[]);
|
|
4882
4997
|
if(!patches.length)return;
|
|
4883
|
-
const groupId=
|
|
4998
|
+
const groupId=tgt&&tgt.groupId;
|
|
4884
4999
|
// Same-slot swap recipes (icon/text swaps) use two stacked members in one
|
|
4885
5000
|
// group. The agent can sometimes return the recipe's blur/scale value for
|
|
4886
5001
|
// only the currently selected member; expand those value patches to every
|
|
@@ -4891,7 +5006,7 @@
|
|
|
4891
5006
|
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
5007
|
const valuePatches=patches.filter(p=>p.member&&(p.blur!=null||p.scale!=null)&&(p.property==="filter"||p.property==="transform"));
|
|
4893
5008
|
for(const p of valuePatches){
|
|
4894
|
-
const target=entries.find(e=>e.kind==="phase"&&e.groupId===groupId&&(!p.phase||e.phase===p.phase))||
|
|
5009
|
+
const target=entries.find(e=>e.kind==="phase"&&e.groupId===groupId&&(!p.phase||e.phase===p.phase))||tgt;
|
|
4895
5010
|
const members=new Map();
|
|
4896
5011
|
for(const t of (target&&target.effectiveTimings)||[]){
|
|
4897
5012
|
const m=t.member||t.memberId;if(m&&!members.has(m))members.set(m,m);
|
|
@@ -4933,7 +5048,7 @@
|
|
|
4933
5048
|
if(p.easing!=null)timingOverride.easing=p.easing;
|
|
4934
5049
|
// resolve the phase entry this patch targets (its sibling open/close
|
|
4935
5050
|
// lives in the same group); default to the active entry.
|
|
4936
|
-
let target=
|
|
5051
|
+
let target=tgt;
|
|
4937
5052
|
if(p.phase&&groupId){
|
|
4938
5053
|
const found=entries.find(e=>e.kind==="phase"&&e.groupId===groupId&&e.phase===p.phase);
|
|
4939
5054
|
if(found)target=found;
|
|
@@ -5093,12 +5208,17 @@
|
|
|
5093
5208
|
_TX_LIVE_BASE.delete(el);
|
|
5094
5209
|
applied.delete(el);
|
|
5095
5210
|
}
|
|
5211
|
+
// keep FLIP jump-to-start states instant under our inline transition
|
|
5212
|
+
const guard=new Set();
|
|
5213
|
+
for(const el of desired.keys()){ for(const s of tlCollectNoneSelectors(el)) guard.add(s); }
|
|
5214
|
+
tlSyncNoneGuard(TL_LP_GUARD_FLAT_ID,guard);
|
|
5096
5215
|
},[entries,registry]);
|
|
5097
5216
|
// restore everything on unmount
|
|
5098
5217
|
useEffect(()=>()=>{
|
|
5099
5218
|
const applied=liveAppliedRef.current;
|
|
5100
5219
|
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); }
|
|
5101
5220
|
applied.clear();
|
|
5221
|
+
tlSyncNoneGuard(TL_LP_GUARD_FLAT_ID,null);
|
|
5102
5222
|
},[]);
|
|
5103
5223
|
// ── live styling: grouped phases (state-gated) ──────────────────────────
|
|
5104
5224
|
// Open and close of one component share the SAME element(s), so a single
|
|
@@ -5223,6 +5343,10 @@
|
|
|
5223
5343
|
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{}}
|
|
5224
5344
|
store.applied.delete(el);
|
|
5225
5345
|
}
|
|
5346
|
+
// keep FLIP jump-to-start states instant under our inline transition
|
|
5347
|
+
const guard=new Set();
|
|
5348
|
+
for(const el of desiredCss.keys()){ for(const s of tlCollectNoneSelectors(el)) guard.add(s); }
|
|
5349
|
+
tlSyncNoneGuard(TL_LP_GUARD_PHASE_ID,guard);
|
|
5226
5350
|
};
|
|
5227
5351
|
// Coalesce via microtask (NOT rAF): the gate must update the moment the
|
|
5228
5352
|
// state class/attr flips — before the browser styles the next frame — so
|
|
@@ -5619,7 +5743,7 @@
|
|
|
5619
5743
|
h(RefinePanel,{open:refineOpen,onClose:()=>setRefineOpen(false),phase:refinePhase,label:refineLabel,
|
|
5620
5744
|
refineType,onType:changeRefineType,suggestions:refineSuggestions,summary:refineSummary,error:refineError,
|
|
5621
5745
|
appliedIds,onApply:applySuggestion,onApplyAll:applyAllSuggestions,
|
|
5622
|
-
|
|
5746
|
+
scope:refineScope,onScope:changeRefineScope,llmAvailable,cliInstalled,relayDown,agentReason,onReconnect:reconnectAgent,onStart:startScan,
|
|
5623
5747
|
lanes:active?.effectiveTimings||[],resolvedTheme}))
|
|
5624
5748
|
: h("div",{className:"tl-panel-main"},
|
|
5625
5749
|
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.
|
|
3
|
+
"version": "0.3.33",
|
|
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";
|