transitions-refine 0.3.20 → 0.3.22
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 +44 -13
- package/package.json +1 -1
- package/server/group-deterministic.mjs +370 -0
- package/server/relay.mjs +34 -33
package/demo.html
CHANGED
|
@@ -2686,7 +2686,7 @@
|
|
|
2686
2686
|
// Shared "Connect with your agent" copy — used by the full-panel gate and by
|
|
2687
2687
|
// the Refine area when no live agent is connected, so both read identically.
|
|
2688
2688
|
// `cliMissing` swaps the body to an install-first hint.
|
|
2689
|
-
function AgentConnectBody({cliMissing,reason,onReconnect}){
|
|
2689
|
+
function AgentConnectBody({cliMissing,relayDown,reason,onReconnect}){
|
|
2690
2690
|
const[rechecking,setRechecking]=useState(false);
|
|
2691
2691
|
const recheck=async()=>{
|
|
2692
2692
|
if(!onReconnect||rechecking) return;
|
|
@@ -2694,7 +2694,15 @@
|
|
|
2694
2694
|
try{ await onReconnect(); } finally { setRechecking(false); }
|
|
2695
2695
|
};
|
|
2696
2696
|
return h(React.Fragment,null,
|
|
2697
|
-
|
|
2697
|
+
relayDown
|
|
2698
|
+
// The relay isn't reachable at all — Reconnect can't help (it talks to a
|
|
2699
|
+
// running relay). The only fix is starting the relay again.
|
|
2700
|
+
? h("p",{className:"tl-gate-text"},
|
|
2701
|
+
"The relay isn’t running. Start it with ",
|
|
2702
|
+
h("code",{className:"tl-code"},"npx transitions-refine live"),
|
|
2703
|
+
" in your project (or ",h("code",{className:"tl-code"},"/refine live"),
|
|
2704
|
+
" in your editor), then retry.")
|
|
2705
|
+
: cliMissing
|
|
2698
2706
|
? h("p",{className:"tl-gate-text"},"Install an agent CLI, then run ",
|
|
2699
2707
|
h("code",{className:"tl-code"},"npx transitions-refine live"),
|
|
2700
2708
|
" (recommended) or ",h("code",{className:"tl-code"},"/refine live")," in your editor.")
|
|
@@ -2704,13 +2712,14 @@
|
|
|
2704
2712
|
h("code",{className:"tl-code"},"/refine live"),
|
|
2705
2713
|
" in your editor if the relay is already up without an agent wired."),
|
|
2706
2714
|
// 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
|
-
|
|
2715
|
+
// silent fall-back to poller mode is never a mystery. Hidden when the relay
|
|
2716
|
+
// is down (the reason is stale). The relay self-heals on a TTL; Reconnect
|
|
2717
|
+
// forces an immediate re-check (or just re-probes /health if it's down).
|
|
2718
|
+
!relayDown&&reason&&h("p",{className:"tl-gate-reason"},reason),
|
|
2710
2719
|
onReconnect&&h("button",{className:"tl-gate-recheck",disabled:rechecking,onClick:recheck},
|
|
2711
|
-
rechecking?"Reconnecting…":"Reconnect agent"));
|
|
2720
|
+
rechecking?(relayDown?"Retrying…":"Reconnecting…"):(relayDown?"Retry connection":"Reconnect agent")));
|
|
2712
2721
|
}
|
|
2713
|
-
function RefinePanel({open,onClose,phase,label,refineType,onType,suggestions,summary,error,appliedIds,onApply,onApplyAll,mode,onMode,llmAvailable,cliInstalled,agentReason,onReconnect,onStart,lanes}){
|
|
2722
|
+
function RefinePanel({open,onClose,phase,label,refineType,onType,suggestions,summary,error,appliedIds,onApply,onApplyAll,mode,onMode,llmAvailable,cliInstalled,relayDown,agentReason,onReconnect,onStart,lanes}){
|
|
2714
2723
|
// mount-on-open; keep mounted through the panel-reveal slide-out, then unmount
|
|
2715
2724
|
const[render,setRender]=useState(open);
|
|
2716
2725
|
const[panelOpen,setPanelOpen]=useState(false);
|
|
@@ -2820,7 +2829,7 @@
|
|
|
2820
2829
|
// /refine live makes the agent available and Start scanning works again.
|
|
2821
2830
|
return h("div",{className:"tl-refine-center"},
|
|
2822
2831
|
h("div",{className:"tl-refine-unavail-title"},"Connect with your agent"),
|
|
2823
|
-
h(AgentConnectBody,{cliMissing:cliInstalled===false,reason:agentReason,onReconnect}));
|
|
2832
|
+
h(AgentConnectBody,{cliMissing:cliInstalled===false,relayDown,reason:agentReason,onReconnect}));
|
|
2824
2833
|
}
|
|
2825
2834
|
const idleDescR = agentMode ? (REFINE_TYPES.find(t=>t.key===rt)||REFINE_TYPES[0]).desc : (rt==="replace"
|
|
2826
2835
|
? "Deterministic mode can't pick a recipe to replace the transition — switch to Agent mode for replacements."
|
|
@@ -3812,6 +3821,7 @@
|
|
|
3812
3821
|
const[llmAvailable,setLlmAvailable]=useState(null); // null=unknown, true/false from /health
|
|
3813
3822
|
const[cliInstalled,setCliInstalled]=useState(null); // null=unknown, true/false from /health
|
|
3814
3823
|
const[agentReason,setAgentReason]=useState(null); // why no CLI is wired (/health agentReason)
|
|
3824
|
+
const[relayDown,setRelayDown]=useState(false); // /health unreachable → relay not running
|
|
3815
3825
|
// chatLoop = LLM served by the in-chat `/refine live` poll loop (pollerActive
|
|
3816
3826
|
// and no wired REFINE_AGENT_CMD). This is the mode that bills agent turns
|
|
3817
3827
|
// while idle, so the header shows a credit warning + Stop for it.
|
|
@@ -3833,7 +3843,7 @@
|
|
|
3833
3843
|
const chatLoopRef=useRef(false);
|
|
3834
3844
|
// probe the relay so the idle panel can show the right availability copy
|
|
3835
3845
|
const refreshHealth=useCallback(async()=>{
|
|
3836
|
-
try{const j=await relayHealth();setLlmAvailable(!!j.llmAvailable);
|
|
3846
|
+
try{const j=await relayHealth();setRelayDown(false);setLlmAvailable(!!j.llmAvailable);
|
|
3837
3847
|
setCliInstalled(j.cliInstalled==null?null:!!j.cliInstalled);
|
|
3838
3848
|
setAgentReason(j.agentReason||null);
|
|
3839
3849
|
const loop=!!j.pollerActive&&!j.agentCmd;
|
|
@@ -3844,7 +3854,12 @@
|
|
|
3844
3854
|
chatLoopRef.current=true;setChatLoop(true);setLiveStopped(false);
|
|
3845
3855
|
}else{chatLoopRef.current=false;setChatLoop(false);setLiveStopped(!quietStopRef.current);} // draining
|
|
3846
3856
|
return j;}
|
|
3847
|
-
|
|
3857
|
+
// /health unreachable = the relay isn't running (the daemon a prior
|
|
3858
|
+
// `npx transitions-refine live` started was reaped when the agent shell
|
|
3859
|
+
// ended, etc.). DON'T claim the CLI is missing — that shows the wrong
|
|
3860
|
+
// "install a CLI" copy AND a Reconnect button that POSTs to a dead relay.
|
|
3861
|
+
// Flag relayDown so the gate tells the user to restart the relay instead.
|
|
3862
|
+
catch{chatLoopRef.current=false;setRelayDown(true);setLlmAvailable(false);setChatLoop(false);return null;}
|
|
3848
3863
|
},[]);
|
|
3849
3864
|
const stopLive=useCallback(async()=>{
|
|
3850
3865
|
quietStopRef.current=false;
|
|
@@ -4367,6 +4382,7 @@
|
|
|
4367
4382
|
// resolved — lets the agent recover the opposite phase + toggled state
|
|
4368
4383
|
// without reading source. Empty → agent falls back to source reads.
|
|
4369
4384
|
cssRules:harvestCssHints(e)}));
|
|
4385
|
+
let provisionalShown=false; // applied the relay's instant in-process grouping
|
|
4370
4386
|
try{
|
|
4371
4387
|
const{id}=await relayCreateJob({kind:"scan",url:location.href,raw});
|
|
4372
4388
|
for(let i=0;i<520;i++){
|
|
@@ -4376,13 +4392,28 @@
|
|
|
4376
4392
|
await new Promise(r=>setTimeout(r,i<20?120:300));
|
|
4377
4393
|
if(scanTokenRef.current!==token)return;
|
|
4378
4394
|
const job=await relayGetJob(id);
|
|
4395
|
+
// Provisional groups: the relay grouped the CSSOM harvest in-process and
|
|
4396
|
+
// attached it BEFORE the agent answered. Show it instantly so the panel
|
|
4397
|
+
// never sits on "Grouping…" through a cold agent turn; the agent's final
|
|
4398
|
+
// result (with polished names) overwrites it on `done` below.
|
|
4399
|
+
if(job.result&&job.result.provisional&&!provisionalShown){
|
|
4400
|
+
const pg=job.result.groups||[];
|
|
4401
|
+
if(pg.length){registry.setGroups(pg);setScanned(true);provisionalShown=true;}
|
|
4402
|
+
}
|
|
4379
4403
|
if(job.status==="done"){
|
|
4380
4404
|
const groups=(job.result&&job.result.groups)||[];
|
|
4381
4405
|
if(groups.length){registry.setGroups(groups);setScanned(true);
|
|
4382
4406
|
try{localStorage.setItem(GROUP_STORE_KEY,JSON.stringify({groups,sig,ts:Date.now()}));}catch{}}
|
|
4383
4407
|
groupScanRetryRef.current=0;setGroupScanState("done");if(!opts.skipSeed)autoStopAfterScan();return;}
|
|
4384
|
-
if(job.status==="error"){
|
|
4408
|
+
if(job.status==="error"){
|
|
4409
|
+
// No agent answered — but if we already showed provisional groups,
|
|
4410
|
+
// keep them (better than reverting to flat) and treat the scan as done.
|
|
4411
|
+
if(provisionalShown){groupScanRetryRef.current=0;setGroupScanState("done");if(!opts.skipSeed)autoStopAfterScan();return;}
|
|
4412
|
+
throw new Error(job.error||"scan job errored");
|
|
4413
|
+
}
|
|
4385
4414
|
}
|
|
4415
|
+
// Timed out waiting for the agent: keep any provisional groups we showed.
|
|
4416
|
+
if(provisionalShown){groupScanRetryRef.current=0;setGroupScanState("done");if(!opts.skipSeed)autoStopAfterScan();return;}
|
|
4386
4417
|
throw new Error("scan timed out");
|
|
4387
4418
|
}catch(e){
|
|
4388
4419
|
// Transient failure (a cold-start agent spawn racing on its CLI config,
|
|
@@ -4574,13 +4605,13 @@
|
|
|
4574
4605
|
h(RefinePanel,{open:refineOpen,onClose:()=>setRefineOpen(false),phase:refinePhase,label:refineLabel,
|
|
4575
4606
|
refineType,onType:changeRefineType,suggestions:refineSuggestions,summary:refineSummary,error:refineError,
|
|
4576
4607
|
appliedIds,onApply:applySuggestion,onApplyAll:applyAllSuggestions,
|
|
4577
|
-
mode:refineMode,onMode:changeRefineMode,llmAvailable,cliInstalled,agentReason,onReconnect:reconnectAgent,onStart:startScan,
|
|
4608
|
+
mode:refineMode,onMode:changeRefineMode,llmAvailable,cliInstalled,relayDown,agentReason,onReconnect:reconnectAgent,onStart:startScan,
|
|
4578
4609
|
lanes:active?.effectiveTimings||[]}))
|
|
4579
4610
|
: h("div",{className:"tl-panel-main"},
|
|
4580
4611
|
gate==="blocked" && h("div",{className:"tl-gate"},
|
|
4581
4612
|
h("div",{className:"tl-gate-col"},
|
|
4582
4613
|
h("div",{className:"tl-gate-title"},"Connect with your agent"),
|
|
4583
|
-
h(AgentConnectBody,{cliMissing:cliInstalled===false,reason:agentReason,onReconnect:reconnectAgent})))),
|
|
4614
|
+
h(AgentConnectBody,{cliMissing:cliInstalled===false,relayDown,reason:agentReason,onReconnect:reconnectAgent})))),
|
|
4584
4615
|
toast&&createPortal(
|
|
4585
4616
|
h("div",{className:"tl-toast-wrap","data-tl-ui":"","aria-live":"polite"},
|
|
4586
4617
|
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.22",
|
|
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";
|
|
@@ -138,13 +139,6 @@ const PENDING_TIMEOUT_MS = Number(process.env.REFINE_PENDING_TIMEOUT_MS) || 1200
|
|
|
138
139
|
// to stop (it returns {stop:true} from /jobs/next). 0 disables. Only the chat
|
|
139
140
|
// loop is affected — a wired REFINE_AGENT_CMD never polls /jobs/next.
|
|
140
141
|
const POLLER_IDLE_STOP_MS = Number(process.env.REFINE_POLLER_IDLE_STOP_MS) || 600000;
|
|
141
|
-
// While the Stop latch is held, a genuinely new `/refine live` session can resume
|
|
142
|
-
// itself implicitly: only after polling has been *quiet* this long (the old loop
|
|
143
|
-
// actually died) do we treat the next poll as a fresh session and auto-resume.
|
|
144
|
-
// Must exceed a looping agent's poll gap, so an agent that ignores Stop and keeps
|
|
145
|
-
// polling can never clear the latch — it just keeps getting {stop:true}. (An
|
|
146
|
-
// updated agent also resumes explicitly via POST /poller/start, no wait needed.)
|
|
147
|
-
const STOP_QUIET_MS = Number(process.env.REFINE_STOP_QUIET_MS) || 15000;
|
|
148
142
|
|
|
149
143
|
/** @type {Map<string, Job>} */
|
|
150
144
|
const jobs = new Map();
|
|
@@ -160,9 +154,6 @@ let lastPollAt = 0;
|
|
|
160
154
|
// re-polls — can never silently revive the session. This is what makes the
|
|
161
155
|
// panel's Stop button stick instead of flipping "Live" back on seconds later.
|
|
162
156
|
let pollerStopped = false;
|
|
163
|
-
// When did a poll last arrive *while latched*? Used to detect that the old loop
|
|
164
|
-
// went quiet so a genuinely new session (or re-run) can auto-resume.
|
|
165
|
-
let lastStoppedPollAt = 0;
|
|
166
157
|
const pollerActive = () => !pollerStopped && now() - lastPollAt < POLLER_TTL_MS;
|
|
167
158
|
const llmAvailable = () => Boolean(agentCmd()) || pollerActive();
|
|
168
159
|
|
|
@@ -713,6 +704,26 @@ const server = createServer(async (req, res) => {
|
|
|
713
704
|
: (job.request.mode || (llmAvailable() ? "llm" : "deterministic"));
|
|
714
705
|
job.request.mode = mode;
|
|
715
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
|
+
|
|
716
727
|
if (!AUTO) {
|
|
717
728
|
// External-poller-only mode: everything waits on GET /jobs/next.
|
|
718
729
|
} else if (mode === "deterministic") {
|
|
@@ -740,21 +751,15 @@ const server = createServer(async (req, res) => {
|
|
|
740
751
|
|
|
741
752
|
// GET /jobs/next — long-poll claimed by a `/refine live` agent (LLM jobs).
|
|
742
753
|
if (method === "GET" && path === "/jobs/next") {
|
|
743
|
-
// Stopped latch: a Stop was issued and not yet resumed.
|
|
744
|
-
//
|
|
745
|
-
// /poller/start
|
|
746
|
-
//
|
|
747
|
-
//
|
|
754
|
+
// Stopped latch: a Stop was issued and not yet resumed. STICKY — every poll
|
|
755
|
+
// gets {stop:true} and the poller reports inactive until an explicit
|
|
756
|
+
// POST /poller/start (a fresh `/refine live` / loop announcing itself) or a
|
|
757
|
+
// relay restart. We deliberately do NOT auto-resume on a quiet gap: a stubborn
|
|
758
|
+
// agent's straggler poll arriving seconds later would be misread as a new
|
|
759
|
+
// session and flip the panel back to "Live" — which is exactly the bug where
|
|
760
|
+
// Stop "didn't stick". A pending job still wins so we never drop real work.
|
|
748
761
|
if (pollerStopped && !nextPendingLlm()) {
|
|
749
|
-
|
|
750
|
-
// Quiet gap → the previous loop is gone. This poll is a fresh session.
|
|
751
|
-
pollerStopped = false;
|
|
752
|
-
lastStoppedPollAt = 0;
|
|
753
|
-
// fall through to normal handling below (counts as live again)
|
|
754
|
-
} else {
|
|
755
|
-
lastStoppedPollAt = now(); // a poller is still hammering us → hold the latch
|
|
756
|
-
return send(res, 200, { stop: true });
|
|
757
|
-
}
|
|
762
|
+
return send(res, 200, { stop: true });
|
|
758
763
|
}
|
|
759
764
|
lastPollAt = now();
|
|
760
765
|
if (!lastJobAt) lastJobAt = now(); // seed idle window on the loop's first poll
|
|
@@ -762,8 +767,7 @@ const server = createServer(async (req, res) => {
|
|
|
762
767
|
// loop to exit. A pending job always wins so we never drop real work.
|
|
763
768
|
if ((stopRequested || (POLLER_IDLE_STOP_MS && now() - lastJobAt >= POLLER_IDLE_STOP_MS)) && !nextPendingLlm()) {
|
|
764
769
|
stopRequested = false;
|
|
765
|
-
pollerStopped = true; // latch until /poller/start
|
|
766
|
-
lastStoppedPollAt = 0;
|
|
770
|
+
pollerStopped = true; // latch until an explicit /poller/start
|
|
767
771
|
lastJobAt = 0;
|
|
768
772
|
lastPollAt = 0; // loop is exiting → report it inactive immediately on /health
|
|
769
773
|
return send(res, 200, { stop: true });
|
|
@@ -773,8 +777,7 @@ const server = createServer(async (req, res) => {
|
|
|
773
777
|
if (res.writableEnded) return;
|
|
774
778
|
if (stopRequested && !nextPendingLlm()) {
|
|
775
779
|
stopRequested = false;
|
|
776
|
-
pollerStopped = true; // latch until /poller/start
|
|
777
|
-
lastStoppedPollAt = 0;
|
|
780
|
+
pollerStopped = true; // latch until an explicit /poller/start
|
|
778
781
|
lastJobAt = 0;
|
|
779
782
|
lastPollAt = 0; // loop is exiting → report it inactive immediately on /health
|
|
780
783
|
return send(res, 200, { stop: true });
|
|
@@ -797,8 +800,7 @@ const server = createServer(async (req, res) => {
|
|
|
797
800
|
if (method === "POST" && path === "/poller/stop") {
|
|
798
801
|
const wasActive = now() - lastPollAt < POLLER_TTL_MS;
|
|
799
802
|
stopRequested = true;
|
|
800
|
-
pollerStopped = true; // latch: stays stopped until /poller/start
|
|
801
|
-
lastStoppedPollAt = 0;
|
|
803
|
+
pollerStopped = true; // latch: stays stopped until an explicit /poller/start
|
|
802
804
|
lastPollAt = 0; // report inactive immediately; a straggler poll won't revive it
|
|
803
805
|
return send(res, 200, { ok: true, stopping: wasActive });
|
|
804
806
|
}
|
|
@@ -822,11 +824,10 @@ const server = createServer(async (req, res) => {
|
|
|
822
824
|
|
|
823
825
|
// POST /poller/start — a fresh `/refine live` agent (or loop) announces itself
|
|
824
826
|
// and clears the Stop latch so its polls count as live again. Without this an
|
|
825
|
-
// explicit re-run
|
|
826
|
-
// startup, before the first GET /jobs/next.
|
|
827
|
+
// explicit re-run couldn't resume at all (the sticky latch tells every poll to
|
|
828
|
+
// stop). Call it once at loop startup, before the first GET /jobs/next.
|
|
827
829
|
if (method === "POST" && path === "/poller/start") {
|
|
828
830
|
pollerStopped = false;
|
|
829
|
-
lastStoppedPollAt = 0;
|
|
830
831
|
stopRequested = false;
|
|
831
832
|
lastJobAt = now();
|
|
832
833
|
lastPollAt = now();
|