transitions-refine 0.3.10 → 0.3.12

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
@@ -91,15 +91,15 @@
91
91
  /* @inject-skip-start (demo-only page + sample-box styles — excluded from the injected build) */
92
92
  body {
93
93
  font-family: "Inter", system-ui, -apple-system, sans-serif;
94
- background: #fafafa;
94
+ background: #EEEEEE;
95
95
  color: var(--c-text);
96
96
  min-height: 100vh;
97
97
  -webkit-font-smoothing: antialiased;
98
98
  -moz-osx-font-smoothing: grayscale;
99
99
  }
100
100
 
101
- .demo-root { max-width: 900px; margin: 0 auto; padding: 48px 24px 120px; }
102
- .demo-header { margin-bottom: 40px; }
101
+ .demo-root { max-width: 900px; margin: 0 auto; padding: 24px 24px 120px; }
102
+ .demo-header { margin-bottom: 18px; }
103
103
  .demo-header h1 { font-size: 26px; font-weight: 700; letter-spacing: -0.5px; color: #171717; }
104
104
  .demo-header p { color: var(--c-ruler); font-size: 14px; margin-top: 6px; }
105
105
  .demo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 20px; }
@@ -194,6 +194,130 @@
194
194
  @media (prefers-reduced-motion: reduce) {
195
195
  .t-morph, .t-morph-plus, .t-morph-menu { transition: none !important; }
196
196
  }
197
+
198
+ /* transitions.dev motion tokens for the extra prototypes (verbatim from
199
+ 05-menu-dropdown.md / 06-modal.md). Demo-only — excluded from the build. */
200
+ :root {
201
+ --dropdown-open-dur: 250ms; --dropdown-close-dur: 150ms;
202
+ --dropdown-pre-scale: 0.97; --dropdown-closing-scale: 0.99;
203
+ --dropdown-ease: cubic-bezier(0.22, 1, 0.36, 1);
204
+ --modal-open-dur: 250ms; --modal-close-dur: 150ms;
205
+ --modal-scale: 0.96; --modal-scale-close: 0.96;
206
+ --modal-ease: cubic-bezier(0.22, 1, 0.36, 1);
207
+ --modal-overlay-bg: rgba(15, 17, 21, 0.45);
208
+ }
209
+
210
+ /* ── prototype switcher tabs (sliding pill — 16-tabs-sliding.md) ── */
211
+ .proto-tabs {
212
+ position: relative; display: inline-flex; align-items: center; gap: 3px;
213
+ margin-top: 18px; padding: 3px; border-radius: 48px; background: #f1f1f1;
214
+ }
215
+ .proto-tab {
216
+ position: relative; appearance: none; border: 0; background: transparent;
217
+ height: 30px; padding: 4px 14px; border-radius: 48px; cursor: pointer;
218
+ font: 500 13px/1 "Inter", system-ui, sans-serif; color: rgba(15,15,15,0.6);
219
+ z-index: 1; transition: color var(--dropdown-open-dur) var(--dropdown-ease);
220
+ }
221
+ .proto-tab:not(.is-active):hover { color: rgba(15,15,15,0.85); }
222
+ .proto-tab.is-active { color: #0f0f0f; }
223
+ .proto-tabs-pill {
224
+ position: absolute; top: 3px; left: 0; height: 30px; width: 0;
225
+ background: #fff; border-radius: 48px; transform: translateX(0);
226
+ box-shadow: 0 1px 3px rgba(0,0,0,0.08), 0 0 0 1px rgba(0,0,0,0.04);
227
+ transition:
228
+ transform var(--dropdown-open-dur) var(--dropdown-ease),
229
+ width var(--dropdown-open-dur) var(--dropdown-ease);
230
+ will-change: transform, width; z-index: 0; pointer-events: none;
231
+ }
232
+
233
+ /* ── prototype: Menu dropdown (05-menu-dropdown.md) ── */
234
+ .dd-stage { position: relative; min-height: 224px; display: flex; justify-content: center; padding-top: 4px; }
235
+ .dd { position: relative; display: inline-block; }
236
+ .dd-trigger {
237
+ display: inline-flex; align-items: center; gap: 8px; padding: 9px 14px;
238
+ font: 500 14px/1 "Inter", system-ui, sans-serif; color: #1b1b1b;
239
+ background: #fff; border: 0; border-radius: 10px; cursor: pointer;
240
+ box-shadow: 0 1px 3px rgba(0,0,0,0.04), inset 0 0 0 1px rgba(0,0,0,0.06), inset 0 -1px 0 rgba(0,0,0,0.10);
241
+ }
242
+ .dd-trigger:hover { background: #f9f9f9; }
243
+ .dd-caret { transition: transform var(--dropdown-open-dur) var(--dropdown-ease); }
244
+ .dd.is-open .dd-caret { transform: rotate(180deg); }
245
+ /* recipe surface */
246
+ .t-dropdown {
247
+ transform-origin: top left; transform: scale(var(--dropdown-pre-scale));
248
+ opacity: 0; pointer-events: none;
249
+ transition:
250
+ transform var(--dropdown-open-dur) var(--dropdown-ease),
251
+ opacity var(--dropdown-open-dur) var(--dropdown-ease);
252
+ will-change: transform, opacity;
253
+ }
254
+ .t-dropdown.is-open { transform: scale(1); opacity: 1; pointer-events: auto; }
255
+ .t-dropdown.is-closing {
256
+ transform: scale(var(--dropdown-closing-scale)); opacity: 0; pointer-events: none;
257
+ transition:
258
+ transform var(--dropdown-close-dur) var(--dropdown-ease),
259
+ opacity var(--dropdown-close-dur) var(--dropdown-ease);
260
+ }
261
+ .dd-menu {
262
+ position: absolute; top: calc(100% + 8px); left: 0; min-width: 216px; padding: 6px;
263
+ background: #fff; border-radius: 12px;
264
+ box-shadow: 0 0 0 1px rgba(0,0,0,0.06), 0 2px 6px rgba(0,0,0,0.05), 0 4px 42px rgba(0,0,0,0.06);
265
+ z-index: 5;
266
+ }
267
+ .dd-title { padding: 6px 10px 4px; font-size: 11px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: #9b9b9b; }
268
+ .dd-item {
269
+ display: flex; align-items: center; gap: 10px; width: 100%; padding: 8px 10px;
270
+ font: 500 13px/1 "Inter", system-ui, sans-serif; color: #2f2f2f;
271
+ background: transparent; border: 0; border-radius: 8px; cursor: pointer; text-align: left;
272
+ }
273
+ .dd-item:hover { background: #f4f4f5; }
274
+
275
+ /* ── prototype: Modal (06-modal.md) ── */
276
+ .modal-stage {
277
+ position: relative; height: 240px; border-radius: 12px; overflow: hidden;
278
+ background: #f6f7f9; box-shadow: inset 0 0 0 1px rgba(0,0,0,0.05);
279
+ display: grid; place-items: center;
280
+ }
281
+ .modal-open-btn {
282
+ position: relative; z-index: 0; padding: 9px 16px;
283
+ font: 500 14px/1 "Inter", system-ui, sans-serif; color: #fff;
284
+ background: #171717; border: 0; border-radius: 10px; cursor: pointer;
285
+ }
286
+ .modal-backdrop {
287
+ position: absolute; inset: 0; z-index: 1; background: var(--modal-overlay-bg);
288
+ opacity: 0; pointer-events: none; cursor: pointer;
289
+ transition: opacity var(--modal-open-dur) var(--modal-ease);
290
+ }
291
+ .modal-backdrop.is-open { opacity: 1; pointer-events: auto; }
292
+ .modal-backdrop.is-closing { opacity: 0; transition: opacity var(--modal-close-dur) var(--modal-ease); }
293
+ .t-modal {
294
+ position: relative; z-index: 2; width: 264px; padding: 20px; background: #fff;
295
+ border-radius: 14px; box-shadow: 0 10px 40px rgba(0,0,0,0.18);
296
+ transform-origin: center; transform: scale(var(--modal-scale)); opacity: 0; pointer-events: none;
297
+ transition:
298
+ transform var(--modal-open-dur) var(--modal-ease),
299
+ opacity var(--modal-open-dur) var(--modal-ease);
300
+ will-change: transform, opacity;
301
+ }
302
+ .t-modal.is-open { transform: scale(1); opacity: 1; pointer-events: auto; }
303
+ .t-modal.is-closing {
304
+ transform: scale(var(--modal-scale-close)); opacity: 0; pointer-events: none;
305
+ transition:
306
+ transform var(--modal-close-dur) var(--modal-ease),
307
+ opacity var(--modal-close-dur) var(--modal-ease);
308
+ }
309
+ .modal-card-title { font-size: 15px; font-weight: 600; color: #171717; }
310
+ .modal-card-body { margin-top: 6px; font-size: 13px; color: var(--c-ruler); line-height: 1.5; }
311
+ .modal-actions { margin-top: 18px; display: flex; justify-content: flex-end; gap: 8px; }
312
+ .modal-btn { padding: 8px 13px; font: 500 13px/1 "Inter", system-ui, sans-serif; border: 0; border-radius: 8px; cursor: pointer; }
313
+ .modal-btn.secondary { background: #f1f1f2; color: #2f2f2f; }
314
+ .modal-btn.secondary:hover { background: #e9e9eb; }
315
+ .modal-btn.primary { background: #e5484d; color: #fff; }
316
+ .modal-btn.primary:hover { background: #d93b40; }
317
+
318
+ @media (prefers-reduced-motion: reduce) {
319
+ .proto-tabs-pill, .proto-tab, .dd-caret, .t-dropdown, .modal-backdrop, .t-modal { transition: none !important; }
320
+ }
197
321
  /* @inject-skip-end */
198
322
 
199
323
  /* ───────────────────── timeline panel ───────────────────── */
@@ -262,10 +386,32 @@
262
386
  .tl-pill-count.is-single { width: 20px; padding: 0; }
263
387
 
264
388
  /* ── header row ── */
389
+ /* container-type lets the children below respond to the header's own width
390
+ (the panel is full-viewport-width, so this tracks the viewport too). */
265
391
  .tl-header { flex: none; position: relative; z-index: 40; display: flex; align-items: center; gap: 8px; padding: 7.5px 14px;
266
- border-bottom: 1px solid var(--c-line); }
267
- .tl-header-label { font-size: 13px; font-weight: 500; line-height: 18px; color: var(--c-text-mut2); white-space: nowrap; }
268
- .tl-header-count { margin-left: auto; display: inline-flex; align-items: center; font-size: 13px; line-height: 18px; color: var(--c-count); white-space: nowrap; }
392
+ border-bottom: 1px solid var(--c-line); container-type: inline-size; }
393
+ .tl-header-label { font-size: 13px; font-weight: 500; line-height: 18px; color: var(--c-text-mut2); white-space: nowrap; flex: none; }
394
+ /* count is the FIRST thing to give up space (high shrink) and clips to */
395
+ .tl-header-count { margin-left: auto; display: inline-flex; align-items: center; font-size: 13px; line-height: 18px; color: var(--c-count); white-space: nowrap;
396
+ min-width: 0; flex: 0 99 auto; overflow: hidden; }
397
+ .tl-count-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
398
+ /* the transition picker truncates its name SECOND (lower shrink than count) */
399
+ .tl-header .tl-ghost-btn { min-width: 0; flex: 0 1 auto; }
400
+ .tl-ghost-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
401
+ .tl-header .tl-ghost-btn .tl-dim, .tl-header .tl-ghost-chev { flex: none; }
402
+ /* the right-hand cluster never shrinks or gets pushed off — minimize stays put */
403
+ .tl-header .tl-icon-btn, .tl-header .tl-sec-btn,
404
+ .tl-header .tl-accept-btn, .tl-header .tl-refine-btn,
405
+ .tl-header > .t-tt-wrap { flex: none; }
406
+ /* still too tight → drop the "Selected" label, then collapse Accept + Refine
407
+ to circular icon-only buttons (radius is already 60px → perfect circle). */
408
+ @container (max-width: 540px) {
409
+ .tl-header-label { display: none; }
410
+ }
411
+ @container (max-width: 470px) {
412
+ .tl-accept-btn .tl-btn-label, .tl-refine-btn .tl-btn-label { display: none; }
413
+ .tl-accept-btn, .tl-refine-btn { width: 36px; padding: 0; justify-content: center; gap: 0; }
414
+ }
269
415
 
270
416
  /* ── buttons ── */
271
417
  /* topbar buttons share a fully rounded 60px corner radius */
@@ -1289,6 +1435,41 @@
1289
1435
  .tl-scan-note { cursor: help; color: var(--c-count);
1290
1436
  display: inline-flex; align-items: center; gap: 6px; }
1291
1437
  .tl-scan-note .tl-dotm { width: 14px; height: 14px; }
1438
+ .tl-scan-error { color: var(--c-warn, #c2410c); }
1439
+ .tl-scan-error svg { flex: none; }
1440
+ /* /refine live chip — warns the chat-loop mode bills credits while idle */
1441
+ .tl-live-wrap { display: inline-flex; }
1442
+ .tl-live-chip { display: inline-flex; align-items: center; gap: 6px;
1443
+ height: 22px; padding: 0 4px 0 8px; border-radius: 999px;
1444
+ font-size: 11px; font-weight: 600; line-height: 1; cursor: help;
1445
+ color: #b45309; background: rgba(217,119,6,0.12);
1446
+ border: 1px solid rgba(217,119,6,0.28);
1447
+ /* mount: token dropdown-open feel — 250ms Fast, scale 0.97, smooth ease out */
1448
+ animation: tl-live-in 250ms cubic-bezier(0.22,1,0.36,1) both; }
1449
+ html[data-theme="dark"] .tl-live-chip { color: #fbbf24;
1450
+ background: rgba(251,191,36,0.13); border-color: rgba(251,191,36,0.3); }
1451
+ .tl-live-chip.is-stopped { color: var(--c-count); background: rgba(127,127,127,0.1);
1452
+ border-color: rgba(127,127,127,0.26); padding: 0 8px; }
1453
+ .tl-live-chip.is-stopped svg { flex: none; }
1454
+ .tl-live-dot { width: 7px; height: 7px; border-radius: 50%;
1455
+ background: currentColor; flex: none;
1456
+ animation: tl-live-pulse 1600ms linear infinite; }
1457
+ .tl-live-stop { all: unset; cursor: pointer; font: inherit; font-size: 10px;
1458
+ font-weight: 700; letter-spacing: 0.02em; text-transform: uppercase;
1459
+ color: inherit; padding: 3px 7px; border-radius: 999px;
1460
+ background: rgba(217,119,6,0.16);
1461
+ transition: background 150ms cubic-bezier(0.22,1,0.36,1); }
1462
+ html[data-theme="dark"] .tl-live-stop { background: rgba(251,191,36,0.18); }
1463
+ .tl-live-stop:hover { background: rgba(217,119,6,0.32); }
1464
+ html[data-theme="dark"] .tl-live-stop:hover { background: rgba(251,191,36,0.34); }
1465
+ .tl-live-stop:focus-visible { outline: 2px solid currentColor; outline-offset: 1px; }
1466
+ @keyframes tl-live-in { from { opacity: 0; transform: scale(0.97); } to { opacity: 1; transform: scale(1); } }
1467
+ @keyframes tl-live-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }
1468
+ @media (prefers-reduced-motion: reduce) {
1469
+ .tl-live-chip { animation: none; }
1470
+ .tl-live-dot { animation: none; }
1471
+ .tl-live-stop { transition: none; }
1472
+ }
1292
1473
  .tl-scan-shimmer { --shimmer-dur: 2000ms; --shimmer-base: var(--c-count); --shimmer-highlight: #17181c;
1293
1474
  --shimmer-band: 400%; --shimmer-ease: linear; }
1294
1475
  /* loading status text — transitions.dev shimmer text (15) + text states swap (04) */
@@ -1345,6 +1526,7 @@
1345
1526
  .tl-sug-cat.is-easing { color: #a830fd; background: rgba(173,68,247,0.06); box-shadow: inset 0 0 0 1px rgba(173,68,247,0.18); }
1346
1527
  .tl-sug-cat.is-duration { color: #3097fd; background: rgba(111,183,255,0.08); box-shadow: inset 0 0 0 1px rgba(48,151,253,0.18); }
1347
1528
  .tl-sug-cat.is-scale { color: #26953b; background: rgba(38,149,59,0.06); box-shadow: inset 0 0 0 1px rgba(38,149,59,0.18); }
1529
+ .tl-sug-cat.is-blur { color: #0e9aa7; background: rgba(14,154,167,0.07); box-shadow: inset 0 0 0 1px rgba(14,154,167,0.20); }
1348
1530
  .tl-sug-cat.is-distance, .tl-sug-cat.is-delay { color: #c35c00; background: rgba(247,152,68,0.06); box-shadow: inset 0 0 0 1px rgba(205,115,36,0.18); }
1349
1531
  .tl-sug-cat.is-replace { color: #3097fd; background: rgba(111,183,255,0.08); box-shadow: inset 0 0 0 1px rgba(48,151,253,0.18); }
1350
1532
  /* content stack */
@@ -1358,6 +1540,12 @@
1358
1540
  .tl-sug-from { color: #7b7b7b; text-decoration: line-through; }
1359
1541
  .tl-sug-to { color: #1b1b1b; }
1360
1542
  .tl-sug-reason { font-size: 12px; font-weight: 400; line-height: 18px; color: #6d6d6d; }
1543
+ /* "Updates Open + Close" — a recipe swap writes every related phase together */
1544
+ .tl-sug-phases { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 6px;
1545
+ font-size: 11px; font-weight: 500; line-height: 16px; color: #6d6d6d; }
1546
+ .tl-sug-phase { display: inline-flex; align-items: baseline; gap: 4px;
1547
+ padding: 1px 7px; border-radius: 20px; background: rgba(15,15,15,0.05); color: #4a4a4a; }
1548
+ .tl-sug-phase-dur { font-family: "Roboto Mono", ui-monospace, SFMono-Regular, Menlo, monospace; color: #1b1b1b; }
1361
1549
  /* shared white pill button (Figma 580:757) — card Apply + bottom-bar actions */
1362
1550
  .tl-pill-btn { align-self: flex-start; box-sizing: border-box; display: inline-flex; align-items: center;
1363
1551
  justify-content: center; gap: 8px; height: 32px; padding: 6px 12px; border: none; cursor: pointer;
@@ -1654,7 +1842,10 @@
1654
1842
  return out;
1655
1843
  }
1656
1844
  _applyOverrides(baseLanes,po){
1657
- return baseLanes.map(t=>{const o=po[t.laneId];return {...t,durationMs:o?.durationMs??t.durationMs,delayMs:o?.delayMs??t.delayMs,easing:o?.easing??t.easing,spring:o?.spring??t.spring};});
1845
+ return baseLanes.map(t=>{const o=po[t.laneId]||{};return {...t,durationMs:o.durationMs??t.durationMs,delayMs:o.delayMs??t.delayMs,easing:o.easing??t.easing,spring:o.spring??t.spring,
1846
+ // value overrides (scale/blur) come only from applied refine suggestions;
1847
+ // they carry their own from-value + backing var so Accept + live preview work.
1848
+ scale:o.scale??t.scale,blur:o.blur??t.blur,varName:o.varName??t.varName,scaleFrom:o.scaleFrom??t.scaleFrom,blurFrom:o.blurFrom??t.blurFrom};});
1658
1849
  }
1659
1850
  _notify(){
1660
1851
  this.effectiveCache.clear();
@@ -1787,6 +1978,56 @@
1787
1978
  }catch{return [];}
1788
1979
  }
1789
1980
 
1981
+ // Read the non-resting ("pre") VALUE a lane animates from the live stylesheets:
1982
+ // transform-scale (≠1) and filter-blur (≠0), plus the CSS custom property
1983
+ // backing it (var-backed values are the ones we can live-preview). Returns
1984
+ // {scale?|blur?, varName?}. Only meaningful for transform/filter lanes.
1985
+ function readElValueHint(el, property){
1986
+ const out={};
1987
+ try{
1988
+ if(!el||(property!=="transform"&&property!=="filter"))return out;
1989
+ const own=(typeof el.className==="string"?el.className.trim().split(/\s+/):Array.from(el.classList||[])).filter(Boolean);
1990
+ const cs=getComputedStyle(el);
1991
+ const esc=s=>s.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");
1992
+ const tokRes=own.map(c=>new RegExp("\\."+esc(c)+"(?![\\w-])"));
1993
+ const matchTok=sel=>!own.length||tokRes.some(re=>re.test(sel));
1994
+ let bestScale=null,bestBlur=null;
1995
+ const consider=txt=>{
1996
+ if(property==="transform"){
1997
+ const tm=txt.match(/transform\s*:\s*([^;}]+)/i);
1998
+ if(tm){const sc=tm[1].match(/scale\(\s*([^),]+)/i);
1999
+ if(sc){const inner=sc[1].trim();const vm=inner.match(/var\(\s*(--[\w-]+)/);
2000
+ const num=vm?parseFloat(cs.getPropertyValue(vm[1])):parseFloat(inner);
2001
+ // pick the value furthest from the resting 1 (the real pre-scale).
2002
+ if(Number.isFinite(num)&&Math.abs(num-1)>1e-4&&(!bestScale||Math.abs(num-1)>Math.abs(bestScale.scale-1)))
2003
+ bestScale={scale:num,...(vm?{varName:vm[1]}:{})};}}
2004
+ }else{
2005
+ const fm=txt.match(/filter\s*:\s*([^;}]+)/i);
2006
+ if(fm){const bl=fm[1].match(/blur\(\s*([^)]+)\)/i);
2007
+ if(bl){const inner=bl[1].trim();const vm=inner.match(/var\(\s*(--[\w-]+)/);
2008
+ const num=vm?parseFloat(cs.getPropertyValue(vm[1])):parseFloat(inner);
2009
+ if(Number.isFinite(num)&&num>1e-4&&(!bestBlur||num>bestBlur.blur))
2010
+ bestBlur={blur:num,...(vm?{varName:vm[1]}:{})};}}
2011
+ }
2012
+ };
2013
+ const collect=list=>{for(const rule of Array.from(list||[])){
2014
+ if(rule.cssRules&&rule.selectorText===undefined){collect(rule.cssRules);continue;}
2015
+ const sel=rule.selectorText;if(!sel||!matchTok(sel))continue;
2016
+ if(rule.cssText)consider(rule.cssText);}};
2017
+ for(const sheet of Array.from(document.styleSheets||[])){let r;try{r=sheet.cssRules;}catch{continue;}collect(r);}
2018
+ if(bestScale)Object.assign(out,bestScale);
2019
+ if(bestBlur)Object.assign(out,bestBlur);
2020
+ }catch{}
2021
+ return out;
2022
+ }
2023
+ // Same, resolving the element from a CSS selector (used when only a lane's
2024
+ // selector is known, e.g. a grouped phase member).
2025
+ function laneValueHint(selector, property){
2026
+ if(!selector||(property!=="transform"&&property!=="filter"))return {};
2027
+ let el=null;try{el=document.querySelector(selector);}catch{return {};}
2028
+ return el?readElValueHint(el,property):{};
2029
+ }
2030
+
1790
2031
  // ── hooks ──
1791
2032
  const TimelineCtx = createContext(null);
1792
2033
  function useReg(){const{registry}=useContext(TimelineCtx);return useSyncExternalStore(useCallback(cb=>registry.subscribe(cb),[registry]),useCallback(()=>registry.getAll(),[registry]),useCallback(()=>registry.getAll(),[registry]));}
@@ -2019,38 +2260,96 @@
2019
2260
  ];
2020
2261
  return [{ id:"menu-dropdown", label:"Menu dropdown", component:null, phases }];
2021
2262
  }
2022
- // refine deterministic, transitions.dev-token-aligned suggestions. patches
2023
- // derive property/member from request.timings so Apply changes a real lane.
2024
- // Shape mirrors relay.mjs's suggestion contract (id/kind/property/title/from/
2025
- // to/patch/reason, + reference on the replace card).
2263
+ // ── deterministic motion-token engine (mirrors server/motion-tokens.mjs) ──
2264
+ // The website demo answers Refine locally, so it must reach the SAME verdict
2265
+ // the shipped agent would: flag ONLY values that are genuinely off the
2266
+ // transitions.dev tokens (by usage where the skill says so). For the demo
2267
+ // dropdown that means the lone real issue — the 0.8 pre-scale — is the
2268
+ // suggestion, instead of a pile of tweaks for values that are already on-grid.
2269
+ const _TX_DUR_TOKENS = [{v:150,name:"Fast",usage:"quick state changes"},{v:250,name:"Medium",usage:"standard UI motion"},{v:400,name:"Slow",usage:"larger surfaces"}];
2270
+ const _TX_SCALE_TOKENS = [{v:0.96,name:"Large",usage:"modal open / close"},{v:0.97,name:"Medium",usage:"dropdown open"},{v:0.98,name:"Small",usage:"tooltip open"},{v:0.99,name:"Tiny",usage:"dropdown close"}];
2271
+ const _TX_BLUR_TOKENS = [{v:2,name:"Small",usage:"panel reveal, icon swap, text swap"},{v:3,name:"Medium",usage:"page slide, text reveal"},{v:8,name:"Large",usage:"success check open"}];
2272
+ const _TX_TOKEN_EASE = new Set(["cubic-bezier(0.22,1,0.36,1)","ease-in-out","ease-out","linear","cubic-bezier(0.34,1.36,0.64,1)","cubic-bezier(0.34,3.85,0.64,1)"]);
2273
+ const _txLc = s=>String(s||"").toLowerCase();
2274
+ const _txNormEase = s=>String(s||"").replace(/\s+/g,"").toLowerCase();
2275
+ const _txNearest = (arr,val)=>{let best=arr[0],bd=Infinity;for(const t of arr){const d=Math.abs(t.v-val);if(d<bd){bd=d;best=t;}}return{token:best,delta:bd};};
2276
+ const _txScaleUsage = (hint)=>{const h=_txLc(hint);if(!h)return null;const close=/clos/.test(h);
2277
+ if(/\bmodal\b|\bdialog\b|\blightbox\b/.test(h))return _TX_SCALE_TOKENS.find(t=>t.v===0.96);
2278
+ if(/\btooltip\b|\btip\b|\bpopover\b/.test(h))return _TX_SCALE_TOKENS.find(t=>t.v===0.98);
2279
+ if(/\bdropdown\b|drop-down|\bmenu\b|\bselect\b|\bcombobox\b|\bcaret\b/.test(h))return _TX_SCALE_TOKENS.find(t=>t.v===(close?0.99:0.97));
2280
+ return null;};
2281
+ const _txBlurUsage = (hint)=>{const h=_txLc(hint);if(!h)return null;
2282
+ if(/success|check/.test(h))return _TX_BLUR_TOKENS.find(t=>t.v===8);
2283
+ if(/\bpage\b|\bslide\b/.test(h))return _TX_BLUR_TOKENS.find(t=>t.v===3);
2284
+ if(/text/.test(h)&&/reveal/.test(h))return _TX_BLUR_TOKENS.find(t=>t.v===3);
2285
+ if(/panel|icon|swap|skeleton|number|digit|reveal/.test(h))return _TX_BLUR_TOKENS.find(t=>t.v===2);
2286
+ return null;};
2287
+ const _txRefineEasing = (e)=>{const n=_txNormEase(e);if(!n)return false;if(_TX_TOKEN_EASE.has(n))return false;return n==="ease"||n==="ease-in"||n.startsWith("cubic-bezier")||n.startsWith("linear(");};
2288
+ // Token-faithful tweaks for one lane list (mirrors refineTimings()).
2289
+ function _txTokenTweaks(timings, ctx){
2290
+ const out=[]; const c=ctx||{};
2291
+ for(const t of (timings||[])){
2292
+ const prop=t.property||"all";
2293
+ const member=t.member||null;
2294
+ const hint=[c.label,c.selector,c.phase,t.property,t.member].filter(Boolean).join(" ");
2295
+ const mp=(rest)=> member ? {member, ...rest} : rest;
2296
+ if(Number.isFinite(t.durationMs)){const {token,delta}=_txNearest(_TX_DUR_TOKENS,t.durationMs);
2297
+ if(delta>10)out.push({id:"mock-"+prop+"-duration",kind:"duration",property:prop,member,
2298
+ title:"Duration → "+token.name,from:t.durationMs+"ms",to:token.v+"ms",
2299
+ patch:mp({property:prop,durationMs:token.v}),
2300
+ reason:token.name+" ("+token.v+"ms) is the closest motion token — used for "+token.usage+". "+t.durationMs+"ms is off-grid."});}
2301
+ if(_txRefineEasing(t.easing))out.push({id:"mock-"+prop+"-easing",kind:"easing",property:prop,member,
2302
+ title:"Easing → Smooth ease out",from:t.easing,to:_TX_EASE,
2303
+ patch:mp({property:prop,easing:_TX_EASE}),
2304
+ reason:'"'+t.easing+'" is a generic curve. The transitions.dev standard ease-out reads more intentional on opens, closes, slides, and resizes.'});
2305
+ if(Number.isFinite(t.scale)&&Math.abs(t.scale-1)>1e-4){const usage=_txScaleUsage(hint);const {token:near,delta}=_txNearest(_TX_SCALE_TOKENS,t.scale);const token=usage||near;
2306
+ if(token&&(usage||delta>5e-3)&&Math.abs(token.v-t.scale)>1e-4)out.push({id:"mock-"+prop+"-scale",kind:"scale",property:prop,member,
2307
+ title:"Scale → "+token.name,from:String(t.scale),to:String(token.v),
2308
+ patch:mp({property:prop,scale:token.v,...(t.varName?{varName:t.varName}:{})}),
2309
+ reason:token.name+" ("+token.v+") is the transitions.dev scale token for "+token.usage+". "+t.scale+" is "+(usage?"off the usage token":"off-grid")+"."});}
2310
+ if(Number.isFinite(t.blur)&&t.blur>1e-4){const usage=_txBlurUsage(hint);const {token:near,delta}=_txNearest(_TX_BLUR_TOKENS,t.blur);const token=usage||near;
2311
+ if(token&&(usage||delta>0.5)&&Math.abs(token.v-t.blur)>1e-4)out.push({id:"mock-"+prop+"-blur",kind:"blur",property:prop,member,
2312
+ title:"Blur → "+token.name,from:t.blur+"px",to:token.v+"px",
2313
+ patch:mp({property:prop,blur:token.v,...(t.varName?{varName:t.varName}:{})}),
2314
+ reason:token.name+" ("+token.v+"px) is the transitions.dev blur token for "+token.usage+". "+t.blur+"px is "+(usage?"off the usage token":"off-grid")+"."});}
2315
+ }
2316
+ return out;
2317
+ }
2318
+ // refine → token-faithful suggestions (same verdict the shipped agent reaches)
2319
+ // plus the best-fit recipe for the Replace tab. patches derive property/member
2320
+ // from request.timings so Apply changes a real lane. Shape mirrors relay.mjs's
2321
+ // suggestion contract (id/kind/property/title/from/to/patch/reason + reference).
2026
2322
  function _txMockSuggestions(request){
2027
2323
  const t = (request && request.timings) || [];
2324
+ const ctx = { label:(request&&request.label)||null, selector:(request&&request.selector)||null, phase:(request&&request.phase)||null };
2028
2325
  const p0 = (t[0]&&t[0].property) || "opacity";
2029
2326
  const m0 = (t[0]&&t[0].member) || null;
2030
- const p1 = (t[1]&&t[1].property) || p0;
2031
- const m1 = (t[1]&&t[1].member) || m0;
2032
- const from0 = _txMs(t[0]&&t[0].durationMs) || "—";
2033
- const fromE = (t[0]&&t[0].easing) || "ease";
2034
2327
  const mp = (member, rest)=> member ? {member, ...rest} : rest;
2035
- const suggestions=[
2036
- { id:"mock-dur-fast", kind:"duration", property:p0, member:m0,
2037
- title:"DurationFast", from:from0, to:"250ms",
2038
- patch:mp(m0,{property:p0, durationMs:250}),
2039
- reason:"Open motion snaps to the 250ms “medium” token — standard UI motion on transitions.dev." },
2040
- { id:"mock-dur-quick", kind:"duration", property:p1, member:m1,
2041
- title:"Duration → Quick", from:_txMs(t[1]&&t[1].durationMs)||from0, to:"150ms",
2042
- patch:mp(m1,{property:p1, durationMs:150}),
2043
- reason:"Closing feels crisper at the 150ms “fast” token — quick state changes." },
2044
- { id:"mock-ease-smooth", kind:"easing", property:p0, member:m0,
2045
- title:"Easing → Smooth ease out", from:fromE, to:_TX_EASE,
2046
- patch:mp(m0,{property:p0, easing:_TX_EASE}),
2047
- reason:"transitions.dev standard ease-out a gentle decelerate to rest." },
2048
- { id:"mock-replace-menu", kind:"replace", property:p0, member:m0,
2049
- title:"Menu dropdown", reference:"05-menu-dropdown.md",
2050
- patch:mp(m0,{property:"all", durationMs:250, easing:_TX_EASE}),
2051
- reason:"Best-fit transitions.dev recipe (#05 Menu dropdown): scale 0.97→1 + fade, 250ms open / 150ms close on cubic-bezier(0.22, 1, 0.36, 1)." },
2052
- ];
2053
- return { suggestions, summary:"3 motion-token tweaks and 1 transitions.dev recipe match for the Menu dropdown." };
2328
+ // motion-token tweaks: ONLY off-token values, exactly like the real engine.
2329
+ const tweaks = _txTokenTweaks(t, ctx);
2330
+ // related phases (open + close) a recipe swap updates them together: one
2331
+ // `patches` entry per phase, open slower than close (250ms / 150ms tokens).
2332
+ const reqPhases = (request && Array.isArray(request.phases)) ? request.phases.filter(p=>p&&p.phase) : [];
2333
+ const replacePatches = reqPhases.length>1
2334
+ ? reqPhases.map(p=>({phase:p.phase, property:"all", durationMs:/close/i.test(p.phase)?150:250, easing:_TX_EASE, scale:/close/i.test(p.phase)?0.99:0.97}))
2335
+ : null;
2336
+ const replace = { id:"mock-replace-menu", kind:"replace", property:p0, member:m0,
2337
+ title:"Menu dropdown", reference:"05-menu-dropdown.md",
2338
+ patch:mp(m0,{property:"all", durationMs:250, easing:_TX_EASE, scale:0.97}),
2339
+ ...(replacePatches?{patches:replacePatches}:{}),
2340
+ reason:"Best-fit transitions.dev recipe (#05 Menu dropdown): scale 0.97→1 + fade, 250ms open / 150ms close on cubic-bezier(0.22, 1, 0.36, 1)." };
2341
+ const suggestions=[...tweaks, replace];
2342
+ // Scope to the requested refineType so per-tab caches merge cleanly.
2343
+ const rt=(request&&request.refineType)||"both";
2344
+ const scoped=rt==="replace"?suggestions.filter(s=>s.kind==="replace")
2345
+ :rt==="small"?suggestions.filter(s=>s.kind!=="replace")
2346
+ :suggestions;
2347
+ const nTweak=tweaks.length;
2348
+ const tweakWord=nTweak+" motion-token tweak"+(nTweak===1?"":"s");
2349
+ const summary=rt==="replace"?"1 transitions.dev recipe match for the Menu dropdown."
2350
+ :rt==="small"?(nTweak?tweakWord+" for the Menu dropdown.":"The Menu dropdown is already on-grid — no motion-token tweaks needed.")
2351
+ :tweakWord+" and 1 transitions.dev recipe match for the Menu dropdown.";
2352
+ return { suggestions:scoped, summary };
2054
2353
  }
2055
2354
  // statusLog so the panel's shimmer status copy has something to show.
2056
2355
  function _txMockLog(request, createdAt){
@@ -2064,7 +2363,9 @@
2064
2363
  async function relayCreateJob(request){
2065
2364
  if(TX_MOCK){
2066
2365
  const id=_txMockId();
2067
- _txMockJobs.set(id,{request,createdAt:Date.now()});
2366
+ // Website demo: random 4–6s “agent work” so the scanning loader feels real.
2367
+ const delayMs=4000+Math.floor(Math.random()*2001);
2368
+ _txMockJobs.set(id,{request,createdAt:Date.now(),delayMs});
2068
2369
  return { id, status:"pending" };
2069
2370
  }
2070
2371
  const r = await fetch(RELAY_URL+"/jobs",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({request})});
@@ -2077,8 +2378,8 @@
2077
2378
  if(!rec) return { id, status:"error", statusLog:[], result:null, error:"mock: unknown job" };
2078
2379
  const elapsed=Date.now()-rec.createdAt;
2079
2380
  const log=_txMockLog(rec.request, rec.createdAt);
2080
- // ~800ms simulated work: the first poll(s) see "running", then "done".
2081
- if(elapsed<800) return { id, status:"running", statusLog:[log[0]], result:null, error:null };
2381
+ const wait=rec.delayMs!=null?rec.delayMs:800;
2382
+ if(elapsed<wait) return { id, status:"running", statusLog:[log[0]], result:null, error:null };
2082
2383
  return { id, status:"done", statusLog:log, result:_txMockResult(rec.request), error:null };
2083
2384
  }
2084
2385
  const r = await fetch(RELAY_URL+"/jobs/"+id);
@@ -2086,10 +2387,18 @@
2086
2387
  return r.json();
2087
2388
  }
2088
2389
  async function relayHealth(){
2089
- if(TX_MOCK) return { ok:true, auto:true, llmAvailable:true, cliInstalled:true, jobs:0 };
2390
+ if(TX_MOCK) return { ok:true, auto:true, llmAvailable:true, cliInstalled:true, agentCmd:true, pollerActive:false, jobs:0 };
2090
2391
  const r = await fetch(RELAY_URL+"/health");
2091
2392
  if(!r.ok) throw new Error("relay /health failed ("+r.status+")");
2092
- return r.json(); // { ok, auto, llmAvailable, jobs }
2393
+ return r.json(); // { ok, auto, llmAvailable, pollerActive, agentCmd, cliInstalled, jobs }
2394
+ }
2395
+ // Ask the relay to stop the in-chat `/refine live` loop (the credit-burning
2396
+ // poller). No-op for a wired REFINE_AGENT_CMD (it never polls /jobs/next).
2397
+ async function relayStopLive(){
2398
+ if(TX_MOCK) return { ok:true };
2399
+ const r = await fetch(RELAY_URL+"/poller/stop",{method:"POST"});
2400
+ if(!r.ok) throw new Error("relay POST /poller/stop failed ("+r.status+")");
2401
+ return r.json();
2093
2402
  }
2094
2403
 
2095
2404
  const REFINE_MODES=[
@@ -2136,7 +2445,20 @@
2136
2445
  const matches=lanes.filter(t=>t.property===s.property&&t.member);
2137
2446
  if(!matches.length) return null;
2138
2447
  const names=[...new Set(matches.map(t=>t.member))];
2139
- return names.length===1?names[0]:matches[0].member;
2448
+ if(names.length===1) return names[0];
2449
+ // Ambiguous: several lanes share this property (e.g. a caret that rotates
2450
+ // AND a panel that scales both animate `transform`). For a value-bearing
2451
+ // kind, attribute to the lane that actually carries that value rather than
2452
+ // blindly taking the first — read the live non-resting value per lane.
2453
+ if(s.kind==="scale"||s.kind==="blur"){
2454
+ const key=s.kind;
2455
+ const owner=matches.find(t=>{
2456
+ const v=laneValueHint(t.selector,t.property);
2457
+ return key==="scale"?(v.scale!=null&&Math.abs(v.scale-1)>1e-4):(v.blur!=null&&v.blur>1e-4);
2458
+ });
2459
+ if(owner) return owner.member;
2460
+ }
2461
+ return matches[0].member;
2140
2462
  }
2141
2463
  // Apply button — "Apply" ⟷ "✓ Applied". On apply, a LEADING gray check is
2142
2464
  // revealed first and pushes the text to the right: .tl-apply-check tweens its
@@ -2222,7 +2544,7 @@
2222
2544
  const delay=(i)=>({transitionDelay:"calc(var(--stagger-stagger) * "+i+")"});
2223
2545
  // category chip label + color modifier, keyed by the suggestion kind
2224
2546
  const CAT={easing:["Easing","is-easing"],duration:["Duration","is-duration"],
2225
- delay:["Delay","is-delay"],scale:["Scale","is-scale"],distance:["Distance","is-distance"],
2547
+ delay:["Delay","is-delay"],scale:["Scale","is-scale"],blur:["Blur","is-blur"],distance:["Distance","is-distance"],
2226
2548
  replace:["Transition replacement","is-replace"]};
2227
2549
  const cap=(p)=>p&&p!=="all"?p.charAt(0).toUpperCase()+p.slice(1):"";
2228
2550
  let i=0;
@@ -2247,8 +2569,14 @@
2247
2569
  h("div",{className:"tl-sug-body"},
2248
2570
  h("div",{className:"tl-sug-text"},
2249
2571
  isReplace
2250
- ? h("div",{className:"tl-sug-title"},
2251
- "Current transition",h("br"),"\u2192 "+(s.title||"transition")+" from transitions.dev")
2572
+ ? h(React.Fragment,null,
2573
+ h("div",{className:"tl-sug-title"},
2574
+ "Current transition",h("br"),"\u2192 "+(s.title||"transition")+" from transitions.dev"),
2575
+ Array.isArray(s.patches)&&s.patches.length>1&&h("div",{className:"tl-sug-phases"},
2576
+ "Updates ",
2577
+ s.patches.map((pp,pi)=>h("span",{className:"tl-sug-phase",key:pi},
2578
+ (cap(pp.phase)||pp.phase||"Phase"),
2579
+ pp.durationMs!=null&&h("span",{className:"tl-sug-phase-dur"},pp.durationMs+"ms")))))
2252
2580
  : h(React.Fragment,null,
2253
2581
  s.title&&h("div",{className:"tl-sug-title"},s.title),
2254
2582
  (s.from||s.to)&&h("div",{className:"tl-sug-delta"},
@@ -2258,6 +2586,20 @@
2258
2586
  applyBtn));
2259
2587
  }));
2260
2588
  }
2589
+ // Shared "Connect with your agent" copy — used by the full-panel gate and by
2590
+ // the Refine area when no live agent is connected, so both read identically.
2591
+ // `cliMissing` swaps the body to an install-first hint.
2592
+ function AgentConnectBody({cliMissing}){
2593
+ return cliMissing
2594
+ ? h("p",{className:"tl-gate-text"},"Install an agent CLI, then run ",
2595
+ h("code",{className:"tl-code"},"npx transitions-refine live"),
2596
+ " (recommended) or ",h("code",{className:"tl-code"},"/refine live")," in your editor.")
2597
+ : h("p",{className:"tl-gate-text"},
2598
+ "Run ",h("code",{className:"tl-code"},"npx transitions-refine live"),
2599
+ " in your project to start the relay with a persistent agent — works for hours, no chat loop. Or run ",
2600
+ h("code",{className:"tl-code"},"/refine live"),
2601
+ " in your editor if the relay is already up without an agent wired.");
2602
+ }
2261
2603
  function RefinePanel({open,onClose,phase,label,refineType,onType,suggestions,summary,error,appliedIds,onApply,onApplyAll,mode,onMode,llmAvailable,cliInstalled,onStart,lanes}){
2262
2604
  // mount-on-open; keep mounted through the panel-reveal slide-out, then unmount
2263
2605
  const[render,setRender]=useState(open);
@@ -2363,12 +2705,12 @@
2363
2705
  }
2364
2706
  // idle
2365
2707
  if(!agentReady){
2366
- const cliMissing = cliInstalled===false;
2708
+ // Same "Connect with your agent" copy as the full-panel gate, so opening
2709
+ // Refine after a post-scan auto-stop reads identically. Re-running
2710
+ // /refine live makes the agent available and Start scanning works again.
2367
2711
  return h("div",{className:"tl-refine-center"},
2368
- h("div",{className:"tl-refine-unavail-title"},"Agent unavailable."),
2369
- cliMissing
2370
- ? h("p",{className:"tl-refine-unavail-text"},"Install Cursor CLI by ",h("code",{className:"tl-code"},"/refine live")," command in your agent to enable live functionality.")
2371
- : h("p",{className:"tl-refine-unavail-text"},"Run the ",h("code",{className:"tl-code"},"/refine live")," command in your agent to enable live functionality."));
2712
+ h("div",{className:"tl-refine-unavail-title"},"Connect with your agent"),
2713
+ h(AgentConnectBody,{cliMissing:cliInstalled===false}));
2372
2714
  }
2373
2715
  const idleDescR = agentMode ? (REFINE_TYPES.find(t=>t.key===rt)||REFINE_TYPES[0]).desc : (rt==="replace"
2374
2716
  ? "Deterministic mode can't pick a recipe to replace the transition — switch to Agent mode for replacements."
@@ -2436,23 +2778,44 @@
2436
2778
  // originally-scanned values — the set of changes Accept writes to source.
2437
2779
  // Each change carries member/selector/phase context so the agent can target
2438
2780
  // the right state rule (e.g. .is-open vs .is-closing) for a grouped phase.
2439
- function computeChanges(active){
2781
+ function computeChanges(active,allEntries){
2440
2782
  if(!active)return[];
2441
- const base=active.baseLanes||[];
2442
- const bmap=new Map(base.map(t=>[t.laneId,t]));
2783
+ // Write the active phase plus — for a grouped phase — its sibling phases
2784
+ // (open/close) so a recipe swap applied across both is saved to both. Only
2785
+ // phases that actually have edits contribute changes, so a single-phase tweak
2786
+ // still writes just that phase. Each change carries its own `phase`.
2787
+ let targets=[active];
2788
+ if(active.kind==="phase"&&active.groupId&&Array.isArray(allEntries)){
2789
+ const sibs=allEntries.filter(e=>e.kind==="phase"&&e.groupId===active.groupId&&e.id!==active.id);
2790
+ targets=targets.concat(sibs);
2791
+ }
2443
2792
  const out=[];
2444
- for(const t of (active.effectiveTimings||[])){
2445
- const b=bmap.get(t.laneId);if(!b)continue;
2446
- if(t.durationMs!==b.durationMs||t.delayMs!==b.delayMs||(t.easing||"")!==(b.easing||"")){
2447
- out.push({property:t.property,member:t.member||null,selector:t.selector||null,
2448
- from:{durationMs:b.durationMs,delayMs:b.delayMs,easing:b.easing},
2449
- to:{durationMs:t.durationMs,delayMs:t.delayMs,easing:t.easing}});
2793
+ for(const tgt of targets){
2794
+ const base=tgt.baseLanes||[];
2795
+ const bmap=new Map(base.map(t=>[t.laneId,t]));
2796
+ for(const t of (tgt.effectiveTimings||[])){
2797
+ const b=bmap.get(t.laneId);if(!b)continue;
2798
+ if(t.durationMs!==b.durationMs||t.delayMs!==b.delayMs||(t.easing||"")!==(b.easing||"")){
2799
+ out.push({property:t.property,member:t.member||null,selector:t.selector||null,phase:tgt.phase||null,
2800
+ from:{durationMs:b.durationMs,delayMs:b.delayMs,easing:b.easing},
2801
+ to:{durationMs:t.durationMs,delayMs:t.delayMs,easing:t.easing}});
2802
+ }
2803
+ // value edits (scale/blur) live as overrides on the effective lane and
2804
+ // carry their own from-value + backing var; emit one change each.
2805
+ if(t.scale!=null){
2806
+ out.push({property:t.property,member:t.member||null,selector:t.selector||null,phase:tgt.phase||null,varName:t.varName||null,
2807
+ from:{scale:t.scaleFrom},to:{scale:t.scale}});
2808
+ }
2809
+ if(t.blur!=null){
2810
+ out.push({property:t.property,member:t.member||null,selector:t.selector||null,phase:tgt.phase||null,varName:t.varName||null,
2811
+ from:{blur:t.blurFrom},to:{blur:t.blur}});
2812
+ }
2450
2813
  }
2451
2814
  }
2452
2815
  return out;
2453
2816
  }
2454
2817
 
2455
- function Header({entries,active,onSelect,onReset,onCopy,copied,snap,setSnap,onMinimize,onRefine,refineActive,onAccept,acceptState,acceptDisabled,acceptError,scanning,onRescan,pick,setPick}){
2818
+ function Header({entries,active,onSelect,onReset,onCopy,copied,snap,setSnap,onMinimize,onRefine,refineActive,onAccept,acceptState,acceptDisabled,acceptError,scanning,scanError,onRescan,pick,setPick,chatLoop,liveStopped,onStopLive}){
2456
2819
  const[setg,setSetg]=useState(false);
2457
2820
  // settings dropdown sub-page (main menu ↔ keyboard-shortcuts cheat sheet)
2458
2821
  const[settingsView,setSettingsView]=useState("main"); // "main" | "shortcuts"
@@ -2505,7 +2868,7 @@
2505
2868
  return h("div",{className:"tl-header"},
2506
2869
  h("span",{className:"tl-header-label"},"Selected"),
2507
2870
  h("button",{ref:pickRef,className:cx("tl-ghost-btn",pick&&"is-active"),disabled:!active,onClick:()=>setPick(v=>!v)},
2508
- active?h(React.Fragment,null,h("span",null,active.label),h("span",{className:"tl-dim"}," "+active.durationMs+"ms"))
2871
+ active?h(React.Fragment,null,h("span",{className:"tl-ghost-label"},active.label),h("span",{className:"tl-dim"}," "+active.durationMs+"ms"))
2509
2872
  :h("span",{className:"tl-dim"},"None"),
2510
2873
  h("span",{className:"tl-ghost-chev"},h(Ic,{name:"chevron"}))),
2511
2874
  h(Dropdown,{open:pick,onClose:()=>setPick(false),triggerRef:pickRef,width:Math.max(240,(pickRef.current&&pickRef.current.offsetWidth)||240),align:"left"},
@@ -2521,7 +2884,6 @@
2521
2884
  ...fGroups.map(g=>h(React.Fragment,{key:g.id},
2522
2885
  h("div",{className:"tl-menu-section"},g.label),
2523
2886
  ...g.phases.map(phaseItem))),
2524
- fFlat.length>0&&fGroups.length>0&&h("div",{className:"tl-menu-section"},"Ungrouped"),
2525
2887
  ...fFlat.map(flatItem))),
2526
2888
  h("span",{className:"tl-header-count"},
2527
2889
  scanning
@@ -2531,7 +2893,33 @@
2531
2893
  h("span",{className:"t-shimmer tl-scan-shimmer","data-text":"Agent scanning transitions…"},"Agent scanning transitions…")),
2532
2894
  h("span",{className:"t-tt tl-tt-below tl-tt-msg",role:"tooltip"},
2533
2895
  "Agent is working on understanding your transitions and creating grouping and naming. This usually takes a few seconds to a minute."))
2534
- : entries.length+" transition"+(entries.length===1?"":"s")+" found"),
2896
+ : scanError
2897
+ ? h("span",{className:"t-tt-wrap"},
2898
+ h("span",{className:"tl-scan-note tl-scan-error",tabIndex:0},
2899
+ h(Ic,{name:"help",size:13}),
2900
+ h("span",null,"Couldn’t group — agent offline")),
2901
+ h("span",{className:"t-tt tl-tt-below tl-tt-msg",role:"tooltip"},
2902
+ /refine live|REFINE_AGENT_CMD/i.test(scanError)
2903
+ ? scanError
2904
+ : scanError+" — grouping needs the agent. Run `npx transitions-refine live` (or `/refine live` in your editor)."))
2905
+ : h("span",{className:"tl-count-text"},entries.length+" transition"+(entries.length===1?"":"s")+" found")),
2906
+ chatLoop
2907
+ ? h("span",{className:"t-tt-wrap tl-live-wrap"},
2908
+ h("span",{className:"tl-live-chip",tabIndex:0},
2909
+ h("span",{className:"tl-live-dot"}),
2910
+ h("span",null,"Live"),
2911
+ h("button",{className:"tl-live-stop",title:"Stop /refine live",
2912
+ onClick:(e)=>{e.stopPropagation();onStopLive&&onStopLive();}},"Stop")),
2913
+ h("span",{className:"t-tt tl-tt-below tl-tt-msg",role:"tooltip"},
2914
+ "`/refine live` is polling in your editor chat — it uses agent credits while idle. Stop it when you’re done, or it auto-stops after 10 min idle."))
2915
+ : liveStopped
2916
+ ? h("span",{className:"t-tt-wrap tl-live-wrap"},
2917
+ h("span",{className:"tl-live-chip is-stopped",tabIndex:0},
2918
+ h(Ic,{name:"check",size:12}),
2919
+ h("span",null,"Live stopped")),
2920
+ h("span",{className:"t-tt tl-tt-below tl-tt-msg",role:"tooltip"},
2921
+ "The `/refine live` loop was asked to stop and will exit on its next poll. Run `/refine live` again to resume."))
2922
+ : null,
2535
2923
  h("button",{ref:gearRef,className:cx("tl-icon-btn",setg&&"is-active"),title:"Settings",onClick:()=>setSetg(v=>!v)},h(Ic,{name:"dotsv"})),
2536
2924
  h(Dropdown,{open:setg,onClose:()=>{setSetg(false);setSettingsView("main");},triggerRef:gearRef,width:240,align:"right"},
2537
2925
  h(SettingsSlide,{view:settingsView,
@@ -2591,14 +2979,14 @@
2591
2979
  acceptState==="saving"
2592
2980
  ? h("span",{className:"tl-accept-spin","aria-hidden":"true"})
2593
2981
  : h(Ic,{name:"accept"}),
2594
- h("span",null,acceptState==="done"?"Done":"Accept")),
2982
+ h("span",{className:"tl-btn-label"},acceptState==="done"?"Done":"Accept")),
2595
2983
  h("span",{className:cx("t-tt","tl-tt-below",acceptState==="error"&&acceptError&&"tl-tt-msg"),role:"tooltip"},
2596
2984
  acceptState==="error"&&acceptError?acceptError
2597
2985
  :acceptState==="done"?"Saved to your code"
2598
2986
  :acceptDisabled?"No changes to save"
2599
2987
  :h(React.Fragment,null,"Save changes to your codebase",ttHint("A")))),
2600
2988
  h("span",{className:"t-tt-wrap"},
2601
- h("button",{className:cx("tl-refine-btn t-tt-trigger",refineActive&&"is-active"),disabled:!active,onClick:onRefine},
2989
+ h("button",{className:cx("tl-refine-btn t-tt-trigger",refineActive&&"is-active"),disabled:!active,onClick:onRefine,"aria-label":"Refine"},
2602
2990
  h(Ic,{name:"wand"}),
2603
2991
  h("span",{className:"tl-refine-sparks","aria-hidden":"true"},
2604
2992
  h("i",{style:{"--ox":"-1px","--oy":"-3px","--sx":"0px","--sy":"-11px","--sd":"900ms","--sdelay":"0ms"}}),
@@ -2611,7 +2999,7 @@
2611
2999
  h("i",{style:{"--ox":"2px","--oy":"5px","--sx":"9px","--sy":"9px","--sd":"1000ms","--sdelay":"760ms"}}),
2612
3000
  h("i",{style:{"--ox":"-5px","--oy":"-2px","--sx":"-9px","--sy":"-9px","--sd":"930ms","--sdelay":"180ms"}}),
2613
3001
  h("i",{style:{"--ox":"3px","--oy":"1px","--sx":"12px","--sy":"1px","--sd":"980ms","--sdelay":"300ms"}})),
2614
- h("span",null,"Refine")),
3002
+ h("span",{className:"tl-btn-label"},"Refine")),
2615
3003
  h("span",{className:"t-tt tl-tt-below",role:"tooltip"},"Refine",ttHint("R"))),
2616
3004
  h("span",{className:"t-tt-wrap"},
2617
3005
  h("button",{className:"tl-icon-btn ghost t-tt-trigger","aria-label":"Minimize",onClick:onMinimize},h(Ic,{name:"minimize"})),
@@ -3282,18 +3670,85 @@
3282
3670
  const[acceptError,setAcceptError]=useState(null);
3283
3671
  // ── grouped scan (agent reads source → Open/Close phases) ──
3284
3672
  const[groupScanState,setGroupScanState]=useState("idle"); // idle | scanning | done | error
3673
+ // Whether the one-time grouping scan has produced groups. Once true the main
3674
+ // panel stays usable even after a chat-loop agent disconnects (so the panel
3675
+ // doesn't snap back to the gate). Seeded synchronously from a cached grouping
3676
+ // or a host-baked seed so a returning user never flashes the gate. (Key is
3677
+ // inlined to match GROUP_STORE_KEY, which is defined further down.)
3678
+ const[scanned,setScanned]=useState(()=>{
3679
+ try{
3680
+ if(typeof window!=="undefined"&&Array.isArray(window.__TX_SEED_GROUPS)&&window.__TX_SEED_GROUPS.length)return true;
3681
+ const c=JSON.parse(localStorage.getItem("tlGroups:"+location.pathname)||"null");
3682
+ return !!(c&&Array.isArray(c.groups)&&c.groups.length);
3683
+ }catch{return false;}
3684
+ });
3685
+ // last scan failure message (relay surfaces "needs the agent. Run /refine
3686
+ // live …") so a failed Rescan can explain WHY nothing re-grouped instead of
3687
+ // silently showing the same flat count.
3688
+ const[groupScanErr,setGroupScanErr]=useState(null);
3285
3689
  const didGroupScanRef=useRef(false);
3690
+ // One failed agent spawn (e.g. cursor-agent's cli-config race on cold start)
3691
+ // shouldn't leave the panel stuck flat until a manual rescan. Allow a couple
3692
+ // of automatic re-tries with backoff before giving up; reset on success and
3693
+ // on manual rescan so each fresh scan gets its own retry budget.
3694
+ const groupScanRetryRef=useRef(0);
3695
+ const GROUP_SCAN_MAX_RETRIES=2;
3286
3696
  const[refineLabel,setRefineLabel]=useState(null);
3287
3697
  const[appliedIds,setAppliedIds]=useState({});
3288
3698
  const[refineMode,setRefineMode]=useState("llm"); // llm (Agent) | deterministic
3289
3699
  const[refineType,setRefineType]=useState("small"); // small | replace
3290
3700
  const[llmAvailable,setLlmAvailable]=useState(null); // null=unknown, true/false from /health
3291
3701
  const[cliInstalled,setCliInstalled]=useState(null); // null=unknown, true/false from /health
3702
+ // chatLoop = LLM served by the in-chat `/refine live` poll loop (pollerActive
3703
+ // and no wired REFINE_AGENT_CMD). This is the mode that bills agent turns
3704
+ // while idle, so the header shows a credit warning + Stop for it.
3705
+ const[chatLoop,setChatLoop]=useState(false);
3706
+ const[liveStopped,setLiveStopped]=useState(false); // user hit Stop → show paused chip
3707
+ // After a Stop, the loop stays pollerActive for its ~120s TTL while it
3708
+ // winds down, so we can't just re-read pollerActive. stopPendingRef tracks
3709
+ // "a stop was requested"; sawPollerGoneRef flips once the poller actually
3710
+ // disappears, so only a poller that returns *after that* counts as a fresh
3711
+ // `/refine live` and flips the chip back to Live. (Refs because
3712
+ // refreshHealth is a []-deps callback and would otherwise read stale state.)
3713
+ const stopPendingRef=useRef(false);
3714
+ const sawPollerGoneRef=useRef(false);
3715
+ // A quiet stop (the after-scan auto-stop) drains the loop without showing the
3716
+ // "Live stopped" chip — it's a normal connect-for-scan, not a user Stop.
3717
+ const quietStopRef=useRef(false);
3718
+ // Latest chatLoop value for callbacks (runGroupScan, autoStopAfterScan) that
3719
+ // are []-deps and would otherwise read a stale value.
3720
+ const chatLoopRef=useRef(false);
3292
3721
  // probe the relay so the idle panel can show the right availability copy
3293
3722
  const refreshHealth=useCallback(async()=>{
3294
3723
  try{const j=await relayHealth();setLlmAvailable(!!j.llmAvailable);
3295
- setCliInstalled(j.cliInstalled==null?null:!!j.cliInstalled);return j;}
3296
- catch{setLlmAvailable(false);setCliInstalled(false);return null;}
3724
+ setCliInstalled(j.cliInstalled==null?null:!!j.cliInstalled);
3725
+ const loop=!!j.pollerActive&&!j.agentCmd;
3726
+ if(!stopPendingRef.current){chatLoopRef.current=loop;setChatLoop(loop);setLiveStopped(false);return j;}
3727
+ if(!j.pollerActive)sawPollerGoneRef.current=true;
3728
+ if(loop&&sawPollerGoneRef.current){ // a fresh loop started after the stop
3729
+ stopPendingRef.current=false;quietStopRef.current=false;
3730
+ chatLoopRef.current=true;setChatLoop(true);setLiveStopped(false);
3731
+ }else{chatLoopRef.current=false;setChatLoop(false);setLiveStopped(!quietStopRef.current);} // draining
3732
+ return j;}
3733
+ catch{chatLoopRef.current=false;setLlmAvailable(false);setCliInstalled(false);setChatLoop(false);return null;}
3734
+ },[]);
3735
+ const stopLive=useCallback(async()=>{
3736
+ quietStopRef.current=false;
3737
+ stopPendingRef.current=true;sawPollerGoneRef.current=false;
3738
+ chatLoopRef.current=false;setLiveStopped(true);setChatLoop(false);
3739
+ try{await relayStopLive();}catch{}
3740
+ refreshHealth();
3741
+ },[refreshHealth]);
3742
+ // Quiet auto-stop after the one-time grouping scan: in chat-loop mode the
3743
+ // loop has done its only needed job (the scan), so stop it to avoid idle
3744
+ // credit burn. Drains like a normal stop but shows no chip; the Refine area
3745
+ // then shows "Connect with your agent" until the user re-runs /refine live.
3746
+ const autoStopAfterScan=useCallback(async()=>{
3747
+ if(!chatLoopRef.current)return; // only the in-chat loop burns idle credits
3748
+ quietStopRef.current=true;
3749
+ stopPendingRef.current=true;sawPollerGoneRef.current=false;
3750
+ chatLoopRef.current=false;setChatLoop(false);setLiveStopped(false);
3751
+ try{await relayStopLive();}catch{}
3297
3752
  },[]);
3298
3753
  // a live agent = the relay reports an agent it can drive (REFINE_AGENT_CMD
3299
3754
  // wired OR a `/refine live` loop polling). Until then the panel is gated.
@@ -3363,30 +3818,45 @@
3363
3818
  refineCacheRef.current.delete(active.id+"::"+type);
3364
3819
  try{
3365
3820
  const et=active.effectiveTimings||[];
3366
- const timings=et.map(t=>({property:t.property,member:t.member||null,durationMs:t.durationMs,delayMs:t.delayMs,easing:t.easing}));
3821
+ // attach the lane's non-resting scale/blur value (+ backing var) so the
3822
+ // agent + deterministic engine can refine transform/filter VALUES too.
3823
+ const withVal=(t)=>{const v=laneValueHint(t.selector,t.property);return {property:t.property,member:t.member||null,durationMs:t.durationMs,delayMs:t.delayMs,easing:t.easing,
3824
+ ...(v.scale!=null?{scale:v.scale}:{}),...(v.blur!=null?{blur:v.blur}:{}),...(v.varName?{varName:v.varName}:{})};};
3825
+ const timings=et.map(withVal);
3367
3826
  const selector=(active.bindings&&active.bindings.selector)||(et[0]&&et[0].selector)||null;
3827
+ // A recipe swap (replace/both) on a grouped phase should update its related
3828
+ // phases (open + close) together — they're one motion. Send the whole
3829
+ // group's phases so the agent can return a per-phase `patches` array.
3830
+ let phases;
3831
+ if((type==="replace"||type==="both")&&active.kind==="phase"&&active.groupId){
3832
+ const gp=entries.filter(e=>e.kind==="phase"&&e.groupId===active.groupId);
3833
+ if(gp.length>1)phases=gp.map(e=>({phase:e.phase||e.phaseLabel||"phase",label:e.phaseLabel||e.phase||"Phase",
3834
+ timings:(e.effectiveTimings||[]).map(withVal)}));
3835
+ }
3368
3836
  const{id}=await relayCreateJob({transitionId:active.id,label:active.label,selector,
3369
- phase:active.phase||null,group:active.groupLabel||null,timings,mode,refineType:type});
3837
+ phase:active.phase||null,group:active.groupLabel||null,timings,mode,refineType:type,phases});
3370
3838
  setRefineJobId(id);
3371
3839
  }catch(e){
3372
3840
  setRefinePhase("error");
3373
3841
  setRefineError(h(React.Fragment,null,"Couldn't reach the refine relay. Start it with ",h("code",{className:"tl-code"},"npx transitions-refine live"),"."));
3374
3842
  }
3375
- },[active,refineMode,refineType,llmAvailable]);
3843
+ },[active,refineMode,refineType,llmAvailable,entries]);
3376
3844
  const changeRefineMode=useCallback((mode)=>{setRefineMode(mode);setRefinePhase("idle");refineCacheRef.current.clear();refreshHealth();},[refreshHealth]);
3377
- // Switching tabs reuses a cached scan when present; otherwise (llm only) it
3378
- // scans the newly-selected type on demand the trade-off for not scanning
3379
- // the recipe upfront. Deterministic feeds both tabs from one "both" pass, so
3380
- // its tab switch is a pure filter. Before any scan (idle/error), tabs just
3381
- // preview copy and never auto-scan.
3845
+ // Switching tabs reuses a cached scan when present; otherwise (llm only) the
3846
+ // tab drops back to its idle state and waits for an explicit Start-scanning
3847
+ // click switching never auto-runs a scan. Deterministic feeds both tabs
3848
+ // from one "both" pass, so its tab switch is a pure filter. Before any scan
3849
+ // (idle/error), tabs just preview copy and never auto-scan.
3382
3850
  const changeRefineType=useCallback((t)=>{
3383
3851
  setRefineType(t);
3384
3852
  if(!active)return;
3385
3853
  if(scanModeRef.current==="deterministic")return;
3386
3854
  if(refinePhase!=="done"&&refinePhase!=="scanning")return;
3387
3855
  if(refineCacheRef.current.has(active.id+"::"+t)){applyMerged(active.id);setRefinePhase("done");return;}
3388
- startScan(t,{keepApplied:true});
3389
- },[active,refinePhase,applyMerged,startScan]);
3856
+ // No cached result for this tab → show its idle scan button and wait for
3857
+ // the user. (A scan in progress is left running so it can finish.)
3858
+ if(refinePhase==="done")setRefinePhase("idle");
3859
+ },[active,refinePhase,applyMerged]);
3390
3860
  // poll the relay while a job is running
3391
3861
  useEffect(()=>{
3392
3862
  if(!refineJobId||refinePhase!=="scanning")return;
@@ -3420,20 +3890,43 @@
3420
3890
  return()=>{live=false;if(to)clearTimeout(to);};
3421
3891
  },[refineJobId,refinePhase]);
3422
3892
  const applySuggestion=useCallback((s)=>{
3423
- const p=s.patch||{};if(!p.property)return;
3424
- const o={};
3425
- if(p.durationMs!=null)o.durationMs=p.durationMs;
3426
- if(p.delayMs!=null)o.delayMs=p.delayMs;
3427
- if(p.easing!=null)o.easing=p.easing;
3428
- // map a property patch onto the matching lane(s) — for a grouped phase the
3429
- // same property can appear on several members, so apply to each.
3430
- const et=(active&&active.effectiveTimings)||[];
3431
- let lanes=p.property==="all"?et:et.filter(t=>t.property===p.property);
3432
- if(p.member)lanes=lanes.filter(t=>(t.member===p.member||t.memberId===p.member));
3433
- const ids=lanes.length?lanes.map(t=>t.laneId):[p.property];
3434
- for(const id of ids)setPropOverride(id,o);
3893
+ // A replace suggestion may carry `patches` (one per related phase, e.g.
3894
+ // open + close) — a recipe swap is one motion, so applying it writes BOTH.
3895
+ // Otherwise fall back to the single `patch` on the active phase.
3896
+ const patches=Array.isArray(s.patches)&&s.patches.length
3897
+ ? s.patches
3898
+ : (s.patch&&s.patch.property?[{...s.patch,phase:(active&&active.phase)||null}]:[]);
3899
+ if(!patches.length)return;
3900
+ const groupId=active&&active.groupId;
3901
+ for(const p of patches){
3902
+ if(!p.property)continue;
3903
+ const o={};
3904
+ if(p.durationMs!=null)o.durationMs=p.durationMs;
3905
+ if(p.delayMs!=null)o.delayMs=p.delayMs;
3906
+ if(p.easing!=null)o.easing=p.easing;
3907
+ // value edits: keep the new value, the backing var, and the from-value
3908
+ // (parsed off the suggestion) so computeChanges can write source on Accept.
3909
+ if(p.scale!=null){o.scale=p.scale;o.scaleFrom=(s.from!=null?parseFloat(s.from):undefined);}
3910
+ if(p.blur!=null){o.blur=p.blur;o.blurFrom=(s.from!=null?parseFloat(s.from):undefined);}
3911
+ if(p.varName!=null)o.varName=p.varName;
3912
+ // resolve the phase entry this patch targets (its sibling open/close
3913
+ // lives in the same group); default to the active entry.
3914
+ let target=active;
3915
+ if(p.phase&&groupId){
3916
+ const found=entries.find(e=>e.kind==="phase"&&e.groupId===groupId&&e.phase===p.phase);
3917
+ if(found)target=found;
3918
+ }
3919
+ if(!target)continue;
3920
+ // map the property patch onto the matching lane(s) — a property can span
3921
+ // several members in a grouped phase, so apply to each.
3922
+ const et=target.effectiveTimings||[];
3923
+ let lanes=p.property==="all"?et:et.filter(t=>t.property===p.property);
3924
+ if(p.member)lanes=lanes.filter(t=>(t.member===p.member||t.memberId===p.member));
3925
+ const ids=lanes.length?lanes.map(t=>t.laneId):[p.property];
3926
+ for(const id of ids)registry.setPropOverride(target.id,id,o);
3927
+ }
3435
3928
  setAppliedIds(prev=>({...prev,[s.id]:true}));
3436
- },[setPropOverride,active]);
3929
+ },[registry,active,entries]);
3437
3930
  const applyAllSuggestions=useCallback(()=>{
3438
3931
  for(const s of refineSuggestions){if(!appliedIds[s.id])applySuggestion(s);}
3439
3932
  },[refineSuggestions,appliedIds,applySuggestion]);
@@ -3449,8 +3942,13 @@
3449
3942
  useEffect(()=>{
3450
3943
  if(typeof document==="undefined")return;
3451
3944
  const applied=liveAppliedRef.current;
3452
- const desired=new Map(); // el → css
3453
- const add=(els,css)=>{ for(const el of els){ if(!el||(el.closest&&el.closest("[data-timeline-panel],[data-tl-ui]")))continue; desired.set(el,css); } };
3945
+ const desired=new Map(); // el → {css, vars:{name:value}}
3946
+ // collect the var-backed scale/blur overrides on a set of lanes into a
3947
+ // {varName: cssValue} map (scale → unitless, blur → px) for live preview.
3948
+ const lanesToVars=(lanes)=>{const v={};for(const l of (lanes||[])){if(!l.varName)continue;
3949
+ if(l.scale!=null)v[l.varName]=String(l.scale);
3950
+ if(l.blur!=null)v[l.varName]=l.blur+"px";}return v;};
3951
+ const add=(els,css,vars)=>{ for(const el of els){ if(!el||(el.closest&&el.closest("[data-timeline-panel],[data-tl-ui]")))continue; desired.set(el,{css,vars:vars||{}}); } };
3454
3952
  for(const item of entries){
3455
3953
  const ov=registry.getPropOverrides(item.id);
3456
3954
  if(!ov||!Object.keys(ov).length)continue; // only edited transitions go live
@@ -3458,31 +3956,40 @@
3458
3956
  for(const m of (item.members||[])){
3459
3957
  if(!m.selector||!m.lanes||!m.lanes.length)continue;
3460
3958
  let els=[];try{els=Array.from(document.querySelectorAll(m.selector));}catch{}
3461
- add(els,transitionLanesToCss(m.lanes));
3959
+ add(els,transitionLanesToCss(m.lanes),lanesToVars(m.lanes));
3462
3960
  }
3463
3961
  }else{
3464
3962
  const lanes=item.effectiveTimings||[];
3465
3963
  if(!lanes.length)continue;
3466
3964
  const els=((item.bindings&&item.bindings.elements)||[]).map(w=>w.deref&&w.deref()).filter(Boolean);
3467
- add(els,transitionLanesToCss(lanes));
3965
+ add(els,transitionLanesToCss(lanes),lanesToVars(lanes));
3468
3966
  }
3469
3967
  }
3968
+ const varsKey=v=>Object.keys(v).sort().map(k=>k+":"+v[k]).join(";");
3470
3969
  // apply new / changed
3471
- for(const [el,css] of desired){
3970
+ for(const [el,d] of desired){
3971
+ const css=d.css,vars=d.vars||{},vk=varsKey(vars);
3472
3972
  const cur=applied.get(el);
3473
- if(cur&&cur.css===css)continue;
3973
+ if(cur&&cur.css===css&&cur.vk===vk)continue;
3474
3974
  if(!cur){
3475
3975
  const s=getComputedStyle(el);
3476
3976
  const z=zipTransitionLists((s.transitionProperty||"").split(","),(s.transitionDuration||"0s").split(","),(s.transitionDelay||"0s").split(","),splitCssValues(s.transitionTimingFunction||"ease"));
3477
3977
  _TX_LIVE_BASE.set(el,{z});
3478
- applied.set(el,{prevInline:el.style.transition||"",css});
3479
- }else applied.set(el,{prevInline:cur.prevInline,css});
3978
+ applied.set(el,{prevInline:el.style.transition||"",css,vars,vk,prevVars:Object.fromEntries(Object.keys(vars).map(n=>[n,el.style.getPropertyValue(n)]))});
3979
+ }else{
3980
+ // revert vars the new desired set no longer drives before re-applying
3981
+ for(const n of Object.keys(cur.vars||{})){if(!(n in vars)){try{const pv=(cur.prevVars||{})[n];if(pv)el.style.setProperty(n,pv);else el.style.removeProperty(n);}catch{}}}
3982
+ const prevVars={...(cur.prevVars||{})};for(const n of Object.keys(vars)){if(!(n in prevVars))prevVars[n]=el.style.getPropertyValue(n);}
3983
+ applied.set(el,{prevInline:cur.prevInline,css,vars,vk,prevVars});
3984
+ }
3480
3985
  try{el.style.transition=css;}catch{}
3986
+ for(const n of Object.keys(vars)){try{el.style.setProperty(n,vars[n]);}catch{}}
3481
3987
  }
3482
3988
  // revert elements that no longer have edits
3483
3989
  for(const [el,rec] of Array.from(applied)){
3484
3990
  if(desired.has(el))continue;
3485
3991
  try{el.style.transition=rec.prevInline;}catch{}
3992
+ for(const n of Object.keys(rec.vars||{})){try{const pv=(rec.prevVars||{})[n];if(pv)el.style.setProperty(n,pv);else el.style.removeProperty(n);}catch{}}
3486
3993
  _TX_LIVE_BASE.delete(el);
3487
3994
  applied.delete(el);
3488
3995
  }
@@ -3490,7 +3997,7 @@
3490
3997
  // restore everything on unmount
3491
3998
  useEffect(()=>()=>{
3492
3999
  const applied=liveAppliedRef.current;
3493
- for(const [el,rec] of applied){ try{el.style.transition=rec.prevInline;}catch{} _TX_LIVE_BASE.delete(el); }
4000
+ for(const [el,rec] of applied){ try{el.style.transition=rec.prevInline;}catch{} for(const n of Object.keys(rec.vars||{})){try{const pv=(rec.prevVars||{})[n];if(pv)el.style.setProperty(n,pv);else el.style.removeProperty(n);}catch{}} _TX_LIVE_BASE.delete(el); }
3494
4001
  applied.clear();
3495
4002
  },[]);
3496
4003
  const startResize=useCallback(e=>{
@@ -3542,11 +4049,20 @@
3542
4049
  }else legacyCopy();
3543
4050
  }catch(e){legacyCopy();}
3544
4051
  },[active,showToast]);
3545
- const resetOverrides=useCallback(()=>{if(active)registry.clearOverride(active.id);},[registry,active]);
4052
+ // Reset the selected transition. For a grouped phase, clear ALL of its
4053
+ // related phases (open/close) too — a recipe swap edits them as one motion,
4054
+ // so Reset should revert them as one.
4055
+ const resetOverrides=useCallback(()=>{
4056
+ if(!active)return;
4057
+ registry.clearOverride(active.id);
4058
+ if(active.kind==="phase"&&active.groupId){
4059
+ for(const e of entries)if(e.kind==="phase"&&e.groupId===active.groupId&&e.id!==active.id)registry.clearOverride(e.id);
4060
+ }
4061
+ },[registry,active,entries]);
3546
4062
  // Accept → send an "apply" job so the agent writes the edited timings into
3547
4063
  // the user's source, then reflect saving / done / error on the button.
3548
4064
  const onAccept=useCallback(async()=>{
3549
- const changes=computeChanges(active);
4065
+ const changes=computeChanges(active,entries);
3550
4066
  if(!active||!changes.length)return;
3551
4067
  setAcceptState("saving");setAcceptError(null);
3552
4068
  try{
@@ -3571,7 +4087,7 @@
3571
4087
  setAcceptState("error");
3572
4088
  setAcceptError("Couldn't reach the relay. Run: npx transitions-refine live");
3573
4089
  }
3574
- },[active]);
4090
+ },[active,entries]);
3575
4091
  // reset Accept feedback when switching transitions
3576
4092
  useEffect(()=>{setAcceptState("idle");setAcceptError(null);},[active&&active.id]);
3577
4093
  // while the agent is applying changes, surface the sticky loader toast
@@ -3601,8 +4117,15 @@
3601
4117
  },[GROUP_STORE_KEY]);
3602
4118
  // monotonic token so an unmount or a newer rescan supersedes an in-flight poll
3603
4119
  const scanTokenRef=useRef(0);
3604
- const runGroupScan=useCallback(async()=>{
4120
+ const runGroupScan=useCallback(async(opts)=>{
4121
+ opts=opts||{};
3605
4122
  const token=++scanTokenRef.current;
4123
+ // Hardcoded grouping (window.__TX_SEED_GROUPS): apply INSTANTLY on first load
4124
+ // — no relay, no agent, no settle loop. Manual "Rescan transitions" passes
4125
+ // skipSeed so the user gets a real agent scan instead of re-applying the bake-in.
4126
+ const seedNow=!opts.skipSeed&&typeof window!=="undefined"&&Array.isArray(window.__TX_SEED_GROUPS)&&window.__TX_SEED_GROUPS.length?window.__TX_SEED_GROUPS:null;
4127
+ if(seedNow){registry.setGroups(seedNow);groupScanRetryRef.current=0;setScanned(true);setGroupScanState("done");if(!opts.skipSeed)autoStopAfterScan();return;}
4128
+ setGroupScanErr(null);
3606
4129
  setGroupScanState("scanning");
3607
4130
  // wait for the flat DOM scan to settle (count stable twice) so the agent
3608
4131
  // sees every transition, not a partial set discovered progressively.
@@ -3620,17 +4143,12 @@
3620
4143
  if(scanTokenRef.current!==token)return;
3621
4144
  const flat=registry.getAll().filter(e=>e.kind!=="phase");
3622
4145
  if(!flat.length){setGroupScanState("idle");return;}
3623
- // Hardcoded grouping (window.__TX_SEED_GROUPS): a host/demo page can ship a
3624
- // pre-computed agent grouping so the panel groups instantly with NO relay
3625
- // or agent round-trip. demo.html uses this to bake in the agent's scan.
3626
- const seed=typeof window!=="undefined"&&Array.isArray(window.__TX_SEED_GROUPS)?window.__TX_SEED_GROUPS:null;
3627
- if(seed&&seed.length){registry.setGroups(seed);setGroupScanState("done");return;}
3628
4146
  // valid cache (same content signature) → apply instantly, skip the agent.
3629
4147
  // A stale cache (different sig, e.g. content changed) falls through to a
3630
4148
  // fresh scan and is overwritten below.
3631
4149
  const sig=flatSig(flat);
3632
4150
  const cached=readGroupCache();
3633
- if(cached&&cached.sig===sig){registry.setGroups(cached.groups);setGroupScanState("done");return;}
4151
+ if(cached&&cached.sig===sig){registry.setGroups(cached.groups);groupScanRetryRef.current=0;setScanned(true);setGroupScanState("done");if(!opts.skipSeed)autoStopAfterScan();return;}
3634
4152
  setGroupScanState("scanning");
3635
4153
  const raw=flat.map(e=>({label:e.label,selector:e.bindings&&e.bindings.selector,
3636
4154
  properties:e.properties,
@@ -3648,14 +4166,30 @@
3648
4166
  const job=await relayGetJob(id);
3649
4167
  if(job.status==="done"){
3650
4168
  const groups=(job.result&&job.result.groups)||[];
3651
- if(groups.length){registry.setGroups(groups);
4169
+ if(groups.length){registry.setGroups(groups);setScanned(true);
3652
4170
  try{localStorage.setItem(GROUP_STORE_KEY,JSON.stringify({groups,sig,ts:Date.now()}));}catch{}}
3653
- setGroupScanState("done");return;}
3654
- if(job.status==="error"){setGroupScanState("error");return;}
4171
+ groupScanRetryRef.current=0;setGroupScanState("done");if(!opts.skipSeed)autoStopAfterScan();return;}
4172
+ if(job.status==="error"){throw new Error(job.error||"scan job errored");}
4173
+ }
4174
+ throw new Error("scan timed out");
4175
+ }catch(e){
4176
+ // Transient failure (a cold-start agent spawn racing on its CLI config,
4177
+ // a relay blip): auto-retry a couple of times with backoff before
4178
+ // surfacing the error, instead of leaving the panel stuck flat.
4179
+ if(scanTokenRef.current!==token)return;
4180
+ if(groupScanRetryRef.current<GROUP_SCAN_MAX_RETRIES){
4181
+ groupScanRetryRef.current++;
4182
+ const backoff=700*groupScanRetryRef.current;
4183
+ setGroupScanState("scanning");
4184
+ setTimeout(()=>{if(scanTokenRef.current===token)runGroupScan(opts);},backoff);
4185
+ return;
3655
4186
  }
4187
+ // gave up → stay flat, retry next open/rescan. Keep the relay's reason
4188
+ // (agent unavailable / run /refine live) so the header can surface it.
4189
+ setGroupScanErr(String((e&&e.message)||e||"Scan failed"));
3656
4190
  setGroupScanState("error");
3657
- }catch(e){ setGroupScanState("error"); /* relay down → stay flat, retry next open */ }
3658
- },[registry,GROUP_STORE_KEY,flatSig,readGroupCache]);
4191
+ }
4192
+ },[registry,GROUP_STORE_KEY,flatSig,readGroupCache,autoStopAfterScan]);
3659
4193
  // manual "Rescan transitions" — drop the cache AND clear the in-memory
3660
4194
  // groups first. Once groups claim every DOM transition the snapshot has no
3661
4195
  // "flat" entries left, so the settle loop above would otherwise spin forever
@@ -3663,25 +4197,28 @@
3663
4197
  // the flat entries the agent needs to re-group.
3664
4198
  const rescanTransitions=useCallback(()=>{
3665
4199
  try{localStorage.removeItem(GROUP_STORE_KEY);}catch{}
4200
+ groupScanRetryRef.current=0; // fresh manual scan → fresh retry budget
3666
4201
  registry.clearGroups();
3667
- runGroupScan();
4202
+ runGroupScan({skipSeed:true});
3668
4203
  },[runGroupScan,GROUP_STORE_KEY,registry]);
3669
4204
  // Gate the auto group-scan behind a live agent: the panel stays on the
3670
- // "Before we start" screen until `/refine live` (or a wired REFINE_AGENT_CMD)
3671
- // makes the relay report an agent. The first time we see a live agent we run
3672
- // the resolver once — it settles the flat scan, then re-applies cached groups
3673
- // (signature match, no agent) or kicks off a fresh agent scan. Rescan is
3674
- // manual after that.
4205
+ // "Connect with your agent" screen until `/refine live` (or a wired
4206
+ // REFINE_AGENT_CMD) makes the relay report an agent. The first time we see a
4207
+ // live agent we run the resolver once — it settles the flat scan, then
4208
+ // re-applies cached groups (signature match, no agent) or kicks off a fresh
4209
+ // agent scan. Rescan is manual after that.
3675
4210
  useEffect(()=>{
3676
4211
  // A host/demo page can hardcode a grouping via window.__TX_SEED_GROUPS so
3677
4212
  // the panel shows it WITHOUT a live agent (demo.html bakes in the agent's
3678
- // initial scan this way). Otherwise the agent group-scan stays gated behind
3679
- // a live agent as usual.
4213
+ // initial scan this way). A prior cached grouping likewise lets a returning
4214
+ // user skip the gate — apply it without a live agent. Otherwise the agent
4215
+ // group-scan stays gated behind a live agent as usual.
3680
4216
  const seeded=typeof window!=="undefined"&&Array.isArray(window.__TX_SEED_GROUPS)&&window.__TX_SEED_GROUPS.length;
3681
- if((!live&&!seeded)||didGroupScanRef.current)return;
4217
+ const cachedExists=!!readGroupCache();
4218
+ if((!live&&!seeded&&!cachedExists)||didGroupScanRef.current)return;
3682
4219
  didGroupScanRef.current=true;
3683
4220
  runGroupScan();
3684
- },[live,runGroupScan]);
4221
+ },[live,runGroupScan,readGroupCache]);
3685
4222
  useEffect(()=>()=>{scanTokenRef.current++;},[]);
3686
4223
 
3687
4224
  // whole-component open/close uses the transitions.dev panel reveal:
@@ -3744,7 +4281,7 @@
3744
4281
  panelHotRef.current=hoveredRef.current||!!(el&&document.activeElement&&el.contains(document.activeElement));
3745
4282
  },[]);
3746
4283
  const kbdRef=useRef({});
3747
- kbdRef.current={entries,active,acceptState,acceptDisabled:computeChanges(active).length===0,minimized,
4284
+ kbdRef.current={entries,active,acceptState,acceptDisabled:computeChanges(active,entries).length===0,minimized,
3748
4285
  openRefine,resetOverrides,copyValues,onAccept,setActiveId,setMinimized,setPickOpen};
3749
4286
  useEffect(()=>{
3750
4287
  const onKey=e=>{
@@ -3798,9 +4335,12 @@
3798
4335
  window.addEventListener("keydown",onKey);
3799
4336
  return()=>window.removeEventListener("keydown",onKey);
3800
4337
  },[recomputeHot]);
3801
- const gate = !live
3802
- ? (llmAvailable===null ? "loading" : "blocked")
3803
- : "ready";
4338
+ // Ready once a live agent is connected OR the one-time grouping scan has
4339
+ // produced groups (so the panel stays usable after a chat-loop agent
4340
+ // auto-stops post-scan). Only an un-scanned, agent-less panel shows the gate.
4341
+ const gate = (live || scanned)
4342
+ ? "ready"
4343
+ : (llmAvailable===null ? "loading" : "blocked");
3804
4344
  return h(React.Fragment,null,
3805
4345
  render&&h("div",{className:"t-panel-slide","data-timeline-panel":true,ref:panelRootRef,
3806
4346
  onMouseEnter:()=>{hoveredRef.current=true;},onMouseLeave:()=>{hoveredRef.current=false;},
@@ -3812,8 +4352,10 @@
3812
4352
  h("div",{className:"tl-panel-main"},
3813
4353
  h(Header,{entries,active,onSelect:setActiveId,onReset:resetOverrides,onCopy:copyValues,copied,
3814
4354
  snap,setSnap,onMinimize:()=>setMinimized(true),onRefine:openRefine,refineActive:refineOpen,
3815
- onAccept,acceptState,acceptDisabled:computeChanges(active).length===0,acceptError,
3816
- scanning:groupScanState==="scanning",onRescan:rescanTransitions,pick:pickOpen,setPick:setPickOpen}),
4355
+ onAccept,acceptState,acceptDisabled:computeChanges(active,entries).length===0,acceptError,
4356
+ scanning:groupScanState==="scanning",scanError:groupScanState==="error"?groupScanErr:null,
4357
+ onRescan:rescanTransitions,pick:pickOpen,setPick:setPickOpen,
4358
+ chatLoop,liveStopped,onStopLive:stopLive}),
3817
4359
  active
3818
4360
  ?h(Body,{entry:active,onPropChange:(prop,o)=>setPropOverride(prop,o),snap})
3819
4361
  :h("div",{className:"tl-empty"},"Select a transition to inspect and edit it")),
@@ -3825,10 +4367,8 @@
3825
4367
  : h("div",{className:"tl-panel-main"},
3826
4368
  gate==="blocked" && h("div",{className:"tl-gate"},
3827
4369
  h("div",{className:"tl-gate-col"},
3828
- h("div",{className:"tl-gate-title"},"Before we start"),
3829
- h("p",{className:"tl-gate-text"},
3830
- "Please run the ",h("code",{className:"tl-code"},"/refine live"),
3831
- " command in your agent to enable live features, such as scanning and refining transitions.")))),
4370
+ h("div",{className:"tl-gate-title"},"Connect with your agent"),
4371
+ h(AgentConnectBody,{cliMissing:cliInstalled===false})))),
3832
4372
  toast&&createPortal(
3833
4373
  h("div",{className:"tl-toast-wrap","data-tl-ui":"","aria-live":"polite"},
3834
4374
  h("div",{className:cx("tl-toast",toast.closing&&"is-closing")},
@@ -3847,12 +4387,12 @@
3847
4387
  }
3848
4388
 
3849
4389
  // ── demo boxes ──
3850
- // Hardcoded grouping — the transitions.dev agent's initial scan of this demo
3851
- // (run once via the live relay), baked in so the panel groups the morph
3852
- // instantly with NO relay/agent round-trip. Member labels are cleaned up from
3853
- // the agent's output ("Morph container"→"Container", "Morph menu"→"Menu panel").
3854
- // Edit here if the morph's transitions change, or delete window.__TX_SEED_GROUPS
3855
- // to fall back to a live agent scan. Shape mirrors relay.mjs buildScanPrompt's
4390
+ // Hardcoded groupings — the transitions.dev agent's initial scan of each
4391
+ // prototype, baked in so the panel groups instantly with NO relay/agent
4392
+ // round-trip. The active prototype's seed is written to window.__TX_SEED_GROUPS
4393
+ // by App (PROTO_SEEDS map below). Member labels are cleaned up from the agent's
4394
+ // output. Edit here if a prototype's transitions change, or clear the seed to
4395
+ // fall back to a live agent scan. Shape mirrors relay.mjs buildScanPrompt's
3856
4396
  // groups[] (the same structure TransitionRegistry.setGroups consumes). This is
3857
4397
  // below the inject CUT_MARKER, so it never ships in the injected/website build.
3858
4398
  const _mEO="cubic-bezier(0.22, 1, 0.36, 1)"; // smooth ease-out (open)
@@ -3879,7 +4419,39 @@
3879
4419
  {id:"morph-item-bg",label:"Menu item",selector:".t-morph-menu button.morph-item",propertyTimings:[
3880
4420
  _mpt("background",120,"ease")]}]}]},
3881
4421
  ];
3882
- if(typeof window!=="undefined")window.__TX_SEED_GROUPS=MORPH_SEED_GROUPS;
4422
+ // Menu dropdown (05-menu-dropdown.md) — the surface scales+fades from its
4423
+ // trigger; the caret flips. Close runs shorter and dips to a smaller scale.
4424
+ const DROPDOWN_SEED_GROUPS=[
4425
+ {id:"dropdown",label:"Menu dropdown",component:null,phases:[
4426
+ {id:"dropdown:open",phase:"open",label:"Open",stateTarget:".t-dropdown",fromState:null,toState:".is-open",members:[
4427
+ {id:"dd-caret",label:"Caret",selector:".dd-caret",propertyTimings:[
4428
+ _mpt("transform",250,_mEO)]},
4429
+ {id:"dd-menu",label:"Menu",selector:".t-dropdown",propertyTimings:[
4430
+ _mpt("transform",250,_mEO),_mpt("opacity",250,_mEO)]}]},
4431
+ {id:"dropdown:close",phase:"close",label:"Close",stateTarget:".t-dropdown",fromState:".is-open",toState:null,members:[
4432
+ {id:"dd-caret",label:"Caret",selector:".dd-caret",propertyTimings:[
4433
+ _mpt("transform",250,_mEO)]},
4434
+ {id:"dd-menu",label:"Menu",selector:".t-dropdown",propertyTimings:[
4435
+ _mpt("transform",150,_mEO),_mpt("opacity",150,_mEO)]}]}]},
4436
+ ];
4437
+ // Modal (06-modal.md) — the dialog scales up from center over a fading
4438
+ // backdrop; close dips back down. Both phases share the standard ease-out.
4439
+ const MODAL_SEED_GROUPS=[
4440
+ {id:"modal",label:"Modal",component:null,phases:[
4441
+ {id:"modal:open",phase:"open",label:"Open",stateTarget:".t-modal",fromState:null,toState:".is-open",members:[
4442
+ {id:"modal-backdrop",label:"Backdrop",selector:".modal-backdrop",propertyTimings:[
4443
+ _mpt("opacity",250,_mEO)]},
4444
+ {id:"modal-dialog",label:"Dialog",selector:".t-modal",propertyTimings:[
4445
+ _mpt("transform",250,_mEO),_mpt("opacity",250,_mEO)]}]},
4446
+ {id:"modal:close",phase:"close",label:"Close",stateTarget:".t-modal",fromState:".is-open",toState:null,members:[
4447
+ {id:"modal-backdrop",label:"Backdrop",selector:".modal-backdrop",propertyTimings:[
4448
+ _mpt("opacity",150,_mEO)]},
4449
+ {id:"modal-dialog",label:"Dialog",selector:".t-modal",propertyTimings:[
4450
+ _mpt("transform",150,_mEO),_mpt("opacity",150,_mEO)]}]}]},
4451
+ ];
4452
+ // Active prototype → seed. App writes window.__TX_SEED_GROUPS[proto] before the
4453
+ // (remounted) panel runs its group scan, so each tab groups agent-free.
4454
+ const PROTO_SEEDS={morph:MORPH_SEED_GROUPS,dropdown:DROPDOWN_SEED_GROUPS,modal:MODAL_SEED_GROUPS};
3883
4455
 
3884
4456
  // Plus → menu morph — transitions.dev (20-plus-menu-morph.md). The trigger
3885
4457
  // button morphs into the panel it opens; CSS owns the morph, JS only flips
@@ -3912,6 +4484,100 @@
3912
4484
  h("path",{d:"M10 4.5v11M4.5 10h11",stroke:"currentColor","strokeWidth":1.8,"strokeLinecap":"round"}))))));
3913
4485
  }
3914
4486
 
4487
+ // Menu dropdown — transitions.dev (05-menu-dropdown.md). The .t-dropdown surface
4488
+ // owns the scale+fade; close swaps .is-open for .is-closing, then drops the class
4489
+ // after --dropdown-close-dur so the closing scale animates before it resets.
4490
+ function BoxDropdown(){
4491
+ const[open,setOpen]=useState(false);
4492
+ const[closing,setClosing]=useState(false);
4493
+ const ref=useRef(null), toRef=useRef(null);
4494
+ const close=useCallback(()=>{
4495
+ setOpen(false);setClosing(true);
4496
+ if(toRef.current)clearTimeout(toRef.current);
4497
+ toRef.current=setTimeout(()=>setClosing(false),150);
4498
+ },[]);
4499
+ const toggle=useCallback(()=>{setOpen(o=>{if(o){close();return false;}setClosing(false);return true;});},[close]);
4500
+ useEffect(()=>{
4501
+ const onDoc=e=>{if(ref.current&&!ref.current.contains(e.target))close();};
4502
+ const onKey=e=>{if(e.key==="Escape")close();};
4503
+ document.addEventListener("click",onDoc);
4504
+ document.addEventListener("keydown",onKey);
4505
+ return()=>{document.removeEventListener("click",onDoc);document.removeEventListener("keydown",onKey);if(toRef.current)clearTimeout(toRef.current);};
4506
+ },[close]);
4507
+ const item=(label,icon)=>h("button",{className:"dd-item",type:"button",onClick:close},
4508
+ h(Ic,{name:icon,size:16}),label);
4509
+ return h("div",{className:"card"},
4510
+ h("h2",null,"Menu dropdown"),
4511
+ h("p",null,"Click Menu. The panel grows from its trigger with a scale + fade and the caret flips. Closing runs shorter and dips to a smaller scale."),
4512
+ h("div",{className:"dd-stage"},
4513
+ h("div",{className:cx("dd",open&&"is-open"),ref},
4514
+ h("button",{className:"dd-trigger",type:"button","aria-haspopup":"menu","aria-expanded":String(open),
4515
+ onClick:e=>{e.stopPropagation();toggle();}},
4516
+ "Menu",
4517
+ h("svg",{className:"dd-caret",width:14,height:14,viewBox:"0 0 16 16",fill:"none","aria-hidden":"true"},
4518
+ h("path",{d:"M4 6l4 4 4-4",stroke:"currentColor","strokeWidth":1.6,"strokeLinecap":"round","strokeLinejoin":"round"}))),
4519
+ h("div",{className:cx("t-dropdown","dd-menu",open&&"is-open",closing&&"is-closing"),"data-origin":"top-left",role:"menu","aria-hidden":String(!open)},
4520
+ h("div",{className:"dd-title"},"Actions"),
4521
+ item("New file","copy"),
4522
+ item("Refresh","restart"),
4523
+ item("Settings","gear")))));
4524
+ }
4525
+
4526
+ // Modal — transitions.dev (06-modal.md). The .t-modal dialog scales up from
4527
+ // center over a fading .modal-backdrop; same close-then-cleanup pattern as the
4528
+ // dropdown. Scoped to the stage (not full-viewport) so it stays in the scan root.
4529
+ function BoxModal(){
4530
+ const[open,setOpen]=useState(false);
4531
+ const[closing,setClosing]=useState(false);
4532
+ const toRef=useRef(null);
4533
+ const close=useCallback(()=>{
4534
+ setOpen(false);setClosing(true);
4535
+ if(toRef.current)clearTimeout(toRef.current);
4536
+ toRef.current=setTimeout(()=>setClosing(false),150);
4537
+ },[]);
4538
+ const openIt=useCallback(()=>{setClosing(false);setOpen(true);},[]);
4539
+ useEffect(()=>{
4540
+ const onKey=e=>{if(e.key==="Escape")close();};
4541
+ document.addEventListener("keydown",onKey);
4542
+ return()=>{document.removeEventListener("keydown",onKey);if(toRef.current)clearTimeout(toRef.current);};
4543
+ },[close]);
4544
+ const st=(base)=>cx(base,open&&"is-open",closing&&"is-closing");
4545
+ return h("div",{className:"card"},
4546
+ h("h2",null,"Modal"),
4547
+ h("p",null,"Open the dialog. It scales up from 0.96 over a fading backdrop; closing dips back down. Click the backdrop or press Escape to dismiss."),
4548
+ h("div",{className:"modal-stage"},
4549
+ h("button",{className:"modal-open-btn",type:"button",onClick:openIt},"Open dialog"),
4550
+ h("div",{className:st("modal-backdrop"),onClick:close,"aria-hidden":"true"}),
4551
+ h("div",{className:st("t-modal"),role:"dialog","aria-modal":"true","aria-hidden":String(!open)},
4552
+ h("div",{className:"modal-card-title"},"Delete project?"),
4553
+ h("div",{className:"modal-card-body"},"This permanently removes the project and all of its files. This action can\u2019t be undone."),
4554
+ h("div",{className:"modal-actions"},
4555
+ h("button",{className:"modal-btn secondary",type:"button",onClick:close},"Cancel"),
4556
+ h("button",{className:"modal-btn primary",type:"button",onClick:close},"Delete")))));
4557
+ }
4558
+
4559
+ // Prototype switcher — sliding-pill tabs (16-tabs-sliding.md). JS measures the
4560
+ // active tab's offsetLeft/Width onto the pill; CSS tweens. First paint + resize
4561
+ // snap without a transition; only a user switch animates.
4562
+ function ProtoTabs({value,onChange,items}){
4563
+ const pillRef=useRef(null), tabRefs=useRef({}), firstRef=useRef(true);
4564
+ const place=useCallback((animate)=>{
4565
+ const t=tabRefs.current[value], pill=pillRef.current;
4566
+ if(!t||!pill)return;
4567
+ if(!animate){const prev=pill.style.transition;pill.style.transition="none";
4568
+ pill.style.transform="translateX("+t.offsetLeft+"px)";pill.style.width=t.offsetWidth+"px";
4569
+ void pill.offsetWidth;pill.style.transition=prev;}
4570
+ else{pill.style.transform="translateX("+t.offsetLeft+"px)";pill.style.width=t.offsetWidth+"px";}
4571
+ },[value]);
4572
+ useLayoutEffect(()=>{place(firstRef.current?false:true);firstRef.current=false;},[value,place]);
4573
+ useEffect(()=>{const onR=()=>place(false);window.addEventListener("resize",onR);return()=>window.removeEventListener("resize",onR);},[place]);
4574
+ return h("div",{className:"proto-tabs",role:"tablist"},
4575
+ h("span",{className:"proto-tabs-pill","aria-hidden":"true",ref:pillRef}),
4576
+ items.map(it=>h("button",{key:it.key,role:"tab",type:"button","aria-selected":String(value===it.key),
4577
+ className:cx("proto-tab",value===it.key&&"is-active"),ref:el=>{tabRefs.current[it.key]=el;},
4578
+ onClick:()=>onChange(it.key)},it.label)));
4579
+ }
4580
+
3915
4581
  // ── live controls for the whole-panel reveal transition (demo harness) ──
3916
4582
  // transitions.dev motion tokens (values lifted verbatim from the skill's _root.css)
3917
4583
  // transitions.dev easing motion tokens. `token` is the actual skill CSS var
@@ -4011,9 +4677,25 @@
4011
4677
  );
4012
4678
  }
4013
4679
 
4680
+ // Prototypes the demo can switch between. Each owns a component + a description
4681
+ // + a baked-in seed grouping (PROTO_SEEDS) so the panel groups it agent-free.
4682
+ const PROTOS=[
4683
+ {key:"morph", label:"Morph menu", comp:BoxMorph, desc:"the plus \u2192 menu morph"},
4684
+ {key:"dropdown",label:"Dropdown", comp:BoxDropdown, desc:"an anchored menu dropdown"},
4685
+ {key:"modal", label:"Modal", comp:BoxModal, desc:"a centered modal dialog"},
4686
+ ];
4014
4687
  function App(){
4015
- const rootRef=useRef(null);const registry=useMemo(()=>new TransitionRegistry(),[]);const[activeId,setActiveId]=useState(null);
4016
- useEffect(()=>{const root=rootRef.current??document.body;const scanner=new DomScanner(root,registry);scanner.start();return()=>{scanner.stop();};},[registry]);
4688
+ const[proto,setProto]=useState("dropdown");
4689
+ // Fresh registry per prototype so switching tabs starts the scan clean. The
4690
+ // active prototype's baked grouping is published before the (remounted) panel
4691
+ // runs its seeded group scan — see runGroupScan's window.__TX_SEED_GROUPS path.
4692
+ if(typeof window!=="undefined")window.__TX_SEED_GROUPS=PROTO_SEEDS[proto];
4693
+ const rootRef=useRef(null);const scanRef=useRef(null);
4694
+ const registry=useMemo(()=>new TransitionRegistry(),[proto]);
4695
+ const[activeId,setActiveId]=useState(null);
4696
+ // Scan only the prototype stage (not the header), so the switcher tabs' own
4697
+ // pill transition is never picked up as a demo transition.
4698
+ useEffect(()=>{const root=scanRef.current??document.body;const scanner=new DomScanner(root,registry);scanner.start();return()=>{scanner.stop();};},[registry]);
4017
4699
  const ctx=useMemo(()=>({registry,activeId,setActiveId}),[registry,activeId]);
4018
4700
  // Demo-only tweak controls: hidden by default. Append ?controls to the URL
4019
4701
  // to show them for testing. (This whole block is below the inject CUT_MARKER,
@@ -4022,6 +4704,7 @@
4022
4704
  // TESTING ONLY (not part of the tool): top-right toggle to hide the demo
4023
4705
  // page content so the panel can be inspected against a blank page.
4024
4706
  const[demoHidden,setDemoHidden]=useState(false);
4707
+ const active=PROTOS.find(p=>p.key===proto)||PROTOS[0];
4025
4708
  return h(TimelineCtx.Provider,{value:ctx},
4026
4709
  showControls&&h(PanelControls),
4027
4710
  h("button",{type:"button",onClick:()=>setDemoHidden(v=>!v),
@@ -4032,9 +4715,13 @@
4032
4715
  font:"500 12px/1 ui-sans-serif,system-ui,sans-serif",
4033
4716
  boxShadow:"0 1px 3px rgba(0,0,0,0.12)"}},
4034
4717
  demoHidden?"Show demo":"Hide demo"),
4035
- h("div",{ref:rootRef,className:"demo-root",style:{display:demoHidden?"none":undefined}},h("div",{className:"demo-header"},h("h1",null,"Timeline Inspector \u2014 Demo"),h("p",null,"A single transitions.dev element: the plus \u2192 menu morph. Its transitions are auto-detected \u2014 select one below, drag the bars to edit timing, then interact with the component itself to preview the change.")),
4036
- h("div",{className:"demo-grid"},h(BoxMorph))),
4037
- h(TimelinePanel));
4718
+ h("div",{ref:rootRef,className:"demo-root",style:{display:demoHidden?"none":undefined}},
4719
+ h("div",{className:"demo-header"},
4720
+ h("h1",null,"Timeline Inspector \u2014 Demo"),
4721
+ h("p",null,"A live transitions.dev component: ",active.desc,". Its transitions are auto-detected \u2014 select one below, drag the bars to edit timing, then interact with the component itself to preview the change."),
4722
+ h(ProtoTabs,{value:proto,onChange:setProto,items:PROTOS})),
4723
+ h("div",{ref:scanRef,className:"demo-grid"},h(active.comp,{key:proto}))),
4724
+ h(TimelinePanel,{key:proto}));
4038
4725
  }
4039
4726
 
4040
4727
  createRoot(document.getElementById("root")).render(h(App));