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 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
- .tl-refine-titles h3 { font-size: 13px; font-weight: 500; line-height: 14px; color: #17181c; }
1206
- .tl-refine-titles p { margin-top: 2px; font-size: 12px; font-weight: 400; line-height: 14px; color: #676767;
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
- cliMissing
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. The relay self-heals
2708
- // on a TTL; Reconnect just forces an immediate re-check.
2709
- reason&&h("p",{className:"tl-gate-reason"},reason),
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
- catch{chatLoopRef.current=false;setLlmAvailable(false);setCliInstalled(false);setChatLoop(false);return null;}
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
- const desired=new Map(); // el → {css,vars}
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
- if(!active)continue;
4181
- for(const mm of sp.members) for(const el of txMemberEls(mm.selector,sp.stateTarget)){ if(inUI(el))continue; desired.set(el,{css:mm.css,vars:mm.vars}); }
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
- for(const [el,d] of desired){
4184
- const vars=d.vars||{},vk=varsKey(vars),cur=store.applied.get(el);
4185
- if(cur&&cur.css===d.css&&cur.vk===vk)continue;
4186
- if(!cur) store.applied.set(el,{prevInline:el.style.transition||"",prevVars:Object.fromEntries(Object.keys(vars).map(n=>[n,el.style.getPropertyValue(n)])),css:d.css,vk});
4187
- else store.applied.set(el,{prevInline:cur.prevInline,prevVars:cur.prevVars,css:d.css,vk});
4188
- try{el.style.transition=d.css;}catch{}
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(desired.has(el))continue;
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"){throw new Error(job.error||"scan job errored");}
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.21",
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") {