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 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
- cliMissing
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. The relay self-heals
2708
- // on a TTL; Reconnect just forces an immediate re-check.
2709
- reason&&h("p",{className:"tl-gate-reason"},reason),
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
- catch{chatLoopRef.current=false;setLlmAvailable(false);setCliInstalled(false);setChatLoop(false);return null;}
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"){throw new Error(job.error||"scan job errored");}
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.20",
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. Keep telling every
744
- // poller to exit and report inactive until either an explicit POST
745
- // /poller/start, or polling goes quiet for STOP_QUIET_MS (the old loop died)
746
- // and a new poll arrives only then is it a genuinely fresh session.
747
- // A pending job always wins so we never drop real work on the edge.
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
- if (lastStoppedPollAt && now() - lastStoppedPollAt >= STOP_QUIET_MS) {
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 or a quiet gap
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 or a quiet gap
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 or a quiet gap
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 would have to wait out the quiet window. Call it once at loop
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();