transitions-refine 0.3.9 → 0.3.11

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
@@ -39,6 +39,9 @@
39
39
  --drop-btn: 0 1px 3px 0 rgba(4,41,117,0.08);
40
40
  /* secondary/raised white button (Reset, Settings, Copy, Accept) — exact spec */
41
41
  --shadow-btn: 0 1px 3px 0 rgba(0,0,0,0.04), inset 0 0 0 1px rgba(0,0,0,0.06), inset 0 -1px 0 0 rgba(0,0,0,0.10), inset 0 0 0 1px rgba(196,196,196,0.10);
42
+ /* inset-only portion of --shadow-btn — for overlay rings that must reproduce
43
+ the Reset button's visible border on top of a separate fill layer */
44
+ --shadow-btn-inset: inset 0 0 0 1px rgba(0,0,0,0.06), inset 0 -1px 0 0 rgba(0,0,0,0.10), inset 0 0 0 1px rgba(196,196,196,0.10);
42
45
  --drop-line: 0 1px 3px 0 rgba(0,0,0,0.08);
43
46
  --shadow-border: 0 0 0 1px rgba(0,0,0,0.06), 0 1px 2px -1px rgba(0,0,0,0.06), 0 2px 4px 0 rgba(0,0,0,0.04);
44
47
  --shadow-border-hover: 0 0 0 1px rgba(0,0,0,0.08), 0 1px 2px -1px rgba(0,0,0,0.08), 0 2px 4px 0 rgba(0,0,0,0.06);
@@ -48,7 +51,7 @@
48
51
 
49
52
  /* transitions.dev — menu dropdown */
50
53
  --dropdown-open-dur: 250ms; --dropdown-close-dur: 150ms;
51
- --dropdown-pre-scale: 0.97; --dropdown-closing-scale: 0.99;
54
+ --dropdown-pre-scale: 0.8; --dropdown-closing-scale: 0.99;
52
55
  --dropdown-ease: cubic-bezier(0.22, 1, 0.36, 1);
53
56
  /* transitions.dev — icon swap */
54
57
  --icon-swap-dur: 200ms; --icon-swap-blur: 2px; --icon-swap-start-scale: 0.25; --icon-swap-ease: ease-in-out;
@@ -88,15 +91,15 @@
88
91
  /* @inject-skip-start (demo-only page + sample-box styles — excluded from the injected build) */
89
92
  body {
90
93
  font-family: "Inter", system-ui, -apple-system, sans-serif;
91
- background: #fafafa;
94
+ background: #EEEEEE;
92
95
  color: var(--c-text);
93
96
  min-height: 100vh;
94
97
  -webkit-font-smoothing: antialiased;
95
98
  -moz-osx-font-smoothing: grayscale;
96
99
  }
97
100
 
98
- .demo-root { max-width: 900px; margin: 0 auto; padding: 48px 24px 120px; }
99
- .demo-header { margin-bottom: 40px; }
101
+ .demo-root { max-width: 900px; margin: 0 auto; padding: 24px 24px 120px; }
102
+ .demo-header { margin-bottom: 18px; }
100
103
  .demo-header h1 { font-size: 26px; font-weight: 700; letter-spacing: -0.5px; color: #171717; }
101
104
  .demo-header p { color: var(--c-ruler); font-size: 14px; margin-top: 6px; }
102
105
  .demo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 20px; }
@@ -191,6 +194,130 @@
191
194
  @media (prefers-reduced-motion: reduce) {
192
195
  .t-morph, .t-morph-plus, .t-morph-menu { transition: none !important; }
193
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.8; --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
+ }
194
321
  /* @inject-skip-end */
195
322
 
196
323
  /* ───────────────────── timeline panel ───────────────────── */
@@ -259,10 +386,32 @@
259
386
  .tl-pill-count.is-single { width: 20px; padding: 0; }
260
387
 
261
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). */
262
391
  .tl-header { flex: none; position: relative; z-index: 40; display: flex; align-items: center; gap: 8px; padding: 7.5px 14px;
263
- border-bottom: 1px solid var(--c-line); }
264
- .tl-header-label { font-size: 13px; font-weight: 500; line-height: 18px; color: var(--c-text-mut2); white-space: nowrap; }
265
- .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
+ }
266
415
 
267
416
  /* ── buttons ── */
268
417
  /* topbar buttons share a fully rounded 60px corner radius */
@@ -327,7 +476,7 @@
327
476
  border: none; border-radius: 8px; background: transparent; color: var(--c-blue);
328
477
  box-shadow: var(--drop-btn);
329
478
  font: inherit; font-size: 13px; font-weight: 500; line-height: 14px;
330
- cursor: pointer; white-space: nowrap; transition: color 0.12s ease; }
479
+ cursor: pointer; white-space: nowrap; transition: color 0.12s ease, scale 0.12s ease; }
331
480
  .tl-refine-btn::before { content: ""; position: absolute; inset: 0; border-radius: inherit;
332
481
  background: var(--c-blue-bg);
333
482
  background: color(display-p3 0 0.451 0.898 / 0.06);
@@ -339,7 +488,7 @@
339
488
  /* Figma: wand icon stroke is #0073E5, distinct from the #0071e2 label */
340
489
  .tl-refine-btn > svg { color: var(--c-blue-pressed); }
341
490
  .tl-refine-btn:hover:not(:disabled)::before { background: var(--c-blue-bg-h); background: color(display-p3 0 0.451 0.898 / 0.10); }
342
- .tl-refine-btn:active:not(:disabled) { color: var(--c-blue-pressed); }
491
+ .tl-refine-btn:active:not(:disabled) { color: var(--c-blue-pressed); scale: 0.96; }
343
492
  .tl-refine-btn:active:not(:disabled)::before { background: var(--c-blue-bg-h); background: color(display-p3 0 0.451 0.898 / 0.10); }
344
493
  .tl-refine-btn:disabled { cursor: default; color: var(--c-blue); }
345
494
  .tl-refine-btn:disabled::before { background: var(--c-blue-bg); background: color(display-p3 0 0.451 0.898 / 0.06); }
@@ -412,7 +561,15 @@
412
561
  .tl-bar { position: absolute; top: 50%; height: 24px; transform: translateY(-50%); background: var(--c-track);
413
562
  border-radius: 8px; min-width: 24px; cursor: grab;
414
563
  box-shadow: 0 1px 3px 0 rgba(0,0,0,0.08), inset 0 0 0 1px rgba(0,0,0,0.06), inset 0 -1px 0 0 rgba(0,0,0,0.1), inset 0 0 0 1px rgba(196,196,196,0.1);
415
- transition: background 0.12s ease; }
564
+ /* card resize (transitions.dev #01): tween left/width when timing changes
565
+ programmatically (Apply, snap, value-field edit). Suppressed mid-drag
566
+ (.is-dragging) so the bar tracks the cursor 1:1. */
567
+ transition: background 0.12s ease,
568
+ left 220ms cubic-bezier(0.22, 1, 0.36, 1),
569
+ width 220ms cubic-bezier(0.22, 1, 0.36, 1); will-change: left, width; }
570
+ .tl-bar.is-dragging { transition: background 0.12s ease; }
571
+ @media (prefers-reduced-motion: reduce) {
572
+ .tl-bar { transition: background 0.12s ease !important; } }
416
573
  .tl-prop-row:hover .tl-bar, .tl-prop-row.selected .tl-bar { background: var(--c-track-h); }
417
574
  .tl-bar:active { cursor: grabbing; }
418
575
  /* resize handles — Figma node 13044:2625: 2×14px, #767676 @60%, centered, ~5px edge inset */
@@ -450,18 +607,23 @@
450
607
  .tl-field-fill { position: absolute; left: 0; top: 0; bottom: 0; min-width: 32px; border-radius: 8px;
451
608
  background: var(--c-fill);
452
609
  box-shadow: inset 0 0 0 1px rgba(0,0,0,0.02), inset 0 -1px 0 0 rgba(0,0,0,0.08), inset 0 0 0 1px rgba(191,191,191,0.10), 0 1px 3px 0 rgba(0,0,0,0.06);
453
- pointer-events: none; transition: background 0.12s ease, opacity 0.12s ease; z-index: 1; }
454
- .tl-field.is-dragging .tl-field-fill { background: var(--c-fill-a); }
610
+ /* card resize (#01): tween width on programmatic changes + release snap;
611
+ suppressed mid-drag so it tracks the cursor 1:1. */
612
+ pointer-events: none; transition: background 0.12s ease, opacity 0.12s ease,
613
+ width 220ms cubic-bezier(0.22, 1, 0.36, 1); z-index: 1; }
614
+ .tl-field.is-dragging .tl-field-fill { background: var(--c-fill-a); transition: background 0.12s ease, opacity 0.12s ease; }
615
+ @media (prefers-reduced-motion: reduce) {
616
+ .tl-field-fill { transition: background 0.12s ease, opacity 0.12s ease !important; } }
455
617
  .tl-field.is-dragging .tl-field-label { opacity: 0.7; }
456
618
  .tl-field.is-editing .tl-field-fill { opacity: 0; }
457
619
  .tl-field-track { position: absolute; inset: 0; cursor: ew-resize; z-index: 2; }
458
620
  .tl-field-label { position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
459
621
  font-size: 13px; font-weight: 500; color: var(--c-text-mut); pointer-events: none; z-index: 3; }
460
622
  .tl-field-value { position: absolute; right: 12px; top: 50%; transform: translateY(-50%);
461
- font-size: 13px; color: var(--c-text); font-weight: 500; cursor: text; z-index: 3;
623
+ font-size: 13px; color: var(--c-text); font-weight: 400; cursor: text; z-index: 3;
462
624
  font-variant-numeric: tabular-nums; }
463
625
  .tl-field-input { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); width: 64px;
464
- text-align: right; border: none; background: transparent; font: inherit; font-size: 13px; font-weight: 500;
626
+ text-align: right; border: none; background: transparent; font: inherit; font-size: 13px; font-weight: 400;
465
627
  color: var(--c-text); outline: none; z-index: 4; font-variant-numeric: tabular-nums; }
466
628
  /* thumb at the right edge of the fill (Figma #767676 2×20px, inset 8px, hover 60% / drag 100%) */
467
629
  .tl-field-thumb { position: absolute; right: 8px; top: 7px; height: 18px; width: 2px;
@@ -520,7 +682,7 @@
520
682
  /* select trigger — Logram design system (node 13064:2418): flat #f7f7f7 fill */
521
683
  .tl-select { display: flex; align-items: center; justify-content: space-between; width: 100%; height: 32px;
522
684
  border: none; border-radius: 8px; background: #f7f7f7; padding: 6px 10px 6px 12px;
523
- font: inherit; font-size: 13px; font-weight: 500; line-height: 16px; color: #1b1b1b; cursor: pointer; text-align: left;
685
+ font: inherit; font-size: 13px; font-weight: 400; line-height: 16px; color: #1b1b1b; cursor: pointer; text-align: left;
524
686
  transition: background 0.12s ease; }
525
687
  .tl-select:hover { background: var(--c-sec-h); }
526
688
  .tl-select.is-open { background: var(--c-sec-h); }
@@ -546,7 +708,7 @@
546
708
  .tl-cubic-cell:focus-within { box-shadow: inset 0 0 0 1px rgba(0,0,0,0.14); }
547
709
  .tl-cubic-row input { flex: 1; min-width: 0; width: 100%; height: 100%; border: none; background: transparent;
548
710
  box-sizing: border-box; padding: 0; text-align: left;
549
- font: inherit; font-size: 12px; font-weight: 500; line-height: 16px; color: #1b1b1b; outline: none;
711
+ font: inherit; font-size: 12px; font-weight: 400; line-height: 16px; color: #1b1b1b; outline: none;
550
712
  -moz-appearance: textfield; appearance: textfield; }
551
713
  .tl-cubic-row input::-webkit-outer-spin-button, .tl-cubic-row input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; width: 0; }
552
714
  .tl-cubic-row input::placeholder { color: #979797; }
@@ -709,6 +871,12 @@
709
871
  /* trailing chevron on link rows (Figma 581:2828 — #696969 right chevron) */
710
872
  .tl-menu-chevr { display: flex; flex: none; color: #696969; }
711
873
 
874
+ /* trailing slot on a menu row — current value + chevron (Figma: Theme row
875
+ shows "System" then a right chevron and opens the theme picker sub-page). */
876
+ .tl-menu-trail { display: flex; align-items: center; gap: 4px; }
877
+ .tl-menu-value { font-size: 11px; line-height: 14px; color: #8b8b8b;
878
+ font-variant-numeric: tabular-nums; }
879
+
712
880
  /* ── settings dropdown sub-page slider (transitions.dev · 08-page-side-by-side) ──
713
881
  One dropdown surface that pages internally: main settings ↔ keyboard
714
882
  shortcuts. Both pages stay mounted so they can cross-slide; the surface
@@ -716,6 +884,10 @@
716
884
  smoothly instead of jumping. Page 1 exits left, page 2 exits right. */
717
885
  .tl-set-slide.t-page-slide {
718
886
  position: relative;
887
+ /* bleed past the .tl-menu 6px padding so the clipped (overflow:hidden)
888
+ viewport reaches the menu edges — lets dividers go truly full-width.
889
+ The 6px row inset is restored on .tl-set-page below. */
890
+ margin-left: -6px; margin-right: -6px;
719
891
  --page-slide-dur: 250ms; /* Fast — page slide */
720
892
  --page-fade-dur: 250ms;
721
893
  --page-slide-distance: 8px; /* Base distance */
@@ -728,9 +900,11 @@
728
900
  overflow: hidden;
729
901
  }
730
902
  .tl-set-slide .t-page[data-page-id="1"] { --t-page-from-x: calc(var(--page-slide-distance) * -1); }
731
- .tl-set-slide .t-page[data-page-id="2"] { --t-page-from-x: var(--page-slide-distance); }
903
+ .tl-set-slide .t-page[data-page-id="2"],
904
+ .tl-set-slide .t-page[data-page-id="3"] { --t-page-from-x: var(--page-slide-distance); }
732
905
  .tl-set-slide .t-page {
733
906
  position: absolute; top: 0; left: 0; right: 0;
907
+ box-sizing: border-box; padding-left: 6px; padding-right: 6px;
734
908
  opacity: 0; pointer-events: none;
735
909
  transform: translateX(calc(var(--t-page-from-x, 0px) * var(--page-exit-enabled)));
736
910
  filter: blur(calc(var(--page-blur) * var(--page-exit-enabled)));
@@ -741,7 +915,8 @@
741
915
  will-change: opacity, transform, filter;
742
916
  }
743
917
  .tl-set-slide[data-page="1"] .t-page[data-page-id="1"],
744
- .tl-set-slide[data-page="2"] .t-page[data-page-id="2"] {
918
+ .tl-set-slide[data-page="2"] .t-page[data-page-id="2"],
919
+ .tl-set-slide[data-page="3"] .t-page[data-page-id="3"] {
745
920
  opacity: 1; pointer-events: auto; transform: translateX(0); filter: blur(0);
746
921
  transition-delay: var(--page-stagger);
747
922
  }
@@ -887,6 +1062,12 @@
887
1062
  transform: translate(0, 0) scale(var(--tt-scale)); }
888
1063
  .t-tt-wrap:hover .t-tt.tl-tt-msg,
889
1064
  .t-tt-trigger:focus-visible + .t-tt.tl-tt-msg { transform: translate(0, 0) scale(1); }
1065
+ /* right-anchored variant for the panel's rightmost trigger (Minimize) so the
1066
+ bubble grows leftward from the right edge and never clips off the panel. */
1067
+ .t-tt.tl-tt-right { left: auto; right: 0; transform-origin: 100% 0;
1068
+ transform: translate(0, 0) scale(var(--tt-scale)); }
1069
+ .t-tt-wrap:hover .t-tt.tl-tt-right,
1070
+ .t-tt-trigger:focus-visible + .t-tt.tl-tt-right { transform: translate(0, 0) scale(1); }
890
1071
 
891
1072
  /* ═════ transitions.dev — panel reveal (per-phase distance/ease/scale) ═════ */
892
1073
  /* base = closed / close-end state: resolved travel + scale come from the
@@ -1018,7 +1199,7 @@
1018
1199
  /* header: title block (left) + Agent/Deterministic mode dropdown (right) */
1019
1200
  /* match the timeline top bar exactly: 7.5px/14px padding, centered, 52px tall (36px row + 15 + 1px border) */
1020
1201
  .tl-refine-head { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between;
1021
- gap: 8px; box-sizing: border-box; padding: 7.5px 14px;
1202
+ gap: 8px; box-sizing: border-box; padding: 7.5px 7.5px 7.5px 14px;
1022
1203
  border-bottom: 1px solid #f0f0f0; }
1023
1204
  .tl-refine-titles { min-width: 0; }
1024
1205
  .tl-refine-titles h3 { font-size: 13px; font-weight: 500; line-height: 14px; color: #17181c; }
@@ -1047,6 +1228,49 @@
1047
1228
  .tl-refine-tab[aria-selected="true"] { background: rgba(170,170,170,0.10); color: #17181c; }
1048
1229
  .tl-refine-body { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column;
1049
1230
  padding: 0 16px; }
1231
+ /* ── refine-type tab content slider (transitions.dev · 08-page-side-by-side) ──
1232
+ Switching the type tabs (Small refinements ↔ Replace transition) slides the
1233
+ body content side-by-side: the outgoing page exits with blur+fade while the
1234
+ incoming page slides in from the opposite edge. Both pages stay mounted so
1235
+ they cross-slide. Page 1 (Small, tab index 0) exits left; page 2 (Replace,
1236
+ tab index 1) exits right — going to Replace brings new content in from the
1237
+ right, going back brings it from the left. Mirrors the settings dropdown's
1238
+ .tl-set-slide tokens exactly. Pages are absolutely stacked and fill the
1239
+ fixed body region, scrolling internally, so no height tween is needed. */
1240
+ .tl-refine-body.tl-refine-pages.t-page-slide {
1241
+ position: relative; overflow: hidden; padding: 0;
1242
+ --page-slide-dur: 250ms; /* Fast — page slide */
1243
+ --page-fade-dur: 250ms;
1244
+ --page-slide-distance: 8px; /* Base distance */
1245
+ --page-blur: 3px; /* Medium blur */
1246
+ --page-stagger: 0ms;
1247
+ --page-exit-enabled: 1;
1248
+ --page-slide-ease: cubic-bezier(0.22, 1, 0.36, 1); /* Smooth ease out */
1249
+ --page-fade-ease: cubic-bezier(0.22, 1, 0.36, 1);
1250
+ }
1251
+ .tl-refine-pages .t-page[data-page-id="1"] { --t-page-from-x: calc(var(--page-slide-distance) * -1); }
1252
+ .tl-refine-pages .t-page[data-page-id="2"] { --t-page-from-x: var(--page-slide-distance); }
1253
+ .tl-refine-pages .t-page {
1254
+ position: absolute; inset: 0; overflow-y: auto;
1255
+ display: flex; flex-direction: column; box-sizing: border-box; padding: 0 16px;
1256
+ opacity: 0; pointer-events: none;
1257
+ transform: translateX(calc(var(--t-page-from-x, 0px) * var(--page-exit-enabled)));
1258
+ filter: blur(calc(var(--page-blur) * var(--page-exit-enabled)));
1259
+ transition:
1260
+ opacity var(--page-fade-dur) var(--page-fade-ease),
1261
+ transform var(--page-slide-dur) var(--page-slide-ease),
1262
+ filter var(--page-slide-dur) var(--page-slide-ease);
1263
+ will-change: opacity, transform, filter;
1264
+ }
1265
+ .tl-refine-pages[data-page="1"] .t-page[data-page-id="1"],
1266
+ .tl-refine-pages[data-page="2"] .t-page[data-page-id="2"] {
1267
+ opacity: 1; pointer-events: auto; transform: translateX(0); filter: blur(0);
1268
+ transition-delay: var(--page-stagger);
1269
+ }
1270
+ @media (prefers-reduced-motion: reduce) {
1271
+ .tl-refine-pages.t-page-slide,
1272
+ .tl-refine-pages .t-page { transition: none !important; }
1273
+ }
1050
1274
  /* idle + unavailable share a vertically-centred column */
1051
1275
  .tl-refine-center { flex: 1; display: flex; flex-direction: column; align-items: center;
1052
1276
  justify-content: center; gap: 12px; text-align: center; padding: 16px 0; }
@@ -1092,7 +1316,8 @@
1092
1316
  height var(--resize-dur) var(--resize-ease),
1093
1317
  border-radius var(--resize-dur) var(--resize-ease),
1094
1318
  color var(--resize-dur) var(--resize-ease),
1095
- box-shadow var(--resize-dur) var(--resize-ease); }
1319
+ box-shadow var(--resize-dur) var(--resize-ease),
1320
+ scale 0.12s ease; }
1096
1321
  .tl-scan-morph::before { content: ""; position: absolute; inset: 0; border-radius: inherit;
1097
1322
  background: var(--c-blue-bg);
1098
1323
  background: color(display-p3 0 0.451 0.898 / 0.06);
@@ -1104,7 +1329,7 @@
1104
1329
  .tl-refine-foot .tl-scan-morph { margin-bottom: 20px; }
1105
1330
  .tl-scan-morph:hover:not(:disabled):not(.is-scanning)::before {
1106
1331
  background: var(--c-blue-bg-h); background: color(display-p3 0 0.451 0.898 / 0.10); }
1107
- .tl-scan-morph:active:not(.is-scanning):not(:disabled) { color: var(--c-blue-pressed); }
1332
+ .tl-scan-morph:active:not(.is-scanning):not(:disabled) { color: var(--c-blue-pressed); scale: 0.96; }
1108
1333
  .tl-scan-morph:active:not(.is-scanning):not(:disabled)::before {
1109
1334
  background: var(--c-blue-bg-h); background: color(display-p3 0 0.451 0.898 / 0.10); }
1110
1335
  .tl-scan-morph:disabled:not(.is-scanning) { cursor: default; color: var(--c-blue); }
@@ -1114,12 +1339,12 @@
1114
1339
  color: var(--c-blue-pressed); opacity: 0.5; }
1115
1340
  .tl-scan-morph.is-scanning { width: 100%; height: 49px; border-radius: 60px;
1116
1341
  padding: 8px 16px; cursor: default; color: #676767; background: transparent;
1117
- box-shadow: none; }
1342
+ box-shadow: var(--shadow-btn); }
1118
1343
  .tl-scan-morph.is-scanning::before {
1119
1344
  background: #fff;
1120
1345
  transition: background var(--resize-dur) var(--resize-ease); }
1121
1346
  .tl-scan-morph.is-scanning::after {
1122
- box-shadow: inset 0 0 0 1px #f0f0f0;
1347
+ box-shadow: var(--shadow-btn-inset);
1123
1348
  transition: box-shadow var(--resize-dur) var(--resize-ease); }
1124
1349
  /* Two faces share one grid cell and cross-blur (icon swap, 09) between the
1125
1350
  label and the icon+status; whichever is hidden fades out with blur+scale.
@@ -1210,6 +1435,41 @@
1210
1435
  .tl-scan-note { cursor: help; color: var(--c-count);
1211
1436
  display: inline-flex; align-items: center; gap: 6px; }
1212
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
+ }
1213
1473
  .tl-scan-shimmer { --shimmer-dur: 2000ms; --shimmer-base: var(--c-count); --shimmer-highlight: #17181c;
1214
1474
  --shimmer-band: 400%; --shimmer-ease: linear; }
1215
1475
  /* loading status text — transitions.dev shimmer text (15) + text states swap (04) */
@@ -1242,7 +1502,7 @@
1242
1502
  font: inherit; transition: background 0.12s ease; }
1243
1503
  .tl-mode-row:hover { background: rgba(170,170,170,0.08); }
1244
1504
  .tl-mode-row-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
1245
- .tl-mode-row-title { font-size: 14px; font-weight: 500; line-height: 16px; color: #1b1b1b; }
1505
+ .tl-mode-row-title { font-size: 13px; font-weight: 500; line-height: 16px; color: #1b1b1b; }
1246
1506
  .tl-mode-row-desc { font-size: 12px; font-weight: 400; line-height: 18px; color: #676767; text-wrap: balance; }
1247
1507
  .tl-mode-row-check { flex: 0 0 auto; width: 16px; height: 16px; display: flex; color: #17181c; }
1248
1508
  @keyframes tl-fade-up { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
@@ -1266,6 +1526,7 @@
1266
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); }
1267
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); }
1268
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); }
1269
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); }
1270
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); }
1271
1532
  /* content stack */
@@ -1279,6 +1540,12 @@
1279
1540
  .tl-sug-from { color: #7b7b7b; text-decoration: line-through; }
1280
1541
  .tl-sug-to { color: #1b1b1b; }
1281
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; }
1282
1549
  /* shared white pill button (Figma 580:757) — card Apply + bottom-bar actions */
1283
1550
  .tl-pill-btn { align-self: flex-start; box-sizing: border-box; display: inline-flex; align-items: center;
1284
1551
  justify-content: center; gap: 8px; height: 32px; padding: 6px 12px; border: none; cursor: pointer;
@@ -1289,10 +1556,15 @@
1289
1556
  transition: background 0.12s ease, scale 0.12s ease, opacity 0.12s ease; }
1290
1557
  .tl-pill-btn .tl-dim { color: #696969; }
1291
1558
  .tl-pill-btn:hover:not(:disabled) { background: #fafafa; }
1292
- .tl-pill-btn:active:not(:disabled) { scale: 0.98; }
1293
- .tl-pill-btn:disabled { opacity: 0.45; cursor: default; }
1294
- /* applied state — GRAY, never green (matches Accept icon --c-text #17181c) */
1295
- .tl-pill-btn.is-applied { color: var(--c-text); cursor: default; }
1559
+ .tl-pill-btn:active:not(:disabled) { scale: 0.96; }
1560
+ /* disabled flat grey fill matching the active segmented tab, no raised
1561
+ shadow, muted text (instead of a faded white button). */
1562
+ .tl-pill-btn:disabled { cursor: not-allowed; opacity: 1; color: var(--c-disabled);
1563
+ background: rgba(170,170,170,0.1); box-shadow: none; }
1564
+ /* applied state — GRAY, never green (matches Accept icon --c-text #17181c).
1565
+ Flat grey fill matching the active segmented tab, no raised shadow. */
1566
+ .tl-pill-btn.is-applied { color: var(--c-text); cursor: not-allowed; opacity: 1;
1567
+ background: rgba(170,170,170,0.1); box-shadow: none; }
1296
1568
  .tl-pill-btn.is-applied svg path { stroke: var(--c-text); }
1297
1569
  /* Apply ⟷ Applied: a LEADING check is revealed first and pushes the text to
1298
1570
  the right, then the differing SUFFIX cross-blurs. The shared stem "Appl"
@@ -1570,7 +1842,10 @@
1570
1842
  return out;
1571
1843
  }
1572
1844
  _applyOverrides(baseLanes,po){
1573
- 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};});
1574
1849
  }
1575
1850
  _notify(){
1576
1851
  this.effectiveCache.clear();
@@ -1703,6 +1978,56 @@
1703
1978
  }catch{return [];}
1704
1979
  }
1705
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
+
1706
2031
  // ── hooks ──
1707
2032
  const TimelineCtx = createContext(null);
1708
2033
  function useReg(){const{registry}=useContext(TimelineCtx);return useSyncExternalStore(useCallback(cb=>registry.subscribe(cb),[registry]),useCallback(()=>registry.getAll(),[registry]),useCallback(()=>registry.getAll(),[registry]));}
@@ -1758,6 +2083,8 @@
1758
2083
  // accept checkmark (Figma node 580:9057) — slimmer 1.5px check centered in a
1759
2084
  // square 16-frame (the glyph spans ~50% width, like the source icon frame).
1760
2085
  ICONS.accept = {vb:"0 0 16 16", svg:`<path d="M4 8.4268L6.46155 11.19223L12 4.97001" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`};
2086
+ // external-link (Figma "link-external-01") — box with an arrow leaving the top-right
2087
+ ICONS.linkout = {vb:"0 0 16 16", svg:`<path d="M9.5 3.5H12.5V6.5M12.5 3.5L7.5 8.5M11.5 9.25V11.5C11.5 12.0523 11.0523 12.5 10.5 12.5H4.5C3.94772 12.5 3.5 12.0523 3.5 11.5V5.5C3.5 4.94772 3.94772 4.5 4.5 4.5H6.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`};
1761
2088
  function Ic({name, size=16}){
1762
2089
  const ic = ICONS[name];
1763
2090
  if(!ic) return null;
@@ -1860,44 +2187,218 @@
1860
2187
  // page so the one dropdown surface resizes smoothly between pages. Lives
1861
2188
  // inside the Dropdown portal, so its layout effect fires when the surface
1862
2189
  // mounts (the parent Header doesn't re-render on the portal's own mount).
1863
- function SettingsSlide({view,mainPage,shortcutsPage,onBack,onClose}){
1864
- const slideRef=useRef(null), p1=useRef(null), p2=useRef(null);
2190
+ function SettingsSlide({view,mainPage,shortcutsPage,themePage,onBack,onClose}){
2191
+ const slideRef=useRef(null), p1=useRef(null), p2=useRef(null), p3=useRef(null);
2192
+ const pageNum=view==="shortcuts"?"2":view==="theme"?"3":"1";
1865
2193
  useLayoutEffect(()=>{
1866
2194
  const slide=slideRef.current; if(!slide) return;
1867
- const active=view==="shortcuts"?p2.current:p1.current;
2195
+ const active=view==="shortcuts"?p2.current:view==="theme"?p3.current:p1.current;
1868
2196
  if(active) slide.style.height=active.offsetHeight+"px";
1869
2197
  });
1870
- // Escape steps back from the sub-page first, then closes the dropdown —
2198
+ // Escape steps back from a sub-page first, then closes the dropdown —
1871
2199
  // mirrors the picker's nested-dismiss behaviour.
1872
2200
  useEffect(()=>{
1873
2201
  const onKey=e=>{ if(e.key!=="Escape")return;
1874
- if(view==="shortcuts"){onBack&&onBack();} else {onClose&&onClose();} };
2202
+ if(view!=="main"){onBack&&onBack();} else {onClose&&onClose();} };
1875
2203
  document.addEventListener("keydown",onKey);
1876
2204
  return()=>document.removeEventListener("keydown",onKey);
1877
2205
  },[view,onBack,onClose]);
1878
- return h("div",{ref:slideRef,className:"tl-set-slide t-page-slide","data-page":view==="shortcuts"?"2":"1"},
1879
- h("div",{ref:p1,className:"tl-set-page t-page","data-page-id":"1","aria-hidden":view==="shortcuts"},mainPage),
1880
- h("div",{ref:p2,className:"tl-set-page t-page","data-page-id":"2","aria-hidden":view!=="shortcuts"},shortcutsPage));
2206
+ return h("div",{ref:slideRef,className:"tl-set-slide t-page-slide","data-page":pageNum},
2207
+ h("div",{ref:p1,className:"tl-set-page t-page","data-page-id":"1","aria-hidden":view!=="main"},mainPage),
2208
+ h("div",{ref:p2,className:"tl-set-page t-page","data-page-id":"2","aria-hidden":view!=="shortcuts"},shortcutsPage),
2209
+ h("div",{ref:p3,className:"tl-set-page t-page","data-page-id":"3","aria-hidden":view!=="theme"},themePage));
1881
2210
  }
1882
2211
 
1883
2212
  // ── refine relay client ──
1884
2213
  // Talks to the local relay (server/relay.mjs). The browser posts a job and
1885
2214
  // polls; the relay answers each job with one agent run (one-shot).
1886
2215
  const RELAY_URL = (window.REFINE_RELAY_URL) || "http://localhost:7331";
2216
+
2217
+ // ── demo/mock agent (guarded — additive, OFF by default) ──
2218
+ // When `window.__TX_REFINE_MOCK` is set (the standalone website-demo.html),
2219
+ // the three relay fns below short-circuit to a built-in mock so the panel is
2220
+ // fully interactive with NO relay/LLM/CLI. When the flag is UNSET this whole
2221
+ // block is inert and every fn falls through to the real fetch path unchanged.
2222
+ const TX_MOCK = typeof window!=="undefined" && window.__TX_REFINE_MOCK;
2223
+ const _txMockJobs = new Map(); // id → { request, createdAt }
2224
+ const _txMockId = ()=>"mock-"+Date.now()+"-"+Math.random().toString(36).slice(2,8);
2225
+ const _txMs = (n)=>(n==null?"":Math.round(n)+"ms");
2226
+ const _TX_EASE = "cubic-bezier(0.22, 1, 0.36, 1)"; // transitions.dev standard ease-out
2227
+ // Build a "done" result shaped by the stored request kind (mirrors relay.mjs
2228
+ // parseScanOutput/parseApplyOutput/parseAgentOutput shapes).
2229
+ function _txMockResult(request){
2230
+ const kind = request && request.kind;
2231
+ if(kind==="scan") return { groups: _txMockGroups(request), summary: "Grouped 1 component — Menu dropdown (open + close)." };
2232
+ if(kind==="apply") return { applied: true, summary: "Demo mode — simulated writing the change to your source. (No files touched.)", files: [] };
2233
+ return _txMockSuggestions(request);
2234
+ }
2235
+ // scan → one "Menu dropdown" group with Open + Close phases. Members are
2236
+ // derived from the raw DOM-detected transitions so the timeline shows real
2237
+ // lanes; selectors come straight from the live scan so they resolve in the
2238
+ // DOM. Shape mirrors relay.mjs buildScanPrompt's groups[] (id/label/phases →
2239
+ // phase{id,phase,label,stateTarget,fromState,toState,members[{id,label,
2240
+ // selector,propertyTimings}]}) and what TransitionRegistry.setGroups expects.
2241
+ function _txMockGroups(request){
2242
+ const raw = (request && request.raw) || [];
2243
+ const base = raw.length ? raw : [{label:"Menu", selector:".wd-dd .wd-dd-menu", properties:["opacity","transform"]}];
2244
+ // OPEN phase matches the current DOM → copy the scanned timings verbatim
2245
+ // (so the timeline shows the element's real, possibly off-token values).
2246
+ const open = base.map((e,i)=>({
2247
+ id:"m"+i,
2248
+ label:e.label||("Element "+(i+1)),
2249
+ selector:e.selector||".wd-dd .wd-dd-menu",
2250
+ propertyTimings:((e.timings&&e.timings.length)?e.timings:(e.properties||["opacity","transform"]).map(p=>({property:p,durationMs:280,delayMs:0,easing:"ease"})))
2251
+ .map(t=>({property:t.property, durationMs:t.durationMs!=null?t.durationMs:280, delayMs:t.delayMs||0, easing:t.easing||"ease"})),
2252
+ }));
2253
+ // CLOSE phase lives on a state variant the DOM scan can't see; the recipe's
2254
+ // faster close (the authored 180ms) is recovered here, mirroring how the
2255
+ // real agent reads the opposite-phase timings from cssRules.
2256
+ const close = open.map(m=>({ ...m, propertyTimings:m.propertyTimings.map(t=>({property:t.property, durationMs:180, delayMs:0, easing:"ease"})) }));
2257
+ const phases=[
2258
+ {id:"menu-dropdown:open", phase:"open", label:"Open", stateTarget:".wd-dd", fromState:null, toState:".is-open", members:open},
2259
+ {id:"menu-dropdown:close",phase:"close", label:"Close", stateTarget:".wd-dd", fromState:".is-open", toState:null, members:close},
2260
+ ];
2261
+ return [{ id:"menu-dropdown", label:"Menu dropdown", component:null, phases }];
2262
+ }
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).
2322
+ function _txMockSuggestions(request){
2323
+ const t = (request && request.timings) || [];
2324
+ const ctx = { label:(request&&request.label)||null, selector:(request&&request.selector)||null, phase:(request&&request.phase)||null };
2325
+ const p0 = (t[0]&&t[0].property) || "opacity";
2326
+ const m0 = (t[0]&&t[0].member) || null;
2327
+ const mp = (member, rest)=> member ? {member, ...rest} : rest;
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 };
2353
+ }
2354
+ // statusLog so the panel's shimmer status copy has something to show.
2355
+ function _txMockLog(request, createdAt){
2356
+ const kind = request && request.kind;
2357
+ const head = kind==="scan" ? "Grouping transitions from your source…"
2358
+ : kind==="apply" ? "Writing “"+((request&&request.label)||"transition")+"” to your code…"
2359
+ : "Scanning “"+((request&&request.label)||"transition")+"”…";
2360
+ return [{message:head, at:createdAt}, {message:"Reading the Transitions.dev skill…", at:createdAt+350}];
2361
+ }
2362
+
1887
2363
  async function relayCreateJob(request){
2364
+ if(TX_MOCK){
2365
+ const id=_txMockId();
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});
2369
+ return { id, status:"pending" };
2370
+ }
1888
2371
  const r = await fetch(RELAY_URL+"/jobs",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({request})});
1889
2372
  if(!r.ok) throw new Error("relay POST /jobs failed ("+r.status+")");
1890
2373
  return r.json(); // { id, status }
1891
2374
  }
1892
2375
  async function relayGetJob(id){
2376
+ if(TX_MOCK){
2377
+ const rec=_txMockJobs.get(id);
2378
+ if(!rec) return { id, status:"error", statusLog:[], result:null, error:"mock: unknown job" };
2379
+ const elapsed=Date.now()-rec.createdAt;
2380
+ const log=_txMockLog(rec.request, rec.createdAt);
2381
+ const wait=rec.delayMs!=null?rec.delayMs:800;
2382
+ if(elapsed<wait) return { id, status:"running", statusLog:[log[0]], result:null, error:null };
2383
+ return { id, status:"done", statusLog:log, result:_txMockResult(rec.request), error:null };
2384
+ }
1893
2385
  const r = await fetch(RELAY_URL+"/jobs/"+id);
1894
2386
  if(!r.ok) throw new Error("relay GET /jobs/:id failed ("+r.status+")");
1895
2387
  return r.json();
1896
2388
  }
1897
2389
  async function relayHealth(){
2390
+ if(TX_MOCK) return { ok:true, auto:true, llmAvailable:true, cliInstalled:true, agentCmd:true, pollerActive:false, jobs:0 };
1898
2391
  const r = await fetch(RELAY_URL+"/health");
1899
2392
  if(!r.ok) throw new Error("relay /health failed ("+r.status+")");
1900
- 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();
1901
2402
  }
1902
2403
 
1903
2404
  const REFINE_MODES=[
@@ -1944,7 +2445,20 @@
1944
2445
  const matches=lanes.filter(t=>t.property===s.property&&t.member);
1945
2446
  if(!matches.length) return null;
1946
2447
  const names=[...new Set(matches.map(t=>t.member))];
1947
- 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;
1948
2462
  }
1949
2463
  // Apply button — "Apply" ⟷ "✓ Applied". On apply, a LEADING gray check is
1950
2464
  // revealed first and pushes the text to the right: .tl-apply-check tweens its
@@ -2012,18 +2526,25 @@
2012
2526
  h("span",{className:"tl-apply-seg tl-apply-y"},"y"),
2013
2527
  h("span",{className:"tl-apply-seg tl-apply-ied"},"ied"))));
2014
2528
  }
2015
- function RefineResults({suggestions,appliedIds,onApply,lanes}){
2529
+ function RefineResults({suggestions,appliedIds,onApply,lanes,revealedRef,active=true}){
2016
2530
  const ref=useRef(null);
2017
2531
  useEffect(()=>{
2018
2532
  const el=ref.current;if(!el)return;
2533
+ // Both type tabs keep their results mounted so they can cross-slide
2534
+ // (page-side-by-side). Only the visible page plays the staggered reveal,
2535
+ // and only once per scan; the hidden page just shows its final state so
2536
+ // switching tabs slides finished content in without re-staggering.
2537
+ if(!active){ el.classList.add("is-shown"); return; }
2538
+ if(revealedRef&&revealedRef.current){ el.classList.add("is-shown"); return; }
2019
2539
  el.classList.remove("is-shown");
2020
2540
  void el.offsetHeight; // reflow from the start state before playing
2021
2541
  el.classList.add("is-shown");
2022
- },[]);
2542
+ if(revealedRef) revealedRef.current=true;
2543
+ },[active]);
2023
2544
  const delay=(i)=>({transitionDelay:"calc(var(--stagger-stagger) * "+i+")"});
2024
2545
  // category chip label + color modifier, keyed by the suggestion kind
2025
2546
  const CAT={easing:["Easing","is-easing"],duration:["Duration","is-duration"],
2026
- 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"],
2027
2548
  replace:["Transition replacement","is-replace"]};
2028
2549
  const cap=(p)=>p&&p!=="all"?p.charAt(0).toUpperCase()+p.slice(1):"";
2029
2550
  let i=0;
@@ -2048,8 +2569,14 @@
2048
2569
  h("div",{className:"tl-sug-body"},
2049
2570
  h("div",{className:"tl-sug-text"},
2050
2571
  isReplace
2051
- ? h("div",{className:"tl-sug-title"},
2052
- "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")))))
2053
2580
  : h(React.Fragment,null,
2054
2581
  s.title&&h("div",{className:"tl-sug-title"},s.title),
2055
2582
  (s.from||s.to)&&h("div",{className:"tl-sug-delta"},
@@ -2059,6 +2586,20 @@
2059
2586
  applyBtn));
2060
2587
  }));
2061
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
+ }
2062
2603
  function RefinePanel({open,onClose,phase,label,refineType,onType,suggestions,summary,error,appliedIds,onApply,onApplyAll,mode,onMode,llmAvailable,cliInstalled,onStart,lanes}){
2063
2604
  // mount-on-open; keep mounted through the panel-reveal slide-out, then unmount
2064
2605
  const[render,setRender]=useState(open);
@@ -2067,6 +2608,11 @@
2067
2608
  const[modeOpen,setModeOpen]=useState(false);
2068
2609
  const[statusIx,setStatusIx]=useState(0);
2069
2610
  const modeRef=useRef(null);
2611
+ // The results reveal should play once per scan, not on every tab switch.
2612
+ // This persists across RefineResults remounts; reset it when a new scan
2613
+ // starts (or the panel returns to idle) so fresh results animate again.
2614
+ const revealedRef=useRef(false);
2615
+ useEffect(()=>{ if(phase==="scanning"||phase==="idle") revealedRef.current=false; },[phase]);
2070
2616
  useEffect(()=>{
2071
2617
  let raf, to;
2072
2618
  if(open){
@@ -2107,14 +2653,11 @@
2107
2653
  // null (unknown / still probing) is treated as ready so the panel doesn't
2108
2654
  // flash the "unavailable" copy before /health resolves.
2109
2655
  const agentReady = !agentMode || llmAvailable!==false;
2110
- const typeDesc = (REFINE_TYPES.find(t=>t.key===refineType)||REFINE_TYPES[0]).desc;
2111
2656
  const modeLabel = (REFINE_MODES.find(m=>m.key===mode)||REFINE_MODES[0]).label;
2112
- // Idle empty-state copy is mode-aware: Agent (llm) keeps the type's
2113
- // agent-oriented desc; Deterministic has no agent, so it describes the
2114
- // math-snap behavior (and that "replace" needs Agent mode).
2115
- const idleDesc = agentMode ? typeDesc : (refineType==="replace"
2116
- ? "Deterministic mode can't pick a recipe to replace the transition — switch to Agent mode for replacements."
2117
- : "Deterministic mode snaps your transition's timing to the nearest Transitions.dev motion tokens — mathematically, with no agent or credits.");
2657
+ // Idle empty-state copy is mode-aware (computed per type in bodyFor):
2658
+ // Agent (llm) keeps the type's agent-oriented desc; Deterministic has no
2659
+ // agent, so it describes the math-snap behavior (and that "replace" needs
2660
+ // Agent mode).
2118
2661
  // One persistent control for the whole foot: in idle/done/error it's the
2119
2662
  // pill button; while scanning it carries `.is-scanning` and the same DOM
2120
2663
  // node morphs (card resize) into the loading rectangle with the border-beam.
@@ -2135,25 +2678,58 @@
2135
2678
  h(ShimmerSwapText,{text:REFINE_STATUS[statusIx]}))));
2136
2679
  };
2137
2680
 
2138
- let body, foot, footBar=false;
2681
+ // Body content for a given refine type. Both type tabs render their own
2682
+ // body so they can cross-slide (page-side-by-side). Phase is global, so
2683
+ // only the type-specific bits (filtered suggestions, empty/idle copy)
2684
+ // change per page. `isActive` tells the results list whether to play its
2685
+ // reveal (only the visible page does).
2686
+ const bodyFor=(rt,isActive)=>{
2687
+ const vis = suggestions.filter(s=> rt==="replace" ? s.kind==="replace" : s.kind!=="replace");
2688
+ if(phase==="scanning"){
2689
+ return h("div",{className:"tl-refine-center"});
2690
+ }
2691
+ if(phase==="error"){
2692
+ return h("div",{className:"tl-refine-center"},
2693
+ h("div",{className:"tl-refine-unavail-title"},"Something went wrong."),
2694
+ h("div",{className:"tl-refine-unavail-text"},error||"The agent reported an error."));
2695
+ }
2696
+ if(phase==="done"){
2697
+ if(vis.length===0){
2698
+ const emptyMsg = rt==="replace"
2699
+ ? "I didn't find any transition that would be a good fit as a replacement."
2700
+ : "Already aligned to the transitions.dev motion tokens. Nothing to refine.";
2701
+ return h("div",{className:"tl-refine-center"},
2702
+ h("p",{className:"tl-refine-idle-text"},emptyMsg));
2703
+ }
2704
+ return h(RefineResults,{suggestions:vis,appliedIds,onApply,lanes:lanes||[],revealedRef,active:isActive});
2705
+ }
2706
+ // idle
2707
+ if(!agentReady){
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.
2711
+ return h("div",{className:"tl-refine-center"},
2712
+ h("div",{className:"tl-refine-unavail-title"},"Connect with your agent"),
2713
+ h(AgentConnectBody,{cliMissing:cliInstalled===false}));
2714
+ }
2715
+ const idleDescR = agentMode ? (REFINE_TYPES.find(t=>t.key===rt)||REFINE_TYPES[0]).desc : (rt==="replace"
2716
+ ? "Deterministic mode can't pick a recipe to replace the transition — switch to Agent mode for replacements."
2717
+ : "Deterministic mode snaps your transition's timing to the nearest Transitions.dev motion tokens — mathematically, with no agent or credits.");
2718
+ return h("div",{className:"tl-refine-center"},
2719
+ h("p",{className:"tl-refine-idle-text"},idleDescR));
2720
+ };
2721
+
2722
+ // Foot tracks the active type (it lives below the sliding body, so it
2723
+ // just updates in place on a tab switch).
2724
+ let foot, footBar=false;
2139
2725
  if(phase==="scanning"){
2140
- body = h("div",{className:"tl-refine-center"});
2141
2726
  foot = startBtn();
2142
2727
  } else if(phase==="error"){
2143
- body = h("div",{className:"tl-refine-center"},
2144
- h("div",{className:"tl-refine-unavail-title"},"Something went wrong."),
2145
- h("div",{className:"tl-refine-unavail-text"},error||"The agent reported an error."));
2146
2728
  foot = startBtn({label:"Try again"});
2147
2729
  } else if(phase==="done"){
2148
2730
  if(visible.length===0){
2149
- const emptyMsg = refineType==="replace"
2150
- ? "I didn't find any transition that would be a good fit as a replacement."
2151
- : "Already aligned to the transitions.dev motion tokens. Nothing to refine.";
2152
- body = h("div",{className:"tl-refine-center"},
2153
- h("p",{className:"tl-refine-idle-text"},emptyMsg));
2154
2731
  foot = startBtn({label:"Scan again"});
2155
2732
  } else {
2156
- body = h(RefineResults,{suggestions:visible,appliedIds,onApply,lanes:lanes||[]});
2157
2733
  footBar = true;
2158
2734
  foot = h(React.Fragment,null,
2159
2735
  h("button",{className:"tl-pill-btn",disabled:pending.length===0,
@@ -2162,19 +2738,7 @@
2162
2738
  h("button",{className:"tl-pill-btn",onClick:onStart},"Scan again"));
2163
2739
  }
2164
2740
  } else { // idle
2165
- if(!agentReady){
2166
- const cliMissing = cliInstalled===false;
2167
- body = h("div",{className:"tl-refine-center"},
2168
- h("div",{className:"tl-refine-unavail-title"},"Agent unavailable."),
2169
- cliMissing
2170
- ? 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.")
2171
- : h("p",{className:"tl-refine-unavail-text"},"Run the ",h("code",{className:"tl-code"},"/refine live")," command in your agent to enable live functionality."));
2172
- foot = startBtn({disabled:true});
2173
- } else {
2174
- body = h("div",{className:"tl-refine-center"},
2175
- h("p",{className:"tl-refine-idle-text"},idleDesc));
2176
- foot = startBtn({});
2177
- }
2741
+ foot = startBtn(agentReady?{}:{disabled:true});
2178
2742
  }
2179
2743
 
2180
2744
  return h("div",{className:"tl-refine-panel","data-open":panelOpen?"true":"false","data-phase":slidePhase,
@@ -2202,7 +2766,11 @@
2202
2766
  REFINE_TYPES.map(t=>h("button",{key:t.key,className:"tl-refine-tab",role:"tab",
2203
2767
  "aria-selected":refineType===t.key?"true":"false",
2204
2768
  onClick:()=>{if(t.key!==refineType)onType(t.key);}},t.label))),
2205
- h("div",{className:"tl-refine-body"},body),
2769
+ h("div",{className:"tl-refine-body tl-refine-pages t-page-slide","data-page":refineType==="replace"?"2":"1"},
2770
+ h("div",{className:"tl-refine-page t-page","data-page-id":"1","aria-hidden":refineType!=="small"},
2771
+ bodyFor("small",refineType==="small")),
2772
+ h("div",{className:"tl-refine-page t-page","data-page-id":"2","aria-hidden":refineType!=="replace"},
2773
+ bodyFor("replace",refineType==="replace"))),
2206
2774
  h("div",{className:cx("tl-refine-foot",footBar&&"is-bar")},foot)));
2207
2775
  }
2208
2776
 
@@ -2210,26 +2778,50 @@
2210
2778
  // originally-scanned values — the set of changes Accept writes to source.
2211
2779
  // Each change carries member/selector/phase context so the agent can target
2212
2780
  // the right state rule (e.g. .is-open vs .is-closing) for a grouped phase.
2213
- function computeChanges(active){
2781
+ function computeChanges(active,allEntries){
2214
2782
  if(!active)return[];
2215
- const base=active.baseLanes||[];
2216
- 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
+ }
2217
2792
  const out=[];
2218
- for(const t of (active.effectiveTimings||[])){
2219
- const b=bmap.get(t.laneId);if(!b)continue;
2220
- if(t.durationMs!==b.durationMs||t.delayMs!==b.delayMs||(t.easing||"")!==(b.easing||"")){
2221
- out.push({property:t.property,member:t.member||null,selector:t.selector||null,
2222
- from:{durationMs:b.durationMs,delayMs:b.delayMs,easing:b.easing},
2223
- 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
+ }
2224
2813
  }
2225
2814
  }
2226
2815
  return out;
2227
2816
  }
2228
2817
 
2229
- 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}){
2230
2819
  const[setg,setSetg]=useState(false);
2231
2820
  // settings dropdown sub-page (main menu ↔ keyboard-shortcuts cheat sheet)
2232
2821
  const[settingsView,setSettingsView]=useState("main"); // "main" | "shortcuts"
2822
+ // theme preference — UI only for now (dark theme not wired up yet)
2823
+ const[themePref,setThemePref]=useState("system"); // "light" | "dark" | "system"
2824
+ const THEME_OPTS=[{key:"light",label:"Light"},{key:"dark",label:"Dark"},{key:"system",label:"System"}];
2233
2825
  const isMac=typeof navigator!=="undefined"&&/mac/i.test((navigator.userAgentData&&navigator.userAgentData.platform)||navigator.platform||navigator.userAgent||"");
2234
2826
  const modKey=isMac?"Cmd":"Ctrl";
2235
2827
  // small gray shortcut hint appended inside a tooltip (e.g. "⌘A")
@@ -2276,7 +2868,7 @@
2276
2868
  return h("div",{className:"tl-header"},
2277
2869
  h("span",{className:"tl-header-label"},"Selected"),
2278
2870
  h("button",{ref:pickRef,className:cx("tl-ghost-btn",pick&&"is-active"),disabled:!active,onClick:()=>setPick(v=>!v)},
2279
- 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"))
2280
2872
  :h("span",{className:"tl-dim"},"None"),
2281
2873
  h("span",{className:"tl-ghost-chev"},h(Ic,{name:"chevron"}))),
2282
2874
  h(Dropdown,{open:pick,onClose:()=>setPick(false),triggerRef:pickRef,width:Math.max(240,(pickRef.current&&pickRef.current.offsetWidth)||240),align:"left"},
@@ -2292,7 +2884,6 @@
2292
2884
  ...fGroups.map(g=>h(React.Fragment,{key:g.id},
2293
2885
  h("div",{className:"tl-menu-section"},g.label),
2294
2886
  ...g.phases.map(phaseItem))),
2295
- fFlat.length>0&&fGroups.length>0&&h("div",{className:"tl-menu-section"},"Ungrouped"),
2296
2887
  ...fFlat.map(flatItem))),
2297
2888
  h("span",{className:"tl-header-count"},
2298
2889
  scanning
@@ -2302,29 +2893,70 @@
2302
2893
  h("span",{className:"t-shimmer tl-scan-shimmer","data-text":"Agent scanning transitions…"},"Agent scanning transitions…")),
2303
2894
  h("span",{className:"t-tt tl-tt-below tl-tt-msg",role:"tooltip"},
2304
2895
  "Agent is working on understanding your transitions and creating grouping and naming. This usually takes a few seconds to a minute."))
2305
- : 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,
2306
2923
  h("button",{ref:gearRef,className:cx("tl-icon-btn",setg&&"is-active"),title:"Settings",onClick:()=>setSetg(v=>!v)},h(Ic,{name:"dotsv"})),
2307
2924
  h(Dropdown,{open:setg,onClose:()=>{setSetg(false);setSettingsView("main");},triggerRef:gearRef,width:240,align:"right"},
2308
2925
  h(SettingsSlide,{view:settingsView,
2309
2926
  onBack:()=>setSettingsView("main"),
2310
2927
  onClose:()=>{setSetg(false);setSettingsView("main");},
2311
- // ── main settings page ──
2928
+ // ── main settings page (Figma 580:4723) ──
2312
2929
  mainPage:h(React.Fragment,null,
2313
- h("div",{className:"tl-menu-head"},
2314
- h("span",null,"Transitions.dev ",h("span",{className:"tl-menu-head-name"},"Refine")),
2315
- h("span",{className:"tl-menu-head-ver"},REFINE_VERSION)),
2316
- h("div",{className:"tl-menu-divider"}),
2317
2930
  h(MenuItem,{disabled:!active,onClick:()=>{onCopy&&onCopy();setSetg(false);},
2318
2931
  left:h("span",{className:"tl-menu-icon"},h(Ic,{name:"copy"})),
2319
2932
  right:copied&&h("span",{className:"tl-menu-check"},h(Ic,{name:"accept"}))},copied?"Copied":"Copy values"),
2320
2933
  h(MenuItem,{disabled:scanning,onClick:()=>{setSetg(false);onRescan&&onRescan();},
2321
2934
  left:h("span",{className:"tl-menu-icon"},h(Ic,{name:"restart"}))},
2322
2935
  scanning?"Rescanning…":"Rescan transitions"),
2936
+ h("div",{className:"tl-menu-divider"}),
2937
+ // theme picker row — shows the current value + chevron; opens the sub-page
2938
+ h(MenuItem,{onClick:()=>setSettingsView("theme"),
2939
+ right:h("span",{className:"tl-menu-trail"},
2940
+ h("span",{className:"tl-menu-value"},(THEME_OPTS.find(o=>o.key===themePref)||{}).label),
2941
+ h("span",{className:"tl-menu-chevr"},h(Ic,{name:"chevronr",size:16})))},"Theme"),
2323
2942
  h(MenuItem,{onClick:()=>setSettingsView("shortcuts"),
2324
2943
  right:h("span",{className:"tl-menu-chevr"},h(Ic,{name:"chevronr",size:16}))},"Keyboard shortcuts"),
2325
- h("div",{className:"tl-menu-divider"}),
2326
2944
  h(MenuItem,{onClick:()=>{setSetg(false);window.open("https://transitions.dev","_blank","noopener");},
2327
- right:h("span",{className:"tl-menu-chevr"},h(Ic,{name:"chevronr",size:16}))},"Learn more")),
2945
+ right:h("span",{className:"tl-menu-chevr"},h(Ic,{name:"linkout",size:16}))},"Send feedback"),
2946
+ h(MenuItem,{onClick:()=>{setSetg(false);window.open("https://transitions.dev","_blank","noopener");},
2947
+ right:h("span",{className:"tl-menu-chevr"},h(Ic,{name:"linkout",size:16}))},"Learn more"),
2948
+ h("div",{className:"tl-menu-divider"}),
2949
+ h("div",{className:"tl-menu-head"},
2950
+ h("span",null,"Transitions.dev ",h("span",{className:"tl-menu-head-name"},"Refine")),
2951
+ h("span",{className:"tl-menu-head-ver"},REFINE_VERSION))),
2952
+ // ── theme sub-page (Light / Dark / System — System default) ──
2953
+ themePage:h(React.Fragment,null,
2954
+ h("button",{type:"button",className:"tl-set-back",onClick:()=>setSettingsView("main")},
2955
+ h("span",{className:"tl-set-back-ic"},h(Ic,{name:"chevronr",size:16})),
2956
+ h("span",{className:"tl-set-back-title"},"Theme")),
2957
+ h("div",{className:"tl-menu-divider"}),
2958
+ ...THEME_OPTS.map(o=>h(MenuItem,{key:o.key,onClick:()=>{setThemePref(o.key);setSettingsView("main");},
2959
+ right:themePref===o.key&&h("span",{className:"tl-menu-check"},h(Ic,{name:"accept"}))},o.label))),
2328
2960
  // ── keyboard-shortcuts sub-page ──
2329
2961
  shortcutsPage:h(React.Fragment,null,
2330
2962
  h("button",{type:"button",className:"tl-set-back",onClick:()=>setSettingsView("main")},
@@ -2347,14 +2979,14 @@
2347
2979
  acceptState==="saving"
2348
2980
  ? h("span",{className:"tl-accept-spin","aria-hidden":"true"})
2349
2981
  : h(Ic,{name:"accept"}),
2350
- h("span",null,acceptState==="done"?"Done":"Accept")),
2982
+ h("span",{className:"tl-btn-label"},acceptState==="done"?"Done":"Accept")),
2351
2983
  h("span",{className:cx("t-tt","tl-tt-below",acceptState==="error"&&acceptError&&"tl-tt-msg"),role:"tooltip"},
2352
2984
  acceptState==="error"&&acceptError?acceptError
2353
2985
  :acceptState==="done"?"Saved to your code"
2354
2986
  :acceptDisabled?"No changes to save"
2355
2987
  :h(React.Fragment,null,"Save changes to your codebase",ttHint("A")))),
2356
2988
  h("span",{className:"t-tt-wrap"},
2357
- 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"},
2358
2990
  h(Ic,{name:"wand"}),
2359
2991
  h("span",{className:"tl-refine-sparks","aria-hidden":"true"},
2360
2992
  h("i",{style:{"--ox":"-1px","--oy":"-3px","--sx":"0px","--sy":"-11px","--sd":"900ms","--sdelay":"0ms"}}),
@@ -2367,39 +2999,58 @@
2367
2999
  h("i",{style:{"--ox":"2px","--oy":"5px","--sx":"9px","--sy":"9px","--sd":"1000ms","--sdelay":"760ms"}}),
2368
3000
  h("i",{style:{"--ox":"-5px","--oy":"-2px","--sx":"-9px","--sy":"-9px","--sd":"930ms","--sdelay":"180ms"}}),
2369
3001
  h("i",{style:{"--ox":"3px","--oy":"1px","--sx":"12px","--sy":"1px","--sd":"980ms","--sdelay":"300ms"}})),
2370
- h("span",null,"Refine")),
3002
+ h("span",{className:"tl-btn-label"},"Refine")),
2371
3003
  h("span",{className:"t-tt tl-tt-below",role:"tooltip"},"Refine",ttHint("R"))),
2372
3004
  h("span",{className:"t-tt-wrap"},
2373
3005
  h("button",{className:"tl-icon-btn ghost t-tt-trigger","aria-label":"Minimize",onClick:onMinimize},h(Ic,{name:"minimize"})),
2374
- h("span",{className:"t-tt tl-tt-below",role:"tooltip"},"Minimize",ttHint("."))),
3006
+ h("span",{className:"t-tt tl-tt-below tl-tt-right",role:"tooltip"},"Minimize",ttHint("."))),
2375
3007
  );
2376
3008
  }
2377
3009
 
2378
3010
  function PropTrack({property, member, delayMs, durationMs, selected, onSelect, onDelayChange, onDurationChange, snap, scaleMs, lockDuration, labelW}){
2379
3011
  const trackRef=useRef(null);
2380
- const grid = snap ? 25 : 1;
2381
- const pxToMs=useCallback(px=>{if(!trackRef.current)return 0;const w=trackRef.current.getBoundingClientRect().width;return Math.round((px/w)*scaleMs/grid)*grid;},[grid,scaleMs]);
3012
+ const [dragging,setDragging]=useState(false);
3013
+ // 1ms resolution while dragging so the bar moves smoothly; the snap grid
3014
+ // (25ms) is applied only on release when `snap` is on.
3015
+ const SNAP_GRID = 25;
3016
+ const pxToMs=useCallback((px,g)=>{if(!trackRef.current)return 0;const w=trackRef.current.getBoundingClientRect().width;const gg=g||1;return Math.round((px/w)*scaleMs/gg)*gg;},[scaleMs]);
2382
3017
 
2383
3018
  const startDrag=useCallback((mode,e)=>{
2384
3019
  e.preventDefault(); e.stopPropagation();
2385
3020
  onSelect();
2386
3021
  // a spring controls its own duration, so its bar can be moved (delay) but not resized
2387
3022
  if(lockDuration && mode!=="move") return;
3023
+ setDragging(true);
2388
3024
  const startX=e.clientX; const sd=delayMs; const sdur=durationMs;
3025
+ let lastDel=sd, lastDur=sdur;
2389
3026
  const onMove=e2=>{
2390
- const dx=e2.clientX-startX; const dMs=pxToMs(dx);
3027
+ const dx=e2.clientX-startX; const dMs=pxToMs(dx,1); // smooth: 1ms steps
2391
3028
  if(mode==="move"){
2392
- onDelayChange(Math.max(0,Math.min(sd+dMs,scaleMs-sdur)));
3029
+ lastDel=Math.max(0,Math.min(sd+dMs,scaleMs-sdur));
3030
+ onDelayChange(lastDel);
2393
3031
  } else if(mode==="left"){
2394
- const rawDel=sd+dMs; const clampedDel=Math.max(0,Math.min(rawDel,sd+sdur-grid));
2395
- onDelayChange(clampedDel); onDurationChange(sdur-(clampedDel-sd));
3032
+ const rawDel=sd+dMs; const clampedDel=Math.max(0,Math.min(rawDel,sd+sdur-1));
3033
+ lastDel=clampedDel; lastDur=sdur-(clampedDel-sd);
3034
+ onDelayChange(lastDel); onDurationChange(lastDur);
2396
3035
  } else {
2397
- onDurationChange(Math.max(grid,Math.min(sdur+dMs,scaleMs-sd)));
3036
+ lastDur=Math.max(1,Math.min(sdur+dMs,scaleMs-sd));
3037
+ onDurationChange(lastDur);
3038
+ }
3039
+ };
3040
+ const onUp=()=>{
3041
+ setDragging(false);
3042
+ // snap to the grid once, on release (bar tweens there since the
3043
+ // is-dragging transition lock is now removed).
3044
+ if(snap){
3045
+ const g=SNAP_GRID, sn=v=>Math.round(v/g)*g;
3046
+ if(mode==="move"){ onDelayChange(Math.max(0,Math.min(sn(lastDel),scaleMs-sdur))); }
3047
+ else if(mode==="left"){ onDelayChange(Math.max(0,sn(lastDel))); onDurationChange(Math.max(g,sn(lastDur))); }
3048
+ else { onDurationChange(Math.max(g,Math.min(sn(lastDur),scaleMs-sd))); }
2398
3049
  }
3050
+ window.removeEventListener("mousemove",onMove);window.removeEventListener("mouseup",onUp);
2399
3051
  };
2400
- const onUp=()=>{window.removeEventListener("mousemove",onMove);window.removeEventListener("mouseup",onUp);};
2401
3052
  window.addEventListener("mousemove",onMove);window.addEventListener("mouseup",onUp);
2402
- },[delayMs,durationMs,pxToMs,grid,scaleMs,onSelect,onDelayChange,onDurationChange,lockDuration]);
3053
+ },[delayMs,durationMs,pxToMs,snap,scaleMs,onSelect,onDelayChange,onDurationChange,lockDuration]);
2403
3054
 
2404
3055
  const delPct=(delayMs/scaleMs)*100; const durPct=(durationMs/scaleMs)*100;
2405
3056
  return h("div",{className:cx("tl-prop-row",selected&&"selected",lockDuration&&"is-spring"),onClick:onSelect},
@@ -2408,7 +3059,7 @@
2408
3059
  member&&h("span",{className:"tl-prop-member"},member),
2409
3060
  h("span",{className:"tl-prop-prop"},property))),
2410
3061
  h("div",{className:"tl-prop-track",ref:trackRef},
2411
- h("div",{className:"tl-bar",style:{left:delPct+"%",width:durPct+"%"},onMouseDown:e=>startDrag("move",e),
3062
+ h("div",{className:cx("tl-bar",dragging&&"is-dragging"),style:{left:delPct+"%",width:durPct+"%"},onMouseDown:e=>startDrag("move",e),
2412
3063
  title:lockDuration?"Spring duration is derived \u2014 drag to move, resize is locked":undefined},
2413
3064
  h("span",{className:"tl-bar-handle left"}),
2414
3065
  h("span",{className:"tl-bar-handle right"}),
@@ -2428,7 +3079,8 @@
2428
3079
  const[dragging,setDragging]=useState(false);
2429
3080
  const[menu,setMenu]=useState(false);
2430
3081
  const pct = Math.min(Math.max((value - min) / (max - min), 0), 1) * 100;
2431
- const grid = snap ? step : Math.max(1, Math.round(step/5));
3082
+ // fine grid while dragging (smooth); the snap `step` is applied on release.
3083
+ const dragGrid = Math.max(1, Math.round(step/5));
2432
3084
  const selectAll = useCallback(e=>{
2433
3085
  const el = e && e.target ? e.target : inputRef.current;
2434
3086
  if(!el) return;
@@ -2436,25 +3088,27 @@
2436
3088
  requestAnimationFrame(()=>el.select());
2437
3089
  },[]);
2438
3090
 
2439
- const setFromX = useCallback(clientX => {
3091
+ const setFromX = useCallback((clientX, g) => {
2440
3092
  if(!trackRef.current) return;
2441
3093
  const rect = trackRef.current.getBoundingClientRect();
2442
3094
  const ratio = Math.max(0, Math.min((clientX - rect.left) / rect.width, 1));
2443
3095
  const raw = min + ratio * (max - min);
2444
- const snapped = Math.round(raw / grid) * grid;
3096
+ const gg = g || 1;
3097
+ const snapped = Math.round(raw / gg) * gg;
2445
3098
  onChange(Math.max(min, Math.min(max, snapped)));
2446
- },[min,max,grid,onChange]);
3099
+ },[min,max,onChange]);
2447
3100
 
2448
3101
  const startDrag = useCallback(e=>{
2449
3102
  if(editing || readOnly) return;
2450
3103
  e.preventDefault();
2451
3104
  setDragging(true);
2452
- setFromX(e.clientX);
2453
- const onMove = e2 => setFromX(e2.clientX);
2454
- const onUp = () => { setDragging(false); window.removeEventListener("mousemove",onMove); window.removeEventListener("mouseup",onUp); };
3105
+ let lastX = e.clientX;
3106
+ setFromX(e.clientX, dragGrid); // smooth fine steps during drag
3107
+ const onMove = e2 => { lastX = e2.clientX; setFromX(e2.clientX, dragGrid); };
3108
+ const onUp = () => { setDragging(false); if(snap) setFromX(lastX, step); window.removeEventListener("mousemove",onMove); window.removeEventListener("mouseup",onUp); };
2455
3109
  window.addEventListener("mousemove",onMove);
2456
3110
  window.addEventListener("mouseup",onUp);
2457
- },[setFromX,editing]);
3111
+ },[setFromX,editing,readOnly,snap,step,dragGrid]);
2458
3112
 
2459
3113
  const beginEdit = useCallback(e=>{ e.stopPropagation(); if(readOnly) return; setDraft(String(value)); setEditing(true); },[value,readOnly]);
2460
3114
  const commit = useCallback(()=>{ const n=parseFloat(draft); if(!isNaN(n)) onChange(Math.max(min,Math.min(max,Math.round(n)))); setEditing(false); },[draft,min,max,onChange]);
@@ -3016,18 +3670,85 @@
3016
3670
  const[acceptError,setAcceptError]=useState(null);
3017
3671
  // ── grouped scan (agent reads source → Open/Close phases) ──
3018
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);
3019
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;
3020
3696
  const[refineLabel,setRefineLabel]=useState(null);
3021
3697
  const[appliedIds,setAppliedIds]=useState({});
3022
3698
  const[refineMode,setRefineMode]=useState("llm"); // llm (Agent) | deterministic
3023
3699
  const[refineType,setRefineType]=useState("small"); // small | replace
3024
3700
  const[llmAvailable,setLlmAvailable]=useState(null); // null=unknown, true/false from /health
3025
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);
3026
3721
  // probe the relay so the idle panel can show the right availability copy
3027
3722
  const refreshHealth=useCallback(async()=>{
3028
3723
  try{const j=await relayHealth();setLlmAvailable(!!j.llmAvailable);
3029
- setCliInstalled(j.cliInstalled==null?null:!!j.cliInstalled);return j;}
3030
- 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{}
3031
3752
  },[]);
3032
3753
  // a live agent = the relay reports an agent it can drive (REFINE_AGENT_CMD
3033
3754
  // wired OR a `/refine live` loop polling). Until then the panel is gated.
@@ -3051,35 +3772,91 @@
3051
3772
  refreshHealth();
3052
3773
  },[active,refineOpen,refreshHealth]);
3053
3774
  // "Start scanning" resolves availability first, then posts the job.
3054
- const startScan=useCallback(async()=>{
3775
+ // per-(transition,type) result cache so switching tabs reuses a prior scan
3776
+ // instead of re-asking the agent. In llm mode each scan is scoped to the
3777
+ // active tab (Small = token tweaks, Replace = recipe) so the common Small
3778
+ // view skips the recipe selection + reference-file read. Deterministic stays
3779
+ // a single instant "both" pass that feeds both tabs.
3780
+ const refineCacheRef=useRef(new Map()); // `${id}::${type}` -> {suggestions,summary}
3781
+ const refineScanTypeRef=useRef("small"); // which type the in-flight job targets
3782
+ // results are not comparable across transitions — drop the cache when the
3783
+ // target transition changes.
3784
+ useEffect(()=>{refineCacheRef.current.clear();},[active&&active.id]);
3785
+ // recompute the displayed suggestions from whatever types are cached for a
3786
+ // transition (the panel filters by kind per tab, so a merged array is fine).
3787
+ const applyMerged=useCallback((id)=>{
3788
+ const c=refineCacheRef.current;
3789
+ const both=c.get(id+"::both");
3790
+ if(both){setRefineSuggestions(both.suggestions||[]);setRefineSummary(both.summary||null);return;}
3791
+ const small=c.get(id+"::small"), repl=c.get(id+"::replace");
3792
+ setRefineSuggestions([...(small?small.suggestions:[]),...(repl?repl.suggestions:[])]);
3793
+ setRefineSummary((small&&small.summary)||(repl&&repl.summary)||null);
3794
+ },[]);
3795
+ const startScan=useCallback(async(typeArg,opts)=>{
3055
3796
  if(!active)return;
3056
- scanStartedAtRef.current=Date.now();
3057
- setRefinePhase("scanning");setRefineLog([]);
3058
- setRefineSuggestions([]);setRefineSummary(null);setRefineError(null);
3059
- setAppliedIds({});setRefineLabel(active.label);setRefineJobId(null);
3060
- const j=await refreshHealth();
3061
- const avail=j?!!j.llmAvailable:false;
3797
+ opts=opts||{};
3798
+ // Resolve availability from the continuously-polled /health (2s cadence)
3799
+ // rather than a serial probe on the click path. Unknown (null) counts as
3800
+ // available so the first click isn't blocked; a dead relay surfaces via
3801
+ // the POST below (→ error state).
3802
+ const avail=llmAvailable!==false;
3062
3803
  const mode=(refineMode==="llm"&&!avail)?"deterministic":refineMode;
3063
- scanModeRef.current=mode;
3064
3804
  if(mode!==refineMode)setRefineMode(mode);
3805
+ scanModeRef.current=mode;
3806
+ // deterministic → one "both" pass (instant, free). llm → scope to the
3807
+ // active tab so Small skips recipe work; Replace asks for it on demand.
3808
+ // NOTE: this is also wired as a button onClick, so typeArg may be a click
3809
+ // event — only honour it when it's an explicit "small"/"replace" string.
3810
+ const reqType=(typeArg==="small"||typeArg==="replace")?typeArg:refineType;
3811
+ const type=mode==="deterministic"?"both":reqType;
3812
+ refineScanTypeRef.current=type;
3813
+ scanStartedAtRef.current=Date.now();
3814
+ setRefinePhase("scanning");setRefineLog([]);setRefineError(null);
3815
+ setRefineLabel(active.label);setRefineJobId(null);
3816
+ if(!opts.keepApplied)setAppliedIds({});
3817
+ // re-scan only this type; keep the other tab's cached result for the merge.
3818
+ refineCacheRef.current.delete(active.id+"::"+type);
3065
3819
  try{
3066
3820
  const et=active.effectiveTimings||[];
3067
- 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);
3068
3826
  const selector=(active.bindings&&active.bindings.selector)||(et[0]&&et[0].selector)||null;
3069
- // always scan for BOTH kinds (token tweaks + a whole-transition replacement)
3070
- // in one pass; the tabs just filter which kind they show.
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
+ }
3071
3836
  const{id}=await relayCreateJob({transitionId:active.id,label:active.label,selector,
3072
- phase:active.phase||null,group:active.groupLabel||null,timings,mode,refineType:"both"});
3837
+ phase:active.phase||null,group:active.groupLabel||null,timings,mode,refineType:type,phases});
3073
3838
  setRefineJobId(id);
3074
3839
  }catch(e){
3075
3840
  setRefinePhase("error");
3076
3841
  setRefineError(h(React.Fragment,null,"Couldn't reach the refine relay. Start it with ",h("code",{className:"tl-code"},"npx transitions-refine live"),"."));
3077
3842
  }
3078
- },[active,refineMode,refineType,refreshHealth]);
3079
- const changeRefineMode=useCallback((mode)=>{setRefineMode(mode);setRefinePhase("idle");refreshHealth();},[refreshHealth]);
3080
- // switching tabs only changes which kind of suggestion is shown it never
3081
- // interrupts a scan or discards results, since one scan covers both kinds.
3082
- const changeRefineType=useCallback((t)=>{setRefineType(t);},[]);
3843
+ },[active,refineMode,refineType,llmAvailable,entries]);
3844
+ const changeRefineMode=useCallback((mode)=>{setRefineMode(mode);setRefinePhase("idle");refineCacheRef.current.clear();refreshHealth();},[refreshHealth]);
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.
3850
+ const changeRefineType=useCallback((t)=>{
3851
+ setRefineType(t);
3852
+ if(!active)return;
3853
+ if(scanModeRef.current==="deterministic")return;
3854
+ if(refinePhase!=="done"&&refinePhase!=="scanning")return;
3855
+ if(refineCacheRef.current.has(active.id+"::"+t)){applyMerged(active.id);setRefinePhase("done");return;}
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]);
3083
3860
  // poll the relay while a job is running
3084
3861
  useEffect(()=>{
3085
3862
  if(!refineJobId||refinePhase!=="scanning")return;
@@ -3090,7 +3867,15 @@
3090
3867
  if(!live)return;
3091
3868
  if(Array.isArray(job.statusLog))setRefineLog(job.statusLog);
3092
3869
  if(job.status==="done"){
3093
- const finish=()=>{if(!live)return;setRefineSuggestions((job.result&&job.result.suggestions)||[]);setRefineSummary(job.result&&job.result.summary);setRefinePhase("done");};
3870
+ try{console.log("[refine] "+refineScanTypeRef.current+" job done in "+(Date.now()-scanStartedAtRef.current)+"ms");}catch{}
3871
+ const finish=()=>{if(!live)return;
3872
+ // cache this scan's result under its type, then show the merged set
3873
+ // (so the other tab's cached suggestions stay visible).
3874
+ const type=refineScanTypeRef.current;
3875
+ const id=active?active.id:"";
3876
+ refineCacheRef.current.set(id+"::"+type,{suggestions:(job.result&&job.result.suggestions)||[],summary:(job.result&&job.result.summary)||null});
3877
+ if(active)applyMerged(active.id);else{setRefineSuggestions((job.result&&job.result.suggestions)||[]);setRefineSummary(job.result&&job.result.summary);}
3878
+ setRefinePhase("done");};
3094
3879
  // deterministic mode floors the scanning phase at 2s so it doesn't flash by.
3095
3880
  const minMs=scanModeRef.current==="deterministic"?2000:0;
3096
3881
  const wait=Math.max(0,minMs-(Date.now()-scanStartedAtRef.current));
@@ -3098,27 +3883,50 @@
3098
3883
  return;
3099
3884
  }
3100
3885
  if(job.status==="error"){setRefineError(job.error||"The agent reported an error.");setRefinePhase("error");return;}
3101
- to=setTimeout(tick,500);
3886
+ to=setTimeout(tick,200);
3102
3887
  }catch(e){if(live){setRefineError("Lost connection to the relay.");setRefinePhase("error");}}
3103
3888
  };
3104
- to=setTimeout(tick,400);
3889
+ to=setTimeout(tick,200);
3105
3890
  return()=>{live=false;if(to)clearTimeout(to);};
3106
3891
  },[refineJobId,refinePhase]);
3107
3892
  const applySuggestion=useCallback((s)=>{
3108
- const p=s.patch||{};if(!p.property)return;
3109
- const o={};
3110
- if(p.durationMs!=null)o.durationMs=p.durationMs;
3111
- if(p.delayMs!=null)o.delayMs=p.delayMs;
3112
- if(p.easing!=null)o.easing=p.easing;
3113
- // map a property patch onto the matching lane(s) — for a grouped phase the
3114
- // same property can appear on several members, so apply to each.
3115
- const et=(active&&active.effectiveTimings)||[];
3116
- let lanes=p.property==="all"?et:et.filter(t=>t.property===p.property);
3117
- if(p.member)lanes=lanes.filter(t=>(t.member===p.member||t.memberId===p.member));
3118
- const ids=lanes.length?lanes.map(t=>t.laneId):[p.property];
3119
- 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
+ }
3120
3928
  setAppliedIds(prev=>({...prev,[s.id]:true}));
3121
- },[setPropOverride,active]);
3929
+ },[registry,active,entries]);
3122
3930
  const applyAllSuggestions=useCallback(()=>{
3123
3931
  for(const s of refineSuggestions){if(!appliedIds[s.id])applySuggestion(s);}
3124
3932
  },[refineSuggestions,appliedIds,applySuggestion]);
@@ -3134,8 +3942,13 @@
3134
3942
  useEffect(()=>{
3135
3943
  if(typeof document==="undefined")return;
3136
3944
  const applied=liveAppliedRef.current;
3137
- const desired=new Map(); // el → css
3138
- 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||{}}); } };
3139
3952
  for(const item of entries){
3140
3953
  const ov=registry.getPropOverrides(item.id);
3141
3954
  if(!ov||!Object.keys(ov).length)continue; // only edited transitions go live
@@ -3143,31 +3956,40 @@
3143
3956
  for(const m of (item.members||[])){
3144
3957
  if(!m.selector||!m.lanes||!m.lanes.length)continue;
3145
3958
  let els=[];try{els=Array.from(document.querySelectorAll(m.selector));}catch{}
3146
- add(els,transitionLanesToCss(m.lanes));
3959
+ add(els,transitionLanesToCss(m.lanes),lanesToVars(m.lanes));
3147
3960
  }
3148
3961
  }else{
3149
3962
  const lanes=item.effectiveTimings||[];
3150
3963
  if(!lanes.length)continue;
3151
3964
  const els=((item.bindings&&item.bindings.elements)||[]).map(w=>w.deref&&w.deref()).filter(Boolean);
3152
- add(els,transitionLanesToCss(lanes));
3965
+ add(els,transitionLanesToCss(lanes),lanesToVars(lanes));
3153
3966
  }
3154
3967
  }
3968
+ const varsKey=v=>Object.keys(v).sort().map(k=>k+":"+v[k]).join(";");
3155
3969
  // apply new / changed
3156
- for(const [el,css] of desired){
3970
+ for(const [el,d] of desired){
3971
+ const css=d.css,vars=d.vars||{},vk=varsKey(vars);
3157
3972
  const cur=applied.get(el);
3158
- if(cur&&cur.css===css)continue;
3973
+ if(cur&&cur.css===css&&cur.vk===vk)continue;
3159
3974
  if(!cur){
3160
3975
  const s=getComputedStyle(el);
3161
3976
  const z=zipTransitionLists((s.transitionProperty||"").split(","),(s.transitionDuration||"0s").split(","),(s.transitionDelay||"0s").split(","),splitCssValues(s.transitionTimingFunction||"ease"));
3162
3977
  _TX_LIVE_BASE.set(el,{z});
3163
- applied.set(el,{prevInline:el.style.transition||"",css});
3164
- }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
+ }
3165
3985
  try{el.style.transition=css;}catch{}
3986
+ for(const n of Object.keys(vars)){try{el.style.setProperty(n,vars[n]);}catch{}}
3166
3987
  }
3167
3988
  // revert elements that no longer have edits
3168
3989
  for(const [el,rec] of Array.from(applied)){
3169
3990
  if(desired.has(el))continue;
3170
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{}}
3171
3993
  _TX_LIVE_BASE.delete(el);
3172
3994
  applied.delete(el);
3173
3995
  }
@@ -3175,7 +3997,7 @@
3175
3997
  // restore everything on unmount
3176
3998
  useEffect(()=>()=>{
3177
3999
  const applied=liveAppliedRef.current;
3178
- 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); }
3179
4001
  applied.clear();
3180
4002
  },[]);
3181
4003
  const startResize=useCallback(e=>{
@@ -3227,11 +4049,20 @@
3227
4049
  }else legacyCopy();
3228
4050
  }catch(e){legacyCopy();}
3229
4051
  },[active,showToast]);
3230
- 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]);
3231
4062
  // Accept → send an "apply" job so the agent writes the edited timings into
3232
4063
  // the user's source, then reflect saving / done / error on the button.
3233
4064
  const onAccept=useCallback(async()=>{
3234
- const changes=computeChanges(active);
4065
+ const changes=computeChanges(active,entries);
3235
4066
  if(!active||!changes.length)return;
3236
4067
  setAcceptState("saving");setAcceptError(null);
3237
4068
  try{
@@ -3256,7 +4087,7 @@
3256
4087
  setAcceptState("error");
3257
4088
  setAcceptError("Couldn't reach the relay. Run: npx transitions-refine live");
3258
4089
  }
3259
- },[active]);
4090
+ },[active,entries]);
3260
4091
  // reset Accept feedback when switching transitions
3261
4092
  useEffect(()=>{setAcceptState("idle");setAcceptError(null);},[active&&active.id]);
3262
4093
  // while the agent is applying changes, surface the sticky loader toast
@@ -3286,8 +4117,15 @@
3286
4117
  },[GROUP_STORE_KEY]);
3287
4118
  // monotonic token so an unmount or a newer rescan supersedes an in-flight poll
3288
4119
  const scanTokenRef=useRef(0);
3289
- const runGroupScan=useCallback(async()=>{
4120
+ const runGroupScan=useCallback(async(opts)=>{
4121
+ opts=opts||{};
3290
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);
3291
4129
  setGroupScanState("scanning");
3292
4130
  // wait for the flat DOM scan to settle (count stable twice) so the agent
3293
4131
  // sees every transition, not a partial set discovered progressively.
@@ -3310,7 +4148,7 @@
3310
4148
  // fresh scan and is overwritten below.
3311
4149
  const sig=flatSig(flat);
3312
4150
  const cached=readGroupCache();
3313
- 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;}
3314
4152
  setGroupScanState("scanning");
3315
4153
  const raw=flat.map(e=>({label:e.label,selector:e.bindings&&e.bindings.selector,
3316
4154
  properties:e.properties,
@@ -3328,14 +4166,30 @@
3328
4166
  const job=await relayGetJob(id);
3329
4167
  if(job.status==="done"){
3330
4168
  const groups=(job.result&&job.result.groups)||[];
3331
- if(groups.length){registry.setGroups(groups);
4169
+ if(groups.length){registry.setGroups(groups);setScanned(true);
3332
4170
  try{localStorage.setItem(GROUP_STORE_KEY,JSON.stringify({groups,sig,ts:Date.now()}));}catch{}}
3333
- setGroupScanState("done");return;}
3334
- 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;
3335
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"));
3336
4190
  setGroupScanState("error");
3337
- }catch(e){ setGroupScanState("error"); /* relay down → stay flat, retry next open */ }
3338
- },[registry,GROUP_STORE_KEY,flatSig,readGroupCache]);
4191
+ }
4192
+ },[registry,GROUP_STORE_KEY,flatSig,readGroupCache,autoStopAfterScan]);
3339
4193
  // manual "Rescan transitions" — drop the cache AND clear the in-memory
3340
4194
  // groups first. Once groups claim every DOM transition the snapshot has no
3341
4195
  // "flat" entries left, so the settle loop above would otherwise spin forever
@@ -3343,20 +4197,28 @@
3343
4197
  // the flat entries the agent needs to re-group.
3344
4198
  const rescanTransitions=useCallback(()=>{
3345
4199
  try{localStorage.removeItem(GROUP_STORE_KEY);}catch{}
4200
+ groupScanRetryRef.current=0; // fresh manual scan → fresh retry budget
3346
4201
  registry.clearGroups();
3347
- runGroupScan();
4202
+ runGroupScan({skipSeed:true});
3348
4203
  },[runGroupScan,GROUP_STORE_KEY,registry]);
3349
4204
  // Gate the auto group-scan behind a live agent: the panel stays on the
3350
- // "Before we start" screen until `/refine live` (or a wired REFINE_AGENT_CMD)
3351
- // makes the relay report an agent. The first time we see a live agent we run
3352
- // the resolver once — it settles the flat scan, then re-applies cached groups
3353
- // (signature match, no agent) or kicks off a fresh agent scan. Rescan is
3354
- // 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.
3355
4210
  useEffect(()=>{
3356
- if(!live||didGroupScanRef.current)return;
4211
+ // A host/demo page can hardcode a grouping via window.__TX_SEED_GROUPS so
4212
+ // the panel shows it WITHOUT a live agent (demo.html bakes in the agent's
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.
4216
+ const seeded=typeof window!=="undefined"&&Array.isArray(window.__TX_SEED_GROUPS)&&window.__TX_SEED_GROUPS.length;
4217
+ const cachedExists=!!readGroupCache();
4218
+ if((!live&&!seeded&&!cachedExists)||didGroupScanRef.current)return;
3357
4219
  didGroupScanRef.current=true;
3358
4220
  runGroupScan();
3359
- },[live,runGroupScan]);
4221
+ },[live,runGroupScan,readGroupCache]);
3360
4222
  useEffect(()=>()=>{scanTokenRef.current++;},[]);
3361
4223
 
3362
4224
  // whole-component open/close uses the transitions.dev panel reveal:
@@ -3419,7 +4281,7 @@
3419
4281
  panelHotRef.current=hoveredRef.current||!!(el&&document.activeElement&&el.contains(document.activeElement));
3420
4282
  },[]);
3421
4283
  const kbdRef=useRef({});
3422
- kbdRef.current={entries,active,acceptState,acceptDisabled:computeChanges(active).length===0,minimized,
4284
+ kbdRef.current={entries,active,acceptState,acceptDisabled:computeChanges(active,entries).length===0,minimized,
3423
4285
  openRefine,resetOverrides,copyValues,onAccept,setActiveId,setMinimized,setPickOpen};
3424
4286
  useEffect(()=>{
3425
4287
  const onKey=e=>{
@@ -3473,9 +4335,12 @@
3473
4335
  window.addEventListener("keydown",onKey);
3474
4336
  return()=>window.removeEventListener("keydown",onKey);
3475
4337
  },[recomputeHot]);
3476
- const gate = !live
3477
- ? (llmAvailable===null ? "loading" : "blocked")
3478
- : "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");
3479
4344
  return h(React.Fragment,null,
3480
4345
  render&&h("div",{className:"t-panel-slide","data-timeline-panel":true,ref:panelRootRef,
3481
4346
  onMouseEnter:()=>{hoveredRef.current=true;},onMouseLeave:()=>{hoveredRef.current=false;},
@@ -3487,8 +4352,10 @@
3487
4352
  h("div",{className:"tl-panel-main"},
3488
4353
  h(Header,{entries,active,onSelect:setActiveId,onReset:resetOverrides,onCopy:copyValues,copied,
3489
4354
  snap,setSnap,onMinimize:()=>setMinimized(true),onRefine:openRefine,refineActive:refineOpen,
3490
- onAccept,acceptState,acceptDisabled:computeChanges(active).length===0,acceptError,
3491
- 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}),
3492
4359
  active
3493
4360
  ?h(Body,{entry:active,onPropChange:(prop,o)=>setPropOverride(prop,o),snap})
3494
4361
  :h("div",{className:"tl-empty"},"Select a transition to inspect and edit it")),
@@ -3500,10 +4367,8 @@
3500
4367
  : h("div",{className:"tl-panel-main"},
3501
4368
  gate==="blocked" && h("div",{className:"tl-gate"},
3502
4369
  h("div",{className:"tl-gate-col"},
3503
- h("div",{className:"tl-gate-title"},"Before we start"),
3504
- h("p",{className:"tl-gate-text"},
3505
- "Please run the ",h("code",{className:"tl-code"},"/refine live"),
3506
- " 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})))),
3507
4372
  toast&&createPortal(
3508
4373
  h("div",{className:"tl-toast-wrap","data-tl-ui":"","aria-live":"polite"},
3509
4374
  h("div",{className:cx("tl-toast",toast.closing&&"is-closing")},
@@ -3522,6 +4387,72 @@
3522
4387
  }
3523
4388
 
3524
4389
  // ── demo boxes ──
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
4396
+ // groups[] (the same structure TransitionRegistry.setGroups consumes). This is
4397
+ // below the inject CUT_MARKER, so it never ships in the injected/website build.
4398
+ const _mEO="cubic-bezier(0.22, 1, 0.36, 1)"; // smooth ease-out (open)
4399
+ const _mEM="cubic-bezier(0.34, 1.25, 0.64, 1)"; // morph ease (close settle)
4400
+ const _mpt=(property,durationMs,easing)=>({property,durationMs,delayMs:0,easing});
4401
+ const MORPH_SEED_GROUPS=[
4402
+ {id:"morph",label:"Plus \u2192 menu morph",component:null,phases:[
4403
+ {id:"morph:open",phase:"open",label:"Open",stateTarget:".t-morph",fromState:null,toState:'[data-open="true"]',members:[
4404
+ {id:"morph-container",label:"Container",selector:".t-morph",propertyTimings:[
4405
+ _mpt("width",250,_mEO),_mpt("height",250,_mEO),_mpt("border-radius",250,_mEO)]},
4406
+ {id:"morph-plus",label:"Plus button",selector:".t-morph .t-morph-plus",propertyTimings:[
4407
+ _mpt("opacity",200,_mEO),_mpt("transform",350,_mEO),_mpt("filter",200,_mEO)]},
4408
+ {id:"morph-menu",label:"Menu panel",selector:".t-morph .t-morph-menu",propertyTimings:[
4409
+ _mpt("opacity",200,_mEO),_mpt("transform",350,_mEO),_mpt("filter",200,_mEO)]}]},
4410
+ {id:"morph:close",phase:"close",label:"Close",stateTarget:".t-morph",fromState:'[data-open="true"]',toState:null,members:[
4411
+ {id:"morph-container",label:"Container",selector:".t-morph",propertyTimings:[
4412
+ _mpt("width",400,_mEM),_mpt("height",350,_mEM),_mpt("border-radius",350,_mEM)]},
4413
+ {id:"morph-plus",label:"Plus button",selector:".t-morph .t-morph-plus",propertyTimings:[
4414
+ _mpt("opacity",200,_mEO),_mpt("transform",350,_mEO),_mpt("filter",200,_mEO)]},
4415
+ {id:"morph-menu",label:"Menu panel",selector:".t-morph .t-morph-menu",propertyTimings:[
4416
+ _mpt("opacity",200,_mEO),_mpt("transform",350,_mEO),_mpt("filter",200,_mEO)]}]}]},
4417
+ {id:"morph-item",label:"Menu item hover",component:null,phases:[
4418
+ {id:"morph-item:hover",phase:"hover",label:"Hover",stateTarget:".t-morph-menu button.morph-item",fromState:null,toState:":hover",members:[
4419
+ {id:"morph-item-bg",label:"Menu item",selector:".t-morph-menu button.morph-item",propertyTimings:[
4420
+ _mpt("background",120,"ease")]}]}]},
4421
+ ];
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};
4455
+
3525
4456
  // Plus → menu morph — transitions.dev (20-plus-menu-morph.md). The trigger
3526
4457
  // button morphs into the panel it opens; CSS owns the morph, JS only flips
3527
4458
  // data-open and mirrors it to aria-expanded, closing on outside-click / Escape.
@@ -3553,6 +4484,100 @@
3553
4484
  h("path",{d:"M10 4.5v11M4.5 10h11",stroke:"currentColor","strokeWidth":1.8,"strokeLinecap":"round"}))))));
3554
4485
  }
3555
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
+
3556
4581
  // ── live controls for the whole-panel reveal transition (demo harness) ──
3557
4582
  // transitions.dev motion tokens (values lifted verbatim from the skill's _root.css)
3558
4583
  // transitions.dev easing motion tokens. `token` is the actual skill CSS var
@@ -3652,19 +4677,51 @@
3652
4677
  );
3653
4678
  }
3654
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
+ ];
3655
4687
  function App(){
3656
- const rootRef=useRef(null);const registry=useMemo(()=>new TransitionRegistry(),[]);const[activeId,setActiveId]=useState(null);
3657
- 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]);
3658
4699
  const ctx=useMemo(()=>({registry,activeId,setActiveId}),[registry,activeId]);
3659
4700
  // Demo-only tweak controls: hidden by default. Append ?controls to the URL
3660
4701
  // to show them for testing. (This whole block is below the inject CUT_MARKER,
3661
4702
  // so it never ships in the injected build.)
3662
4703
  const showControls=(()=>{try{return new URLSearchParams(location.search).has("controls");}catch(e){return false;}})();
4704
+ // TESTING ONLY (not part of the tool): top-right toggle to hide the demo
4705
+ // page content so the panel can be inspected against a blank page.
4706
+ const[demoHidden,setDemoHidden]=useState(false);
4707
+ const active=PROTOS.find(p=>p.key===proto)||PROTOS[0];
3663
4708
  return h(TimelineCtx.Provider,{value:ctx},
3664
4709
  showControls&&h(PanelControls),
3665
- h("div",{ref:rootRef,className:"demo-root"},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.")),
3666
- h("div",{className:"demo-grid"},h(BoxMorph))),
3667
- h(TimelinePanel));
4710
+ h("button",{type:"button",onClick:()=>setDemoHidden(v=>!v),
4711
+ title:"Testing only: hide/show demo page content",
4712
+ style:{position:"fixed",top:"12px",right:"12px",zIndex:2147483000,
4713
+ height:"28px",padding:"0 10px",borderRadius:"8px",cursor:"pointer",
4714
+ border:"1px solid rgba(0,0,0,0.12)",background:"#fff",color:"#444",
4715
+ font:"500 12px/1 ui-sans-serif,system-ui,sans-serif",
4716
+ boxShadow:"0 1px 3px rgba(0,0,0,0.12)"}},
4717
+ demoHidden?"Show demo":"Hide demo"),
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}));
3668
4725
  }
3669
4726
 
3670
4727
  createRoot(document.getElementById("root")).render(h(App));