transitions-refine 0.3.21 → 0.3.23
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 +83 -25
- package/package.json +1 -1
- package/server/group-deterministic.mjs +370 -0
- package/server/relay.mjs +21 -0
package/demo.html
CHANGED
|
@@ -1202,8 +1202,11 @@
|
|
|
1202
1202
|
gap: 8px; box-sizing: border-box; padding: 7.5px 7.5px 7.5px 14px;
|
|
1203
1203
|
border-bottom: 1px solid #f0f0f0; }
|
|
1204
1204
|
.tl-refine-titles { min-width: 0; }
|
|
1205
|
-
|
|
1206
|
-
|
|
1205
|
+
/* Explicit margin:0 — the injected build strips the demo-only global *{margin:0}
|
|
1206
|
+
reset, so without this h3/p inherit the UA default block margins (~1em) and
|
|
1207
|
+
the header balloons to ~82px instead of matching the 52px timeline top bar. */
|
|
1208
|
+
.tl-refine-titles h3 { margin: 0; font-size: 13px; font-weight: 500; line-height: 14px; color: #17181c; }
|
|
1209
|
+
.tl-refine-titles p { margin: 2px 0 0; font-size: 12px; font-weight: 400; line-height: 14px; color: #676767;
|
|
1207
1210
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 190px; }
|
|
1208
1211
|
.tl-refine-actions { flex: none; display: flex; align-items: center; gap: 2px; }
|
|
1209
1212
|
.tl-refine-mode { display: flex; align-items: center; gap: 6px; height: 36px;
|
|
@@ -2686,7 +2689,7 @@
|
|
|
2686
2689
|
// Shared "Connect with your agent" copy — used by the full-panel gate and by
|
|
2687
2690
|
// the Refine area when no live agent is connected, so both read identically.
|
|
2688
2691
|
// `cliMissing` swaps the body to an install-first hint.
|
|
2689
|
-
function AgentConnectBody({cliMissing,reason,onReconnect}){
|
|
2692
|
+
function AgentConnectBody({cliMissing,relayDown,reason,onReconnect}){
|
|
2690
2693
|
const[rechecking,setRechecking]=useState(false);
|
|
2691
2694
|
const recheck=async()=>{
|
|
2692
2695
|
if(!onReconnect||rechecking) return;
|
|
@@ -2694,7 +2697,15 @@
|
|
|
2694
2697
|
try{ await onReconnect(); } finally { setRechecking(false); }
|
|
2695
2698
|
};
|
|
2696
2699
|
return h(React.Fragment,null,
|
|
2697
|
-
|
|
2700
|
+
relayDown
|
|
2701
|
+
// The relay isn't reachable at all — Reconnect can't help (it talks to a
|
|
2702
|
+
// running relay). The only fix is starting the relay again.
|
|
2703
|
+
? h("p",{className:"tl-gate-text"},
|
|
2704
|
+
"The relay isn’t running. Start it with ",
|
|
2705
|
+
h("code",{className:"tl-code"},"npx transitions-refine live"),
|
|
2706
|
+
" in your project (or ",h("code",{className:"tl-code"},"/refine live"),
|
|
2707
|
+
" in your editor), then retry.")
|
|
2708
|
+
: cliMissing
|
|
2698
2709
|
? h("p",{className:"tl-gate-text"},"Install an agent CLI, then run ",
|
|
2699
2710
|
h("code",{className:"tl-code"},"npx transitions-refine live"),
|
|
2700
2711
|
" (recommended) or ",h("code",{className:"tl-code"},"/refine live")," in your editor.")
|
|
@@ -2704,13 +2715,14 @@
|
|
|
2704
2715
|
h("code",{className:"tl-code"},"/refine live"),
|
|
2705
2716
|
" in your editor if the relay is already up without an agent wired."),
|
|
2706
2717
|
// Surface WHY the relay couldn't wire a CLI (from /health agentReason) so a
|
|
2707
|
-
// silent fall-back to poller mode is never a mystery.
|
|
2708
|
-
//
|
|
2709
|
-
|
|
2718
|
+
// silent fall-back to poller mode is never a mystery. Hidden when the relay
|
|
2719
|
+
// is down (the reason is stale). The relay self-heals on a TTL; Reconnect
|
|
2720
|
+
// forces an immediate re-check (or just re-probes /health if it's down).
|
|
2721
|
+
!relayDown&&reason&&h("p",{className:"tl-gate-reason"},reason),
|
|
2710
2722
|
onReconnect&&h("button",{className:"tl-gate-recheck",disabled:rechecking,onClick:recheck},
|
|
2711
|
-
rechecking?"Reconnecting…":"Reconnect agent"));
|
|
2723
|
+
rechecking?(relayDown?"Retrying…":"Reconnecting…"):(relayDown?"Retry connection":"Reconnect agent")));
|
|
2712
2724
|
}
|
|
2713
|
-
function RefinePanel({open,onClose,phase,label,refineType,onType,suggestions,summary,error,appliedIds,onApply,onApplyAll,mode,onMode,llmAvailable,cliInstalled,agentReason,onReconnect,onStart,lanes}){
|
|
2725
|
+
function RefinePanel({open,onClose,phase,label,refineType,onType,suggestions,summary,error,appliedIds,onApply,onApplyAll,mode,onMode,llmAvailable,cliInstalled,relayDown,agentReason,onReconnect,onStart,lanes}){
|
|
2714
2726
|
// mount-on-open; keep mounted through the panel-reveal slide-out, then unmount
|
|
2715
2727
|
const[render,setRender]=useState(open);
|
|
2716
2728
|
const[panelOpen,setPanelOpen]=useState(false);
|
|
@@ -2820,7 +2832,7 @@
|
|
|
2820
2832
|
// /refine live makes the agent available and Start scanning works again.
|
|
2821
2833
|
return h("div",{className:"tl-refine-center"},
|
|
2822
2834
|
h("div",{className:"tl-refine-unavail-title"},"Connect with your agent"),
|
|
2823
|
-
h(AgentConnectBody,{cliMissing:cliInstalled===false,reason:agentReason,onReconnect}));
|
|
2835
|
+
h(AgentConnectBody,{cliMissing:cliInstalled===false,relayDown,reason:agentReason,onReconnect}));
|
|
2824
2836
|
}
|
|
2825
2837
|
const idleDescR = agentMode ? (REFINE_TYPES.find(t=>t.key===rt)||REFINE_TYPES[0]).desc : (rt==="replace"
|
|
2826
2838
|
? "Deterministic mode can't pick a recipe to replace the transition — switch to Agent mode for replacements."
|
|
@@ -3812,6 +3824,7 @@
|
|
|
3812
3824
|
const[llmAvailable,setLlmAvailable]=useState(null); // null=unknown, true/false from /health
|
|
3813
3825
|
const[cliInstalled,setCliInstalled]=useState(null); // null=unknown, true/false from /health
|
|
3814
3826
|
const[agentReason,setAgentReason]=useState(null); // why no CLI is wired (/health agentReason)
|
|
3827
|
+
const[relayDown,setRelayDown]=useState(false); // /health unreachable → relay not running
|
|
3815
3828
|
// chatLoop = LLM served by the in-chat `/refine live` poll loop (pollerActive
|
|
3816
3829
|
// and no wired REFINE_AGENT_CMD). This is the mode that bills agent turns
|
|
3817
3830
|
// while idle, so the header shows a credit warning + Stop for it.
|
|
@@ -3833,7 +3846,7 @@
|
|
|
3833
3846
|
const chatLoopRef=useRef(false);
|
|
3834
3847
|
// probe the relay so the idle panel can show the right availability copy
|
|
3835
3848
|
const refreshHealth=useCallback(async()=>{
|
|
3836
|
-
try{const j=await relayHealth();setLlmAvailable(!!j.llmAvailable);
|
|
3849
|
+
try{const j=await relayHealth();setRelayDown(false);setLlmAvailable(!!j.llmAvailable);
|
|
3837
3850
|
setCliInstalled(j.cliInstalled==null?null:!!j.cliInstalled);
|
|
3838
3851
|
setAgentReason(j.agentReason||null);
|
|
3839
3852
|
const loop=!!j.pollerActive&&!j.agentCmd;
|
|
@@ -3844,7 +3857,12 @@
|
|
|
3844
3857
|
chatLoopRef.current=true;setChatLoop(true);setLiveStopped(false);
|
|
3845
3858
|
}else{chatLoopRef.current=false;setChatLoop(false);setLiveStopped(!quietStopRef.current);} // draining
|
|
3846
3859
|
return j;}
|
|
3847
|
-
|
|
3860
|
+
// /health unreachable = the relay isn't running (the daemon a prior
|
|
3861
|
+
// `npx transitions-refine live` started was reaped when the agent shell
|
|
3862
|
+
// ended, etc.). DON'T claim the CLI is missing — that shows the wrong
|
|
3863
|
+
// "install a CLI" copy AND a Reconnect button that POSTs to a dead relay.
|
|
3864
|
+
// Flag relayDown so the gate tells the user to restart the relay instead.
|
|
3865
|
+
catch{chatLoopRef.current=false;setRelayDown(true);setLlmAvailable(false);setChatLoop(false);return null;}
|
|
3848
3866
|
},[]);
|
|
3849
3867
|
const stopLive=useCallback(async()=>{
|
|
3850
3868
|
quietStopRef.current=false;
|
|
@@ -4169,7 +4187,19 @@
|
|
|
4169
4187
|
if(!specs.length) return ()=>txTeardownPhase(store);
|
|
4170
4188
|
const varsKey=v=>Object.keys(v).sort().map(k=>k+":"+v[k]).join(";");
|
|
4171
4189
|
const recompute=()=>{
|
|
4172
|
-
|
|
4190
|
+
// Two concerns, applied DIFFERENTLY:
|
|
4191
|
+
// • TIMING (the inline `transition` string) is direction-specific —
|
|
4192
|
+
// open and close share one element + one inline slot, so it must be
|
|
4193
|
+
// GATED to the active state (open's timing only while open, etc).
|
|
4194
|
+
// • VALUE VARS (scale/blur custom props, e.g. --dropdown-pre-scale)
|
|
4195
|
+
// define a STATE's resting value, not the transition itself. A
|
|
4196
|
+
// "from" var only shows in the OPPOSITE state, so gating it to the
|
|
4197
|
+
// destination state means it's never visible (this was the bug:
|
|
4198
|
+
// accepting a scale edit did nothing live). They're safe to apply
|
|
4199
|
+
// PERSISTENTLY — each var only affects whichever state references
|
|
4200
|
+
// it — which previews both directions. So: vars = always, css = gated.
|
|
4201
|
+
const desiredCss=new Map(); // el → css (state-gated)
|
|
4202
|
+
const desiredVars=new Map(); // el → {name:value} (persistent)
|
|
4173
4203
|
for(const sp of specs){
|
|
4174
4204
|
const stEls=fresh(sp.stateTarget);
|
|
4175
4205
|
// is THIS phase's destination state active on the stateTarget RIGHT NOW?
|
|
@@ -4177,19 +4207,31 @@
|
|
|
4177
4207
|
if(sp.toSuf) active=stEls.length?stEls.some(e=>safeMatches(e,sp.toSuf)):true; // open
|
|
4178
4208
|
else if(sp.fromSuf) active=stEls.length?stEls.some(e=>!safeMatches(e,sp.fromSuf)):true; // close = not in fromState
|
|
4179
4209
|
else active=true; // no state info → always reflect (degrades to old behavior)
|
|
4180
|
-
|
|
4181
|
-
|
|
4210
|
+
for(const mm of sp.members) for(const el of txMemberEls(mm.selector,sp.stateTarget)){
|
|
4211
|
+
if(inUI(el))continue;
|
|
4212
|
+
if(mm.vars&&Object.keys(mm.vars).length){ const ex=desiredVars.get(el)||{}; desiredVars.set(el,{...ex,...mm.vars}); }
|
|
4213
|
+
if(active) desiredCss.set(el,mm.css);
|
|
4214
|
+
}
|
|
4182
4215
|
}
|
|
4183
|
-
|
|
4184
|
-
|
|
4185
|
-
|
|
4186
|
-
|
|
4187
|
-
|
|
4188
|
-
|
|
4216
|
+
const allEls=new Set([...desiredCss.keys(),...desiredVars.keys()]);
|
|
4217
|
+
for(const el of allEls){
|
|
4218
|
+
const css=desiredCss.has(el)?desiredCss.get(el):null;
|
|
4219
|
+
const vars=desiredVars.get(el)||{};
|
|
4220
|
+
const vk=varsKey(vars);
|
|
4221
|
+
const cur=store.applied.get(el);
|
|
4222
|
+
if(cur&&cur.css===css&&cur.vk===vk)continue;
|
|
4223
|
+
if(!cur){
|
|
4224
|
+
store.applied.set(el,{prevInline:el.style.transition||"",prevVars:Object.fromEntries(Object.keys(vars).map(n=>[n,el.style.getPropertyValue(n)])),css,vk});
|
|
4225
|
+
}else{
|
|
4226
|
+
const prevVars={...(cur.prevVars||{})};for(const n of Object.keys(vars)){if(!(n in prevVars))prevVars[n]=el.style.getPropertyValue(n);}
|
|
4227
|
+
store.applied.set(el,{prevInline:cur.prevInline,prevVars,css,vk});
|
|
4228
|
+
}
|
|
4229
|
+
const rec=store.applied.get(el);
|
|
4230
|
+
try{el.style.transition=css!=null?css:rec.prevInline;}catch{}
|
|
4189
4231
|
for(const n of Object.keys(vars)){try{el.style.setProperty(n,vars[n]);}catch{}}
|
|
4190
4232
|
}
|
|
4191
4233
|
for(const [el,rec] of Array.from(store.applied)){
|
|
4192
|
-
if(
|
|
4234
|
+
if(allEls.has(el))continue;
|
|
4193
4235
|
try{el.style.transition=rec.prevInline;}catch{}
|
|
4194
4236
|
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{}}
|
|
4195
4237
|
store.applied.delete(el);
|
|
@@ -4367,6 +4409,7 @@
|
|
|
4367
4409
|
// resolved — lets the agent recover the opposite phase + toggled state
|
|
4368
4410
|
// without reading source. Empty → agent falls back to source reads.
|
|
4369
4411
|
cssRules:harvestCssHints(e)}));
|
|
4412
|
+
let provisionalShown=false; // applied the relay's instant in-process grouping
|
|
4370
4413
|
try{
|
|
4371
4414
|
const{id}=await relayCreateJob({kind:"scan",url:location.href,raw});
|
|
4372
4415
|
for(let i=0;i<520;i++){
|
|
@@ -4376,13 +4419,28 @@
|
|
|
4376
4419
|
await new Promise(r=>setTimeout(r,i<20?120:300));
|
|
4377
4420
|
if(scanTokenRef.current!==token)return;
|
|
4378
4421
|
const job=await relayGetJob(id);
|
|
4422
|
+
// Provisional groups: the relay grouped the CSSOM harvest in-process and
|
|
4423
|
+
// attached it BEFORE the agent answered. Show it instantly so the panel
|
|
4424
|
+
// never sits on "Grouping…" through a cold agent turn; the agent's final
|
|
4425
|
+
// result (with polished names) overwrites it on `done` below.
|
|
4426
|
+
if(job.result&&job.result.provisional&&!provisionalShown){
|
|
4427
|
+
const pg=job.result.groups||[];
|
|
4428
|
+
if(pg.length){registry.setGroups(pg);setScanned(true);provisionalShown=true;}
|
|
4429
|
+
}
|
|
4379
4430
|
if(job.status==="done"){
|
|
4380
4431
|
const groups=(job.result&&job.result.groups)||[];
|
|
4381
4432
|
if(groups.length){registry.setGroups(groups);setScanned(true);
|
|
4382
4433
|
try{localStorage.setItem(GROUP_STORE_KEY,JSON.stringify({groups,sig,ts:Date.now()}));}catch{}}
|
|
4383
4434
|
groupScanRetryRef.current=0;setGroupScanState("done");if(!opts.skipSeed)autoStopAfterScan();return;}
|
|
4384
|
-
if(job.status==="error"){
|
|
4435
|
+
if(job.status==="error"){
|
|
4436
|
+
// No agent answered — but if we already showed provisional groups,
|
|
4437
|
+
// keep them (better than reverting to flat) and treat the scan as done.
|
|
4438
|
+
if(provisionalShown){groupScanRetryRef.current=0;setGroupScanState("done");if(!opts.skipSeed)autoStopAfterScan();return;}
|
|
4439
|
+
throw new Error(job.error||"scan job errored");
|
|
4440
|
+
}
|
|
4385
4441
|
}
|
|
4442
|
+
// Timed out waiting for the agent: keep any provisional groups we showed.
|
|
4443
|
+
if(provisionalShown){groupScanRetryRef.current=0;setGroupScanState("done");if(!opts.skipSeed)autoStopAfterScan();return;}
|
|
4386
4444
|
throw new Error("scan timed out");
|
|
4387
4445
|
}catch(e){
|
|
4388
4446
|
// Transient failure (a cold-start agent spawn racing on its CLI config,
|
|
@@ -4574,13 +4632,13 @@
|
|
|
4574
4632
|
h(RefinePanel,{open:refineOpen,onClose:()=>setRefineOpen(false),phase:refinePhase,label:refineLabel,
|
|
4575
4633
|
refineType,onType:changeRefineType,suggestions:refineSuggestions,summary:refineSummary,error:refineError,
|
|
4576
4634
|
appliedIds,onApply:applySuggestion,onApplyAll:applyAllSuggestions,
|
|
4577
|
-
mode:refineMode,onMode:changeRefineMode,llmAvailable,cliInstalled,agentReason,onReconnect:reconnectAgent,onStart:startScan,
|
|
4635
|
+
mode:refineMode,onMode:changeRefineMode,llmAvailable,cliInstalled,relayDown,agentReason,onReconnect:reconnectAgent,onStart:startScan,
|
|
4578
4636
|
lanes:active?.effectiveTimings||[]}))
|
|
4579
4637
|
: h("div",{className:"tl-panel-main"},
|
|
4580
4638
|
gate==="blocked" && h("div",{className:"tl-gate"},
|
|
4581
4639
|
h("div",{className:"tl-gate-col"},
|
|
4582
4640
|
h("div",{className:"tl-gate-title"},"Connect with your agent"),
|
|
4583
|
-
h(AgentConnectBody,{cliMissing:cliInstalled===false,reason:agentReason,onReconnect:reconnectAgent})))),
|
|
4641
|
+
h(AgentConnectBody,{cliMissing:cliInstalled===false,relayDown,reason:agentReason,onReconnect:reconnectAgent})))),
|
|
4584
4642
|
toast&&createPortal(
|
|
4585
4643
|
h("div",{className:"tl-toast-wrap","data-tl-ui":"","aria-live":"polite"},
|
|
4586
4644
|
h("div",{className:cx("tl-toast",toast.closing&&"is-closing")},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "transitions-refine",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.23",
|
|
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": {
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
// Provisional, in-process grouping straight from the CSSOM harvest
|
|
2
|
+
// (entry.cssRules + entry.timings). It mirrors what buildScanPrompt asks the
|
|
3
|
+
// agent to do MECHANICALLY for the common case — cluster by component root,
|
|
4
|
+
// split open/close phases, copy the current-state timings, read the
|
|
5
|
+
// opposite-phase timings from a state-variant rule — so the panel can show
|
|
6
|
+
// grouped components in <1s instead of waiting out a cold agent turn.
|
|
7
|
+
//
|
|
8
|
+
// This is deliberately PROVISIONAL: the relay still runs the real agent scan
|
|
9
|
+
// (wired CLI) or leaves the job for a `/refine live` agent, and that result
|
|
10
|
+
// OVERWRITES this one when it lands. So any imperfection here is transient and
|
|
11
|
+
// the FINAL naming/grouping stays agent-quality. We return `null` (defer fully
|
|
12
|
+
// to the agent, today's exact behaviour) whenever the harvest is missing or no
|
|
13
|
+
// confident group emerges — never a worse-than-today result.
|
|
14
|
+
|
|
15
|
+
// Component vocabulary — names a cluster from its class tokens. Order = most
|
|
16
|
+
// specific first. Only used for the provisional label; the agent renames later.
|
|
17
|
+
// Matched against a name "haystack" with -/_ normalized to spaces, so it hits
|
|
18
|
+
// both class tokens (dd-dropdown) and human labels ("Dropdown panel").
|
|
19
|
+
const VOCAB = [
|
|
20
|
+
[/\b(?:dropdown|combobox|listbox|autocomplete)\b/i, "Dropdown"],
|
|
21
|
+
[/\bmenu\b/i, "Menu"],
|
|
22
|
+
[/\b(?:modal|dialog|lightbox)\b/i, "Modal"],
|
|
23
|
+
[/\b(?:popover|popup)\b/i, "Popover"],
|
|
24
|
+
[/\btooltip\b/i, "Tooltip"],
|
|
25
|
+
[/\b(?:accordion|collapse|collapsible|disclosure)\b/i, "Accordion"],
|
|
26
|
+
[/\b(?:drawer|offcanvas|sheet)\b/i, "Drawer"],
|
|
27
|
+
[/\bsidebar\b/i, "Sidebar"],
|
|
28
|
+
[/\b(?:toast|snackbar|notification)\b/i, "Toast"],
|
|
29
|
+
[/\btabs?\b/i, "Tabs"],
|
|
30
|
+
[/\bpanel\b/i, "Panel"],
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
// State-token class names. A class on an ancestor (or the element) that matches
|
|
34
|
+
// one of these marks the toggled state that drives a phase.
|
|
35
|
+
const OPEN_CLASS = /^(?:is-)?(?:open|opened|active|show|shown|visible|expanded|entered|in)$/i;
|
|
36
|
+
const CLOSE_CLASS = /^(?:is-)?(?:clos(?:e|ed|ing)|hidden|hide|leaving|exiting|out|collapsed)$/i;
|
|
37
|
+
|
|
38
|
+
function isStateClass(c) {
|
|
39
|
+
return OPEN_CLASS.test(c) || CLOSE_CLASS.test(c);
|
|
40
|
+
}
|
|
41
|
+
function classPhase(c) {
|
|
42
|
+
return CLOSE_CLASS.test(c) ? "close" : "open";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── tiny CSS helpers ─────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
// Split respecting parens/brackets so cubic-bezier(0.22, 1, 0.36, 1) and
|
|
48
|
+
// [data-state="open"] stay intact.
|
|
49
|
+
function splitTop(str, sep) {
|
|
50
|
+
const out = [];
|
|
51
|
+
let depth = 0;
|
|
52
|
+
let cur = "";
|
|
53
|
+
for (const ch of String(str)) {
|
|
54
|
+
if (ch === "(" || ch === "[") depth++;
|
|
55
|
+
else if (ch === ")" || ch === "]") depth = Math.max(0, depth - 1);
|
|
56
|
+
if (ch === sep && depth === 0) {
|
|
57
|
+
out.push(cur);
|
|
58
|
+
cur = "";
|
|
59
|
+
} else cur += ch;
|
|
60
|
+
}
|
|
61
|
+
out.push(cur);
|
|
62
|
+
return out.map((s) => s.trim()).filter(Boolean);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function timeToMs(t) {
|
|
66
|
+
const m = String(t).trim().match(/^(-?[\d.]+)\s*(ms|s)$/i);
|
|
67
|
+
if (!m) return null;
|
|
68
|
+
const n = parseFloat(m[1]);
|
|
69
|
+
if (!Number.isFinite(n)) return null;
|
|
70
|
+
return m[2].toLowerCase() === "s" ? Math.round(n * 1000) : Math.round(n);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parseRule(str) {
|
|
74
|
+
const i = String(str).indexOf("{");
|
|
75
|
+
if (i < 0) return null;
|
|
76
|
+
const head = str.slice(0, i).trim();
|
|
77
|
+
let body = str.slice(i + 1);
|
|
78
|
+
const j = body.lastIndexOf("}");
|
|
79
|
+
if (j >= 0) body = body.slice(0, j);
|
|
80
|
+
return { selectors: splitTop(head, ","), body };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function declMap(body) {
|
|
84
|
+
const m = new Map();
|
|
85
|
+
for (const d of splitTop(body, ";")) {
|
|
86
|
+
const c = d.indexOf(":");
|
|
87
|
+
if (c < 0) continue;
|
|
88
|
+
m.set(d.slice(0, c).trim().toLowerCase(), d.slice(c + 1).trim());
|
|
89
|
+
}
|
|
90
|
+
return m;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const EASING_KW = /^(?:ease|ease-in|ease-out|ease-in-out|linear|step-start|step-end)$/i;
|
|
94
|
+
const EASING_FN = /^(?:cubic-bezier|steps|linear)\s*\(/i;
|
|
95
|
+
|
|
96
|
+
// transition shorthand → [{property,durationMs,delayMs,easing}]
|
|
97
|
+
function parseShorthand(value) {
|
|
98
|
+
const out = [];
|
|
99
|
+
for (const seg of splitTop(value, ",")) {
|
|
100
|
+
const toks = splitTop(seg, " ");
|
|
101
|
+
let prop = null;
|
|
102
|
+
let easing = null;
|
|
103
|
+
const times = [];
|
|
104
|
+
for (const tk of toks) {
|
|
105
|
+
const ms = timeToMs(tk);
|
|
106
|
+
if (ms != null) {
|
|
107
|
+
times.push(ms);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (EASING_KW.test(tk) || EASING_FN.test(tk)) {
|
|
111
|
+
easing = tk;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (!prop && /^[a-z][\w-]*$/i.test(tk)) prop = tk;
|
|
115
|
+
}
|
|
116
|
+
if (prop && prop.toLowerCase() !== "none") {
|
|
117
|
+
out.push({
|
|
118
|
+
property: prop,
|
|
119
|
+
durationMs: times[0] ?? 0,
|
|
120
|
+
delayMs: times[1] ?? 0,
|
|
121
|
+
easing: easing || "ease",
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function timingsFromBody(body) {
|
|
129
|
+
const d = declMap(body);
|
|
130
|
+
if (d.has("transition")) return parseShorthand(d.get("transition"));
|
|
131
|
+
const props = d.get("transition-property");
|
|
132
|
+
if (!props) return [];
|
|
133
|
+
const plist = splitTop(props, ",");
|
|
134
|
+
const durs = splitTop(d.get("transition-duration") || "", ",").map(timeToMs);
|
|
135
|
+
const eas = splitTop(d.get("transition-timing-function") || "", ",");
|
|
136
|
+
const dels = splitTop(d.get("transition-delay") || "", ",").map(timeToMs);
|
|
137
|
+
const at = (arr, i, fb) => (arr.length ? arr[i % arr.length] : fb);
|
|
138
|
+
return plist
|
|
139
|
+
.filter((p) => p && p.toLowerCase() !== "none")
|
|
140
|
+
.map((p, i) => ({
|
|
141
|
+
property: p,
|
|
142
|
+
durationMs: at(durs, i, 0) ?? 0,
|
|
143
|
+
delayMs: at(dels, i, 0) ?? 0,
|
|
144
|
+
easing: at(eas, i, "ease") || "ease",
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── selector analysis ────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
// Classes in the LAST compound of a selector (the element the rule targets).
|
|
151
|
+
function leafClasses(selector) {
|
|
152
|
+
const compounds = String(selector).split(/\s*[>+~]\s*|\s+/).filter(Boolean);
|
|
153
|
+
const last = compounds[compounds.length - 1] || "";
|
|
154
|
+
return (last.match(/\.[-\w]+/g) || []).map((s) => s.slice(1));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Does this selector (one compound chain) target the member identified by its
|
|
158
|
+
// own leaf classes? True when its last compound carries one of those classes.
|
|
159
|
+
function targetsMember(selector, leaf) {
|
|
160
|
+
if (!leaf.length) return false;
|
|
161
|
+
const compounds = String(selector).split(/\s*[>+~]\s*|\s+/).filter(Boolean);
|
|
162
|
+
const last = compounds[compounds.length - 1] || "";
|
|
163
|
+
return leaf.some((c) => new RegExp("\\." + escapeRe(c) + "(?![\\w-])").test(last));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function escapeRe(s) {
|
|
167
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// From a state-variant selector that targets `leaf`, pull out the toggled state
|
|
171
|
+
// token and the stateTarget selector (the element the token sits on, with the
|
|
172
|
+
// token stripped). Returns {stateTarget, token, phase} or null.
|
|
173
|
+
function deriveState(selector, leaf) {
|
|
174
|
+
const compounds = String(selector).split(/\s+/).filter(Boolean); // descendant only — keep it simple
|
|
175
|
+
if (!compounds.length) return null;
|
|
176
|
+
const memberIdx = compounds.length - 1;
|
|
177
|
+
// walk compounds, find the FIRST that carries a state class or state attr.
|
|
178
|
+
for (let i = 0; i < compounds.length; i++) {
|
|
179
|
+
const comp = compounds[i];
|
|
180
|
+
const classes = (comp.match(/\.[-\w]+/g) || []).map((s) => s.slice(1));
|
|
181
|
+
const stateCls = classes.find(isStateClass);
|
|
182
|
+
// attribute state: [data-open], [data-state="open"], [aria-expanded="true"], [open]
|
|
183
|
+
const attr = comp.match(/\[[^\]]+\]/g) || [];
|
|
184
|
+
const stateAttr = attr.find((a) => /data-open|data-state|aria-expanded|aria-hidden|\bopen\b|visible|expanded/i.test(a));
|
|
185
|
+
|
|
186
|
+
if (stateCls) {
|
|
187
|
+
const target = stripToken(comp, "." + stateCls) + ancestorPrefix(compounds, i);
|
|
188
|
+
return { stateTarget: normalizeSel(target), token: "." + stateCls, phase: classPhase(stateCls) };
|
|
189
|
+
}
|
|
190
|
+
if (stateAttr) {
|
|
191
|
+
const target = stripToken(comp, stateAttr) + ancestorPrefix(compounds, i);
|
|
192
|
+
const phase = /clos|hidden|false|collapsed/i.test(stateAttr) ? "close" : "open";
|
|
193
|
+
return { stateTarget: normalizeSel(target), token: stateAttr, phase };
|
|
194
|
+
}
|
|
195
|
+
if (i === memberIdx) break;
|
|
196
|
+
}
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Build the ancestor prefix selector for the compound at index i (everything
|
|
201
|
+
// before it), so stateTarget keeps its context (".wrap .dd" not just ".dd").
|
|
202
|
+
function ancestorPrefix() {
|
|
203
|
+
return ""; // single-compound stateTarget is enough for live-preview scoping
|
|
204
|
+
}
|
|
205
|
+
function stripToken(compound, token) {
|
|
206
|
+
return compound.split(token).join("");
|
|
207
|
+
}
|
|
208
|
+
function normalizeSel(sel) {
|
|
209
|
+
const s = String(sel).trim();
|
|
210
|
+
return s || "*";
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function humanize(token) {
|
|
214
|
+
return String(token)
|
|
215
|
+
.replace(/^[.#]/, "")
|
|
216
|
+
.replace(/[-_]+/g, " ")
|
|
217
|
+
.replace(/\b(is|tl|tx|wd|ui|js|css)\b/gi, "")
|
|
218
|
+
.trim()
|
|
219
|
+
.replace(/\s+/g, " ")
|
|
220
|
+
.replace(/^\w/, (c) => c.toUpperCase()) || "Component";
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function nameFromTokens(tokens) {
|
|
224
|
+
const hay = tokens.join(" ").replace(/[-_]+/g, " ");
|
|
225
|
+
for (const [re, name] of VOCAB) if (re.test(hay)) return name;
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function slug(s) {
|
|
230
|
+
return String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "group";
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── per-entry analysis ───────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
function analyzeEntry(entry) {
|
|
236
|
+
if (!entry || !Array.isArray(entry.cssRules) || !entry.cssRules.length) return null;
|
|
237
|
+
const memberSel = entry.selector || "";
|
|
238
|
+
const leaf = leafClasses(memberSel);
|
|
239
|
+
if (!leaf.length) return null;
|
|
240
|
+
|
|
241
|
+
const rules = entry.cssRules.map(parseRule).filter(Boolean);
|
|
242
|
+
let state = null; // {stateTarget, token, phase} — the open toggle
|
|
243
|
+
let baseTimings = null; // transition on the base (no-state) member rule
|
|
244
|
+
let openTimings = null; // transition on an OPEN-state variant rule
|
|
245
|
+
let closeTimings = null; // transition on a CLOSE-state variant rule
|
|
246
|
+
|
|
247
|
+
for (const rule of rules) {
|
|
248
|
+
for (const sel of rule.selectors) {
|
|
249
|
+
if (!targetsMember(sel, leaf)) continue;
|
|
250
|
+
const st = deriveState(sel, leaf);
|
|
251
|
+
const t = timingsFromBody(rule.body);
|
|
252
|
+
if (st) {
|
|
253
|
+
if (st.phase === "open" && !state) state = st; // prefer an open token for stateTarget
|
|
254
|
+
if (st.phase === "open" && t.length) openTimings = t;
|
|
255
|
+
if (st.phase === "close" && t.length) closeTimings = t;
|
|
256
|
+
if (!state) state = st; // fall back to a close token if that's all there is
|
|
257
|
+
} else if (t.length && !baseTimings) {
|
|
258
|
+
baseTimings = t;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (!state) return null; // no toggle → can't phase it; defer to agent
|
|
263
|
+
|
|
264
|
+
// Authoritative live timings for the element's CURRENT DOM state.
|
|
265
|
+
const current = Array.isArray(entry.timings) && entry.timings.length
|
|
266
|
+
? entry.timings.map((t) => ({ property: t.property, durationMs: t.durationMs, delayMs: t.delayMs, easing: t.easing }))
|
|
267
|
+
: null;
|
|
268
|
+
|
|
269
|
+
// Standard pattern: the base rule's transition drives the OPEN (enter); a
|
|
270
|
+
// `.is-closing`-style variant carries the CLOSE. Fall back sensibly when a
|
|
271
|
+
// phase has no dedicated rule (one transition both ways).
|
|
272
|
+
const open = openTimings || baseTimings || current;
|
|
273
|
+
const close = closeTimings || baseTimings || current;
|
|
274
|
+
if (!open || !close) return null;
|
|
275
|
+
|
|
276
|
+
const memberId = slug(leaf[leaf.length - 1] || entry.label || "member");
|
|
277
|
+
const member = {
|
|
278
|
+
id: memberId,
|
|
279
|
+
label: entry.label || humanize(leaf[leaf.length - 1]),
|
|
280
|
+
selector: memberSel,
|
|
281
|
+
};
|
|
282
|
+
// Name hints: the element's own classes, its stateTarget, AND the human label
|
|
283
|
+
// the scanner already produced (e.g. "Dropdown panel" → matches /dropdown/).
|
|
284
|
+
const labelWords = String(entry.label || "").split(/\s+/).filter(Boolean);
|
|
285
|
+
return {
|
|
286
|
+
stateTarget: state.stateTarget,
|
|
287
|
+
token: state.token,
|
|
288
|
+
member,
|
|
289
|
+
openTimings: open,
|
|
290
|
+
closeTimings: close,
|
|
291
|
+
nameTokens: [...leaf, state.stateTarget, ...labelWords],
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ── public API ───────────────────────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
export function groupDeterministic(raw) {
|
|
298
|
+
if (process.env.REFINE_FAST_GROUP === "0") return null;
|
|
299
|
+
if (!Array.isArray(raw) || !raw.length) return null;
|
|
300
|
+
|
|
301
|
+
// Cluster entries by stateTarget. Every entry must analyze; if ANY entry has
|
|
302
|
+
// cssRules but no resolvable toggle, that one alone shouldn't sink the rest —
|
|
303
|
+
// but we DO require all-or-nothing on the harvest itself to avoid a confusing
|
|
304
|
+
// partial provisional. Entries that don't analyze are simply left ungrouped.
|
|
305
|
+
const clusters = new Map();
|
|
306
|
+
let analyzed = 0;
|
|
307
|
+
for (const entry of raw) {
|
|
308
|
+
const info = analyzeEntry(entry);
|
|
309
|
+
if (!info) continue;
|
|
310
|
+
analyzed++;
|
|
311
|
+
const key = info.stateTarget;
|
|
312
|
+
if (!clusters.has(key)) clusters.set(key, { stateTarget: key, members: [], tokens: [] });
|
|
313
|
+
const c = clusters.get(key);
|
|
314
|
+
c.members.push(info);
|
|
315
|
+
c.tokens.push(...info.nameTokens);
|
|
316
|
+
}
|
|
317
|
+
if (!clusters.size || !analyzed) return null;
|
|
318
|
+
|
|
319
|
+
const groups = [];
|
|
320
|
+
for (const c of clusters.values()) {
|
|
321
|
+
const openTok = c.members[0].token; // the open toggle on the stateTarget
|
|
322
|
+
const name = nameFromTokens(c.tokens) || humanize(c.stateTarget);
|
|
323
|
+
const gid = slug(name + "-" + c.stateTarget);
|
|
324
|
+
|
|
325
|
+
// OPEN phase: base → open (use each member's open timings).
|
|
326
|
+
// CLOSE phase: open → base (use each member's close timings).
|
|
327
|
+
const openMembers = c.members.map((m) => ({
|
|
328
|
+
id: m.member.id,
|
|
329
|
+
label: m.member.label,
|
|
330
|
+
selector: m.member.selector,
|
|
331
|
+
propertyTimings: m.openTimings,
|
|
332
|
+
}));
|
|
333
|
+
const closeMembers = c.members.map((m) => ({
|
|
334
|
+
id: m.member.id,
|
|
335
|
+
label: m.member.label,
|
|
336
|
+
selector: m.member.selector,
|
|
337
|
+
propertyTimings: m.closeTimings,
|
|
338
|
+
}));
|
|
339
|
+
|
|
340
|
+
const phases = [
|
|
341
|
+
{
|
|
342
|
+
id: gid + ":open",
|
|
343
|
+
phase: "open",
|
|
344
|
+
label: "Open",
|
|
345
|
+
stateTarget: c.stateTarget,
|
|
346
|
+
fromState: null,
|
|
347
|
+
toState: openTok,
|
|
348
|
+
members: openMembers,
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
id: gid + ":close",
|
|
352
|
+
phase: "close",
|
|
353
|
+
label: "Close",
|
|
354
|
+
stateTarget: c.stateTarget,
|
|
355
|
+
fromState: openTok,
|
|
356
|
+
toState: null,
|
|
357
|
+
members: closeMembers,
|
|
358
|
+
},
|
|
359
|
+
];
|
|
360
|
+
groups.push({ id: gid, label: name, component: null, phases, provisional: true });
|
|
361
|
+
}
|
|
362
|
+
if (!groups.length) return null;
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
groups,
|
|
366
|
+
summary: `Grouped ${groups.length} component${groups.length === 1 ? "" : "s"} (provisional — refining…).`,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export default groupDeterministic;
|
package/server/relay.mjs
CHANGED
|
@@ -31,6 +31,7 @@ import { delimiter, join } from "node:path";
|
|
|
31
31
|
import { refineTimings, DURATION_TOKENS, SCALE_TOKENS, BLUR_TOKENS, SMOOTH_OUT } from "./motion-tokens.mjs";
|
|
32
32
|
import { buildInjectModule } from "./inject.mjs";
|
|
33
33
|
import { resolveAgentCmd } from "./agent-resolve.mjs";
|
|
34
|
+
import { groupDeterministic } from "./group-deterministic.mjs";
|
|
34
35
|
|
|
35
36
|
const PORT = Number(process.env.REFINE_RELAY_PORT) || 7331;
|
|
36
37
|
const AUTO = process.env.REFINE_AUTO !== "0";
|
|
@@ -703,6 +704,26 @@ const server = createServer(async (req, res) => {
|
|
|
703
704
|
: (job.request.mode || (llmAvailable() ? "llm" : "deterministic"));
|
|
704
705
|
job.request.mode = mode;
|
|
705
706
|
|
|
707
|
+
// Provisional grouping: for a scan, try to group the components IN-PROCESS
|
|
708
|
+
// from the CSSOM harvest the browser already sent (entry.cssRules). This is
|
|
709
|
+
// the slow part — a cold agent turn — so showing a result in <1ms here makes
|
|
710
|
+
// the panel feel instant in BOTH wired and /refine-live modes. The real agent
|
|
711
|
+
// scan still runs below and OVERWRITES this when it lands (see answerJob /
|
|
712
|
+
// POST /jobs/:id/result), so final naming/grouping stays agent-quality. When
|
|
713
|
+
// the grouper isn't confident it returns null → unchanged, agent-only flow.
|
|
714
|
+
if (job.request.kind === "scan") {
|
|
715
|
+
try {
|
|
716
|
+
const prov = groupDeterministic(job.request.raw);
|
|
717
|
+
if (prov && prov.groups && prov.groups.length) {
|
|
718
|
+
job.result = { groups: prov.groups, summary: prov.summary, provisional: true };
|
|
719
|
+
job.updatedAt = now();
|
|
720
|
+
job.statusLog.push({ message: `Grouped ${prov.groups.length} component(s) — refining names…`, at: now() });
|
|
721
|
+
}
|
|
722
|
+
} catch (e) {
|
|
723
|
+
console.warn(` provisional grouping failed (${String((e && e.message) || e).slice(0, 120)}) — deferring to agent`);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
706
727
|
if (!AUTO) {
|
|
707
728
|
// External-poller-only mode: everything waits on GET /jobs/next.
|
|
708
729
|
} else if (mode === "deterministic") {
|