transitions-refine 0.3.8 → 0.3.10

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);
@@ -244,7 +247,7 @@
244
247
  padding: 6px 10px 6px 16px;
245
248
  font-family: "Inter", system-ui, -apple-system, sans-serif;
246
249
  cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 8px;
247
- box-shadow: var(--drop), var(--ring); transition: background 0.15s, scale 0.12s ease;
250
+ box-shadow: var(--shadow-btn); transition: background 0.15s, scale 0.12s ease;
248
251
  animation: tl-pill-in 220ms cubic-bezier(0.22, 1, 0.36, 1) both; }
249
252
  @keyframes tl-pill-in { from { opacity: 0; transform: translateY(6px) scale(0.96); }
250
253
  to { opacity: 1; transform: none; } }
@@ -327,7 +330,7 @@
327
330
  border: none; border-radius: 8px; background: transparent; color: var(--c-blue);
328
331
  box-shadow: var(--drop-btn);
329
332
  font: inherit; font-size: 13px; font-weight: 500; line-height: 14px;
330
- cursor: pointer; white-space: nowrap; transition: color 0.12s ease; }
333
+ cursor: pointer; white-space: nowrap; transition: color 0.12s ease, scale 0.12s ease; }
331
334
  .tl-refine-btn::before { content: ""; position: absolute; inset: 0; border-radius: inherit;
332
335
  background: var(--c-blue-bg);
333
336
  background: color(display-p3 0 0.451 0.898 / 0.06);
@@ -339,7 +342,7 @@
339
342
  /* Figma: wand icon stroke is #0073E5, distinct from the #0071e2 label */
340
343
  .tl-refine-btn > svg { color: var(--c-blue-pressed); }
341
344
  .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); }
345
+ .tl-refine-btn:active:not(:disabled) { color: var(--c-blue-pressed); scale: 0.96; }
343
346
  .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
347
  .tl-refine-btn:disabled { cursor: default; color: var(--c-blue); }
345
348
  .tl-refine-btn:disabled::before { background: var(--c-blue-bg); background: color(display-p3 0 0.451 0.898 / 0.06); }
@@ -369,7 +372,7 @@
369
372
  .tl-insp-title { display: flex; align-items: center; font-size: 13px; font-weight: 500; line-height: 18px; color: #171717; margin-bottom: 16px; text-transform: capitalize; }
370
373
  /* full-bleed divider between the Delay field and the Easing/Springs tabs
371
374
  (Figma 580:9049 — spans the inspector edge-to-edge past the 16px padding) */
372
- .tl-inspector-divider { height: 1px; background: var(--c-line); margin: 0 -16px; }
375
+ .tl-inspector-divider { height: 1px; background: var(--c-line); margin: 4px -16px; }
373
376
  .tl-insp-label { font-size: 12px; line-height: 18px; color: #737373; margin: 10px 0 6px; }
374
377
 
375
378
  /* ── tracks / ruler ── */
@@ -412,7 +415,15 @@
412
415
  .tl-bar { position: absolute; top: 50%; height: 24px; transform: translateY(-50%); background: var(--c-track);
413
416
  border-radius: 8px; min-width: 24px; cursor: grab;
414
417
  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; }
418
+ /* card resize (transitions.dev #01): tween left/width when timing changes
419
+ programmatically (Apply, snap, value-field edit). Suppressed mid-drag
420
+ (.is-dragging) so the bar tracks the cursor 1:1. */
421
+ transition: background 0.12s ease,
422
+ left 220ms cubic-bezier(0.22, 1, 0.36, 1),
423
+ width 220ms cubic-bezier(0.22, 1, 0.36, 1); will-change: left, width; }
424
+ .tl-bar.is-dragging { transition: background 0.12s ease; }
425
+ @media (prefers-reduced-motion: reduce) {
426
+ .tl-bar { transition: background 0.12s ease !important; } }
416
427
  .tl-prop-row:hover .tl-bar, .tl-prop-row.selected .tl-bar { background: var(--c-track-h); }
417
428
  .tl-bar:active { cursor: grabbing; }
418
429
  /* resize handles — Figma node 13044:2625: 2×14px, #767676 @60%, centered, ~5px edge inset */
@@ -450,18 +461,23 @@
450
461
  .tl-field-fill { position: absolute; left: 0; top: 0; bottom: 0; min-width: 32px; border-radius: 8px;
451
462
  background: var(--c-fill);
452
463
  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); }
464
+ /* card resize (#01): tween width on programmatic changes + release snap;
465
+ suppressed mid-drag so it tracks the cursor 1:1. */
466
+ pointer-events: none; transition: background 0.12s ease, opacity 0.12s ease,
467
+ width 220ms cubic-bezier(0.22, 1, 0.36, 1); z-index: 1; }
468
+ .tl-field.is-dragging .tl-field-fill { background: var(--c-fill-a); transition: background 0.12s ease, opacity 0.12s ease; }
469
+ @media (prefers-reduced-motion: reduce) {
470
+ .tl-field-fill { transition: background 0.12s ease, opacity 0.12s ease !important; } }
455
471
  .tl-field.is-dragging .tl-field-label { opacity: 0.7; }
456
472
  .tl-field.is-editing .tl-field-fill { opacity: 0; }
457
473
  .tl-field-track { position: absolute; inset: 0; cursor: ew-resize; z-index: 2; }
458
474
  .tl-field-label { position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
459
475
  font-size: 13px; font-weight: 500; color: var(--c-text-mut); pointer-events: none; z-index: 3; }
460
476
  .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;
477
+ font-size: 13px; color: var(--c-text); font-weight: 400; cursor: text; z-index: 3;
462
478
  font-variant-numeric: tabular-nums; }
463
479
  .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;
480
+ text-align: right; border: none; background: transparent; font: inherit; font-size: 13px; font-weight: 400;
465
481
  color: var(--c-text); outline: none; z-index: 4; font-variant-numeric: tabular-nums; }
466
482
  /* thumb at the right edge of the fill (Figma #767676 2×20px, inset 8px, hover 60% / drag 100%) */
467
483
  .tl-field-thumb { position: absolute; right: 8px; top: 7px; height: 18px; width: 2px;
@@ -520,7 +536,7 @@
520
536
  /* select trigger — Logram design system (node 13064:2418): flat #f7f7f7 fill */
521
537
  .tl-select { display: flex; align-items: center; justify-content: space-between; width: 100%; height: 32px;
522
538
  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;
539
+ font: inherit; font-size: 13px; font-weight: 400; line-height: 16px; color: #1b1b1b; cursor: pointer; text-align: left;
524
540
  transition: background 0.12s ease; }
525
541
  .tl-select:hover { background: var(--c-sec-h); }
526
542
  .tl-select.is-open { background: var(--c-sec-h); }
@@ -528,9 +544,11 @@
528
544
  .tl-select-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
529
545
  .tl-select-chev { display: flex; color: var(--c-ruler); flex: none; }
530
546
  /* easing curve — exact Figma (node 580:11158): #f6f6f7, 8px radius, no grid */
547
+ /* overflow:visible so overshoot handles/lines spill past the box while
548
+ dragging instead of being clipped to the rectangle. */
531
549
  .tl-curve { background: #f6f6f7; border-radius: 8px;
532
- padding: 0; position: relative; overflow: hidden; }
533
- .tl-curve svg { display: block; cursor: crosshair; width: 100%; height: auto; }
550
+ padding: 0; position: relative; overflow: visible; }
551
+ .tl-curve svg { display: block; cursor: crosshair; width: 100%; height: auto; overflow: visible; }
534
552
  .tl-curve-handle { cursor: grab; }
535
553
  .tl-curve-handle:active { cursor: grabbing; }
536
554
  .tl-cubic-row { display: flex; gap: 4px; }
@@ -538,13 +556,13 @@
538
556
  32px #f7f7f7 pill, hover overlay + inset focus ring. */
539
557
  .tl-cubic-cell { flex: 1; min-width: 0; display: flex; flex-direction: row; align-items: center;
540
558
  gap: 4px; height: 32px; box-sizing: border-box;
541
- padding: 0 8px; border-radius: 8px; background: #f7f7f7;
559
+ padding: 0 8px; border-radius: 8px; background: var(--c-field-bg);
542
560
  box-shadow: inset 0 0 0 0 rgba(0,0,0,0.14); transition: box-shadow 0.12s ease, background 0.12s ease; }
543
- .tl-cubic-cell:hover { background: linear-gradient(rgba(170,170,170,0.2),rgba(170,170,170,0.2)), #f7f7f7; }
561
+ .tl-cubic-cell:hover { background: var(--c-sec-h); }
544
562
  .tl-cubic-cell:focus-within { box-shadow: inset 0 0 0 1px rgba(0,0,0,0.14); }
545
563
  .tl-cubic-row input { flex: 1; min-width: 0; width: 100%; height: 100%; border: none; background: transparent;
546
564
  box-sizing: border-box; padding: 0; text-align: left;
547
- font: inherit; font-size: 12px; font-weight: 500; line-height: 16px; color: #1b1b1b; outline: none;
565
+ font: inherit; font-size: 12px; font-weight: 400; line-height: 16px; color: #1b1b1b; outline: none;
548
566
  -moz-appearance: textfield; appearance: textfield; }
549
567
  .tl-cubic-row input::-webkit-outer-spin-button, .tl-cubic-row input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; width: 0; }
550
568
  .tl-cubic-row input::placeholder { color: #979797; }
@@ -682,9 +700,10 @@
682
700
  .tl-menu-item:focus-visible { outline: none; background: #fff; box-shadow: inset 0 0 0 1px rgba(0,115,229,0.4); }
683
701
  .tl-menu-item.disabled { color: var(--c-disabled); pointer-events: none; }
684
702
  .tl-menu-item-label { flex: 1; min-width: 0; display: flex; align-items: center; }
685
- .tl-menu-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
686
- .tl-menu-stack { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
687
- .tl-menu-sub { font-size: 11px; line-height: 14px; }
703
+ .tl-menu-text { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
704
+ .tl-menu-stack { flex: 1; min-width: 0; display: flex; flex-direction: row; align-items: baseline; gap: 6px; }
705
+ .tl-menu-stack .tl-menu-text { flex: 0 1 auto; }
706
+ .tl-menu-sub { flex: 0 0 auto; font-size: 13px; line-height: 18px; }
688
707
  .tl-menu-dim { color: #979797; }
689
708
  .tl-menu-check { display: flex; color: var(--c-text-strong); flex: none; }
690
709
  .tl-menu-empty { padding: 10px 8px; color: var(--c-disabled); font-size: 13px; }
@@ -706,6 +725,83 @@
706
725
  /* trailing chevron on link rows (Figma 581:2828 — #696969 right chevron) */
707
726
  .tl-menu-chevr { display: flex; flex: none; color: #696969; }
708
727
 
728
+ /* trailing slot on a menu row — current value + chevron (Figma: Theme row
729
+ shows "System" then a right chevron and opens the theme picker sub-page). */
730
+ .tl-menu-trail { display: flex; align-items: center; gap: 4px; }
731
+ .tl-menu-value { font-size: 11px; line-height: 14px; color: #8b8b8b;
732
+ font-variant-numeric: tabular-nums; }
733
+
734
+ /* ── settings dropdown sub-page slider (transitions.dev · 08-page-side-by-side) ──
735
+ One dropdown surface that pages internally: main settings ↔ keyboard
736
+ shortcuts. Both pages stay mounted so they can cross-slide; the surface
737
+ height follows the active page (card-resize-style) so it grows/shrinks
738
+ smoothly instead of jumping. Page 1 exits left, page 2 exits right. */
739
+ .tl-set-slide.t-page-slide {
740
+ position: relative;
741
+ /* bleed past the .tl-menu 6px padding so the clipped (overflow:hidden)
742
+ viewport reaches the menu edges — lets dividers go truly full-width.
743
+ The 6px row inset is restored on .tl-set-page below. */
744
+ margin-left: -6px; margin-right: -6px;
745
+ --page-slide-dur: 250ms; /* Fast — page slide */
746
+ --page-fade-dur: 250ms;
747
+ --page-slide-distance: 8px; /* Base distance */
748
+ --page-blur: 3px; /* Medium blur */
749
+ --page-stagger: 0ms;
750
+ --page-exit-enabled: 1;
751
+ --page-slide-ease: cubic-bezier(0.22, 1, 0.36, 1); /* Smooth ease out */
752
+ --page-fade-ease: cubic-bezier(0.22, 1, 0.36, 1);
753
+ transition: height var(--page-slide-dur) var(--page-slide-ease);
754
+ overflow: hidden;
755
+ }
756
+ .tl-set-slide .t-page[data-page-id="1"] { --t-page-from-x: calc(var(--page-slide-distance) * -1); }
757
+ .tl-set-slide .t-page[data-page-id="2"],
758
+ .tl-set-slide .t-page[data-page-id="3"] { --t-page-from-x: var(--page-slide-distance); }
759
+ .tl-set-slide .t-page {
760
+ position: absolute; top: 0; left: 0; right: 0;
761
+ box-sizing: border-box; padding-left: 6px; padding-right: 6px;
762
+ opacity: 0; pointer-events: none;
763
+ transform: translateX(calc(var(--t-page-from-x, 0px) * var(--page-exit-enabled)));
764
+ filter: blur(calc(var(--page-blur) * var(--page-exit-enabled)));
765
+ transition:
766
+ opacity var(--page-fade-dur) var(--page-fade-ease),
767
+ transform var(--page-slide-dur) var(--page-slide-ease),
768
+ filter var(--page-slide-dur) var(--page-slide-ease);
769
+ will-change: opacity, transform, filter;
770
+ }
771
+ .tl-set-slide[data-page="1"] .t-page[data-page-id="1"],
772
+ .tl-set-slide[data-page="2"] .t-page[data-page-id="2"],
773
+ .tl-set-slide[data-page="3"] .t-page[data-page-id="3"] {
774
+ opacity: 1; pointer-events: auto; transform: translateX(0); filter: blur(0);
775
+ transition-delay: var(--page-stagger);
776
+ }
777
+ @media (prefers-reduced-motion: reduce) {
778
+ .tl-set-slide.t-page-slide,
779
+ .tl-set-slide .t-page { transition: none !important; }
780
+ }
781
+ /* sub-page back header — mirrors the menu-item row metrics (h-32, rounded-8,
782
+ 13/16 label) so the surface feels continuous between pages. */
783
+ .tl-set-back { display: flex; align-items: center; gap: 4px; width: 100%; box-sizing: border-box;
784
+ min-height: 32px; padding: 0 8px 0 4px; border: none; background: transparent; border-radius: 8px;
785
+ font: inherit; font-size: 13px; font-weight: 500; line-height: 16px; color: #1b1b1b;
786
+ cursor: pointer; text-align: left; transition: background 0.1s ease, box-shadow 0.1s ease; }
787
+ .tl-set-back:hover { background: #f4f4f5; }
788
+ .tl-set-back:active { background: #ededee; }
789
+ .tl-set-back:focus-visible { outline: none; background: #fff; box-shadow: inset 0 0 0 1px rgba(0,115,229,0.4); }
790
+ /* reuse the right chevron flipped to point back; the menu chevron color */
791
+ .tl-set-back-ic { display: flex; flex: none; color: #696969; transform: scaleX(-1); }
792
+ .tl-set-back-title { flex: 1; min-width: 0; }
793
+ /* shortcut row — label left, keycap cluster right; same h-32 rhythm as rows */
794
+ .tl-kbd-row { display: flex; align-items: center; justify-content: space-between; gap: 12px;
795
+ min-height: 32px; padding: 0 8px; font-size: 13px; line-height: 16px; color: #1b1b1b; }
796
+ .tl-kbd-row-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
797
+ .tl-kbd-cluster { display: flex; align-items: center; gap: 4px; flex: none; }
798
+ .tl-kbd-plus { color: #b8b8b8; font-size: 11px; line-height: 1; }
799
+ /* keycap — hairline via inset shadow (shadows over borders), tabular monospace */
800
+ .tl-kbd { display: inline-flex; align-items: center; justify-content: center; min-width: 18px; height: 18px;
801
+ padding: 0 5px; box-sizing: border-box; border-radius: 5px; background: #f4f4f5;
802
+ box-shadow: inset 0 0 0 1px #ececed; font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
803
+ font-size: 11px; line-height: 1; font-weight: 500; color: #4c4c4c; font-variant-numeric: tabular-nums; }
804
+
709
805
  /* ═════ transitions.dev — menu dropdown (verbatim) ═════ */
710
806
  .t-dropdown {
711
807
  transform-origin: top left;
@@ -808,6 +904,8 @@
808
904
  }
809
905
  /* below-variant for triggers near the panel top edge */
810
906
  .t-tt.tl-tt-below { bottom: auto; top: calc(100% + 8px); transform-origin: 50% 0; font-size: 12px; }
907
+ /* small gray shortcut hint inside a tooltip (matches the cheat-sheet keys) */
908
+ .tl-tt-kbd { margin-left: 8px; opacity: 0.4; }
811
909
  /* multi-line message variant (e.g. Accept error) — long text wraps to a
812
910
  readable measure instead of one overflowing line. Right-anchored so the
813
911
  wide bubble grows leftward and never clips off the panel edge. Mirrors the
@@ -818,6 +916,12 @@
818
916
  transform: translate(0, 0) scale(var(--tt-scale)); }
819
917
  .t-tt-wrap:hover .t-tt.tl-tt-msg,
820
918
  .t-tt-trigger:focus-visible + .t-tt.tl-tt-msg { transform: translate(0, 0) scale(1); }
919
+ /* right-anchored variant for the panel's rightmost trigger (Minimize) so the
920
+ bubble grows leftward from the right edge and never clips off the panel. */
921
+ .t-tt.tl-tt-right { left: auto; right: 0; transform-origin: 100% 0;
922
+ transform: translate(0, 0) scale(var(--tt-scale)); }
923
+ .t-tt-wrap:hover .t-tt.tl-tt-right,
924
+ .t-tt-trigger:focus-visible + .t-tt.tl-tt-right { transform: translate(0, 0) scale(1); }
821
925
 
822
926
  /* ═════ transitions.dev — panel reveal (per-phase distance/ease/scale) ═════ */
823
927
  /* base = closed / close-end state: resolved travel + scale come from the
@@ -899,7 +1003,7 @@
899
1003
  .pc-btn.primary:hover { background: #000; }
900
1004
 
901
1005
  .tl-refine-btn.is-active { color: var(--c-blue-pressed); }
902
- .tl-refine-btn.is-active::before { background: color(display-p3 0 0.451 0.898 / 0.12); }
1006
+ .tl-refine-btn.is-active::before { background: var(--c-blue-bg-a); background: color(display-p3 0 0.451 0.898 / 0.12); }
903
1007
 
904
1008
  /* refine panel slides in from the right using transitions.dev panel reveal
905
1009
  tokens (translate + opacity + cross-blur, per-phase open/close dur/ease). */
@@ -949,7 +1053,7 @@
949
1053
  /* header: title block (left) + Agent/Deterministic mode dropdown (right) */
950
1054
  /* match the timeline top bar exactly: 7.5px/14px padding, centered, 52px tall (36px row + 15 + 1px border) */
951
1055
  .tl-refine-head { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between;
952
- gap: 8px; box-sizing: border-box; padding: 7.5px 14px;
1056
+ gap: 8px; box-sizing: border-box; padding: 7.5px 7.5px 7.5px 14px;
953
1057
  border-bottom: 1px solid #f0f0f0; }
954
1058
  .tl-refine-titles { min-width: 0; }
955
1059
  .tl-refine-titles h3 { font-size: 13px; font-weight: 500; line-height: 14px; color: #17181c; }
@@ -967,8 +1071,10 @@
967
1071
  width: 36px; height: 36px; border: none; background: transparent; border-radius: 60px; cursor: pointer;
968
1072
  color: #676767; transition: background 0.14s ease, color 0.14s ease; }
969
1073
  .tl-refine-close:hover { background: rgba(170,170,170,0.10); color: #17181c; }
970
- /* refine-type tabs (Small refinements / Replace transition) */
971
- .tl-refine-tabs { flex: 0 0 auto; display: flex; align-items: center; gap: 5px; padding: 17px 16px 0; }
1074
+ /* refine-type tabs toolbar strip (Figma 587:9258): 48px white bar, hairline
1075
+ bottom divider, segments inset 16px and packed gap:0 like a segmented control */
1076
+ .tl-refine-tabs { flex: 0 0 auto; box-sizing: border-box; display: flex; align-items: center; gap: 0;
1077
+ height: 48px; padding: 0 16px; background: #fff; border-bottom: 1px solid #f0f0f0; }
972
1078
  .tl-refine-tab { height: 32px; padding: 6px 12px; border: none; background: transparent; cursor: pointer;
973
1079
  border-radius: 8px; font: inherit; font-size: 13px; font-weight: 500; line-height: 14px; color: #676767;
974
1080
  transition: background 0.14s ease, color 0.14s ease; }
@@ -976,6 +1082,49 @@
976
1082
  .tl-refine-tab[aria-selected="true"] { background: rgba(170,170,170,0.10); color: #17181c; }
977
1083
  .tl-refine-body { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column;
978
1084
  padding: 0 16px; }
1085
+ /* ── refine-type tab content slider (transitions.dev · 08-page-side-by-side) ──
1086
+ Switching the type tabs (Small refinements ↔ Replace transition) slides the
1087
+ body content side-by-side: the outgoing page exits with blur+fade while the
1088
+ incoming page slides in from the opposite edge. Both pages stay mounted so
1089
+ they cross-slide. Page 1 (Small, tab index 0) exits left; page 2 (Replace,
1090
+ tab index 1) exits right — going to Replace brings new content in from the
1091
+ right, going back brings it from the left. Mirrors the settings dropdown's
1092
+ .tl-set-slide tokens exactly. Pages are absolutely stacked and fill the
1093
+ fixed body region, scrolling internally, so no height tween is needed. */
1094
+ .tl-refine-body.tl-refine-pages.t-page-slide {
1095
+ position: relative; overflow: hidden; padding: 0;
1096
+ --page-slide-dur: 250ms; /* Fast — page slide */
1097
+ --page-fade-dur: 250ms;
1098
+ --page-slide-distance: 8px; /* Base distance */
1099
+ --page-blur: 3px; /* Medium blur */
1100
+ --page-stagger: 0ms;
1101
+ --page-exit-enabled: 1;
1102
+ --page-slide-ease: cubic-bezier(0.22, 1, 0.36, 1); /* Smooth ease out */
1103
+ --page-fade-ease: cubic-bezier(0.22, 1, 0.36, 1);
1104
+ }
1105
+ .tl-refine-pages .t-page[data-page-id="1"] { --t-page-from-x: calc(var(--page-slide-distance) * -1); }
1106
+ .tl-refine-pages .t-page[data-page-id="2"] { --t-page-from-x: var(--page-slide-distance); }
1107
+ .tl-refine-pages .t-page {
1108
+ position: absolute; inset: 0; overflow-y: auto;
1109
+ display: flex; flex-direction: column; box-sizing: border-box; padding: 0 16px;
1110
+ opacity: 0; pointer-events: none;
1111
+ transform: translateX(calc(var(--t-page-from-x, 0px) * var(--page-exit-enabled)));
1112
+ filter: blur(calc(var(--page-blur) * var(--page-exit-enabled)));
1113
+ transition:
1114
+ opacity var(--page-fade-dur) var(--page-fade-ease),
1115
+ transform var(--page-slide-dur) var(--page-slide-ease),
1116
+ filter var(--page-slide-dur) var(--page-slide-ease);
1117
+ will-change: opacity, transform, filter;
1118
+ }
1119
+ .tl-refine-pages[data-page="1"] .t-page[data-page-id="1"],
1120
+ .tl-refine-pages[data-page="2"] .t-page[data-page-id="2"] {
1121
+ opacity: 1; pointer-events: auto; transform: translateX(0); filter: blur(0);
1122
+ transition-delay: var(--page-stagger);
1123
+ }
1124
+ @media (prefers-reduced-motion: reduce) {
1125
+ .tl-refine-pages.t-page-slide,
1126
+ .tl-refine-pages .t-page { transition: none !important; }
1127
+ }
979
1128
  /* idle + unavailable share a vertically-centred column */
980
1129
  .tl-refine-center { flex: 1; display: flex; flex-direction: column; align-items: center;
981
1130
  justify-content: center; gap: 12px; text-align: center; padding: 16px 0; }
@@ -1021,7 +1170,8 @@
1021
1170
  height var(--resize-dur) var(--resize-ease),
1022
1171
  border-radius var(--resize-dur) var(--resize-ease),
1023
1172
  color var(--resize-dur) var(--resize-ease),
1024
- box-shadow var(--resize-dur) var(--resize-ease); }
1173
+ box-shadow var(--resize-dur) var(--resize-ease),
1174
+ scale 0.12s ease; }
1025
1175
  .tl-scan-morph::before { content: ""; position: absolute; inset: 0; border-radius: inherit;
1026
1176
  background: var(--c-blue-bg);
1027
1177
  background: color(display-p3 0 0.451 0.898 / 0.06);
@@ -1033,7 +1183,7 @@
1033
1183
  .tl-refine-foot .tl-scan-morph { margin-bottom: 20px; }
1034
1184
  .tl-scan-morph:hover:not(:disabled):not(.is-scanning)::before {
1035
1185
  background: var(--c-blue-bg-h); background: color(display-p3 0 0.451 0.898 / 0.10); }
1036
- .tl-scan-morph:active:not(.is-scanning):not(:disabled) { color: var(--c-blue-pressed); }
1186
+ .tl-scan-morph:active:not(.is-scanning):not(:disabled) { color: var(--c-blue-pressed); scale: 0.96; }
1037
1187
  .tl-scan-morph:active:not(.is-scanning):not(:disabled)::before {
1038
1188
  background: var(--c-blue-bg-h); background: color(display-p3 0 0.451 0.898 / 0.10); }
1039
1189
  .tl-scan-morph:disabled:not(.is-scanning) { cursor: default; color: var(--c-blue); }
@@ -1043,12 +1193,12 @@
1043
1193
  color: var(--c-blue-pressed); opacity: 0.5; }
1044
1194
  .tl-scan-morph.is-scanning { width: 100%; height: 49px; border-radius: 60px;
1045
1195
  padding: 8px 16px; cursor: default; color: #676767; background: transparent;
1046
- box-shadow: none; }
1196
+ box-shadow: var(--shadow-btn); }
1047
1197
  .tl-scan-morph.is-scanning::before {
1048
1198
  background: #fff;
1049
1199
  transition: background var(--resize-dur) var(--resize-ease); }
1050
1200
  .tl-scan-morph.is-scanning::after {
1051
- box-shadow: inset 0 0 0 1px #f0f0f0;
1201
+ box-shadow: var(--shadow-btn-inset);
1052
1202
  transition: box-shadow var(--resize-dur) var(--resize-ease); }
1053
1203
  /* Two faces share one grid cell and cross-blur (icon swap, 09) between the
1054
1204
  label and the icon+status; whichever is hidden fades out with blur+scale.
@@ -1131,10 +1281,16 @@
1131
1281
  @keyframes tl-dotm-e { 0%{opacity:1} 16.667%{opacity:.52} 33.333%{opacity:.08} 50%{opacity:.52} 66.667%{opacity:.08} 83.333%{opacity:.52} 100%{opacity:1} }
1132
1282
  @keyframes tl-dotm-f { 0%{opacity:.08} 16.667%{opacity:1} 33.333%{opacity:.52} 50%{opacity:.08} 66.667%{opacity:.52} 83.333%{opacity:1} 100%{opacity:.08} }
1133
1283
  @keyframes tl-dotm-g { 0%{opacity:.08} 16.667%{opacity:.52} 33.333%{opacity:.08} 50%{opacity:.08} 66.667%{opacity:.08} 83.333%{opacity:.52} 100%{opacity:.08} }
1134
- /* background-scan note in the header count slot — plain text, hover for the
1135
- tooltip explaining what the agent is doing. Dotted underline hints it. */
1284
+ /* background-scan note in the header count slot — dot-matrix loader + shimmer
1285
+ text (transitions.dev shimmer text 15). Hover shows the tooltip. */
1286
+ /* the tooltip wrap fills the count slot's height; center its content so the
1287
+ loader + text sit on the slot's vertical midline instead of its bottom. */
1288
+ .tl-header-count .t-tt-wrap { display: inline-flex; align-items: center; align-self: center; }
1136
1289
  .tl-scan-note { cursor: help; color: var(--c-count);
1137
- text-decoration: underline dotted rgba(0,0,0,0.28); text-underline-offset: 2px; }
1290
+ display: inline-flex; align-items: center; gap: 6px; }
1291
+ .tl-scan-note .tl-dotm { width: 14px; height: 14px; }
1292
+ .tl-scan-shimmer { --shimmer-dur: 2000ms; --shimmer-base: var(--c-count); --shimmer-highlight: #17181c;
1293
+ --shimmer-band: 400%; --shimmer-ease: linear; }
1138
1294
  /* loading status text — transitions.dev shimmer text (15) + text states swap (04) */
1139
1295
  .tl-refine-status-text {
1140
1296
  --shimmer-dur: 2000ms; --shimmer-base: #9a9a9a; --shimmer-highlight: #17181c;
@@ -1165,7 +1321,7 @@
1165
1321
  font: inherit; transition: background 0.12s ease; }
1166
1322
  .tl-mode-row:hover { background: rgba(170,170,170,0.08); }
1167
1323
  .tl-mode-row-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
1168
- .tl-mode-row-title { font-size: 14px; font-weight: 500; line-height: 16px; color: #1b1b1b; }
1324
+ .tl-mode-row-title { font-size: 13px; font-weight: 500; line-height: 16px; color: #1b1b1b; }
1169
1325
  .tl-mode-row-desc { font-size: 12px; font-weight: 400; line-height: 18px; color: #676767; text-wrap: balance; }
1170
1326
  .tl-mode-row-check { flex: 0 0 auto; width: 16px; height: 16px; display: flex; color: #17181c; }
1171
1327
  @keyframes tl-fade-up { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
@@ -1212,37 +1368,56 @@
1212
1368
  transition: background 0.12s ease, scale 0.12s ease, opacity 0.12s ease; }
1213
1369
  .tl-pill-btn .tl-dim { color: #696969; }
1214
1370
  .tl-pill-btn:hover:not(:disabled) { background: #fafafa; }
1215
- .tl-pill-btn:active:not(:disabled) { scale: 0.98; }
1216
- .tl-pill-btn:disabled { opacity: 0.45; cursor: default; }
1217
- /* applied state — GRAY, never green (matches Accept icon --c-text #17181c) */
1218
- .tl-pill-btn.is-applied { color: var(--c-text); cursor: default; }
1371
+ .tl-pill-btn:active:not(:disabled) { scale: 0.96; }
1372
+ /* disabled flat grey fill matching the active segmented tab, no raised
1373
+ shadow, muted text (instead of a faded white button). */
1374
+ .tl-pill-btn:disabled { cursor: not-allowed; opacity: 1; color: var(--c-disabled);
1375
+ background: rgba(170,170,170,0.1); box-shadow: none; }
1376
+ /* applied state — GRAY, never green (matches Accept icon --c-text #17181c).
1377
+ Flat grey fill matching the active segmented tab, no raised shadow. */
1378
+ .tl-pill-btn.is-applied { color: var(--c-text); cursor: not-allowed; opacity: 1;
1379
+ background: rgba(170,170,170,0.1); box-shadow: none; }
1219
1380
  .tl-pill-btn.is-applied svg path { stroke: var(--c-text); }
1220
- /* Apply ⟷ Applied: cross-blur text swap same mechanism as the Play/Pause
1221
- button above (and the index Copy → Copied tooltip). Both words stack in one
1222
- slot; the active one is sharp/opaque, the other blurred/transparent, and they
1223
- cross-blur in place while the slot width tweens between the two measured widths.
1224
- The "Applied" label (b) carries the success check to the left of its text. */
1225
- .tl-apply-swap { position: relative; display: inline-block; vertical-align: middle;
1226
- line-height: 14px; width: var(--aw-a, auto);
1227
- transition: width 240ms cubic-bezier(0.22, 1, 0.36, 1); }
1228
- .tl-apply-swap[data-state="applied"] { width: var(--aw-b, auto); }
1229
- .tl-apply-label { white-space: pre;
1381
+ /* Apply ⟷ Applied: a LEADING check is revealed first and pushes the text to
1382
+ the right, then the differing SUFFIX cross-blurs. The shared stem "Appl"
1383
+ stays put while "y" "ied" cross-blur in a slot whose width tweens between
1384
+ the two measured widths. Both the leading check space AND the suffix slot
1385
+ use the SAME resize token (--apply-resize-*: card resize, smooth ease out),
1386
+ so the whole button grows in one synchronized motion. The check lives in its
1387
+ own overflow:hidden box whose width tweens 0 -> (icon + gap), shoving the
1388
+ text rightward the check itself stroke-draws IN PLACE (no translate). */
1389
+ .tl-apply-swap { position: relative; display: inline-flex; align-items: center;
1390
+ vertical-align: middle; line-height: 14px;
1391
+ --apply-resize-dur: 400ms; --apply-resize-ease: cubic-bezier(0.22, 1, 0.36, 1); }
1392
+ /* leading check slot: collapsed to width 0 at rest; expands to the measured
1393
+ icon+gap width (--chk-w) when applied, using the same token as the suffix
1394
+ resize. overflow:hidden clips the pinned-left check so it reveals in place
1395
+ (left edge fixed) rather than translating. */
1396
+ .tl-apply-check { display: inline-flex; align-items: center; overflow: hidden;
1397
+ flex: 0 0 auto; width: 0;
1398
+ transition: width var(--apply-resize-dur) var(--apply-resize-ease); }
1399
+ .tl-apply-swap[data-state="applied"] .tl-apply-check { width: var(--chk-w, 19px); }
1400
+ .tl-apply-stem { display: inline-block; }
1401
+ .tl-apply-suffix { position: relative; display: inline-block; width: var(--sfx-y, auto);
1402
+ transition: width var(--apply-resize-dur) var(--apply-resize-ease); }
1403
+ .tl-apply-swap[data-state="applied"] .tl-apply-suffix { width: var(--sfx-ied, auto); }
1404
+ .tl-apply-seg { white-space: pre;
1230
1405
  transition: opacity 180ms cubic-bezier(0.22, 1, 0.36, 1),
1231
1406
  filter 180ms cubic-bezier(0.22, 1, 0.36, 1);
1232
1407
  will-change: opacity, filter; }
1233
- .tl-apply-a { display: inline-block; }
1234
- .tl-apply-b { position: absolute; top: 0; left: 0; display: inline-flex;
1235
- align-items: center; gap: 6px; }
1236
- .tl-apply-swap .tl-apply-a { opacity: 1; filter: blur(0); }
1237
- .tl-apply-swap .tl-apply-b { opacity: 0; filter: blur(2px); }
1238
- .tl-apply-swap[data-state="applied"] .tl-apply-a { opacity: 0; filter: blur(2px); }
1239
- .tl-apply-swap[data-state="applied"] .tl-apply-b { opacity: 1; filter: blur(0); }
1408
+ .tl-apply-y { display: inline-block; }
1409
+ .tl-apply-ied { position: absolute; top: 0; left: 0; display: inline-block; }
1410
+ .tl-apply-swap .tl-apply-y { opacity: 1; filter: blur(0); }
1411
+ .tl-apply-swap .tl-apply-ied { opacity: 0; filter: blur(2px); }
1412
+ .tl-apply-swap[data-state="applied"] .tl-apply-y { opacity: 0; filter: blur(2px); }
1413
+ .tl-apply-swap[data-state="applied"] .tl-apply-ied { opacity: 1; filter: blur(0); }
1240
1414
  @media (prefers-reduced-motion: reduce) {
1241
- .tl-apply-swap, .tl-apply-label { transition: none !important; }
1415
+ .tl-apply-suffix, .tl-apply-seg { transition: none !important; }
1242
1416
  }
1243
1417
  /* success check (transitions.dev 10-success-check): stroke-draw on the path with
1244
1418
  a plain opacity fade — IN PLACE, no rotate / Y-bob / scale, so the check never
1245
- moves while it draws. GRAY stroke. */
1419
+ moves while it draws. It is the leading element, unclipped by the .tl-apply-check
1420
+ width tween that pushes the text. GRAY stroke. */
1246
1421
  .tl-sug-check { display: inline-flex; opacity: 0; will-change: opacity; }
1247
1422
  .tl-sug-check svg { display: block; overflow: visible; }
1248
1423
  /* stroke-dasharray = path.getTotalLength() of ICONS.accept "M4 8.4268 L6.46155
@@ -1278,6 +1453,7 @@
1278
1453
  .tl-refine-results .t-stagger-line { transition: none !important; }
1279
1454
  .t-resize, .tl-scan-morph { transition: none !important; }
1280
1455
  .tl-pill-btn.tl-apply-btn { transition: background 0.12s ease, scale 0.12s ease, opacity 0.12s ease !important; }
1456
+ .tl-apply-check { transition: none !important; }
1281
1457
  .tl-sug-check { animation: none !important; opacity: 1; }
1282
1458
  .tl-sug-check svg path { animation: none !important; stroke-dashoffset: 0 !important; }
1283
1459
  .tl-scan-face { transition: none !important; }
@@ -1530,7 +1706,10 @@
1530
1706
  _sched(){if(this.rafId!==null)return;this.rafId=requestAnimationFrame(()=>{this.rafId=null;if(this.running&&!this.paused)this.scan();});}
1531
1707
  scan(){const seen=new Map();const w=document.createTreeWalker(this.root,NodeFilter.SHOW_ELEMENT);let n=w.currentNode;while(n){if(n instanceof HTMLElement)this._proc(n,seen);n=w.nextNode();}this.registry.replaceAll(Array.from(seen.values()));}
1532
1708
  _proc(el,seen){
1533
- if(el.closest("[data-timeline-panel]"))return;
1709
+ // Skip the Refine UI itself: the panel ([data-timeline-panel]) plus any of
1710
+ // our surfaces that portal OUT of it (dropdown menus, toasts) — those carry
1711
+ // [data-tl-ui]. Without this the scanner lists the tool's own transitions.
1712
+ if(el.closest("[data-timeline-panel],[data-tl-ui]"))return;
1534
1713
  const s=getComputedStyle(el); const rp=s.transitionProperty;
1535
1714
  if(!rp||rp==="none"||rp==="all")return;
1536
1715
  const props=rp.split(",").map(p=>p.trim());
@@ -1663,6 +1842,8 @@
1663
1842
  // accept checkmark (Figma node 580:9057) — slimmer 1.5px check centered in a
1664
1843
  // square 16-frame (the glyph spans ~50% width, like the source icon frame).
1665
1844
  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"/>`};
1845
+ // external-link (Figma "link-external-01") — box with an arrow leaving the top-right
1846
+ 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"/>`};
1666
1847
  function Ic({name, size=16}){
1667
1848
  const ic = ICONS[name];
1668
1849
  if(!ic) return null;
@@ -1748,7 +1929,7 @@
1748
1929
  const origin=pos?pos.origin:"top-left";
1749
1930
  const cls=closing?"is-closing":shown?"is-open":"";
1750
1931
  return createPortal(
1751
- h("div",{ref,className:cx("t-dropdown","tl-menu",cls),"data-origin":origin,style},children),
1932
+ h("div",{ref,className:cx("t-dropdown","tl-menu",cls),"data-origin":origin,"data-tl-ui":"",style},children),
1752
1933
  target);
1753
1934
  }
1754
1935
 
@@ -1759,21 +1940,153 @@
1759
1940
  right);
1760
1941
  }
1761
1942
 
1943
+ // Settings dropdown body that pages between the main menu and the keyboard-
1944
+ // shortcuts cheat-sheet (transitions.dev · 08-page-side-by-side). Both pages
1945
+ // stay mounted so they cross-slide; the container height follows the active
1946
+ // page so the one dropdown surface resizes smoothly between pages. Lives
1947
+ // inside the Dropdown portal, so its layout effect fires when the surface
1948
+ // mounts (the parent Header doesn't re-render on the portal's own mount).
1949
+ function SettingsSlide({view,mainPage,shortcutsPage,themePage,onBack,onClose}){
1950
+ const slideRef=useRef(null), p1=useRef(null), p2=useRef(null), p3=useRef(null);
1951
+ const pageNum=view==="shortcuts"?"2":view==="theme"?"3":"1";
1952
+ useLayoutEffect(()=>{
1953
+ const slide=slideRef.current; if(!slide) return;
1954
+ const active=view==="shortcuts"?p2.current:view==="theme"?p3.current:p1.current;
1955
+ if(active) slide.style.height=active.offsetHeight+"px";
1956
+ });
1957
+ // Escape steps back from a sub-page first, then closes the dropdown —
1958
+ // mirrors the picker's nested-dismiss behaviour.
1959
+ useEffect(()=>{
1960
+ const onKey=e=>{ if(e.key!=="Escape")return;
1961
+ if(view!=="main"){onBack&&onBack();} else {onClose&&onClose();} };
1962
+ document.addEventListener("keydown",onKey);
1963
+ return()=>document.removeEventListener("keydown",onKey);
1964
+ },[view,onBack,onClose]);
1965
+ return h("div",{ref:slideRef,className:"tl-set-slide t-page-slide","data-page":pageNum},
1966
+ h("div",{ref:p1,className:"tl-set-page t-page","data-page-id":"1","aria-hidden":view!=="main"},mainPage),
1967
+ h("div",{ref:p2,className:"tl-set-page t-page","data-page-id":"2","aria-hidden":view!=="shortcuts"},shortcutsPage),
1968
+ h("div",{ref:p3,className:"tl-set-page t-page","data-page-id":"3","aria-hidden":view!=="theme"},themePage));
1969
+ }
1970
+
1762
1971
  // ── refine relay client ──
1763
1972
  // Talks to the local relay (server/relay.mjs). The browser posts a job and
1764
1973
  // polls; the relay answers each job with one agent run (one-shot).
1765
1974
  const RELAY_URL = (window.REFINE_RELAY_URL) || "http://localhost:7331";
1975
+
1976
+ // ── demo/mock agent (guarded — additive, OFF by default) ──
1977
+ // When `window.__TX_REFINE_MOCK` is set (the standalone website-demo.html),
1978
+ // the three relay fns below short-circuit to a built-in mock so the panel is
1979
+ // fully interactive with NO relay/LLM/CLI. When the flag is UNSET this whole
1980
+ // block is inert and every fn falls through to the real fetch path unchanged.
1981
+ const TX_MOCK = typeof window!=="undefined" && window.__TX_REFINE_MOCK;
1982
+ const _txMockJobs = new Map(); // id → { request, createdAt }
1983
+ const _txMockId = ()=>"mock-"+Date.now()+"-"+Math.random().toString(36).slice(2,8);
1984
+ const _txMs = (n)=>(n==null?"":Math.round(n)+"ms");
1985
+ const _TX_EASE = "cubic-bezier(0.22, 1, 0.36, 1)"; // transitions.dev standard ease-out
1986
+ // Build a "done" result shaped by the stored request kind (mirrors relay.mjs
1987
+ // parseScanOutput/parseApplyOutput/parseAgentOutput shapes).
1988
+ function _txMockResult(request){
1989
+ const kind = request && request.kind;
1990
+ if(kind==="scan") return { groups: _txMockGroups(request), summary: "Grouped 1 component — Menu dropdown (open + close)." };
1991
+ if(kind==="apply") return { applied: true, summary: "Demo mode — simulated writing the change to your source. (No files touched.)", files: [] };
1992
+ return _txMockSuggestions(request);
1993
+ }
1994
+ // scan → one "Menu dropdown" group with Open + Close phases. Members are
1995
+ // derived from the raw DOM-detected transitions so the timeline shows real
1996
+ // lanes; selectors come straight from the live scan so they resolve in the
1997
+ // DOM. Shape mirrors relay.mjs buildScanPrompt's groups[] (id/label/phases →
1998
+ // phase{id,phase,label,stateTarget,fromState,toState,members[{id,label,
1999
+ // selector,propertyTimings}]}) and what TransitionRegistry.setGroups expects.
2000
+ function _txMockGroups(request){
2001
+ const raw = (request && request.raw) || [];
2002
+ const base = raw.length ? raw : [{label:"Menu", selector:".wd-dd .wd-dd-menu", properties:["opacity","transform"]}];
2003
+ // OPEN phase matches the current DOM → copy the scanned timings verbatim
2004
+ // (so the timeline shows the element's real, possibly off-token values).
2005
+ const open = base.map((e,i)=>({
2006
+ id:"m"+i,
2007
+ label:e.label||("Element "+(i+1)),
2008
+ selector:e.selector||".wd-dd .wd-dd-menu",
2009
+ propertyTimings:((e.timings&&e.timings.length)?e.timings:(e.properties||["opacity","transform"]).map(p=>({property:p,durationMs:280,delayMs:0,easing:"ease"})))
2010
+ .map(t=>({property:t.property, durationMs:t.durationMs!=null?t.durationMs:280, delayMs:t.delayMs||0, easing:t.easing||"ease"})),
2011
+ }));
2012
+ // CLOSE phase lives on a state variant the DOM scan can't see; the recipe's
2013
+ // faster close (the authored 180ms) is recovered here, mirroring how the
2014
+ // real agent reads the opposite-phase timings from cssRules.
2015
+ const close = open.map(m=>({ ...m, propertyTimings:m.propertyTimings.map(t=>({property:t.property, durationMs:180, delayMs:0, easing:"ease"})) }));
2016
+ const phases=[
2017
+ {id:"menu-dropdown:open", phase:"open", label:"Open", stateTarget:".wd-dd", fromState:null, toState:".is-open", members:open},
2018
+ {id:"menu-dropdown:close",phase:"close", label:"Close", stateTarget:".wd-dd", fromState:".is-open", toState:null, members:close},
2019
+ ];
2020
+ return [{ id:"menu-dropdown", label:"Menu dropdown", component:null, phases }];
2021
+ }
2022
+ // refine → deterministic, transitions.dev-token-aligned suggestions. patches
2023
+ // derive property/member from request.timings so Apply changes a real lane.
2024
+ // Shape mirrors relay.mjs's suggestion contract (id/kind/property/title/from/
2025
+ // to/patch/reason, + reference on the replace card).
2026
+ function _txMockSuggestions(request){
2027
+ const t = (request && request.timings) || [];
2028
+ const p0 = (t[0]&&t[0].property) || "opacity";
2029
+ const m0 = (t[0]&&t[0].member) || null;
2030
+ const p1 = (t[1]&&t[1].property) || p0;
2031
+ const m1 = (t[1]&&t[1].member) || m0;
2032
+ const from0 = _txMs(t[0]&&t[0].durationMs) || "—";
2033
+ const fromE = (t[0]&&t[0].easing) || "ease";
2034
+ const mp = (member, rest)=> member ? {member, ...rest} : rest;
2035
+ const suggestions=[
2036
+ { id:"mock-dur-fast", kind:"duration", property:p0, member:m0,
2037
+ title:"Duration → Fast", from:from0, to:"250ms",
2038
+ patch:mp(m0,{property:p0, durationMs:250}),
2039
+ reason:"Open motion snaps to the 250ms “medium” token — standard UI motion on transitions.dev." },
2040
+ { id:"mock-dur-quick", kind:"duration", property:p1, member:m1,
2041
+ title:"Duration → Quick", from:_txMs(t[1]&&t[1].durationMs)||from0, to:"150ms",
2042
+ patch:mp(m1,{property:p1, durationMs:150}),
2043
+ reason:"Closing feels crisper at the 150ms “fast” token — quick state changes." },
2044
+ { id:"mock-ease-smooth", kind:"easing", property:p0, member:m0,
2045
+ title:"Easing → Smooth ease out", from:fromE, to:_TX_EASE,
2046
+ patch:mp(m0,{property:p0, easing:_TX_EASE}),
2047
+ reason:"transitions.dev standard ease-out — a gentle decelerate to rest." },
2048
+ { id:"mock-replace-menu", kind:"replace", property:p0, member:m0,
2049
+ title:"Menu dropdown", reference:"05-menu-dropdown.md",
2050
+ patch:mp(m0,{property:"all", durationMs:250, easing:_TX_EASE}),
2051
+ reason:"Best-fit transitions.dev recipe (#05 Menu dropdown): scale 0.97→1 + fade, 250ms open / 150ms close on cubic-bezier(0.22, 1, 0.36, 1)." },
2052
+ ];
2053
+ return { suggestions, summary:"3 motion-token tweaks and 1 transitions.dev recipe match for the Menu dropdown." };
2054
+ }
2055
+ // statusLog so the panel's shimmer status copy has something to show.
2056
+ function _txMockLog(request, createdAt){
2057
+ const kind = request && request.kind;
2058
+ const head = kind==="scan" ? "Grouping transitions from your source…"
2059
+ : kind==="apply" ? "Writing “"+((request&&request.label)||"transition")+"” to your code…"
2060
+ : "Scanning “"+((request&&request.label)||"transition")+"”…";
2061
+ return [{message:head, at:createdAt}, {message:"Reading the Transitions.dev skill…", at:createdAt+350}];
2062
+ }
2063
+
1766
2064
  async function relayCreateJob(request){
2065
+ if(TX_MOCK){
2066
+ const id=_txMockId();
2067
+ _txMockJobs.set(id,{request,createdAt:Date.now()});
2068
+ return { id, status:"pending" };
2069
+ }
1767
2070
  const r = await fetch(RELAY_URL+"/jobs",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({request})});
1768
2071
  if(!r.ok) throw new Error("relay POST /jobs failed ("+r.status+")");
1769
2072
  return r.json(); // { id, status }
1770
2073
  }
1771
2074
  async function relayGetJob(id){
2075
+ if(TX_MOCK){
2076
+ const rec=_txMockJobs.get(id);
2077
+ if(!rec) return { id, status:"error", statusLog:[], result:null, error:"mock: unknown job" };
2078
+ const elapsed=Date.now()-rec.createdAt;
2079
+ const log=_txMockLog(rec.request, rec.createdAt);
2080
+ // ~800ms simulated work: the first poll(s) see "running", then "done".
2081
+ if(elapsed<800) return { id, status:"running", statusLog:[log[0]], result:null, error:null };
2082
+ return { id, status:"done", statusLog:log, result:_txMockResult(rec.request), error:null };
2083
+ }
1772
2084
  const r = await fetch(RELAY_URL+"/jobs/"+id);
1773
2085
  if(!r.ok) throw new Error("relay GET /jobs/:id failed ("+r.status+")");
1774
2086
  return r.json();
1775
2087
  }
1776
2088
  async function relayHealth(){
2089
+ if(TX_MOCK) return { ok:true, auto:true, llmAvailable:true, cliInstalled:true, jobs:0 };
1777
2090
  const r = await fetch(RELAY_URL+"/health");
1778
2091
  if(!r.ok) throw new Error("relay /health failed ("+r.status+")");
1779
2092
  return r.json(); // { ok, auto, llmAvailable, jobs }
@@ -1825,25 +2138,32 @@
1825
2138
  const names=[...new Set(matches.map(t=>t.member))];
1826
2139
  return names.length===1?names[0]:matches[0].member;
1827
2140
  }
1828
- // Apply button — "Apply" ⟷ "Applied" cross-blur text swap (transitions.dev 04,
1829
- // same mechanism as the Play/Pause button): both labels stack in one slot, the
1830
- // active one is sharp/opaque and the other blurred/transparent, and the slot
1831
- // width tweens between the two measured widths (--aw-a -> --aw-b). The gray
1832
- // checkmark draws its stroke path IN PLACE (transitions.dev 10, no rotate/bob/
1833
- // scale) just opacity fade + stroke-dashoffset draw. The same <button> node is
1834
- // reused across states so the swap slot and its widths persist.
2141
+ // Apply button — "Apply" ⟷ "Applied". On apply, a LEADING gray check is
2142
+ // revealed first and pushes the text to the right: .tl-apply-check tweens its
2143
+ // width 0 -> (icon + 6px gap) using the SAME resize token as the suffix slot
2144
+ // (--apply-resize-*, smooth ease out), so the check expansion + the "y" -> "ied"
2145
+ // suffix resize grow the button in one synchronized motion. The stem "Appl"
2146
+ // stays put and "y" "ied" cross-blur (transitions.dev 04). The check itself
2147
+ // stroke-draws IN PLACE (transitions.dev 10, no rotate/bob/scale/translate)
2148
+ // it is pinned to the slot's left edge and the overflow clip reveals it, so it
2149
+ // never translates. The same <button> node is reused across states.
1835
2150
  function ApplyBtn({applied,onApply}){
1836
2151
  const ref=useRef(null);
1837
2152
  const prev=useRef(applied);
1838
- // measure both labels once (after fonts/layout) and pin the two slot widths
1839
- // so the swap can tween between "Apply" and the checked "Applied".
2153
+ // measure both suffixes once (after fonts/layout) and pin the two slot widths
2154
+ // so the suffix can tween between "y" and the checked "ied".
1840
2155
  useLayoutEffect(()=>{
1841
2156
  const btn=ref.current;if(!btn)return;
1842
2157
  const sw=btn.querySelector(".tl-apply-swap"); if(!sw) return;
2158
+ const CHK_GAP=6; // space between the leading check and "Applied"
1843
2159
  const measure=()=>{
1844
- const a=sw.querySelector(".tl-apply-a"), b=sw.querySelector(".tl-apply-b");
1845
- if(a) sw.style.setProperty("--aw-a", Math.ceil(a.getBoundingClientRect().width)+"px");
1846
- if(b) sw.style.setProperty("--aw-b", Math.ceil(b.getBoundingClientRect().width)+"px");
2160
+ const y=sw.querySelector(".tl-apply-y"), ied=sw.querySelector(".tl-apply-ied");
2161
+ if(y) sw.style.setProperty("--sfx-y", Math.ceil(y.getBoundingClientRect().width)+"px");
2162
+ if(ied) sw.style.setProperty("--sfx-ied", Math.ceil(ied.getBoundingClientRect().width)+"px");
2163
+ // leading check slot target width = icon natural width + gap; it stays
2164
+ // measurable even while clipped (the inner .tl-sug-check keeps its size).
2165
+ const chk=sw.querySelector(".tl-sug-check");
2166
+ if(chk) sw.style.setProperty("--chk-w", (Math.ceil(chk.getBoundingClientRect().width)+CHK_GAP)+"px");
1847
2167
  };
1848
2168
  measure();
1849
2169
  if(document.fonts&&document.fonts.ready) document.fonts.ready.then(measure).catch(()=>{});
@@ -1873,23 +2193,32 @@
1873
2193
  className:cx("tl-pill-btn tl-apply-btn",applied&&"is-applied"),
1874
2194
  disabled:applied,onClick:applied?undefined:onApply},
1875
2195
  h("span",{className:"tl-apply-swap","data-state":applied?"applied":"apply"},
1876
- h("span",{className:"tl-apply-label tl-apply-a"},"Apply"),
1877
- h("span",{className:"tl-apply-label tl-apply-b"},
2196
+ h("span",{className:"tl-apply-check"},
1878
2197
  h("span",{className:"tl-sug-check","data-state":"out","aria-hidden":"true"},
1879
2198
  h("svg",{width:13,height:13,viewBox:"0 0 16 16",fill:"none",
1880
2199
  xmlns:"http://www.w3.org/2000/svg",style:{display:"block"}},
1881
2200
  h("path",{d:"M4 8.4268L6.46155 11.19223L12 4.97001",stroke:"currentColor",
1882
- strokeWidth:1.5,strokeLinecap:"round",strokeLinejoin:"round"}))),
1883
- "Applied")));
2201
+ strokeWidth:1.5,strokeLinecap:"round",strokeLinejoin:"round"})))),
2202
+ h("span",{className:"tl-apply-stem"},"Appl"),
2203
+ h("span",{className:"tl-apply-suffix"},
2204
+ h("span",{className:"tl-apply-seg tl-apply-y"},"y"),
2205
+ h("span",{className:"tl-apply-seg tl-apply-ied"},"ied"))));
1884
2206
  }
1885
- function RefineResults({suggestions,appliedIds,onApply,lanes}){
2207
+ function RefineResults({suggestions,appliedIds,onApply,lanes,revealedRef,active=true}){
1886
2208
  const ref=useRef(null);
1887
2209
  useEffect(()=>{
1888
2210
  const el=ref.current;if(!el)return;
2211
+ // Both type tabs keep their results mounted so they can cross-slide
2212
+ // (page-side-by-side). Only the visible page plays the staggered reveal,
2213
+ // and only once per scan; the hidden page just shows its final state so
2214
+ // switching tabs slides finished content in without re-staggering.
2215
+ if(!active){ el.classList.add("is-shown"); return; }
2216
+ if(revealedRef&&revealedRef.current){ el.classList.add("is-shown"); return; }
1889
2217
  el.classList.remove("is-shown");
1890
2218
  void el.offsetHeight; // reflow from the start state before playing
1891
2219
  el.classList.add("is-shown");
1892
- },[]);
2220
+ if(revealedRef) revealedRef.current=true;
2221
+ },[active]);
1893
2222
  const delay=(i)=>({transitionDelay:"calc(var(--stagger-stagger) * "+i+")"});
1894
2223
  // category chip label + color modifier, keyed by the suggestion kind
1895
2224
  const CAT={easing:["Easing","is-easing"],duration:["Duration","is-duration"],
@@ -1923,7 +2252,7 @@
1923
2252
  : h(React.Fragment,null,
1924
2253
  s.title&&h("div",{className:"tl-sug-title"},s.title),
1925
2254
  (s.from||s.to)&&h("div",{className:"tl-sug-delta"},
1926
- s.from&&h("span",{className:"tl-sug-from"},s.from),
2255
+ s.from&&h("span",{className:"tl-sug-from"},s.from),
1927
2256
  s.to&&h("span",{className:"tl-sug-to"},s.to))),
1928
2257
  s.reason&&h("div",{className:"tl-sug-reason"},s.reason)),
1929
2258
  applyBtn));
@@ -1937,6 +2266,11 @@
1937
2266
  const[modeOpen,setModeOpen]=useState(false);
1938
2267
  const[statusIx,setStatusIx]=useState(0);
1939
2268
  const modeRef=useRef(null);
2269
+ // The results reveal should play once per scan, not on every tab switch.
2270
+ // This persists across RefineResults remounts; reset it when a new scan
2271
+ // starts (or the panel returns to idle) so fresh results animate again.
2272
+ const revealedRef=useRef(false);
2273
+ useEffect(()=>{ if(phase==="scanning"||phase==="idle") revealedRef.current=false; },[phase]);
1940
2274
  useEffect(()=>{
1941
2275
  let raf, to;
1942
2276
  if(open){
@@ -1977,14 +2311,11 @@
1977
2311
  // null (unknown / still probing) is treated as ready so the panel doesn't
1978
2312
  // flash the "unavailable" copy before /health resolves.
1979
2313
  const agentReady = !agentMode || llmAvailable!==false;
1980
- const typeDesc = (REFINE_TYPES.find(t=>t.key===refineType)||REFINE_TYPES[0]).desc;
1981
2314
  const modeLabel = (REFINE_MODES.find(m=>m.key===mode)||REFINE_MODES[0]).label;
1982
- // Idle empty-state copy is mode-aware: Agent (llm) keeps the type's
1983
- // agent-oriented desc; Deterministic has no agent, so it describes the
1984
- // math-snap behavior (and that "replace" needs Agent mode).
1985
- const idleDesc = agentMode ? typeDesc : (refineType==="replace"
1986
- ? "Deterministic mode can't pick a recipe to replace the transition — switch to Agent mode for replacements."
1987
- : "Deterministic mode snaps your transition's timing to the nearest Transitions.dev motion tokens — mathematically, with no agent or credits.");
2315
+ // Idle empty-state copy is mode-aware (computed per type in bodyFor):
2316
+ // Agent (llm) keeps the type's agent-oriented desc; Deterministic has no
2317
+ // agent, so it describes the math-snap behavior (and that "replace" needs
2318
+ // Agent mode).
1988
2319
  // One persistent control for the whole foot: in idle/done/error it's the
1989
2320
  // pill button; while scanning it carries `.is-scanning` and the same DOM
1990
2321
  // node morphs (card resize) into the loading rectangle with the border-beam.
@@ -2005,25 +2336,58 @@
2005
2336
  h(ShimmerSwapText,{text:REFINE_STATUS[statusIx]}))));
2006
2337
  };
2007
2338
 
2008
- let body, foot, footBar=false;
2339
+ // Body content for a given refine type. Both type tabs render their own
2340
+ // body so they can cross-slide (page-side-by-side). Phase is global, so
2341
+ // only the type-specific bits (filtered suggestions, empty/idle copy)
2342
+ // change per page. `isActive` tells the results list whether to play its
2343
+ // reveal (only the visible page does).
2344
+ const bodyFor=(rt,isActive)=>{
2345
+ const vis = suggestions.filter(s=> rt==="replace" ? s.kind==="replace" : s.kind!=="replace");
2346
+ if(phase==="scanning"){
2347
+ return h("div",{className:"tl-refine-center"});
2348
+ }
2349
+ if(phase==="error"){
2350
+ return h("div",{className:"tl-refine-center"},
2351
+ h("div",{className:"tl-refine-unavail-title"},"Something went wrong."),
2352
+ h("div",{className:"tl-refine-unavail-text"},error||"The agent reported an error."));
2353
+ }
2354
+ if(phase==="done"){
2355
+ if(vis.length===0){
2356
+ const emptyMsg = rt==="replace"
2357
+ ? "I didn't find any transition that would be a good fit as a replacement."
2358
+ : "Already aligned to the transitions.dev motion tokens. Nothing to refine.";
2359
+ return h("div",{className:"tl-refine-center"},
2360
+ h("p",{className:"tl-refine-idle-text"},emptyMsg));
2361
+ }
2362
+ return h(RefineResults,{suggestions:vis,appliedIds,onApply,lanes:lanes||[],revealedRef,active:isActive});
2363
+ }
2364
+ // idle
2365
+ if(!agentReady){
2366
+ const cliMissing = cliInstalled===false;
2367
+ return h("div",{className:"tl-refine-center"},
2368
+ h("div",{className:"tl-refine-unavail-title"},"Agent unavailable."),
2369
+ cliMissing
2370
+ ? h("p",{className:"tl-refine-unavail-text"},"Install Cursor CLI by ",h("code",{className:"tl-code"},"/refine live")," command in your agent to enable live functionality.")
2371
+ : h("p",{className:"tl-refine-unavail-text"},"Run the ",h("code",{className:"tl-code"},"/refine live")," command in your agent to enable live functionality."));
2372
+ }
2373
+ const idleDescR = agentMode ? (REFINE_TYPES.find(t=>t.key===rt)||REFINE_TYPES[0]).desc : (rt==="replace"
2374
+ ? "Deterministic mode can't pick a recipe to replace the transition — switch to Agent mode for replacements."
2375
+ : "Deterministic mode snaps your transition's timing to the nearest Transitions.dev motion tokens — mathematically, with no agent or credits.");
2376
+ return h("div",{className:"tl-refine-center"},
2377
+ h("p",{className:"tl-refine-idle-text"},idleDescR));
2378
+ };
2379
+
2380
+ // Foot tracks the active type (it lives below the sliding body, so it
2381
+ // just updates in place on a tab switch).
2382
+ let foot, footBar=false;
2009
2383
  if(phase==="scanning"){
2010
- body = h("div",{className:"tl-refine-center"});
2011
2384
  foot = startBtn();
2012
2385
  } else if(phase==="error"){
2013
- body = h("div",{className:"tl-refine-center"},
2014
- h("div",{className:"tl-refine-unavail-title"},"Something went wrong."),
2015
- h("div",{className:"tl-refine-unavail-text"},error||"The agent reported an error."));
2016
2386
  foot = startBtn({label:"Try again"});
2017
2387
  } else if(phase==="done"){
2018
2388
  if(visible.length===0){
2019
- const emptyMsg = refineType==="replace"
2020
- ? "I didn't find any transition that would be a good fit as a replacement."
2021
- : "Already aligned to the transitions.dev motion tokens. Nothing to refine.";
2022
- body = h("div",{className:"tl-refine-center"},
2023
- h("p",{className:"tl-refine-idle-text"},emptyMsg));
2024
2389
  foot = startBtn({label:"Scan again"});
2025
2390
  } else {
2026
- body = h(RefineResults,{suggestions:visible,appliedIds,onApply,lanes:lanes||[]});
2027
2391
  footBar = true;
2028
2392
  foot = h(React.Fragment,null,
2029
2393
  h("button",{className:"tl-pill-btn",disabled:pending.length===0,
@@ -2032,19 +2396,7 @@
2032
2396
  h("button",{className:"tl-pill-btn",onClick:onStart},"Scan again"));
2033
2397
  }
2034
2398
  } else { // idle
2035
- if(!agentReady){
2036
- const cliMissing = cliInstalled===false;
2037
- body = h("div",{className:"tl-refine-center"},
2038
- h("div",{className:"tl-refine-unavail-title"},"Agent unavailable."),
2039
- cliMissing
2040
- ? 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.")
2041
- : h("p",{className:"tl-refine-unavail-text"},"Run the ",h("code",{className:"tl-code"},"/refine live")," command in your agent to enable live functionality."));
2042
- foot = startBtn({disabled:true});
2043
- } else {
2044
- body = h("div",{className:"tl-refine-center"},
2045
- h("p",{className:"tl-refine-idle-text"},idleDesc));
2046
- foot = startBtn({});
2047
- }
2399
+ foot = startBtn(agentReady?{}:{disabled:true});
2048
2400
  }
2049
2401
 
2050
2402
  return h("div",{className:"tl-refine-panel","data-open":panelOpen?"true":"false","data-phase":slidePhase,
@@ -2072,7 +2424,11 @@
2072
2424
  REFINE_TYPES.map(t=>h("button",{key:t.key,className:"tl-refine-tab",role:"tab",
2073
2425
  "aria-selected":refineType===t.key?"true":"false",
2074
2426
  onClick:()=>{if(t.key!==refineType)onType(t.key);}},t.label))),
2075
- h("div",{className:"tl-refine-body"},body),
2427
+ h("div",{className:"tl-refine-body tl-refine-pages t-page-slide","data-page":refineType==="replace"?"2":"1"},
2428
+ h("div",{className:"tl-refine-page t-page","data-page-id":"1","aria-hidden":refineType!=="small"},
2429
+ bodyFor("small",refineType==="small")),
2430
+ h("div",{className:"tl-refine-page t-page","data-page-id":"2","aria-hidden":refineType!=="replace"},
2431
+ bodyFor("replace",refineType==="replace"))),
2076
2432
  h("div",{className:cx("tl-refine-foot",footBar&&"is-bar")},foot)));
2077
2433
  }
2078
2434
 
@@ -2096,9 +2452,27 @@
2096
2452
  return out;
2097
2453
  }
2098
2454
 
2099
- function Header({entries,active,onSelect,onReset,onCopy,copied,snap,setSnap,onMinimize,onRefine,refineActive,onAccept,acceptState,acceptDisabled,acceptError,scanning,onRescan}){
2100
- const[pick,setPick]=useState(false);
2455
+ function Header({entries,active,onSelect,onReset,onCopy,copied,snap,setSnap,onMinimize,onRefine,refineActive,onAccept,acceptState,acceptDisabled,acceptError,scanning,onRescan,pick,setPick}){
2101
2456
  const[setg,setSetg]=useState(false);
2457
+ // settings dropdown sub-page (main menu ↔ keyboard-shortcuts cheat sheet)
2458
+ const[settingsView,setSettingsView]=useState("main"); // "main" | "shortcuts"
2459
+ // theme preference — UI only for now (dark theme not wired up yet)
2460
+ const[themePref,setThemePref]=useState("system"); // "light" | "dark" | "system"
2461
+ const THEME_OPTS=[{key:"light",label:"Light"},{key:"dark",label:"Dark"},{key:"system",label:"System"}];
2462
+ const isMac=typeof navigator!=="undefined"&&/mac/i.test((navigator.userAgentData&&navigator.userAgentData.platform)||navigator.platform||navigator.userAgent||"");
2463
+ const modKey=isMac?"Cmd":"Ctrl";
2464
+ // small gray shortcut hint appended inside a tooltip (e.g. "⌘A")
2465
+ const ttHint=(k)=>h("span",{className:"tl-tt-kbd"},(isMac?"\u2318":"Ctrl ")+k);
2466
+ // ⌫ = Backspace, ↑/↓ = arrows; letters/Enter shown verbatim
2467
+ const SHORTCUTS=[
2468
+ {label:"Accept changes",k:"Enter"},
2469
+ {label:"Reset values",k:"\u232B"},
2470
+ {label:"Copy values",k:"C"},
2471
+ {label:"Toggle Refine panel",k:"E"},
2472
+ {label:"Open transition picker",k:"K"},
2473
+ {label:"Previous transition",k:"\u2191"},
2474
+ {label:"Next transition",k:"\u2193"},
2475
+ ];
2102
2476
  const pickRef=useRef(null), gearRef=useRef(null);
2103
2477
  const[q,setQ]=useState("");
2104
2478
  useEffect(()=>{if(!pick)setQ("");},[pick]); // reset search when the picker closes
@@ -2152,28 +2526,65 @@
2152
2526
  h("span",{className:"tl-header-count"},
2153
2527
  scanning
2154
2528
  ? h("span",{className:"t-tt-wrap"},
2155
- h("span",{className:"tl-scan-note",tabIndex:0},"Agent scanning transitions…"),
2529
+ h("span",{className:"tl-scan-note",tabIndex:0},
2530
+ h(DotmLoader),
2531
+ h("span",{className:"t-shimmer tl-scan-shimmer","data-text":"Agent scanning transitions…"},"Agent scanning transitions…")),
2156
2532
  h("span",{className:"t-tt tl-tt-below tl-tt-msg",role:"tooltip"},
2157
2533
  "Agent is working on understanding your transitions and creating grouping and naming. This usually takes a few seconds to a minute."))
2158
2534
  : entries.length+" transition"+(entries.length===1?"":"s")+" found"),
2159
2535
  h("button",{ref:gearRef,className:cx("tl-icon-btn",setg&&"is-active"),title:"Settings",onClick:()=>setSetg(v=>!v)},h(Ic,{name:"dotsv"})),
2160
- h(Dropdown,{open:setg,onClose:()=>setSetg(false),triggerRef:gearRef,width:210,align:"right"},
2161
- h("div",{className:"tl-menu-head"},
2162
- h("span",null,"Transitions.dev ",h("span",{className:"tl-menu-head-name"},"Refine")),
2163
- h("span",{className:"tl-menu-head-ver"},REFINE_VERSION)),
2164
- h("div",{className:"tl-menu-divider"}),
2165
- h(MenuItem,{disabled:!active,onClick:()=>{onCopy&&onCopy();setSetg(false);},
2166
- left:h("span",{className:"tl-menu-icon"},h(Ic,{name:"copy"})),
2167
- right:copied&&h("span",{className:"tl-menu-check"},h(Ic,{name:"accept"}))},copied?"Copied":"Copy values"),
2168
- h(MenuItem,{disabled:scanning,onClick:()=>{setSetg(false);onRescan&&onRescan();},
2169
- left:h("span",{className:"tl-menu-icon"},h(Ic,{name:"restart"}))},
2170
- scanning?"Rescanning…":"Rescan transitions"),
2171
- h("div",{className:"tl-menu-divider"}),
2172
- h(MenuItem,{onClick:()=>{setSetg(false);window.open("https://transitions.dev","_blank","noopener");},
2173
- right:h("span",{className:"tl-menu-chevr"},h(Ic,{name:"chevronr",size:16}))},"Learn more")),
2536
+ h(Dropdown,{open:setg,onClose:()=>{setSetg(false);setSettingsView("main");},triggerRef:gearRef,width:240,align:"right"},
2537
+ h(SettingsSlide,{view:settingsView,
2538
+ onBack:()=>setSettingsView("main"),
2539
+ onClose:()=>{setSetg(false);setSettingsView("main");},
2540
+ // ── main settings page (Figma 580:4723) ──
2541
+ mainPage:h(React.Fragment,null,
2542
+ h(MenuItem,{disabled:!active,onClick:()=>{onCopy&&onCopy();setSetg(false);},
2543
+ left:h("span",{className:"tl-menu-icon"},h(Ic,{name:"copy"})),
2544
+ right:copied&&h("span",{className:"tl-menu-check"},h(Ic,{name:"accept"}))},copied?"Copied":"Copy values"),
2545
+ h(MenuItem,{disabled:scanning,onClick:()=>{setSetg(false);onRescan&&onRescan();},
2546
+ left:h("span",{className:"tl-menu-icon"},h(Ic,{name:"restart"}))},
2547
+ scanning?"Rescanning…":"Rescan transitions"),
2548
+ h("div",{className:"tl-menu-divider"}),
2549
+ // theme picker row — shows the current value + chevron; opens the sub-page
2550
+ h(MenuItem,{onClick:()=>setSettingsView("theme"),
2551
+ right:h("span",{className:"tl-menu-trail"},
2552
+ h("span",{className:"tl-menu-value"},(THEME_OPTS.find(o=>o.key===themePref)||{}).label),
2553
+ h("span",{className:"tl-menu-chevr"},h(Ic,{name:"chevronr",size:16})))},"Theme"),
2554
+ h(MenuItem,{onClick:()=>setSettingsView("shortcuts"),
2555
+ right:h("span",{className:"tl-menu-chevr"},h(Ic,{name:"chevronr",size:16}))},"Keyboard shortcuts"),
2556
+ h(MenuItem,{onClick:()=>{setSetg(false);window.open("https://transitions.dev","_blank","noopener");},
2557
+ right:h("span",{className:"tl-menu-chevr"},h(Ic,{name:"linkout",size:16}))},"Send feedback"),
2558
+ h(MenuItem,{onClick:()=>{setSetg(false);window.open("https://transitions.dev","_blank","noopener");},
2559
+ right:h("span",{className:"tl-menu-chevr"},h(Ic,{name:"linkout",size:16}))},"Learn more"),
2560
+ h("div",{className:"tl-menu-divider"}),
2561
+ h("div",{className:"tl-menu-head"},
2562
+ h("span",null,"Transitions.dev ",h("span",{className:"tl-menu-head-name"},"Refine")),
2563
+ h("span",{className:"tl-menu-head-ver"},REFINE_VERSION))),
2564
+ // ── theme sub-page (Light / Dark / System — System default) ──
2565
+ themePage:h(React.Fragment,null,
2566
+ h("button",{type:"button",className:"tl-set-back",onClick:()=>setSettingsView("main")},
2567
+ h("span",{className:"tl-set-back-ic"},h(Ic,{name:"chevronr",size:16})),
2568
+ h("span",{className:"tl-set-back-title"},"Theme")),
2569
+ h("div",{className:"tl-menu-divider"}),
2570
+ ...THEME_OPTS.map(o=>h(MenuItem,{key:o.key,onClick:()=>{setThemePref(o.key);setSettingsView("main");},
2571
+ right:themePref===o.key&&h("span",{className:"tl-menu-check"},h(Ic,{name:"accept"}))},o.label))),
2572
+ // ── keyboard-shortcuts sub-page ──
2573
+ shortcutsPage:h(React.Fragment,null,
2574
+ h("button",{type:"button",className:"tl-set-back",onClick:()=>setSettingsView("main")},
2575
+ h("span",{className:"tl-set-back-ic"},h(Ic,{name:"chevronr",size:16})),
2576
+ h("span",{className:"tl-set-back-title"},"Keyboard shortcuts")),
2577
+ h("div",{className:"tl-menu-divider"}),
2578
+ ...SHORTCUTS.map((s,i)=>h("div",{key:i,className:"tl-kbd-row"},
2579
+ h("span",{className:"tl-kbd-row-label"},s.label),
2580
+ h("span",{className:"tl-kbd-cluster"},
2581
+ h("kbd",{className:"tl-kbd"},modKey),
2582
+ h("span",{className:"tl-kbd-plus"},"+"),
2583
+ h("kbd",{className:"tl-kbd"},s.k)))))}),
2584
+ ),
2174
2585
  h("span",{className:"t-tt-wrap"},
2175
2586
  h("button",{className:"tl-sec-btn t-tt-trigger",disabled:!active,onClick:onReset},"Reset"),
2176
- h("span",{className:"t-tt tl-tt-below",role:"tooltip"},"Reset values")),
2587
+ h("span",{className:"t-tt tl-tt-below",role:"tooltip"},"Reset values",ttHint("\u232B"))),
2177
2588
  h("span",{className:"t-tt-wrap"},
2178
2589
  h("button",{className:cx("tl-accept-btn",acceptState==="saving"&&"is-saving",acceptState==="done"&&"is-done"),
2179
2590
  disabled:!active||acceptDisabled||acceptState==="saving"||acceptState==="done",onClick:onAccept,"aria-label":"Accept changes to your code"},
@@ -2185,8 +2596,9 @@
2185
2596
  acceptState==="error"&&acceptError?acceptError
2186
2597
  :acceptState==="done"?"Saved to your code"
2187
2598
  :acceptDisabled?"No changes to save"
2188
- :"Save changes to your codebase")),
2189
- h("button",{className:cx("tl-refine-btn",refineActive&&"is-active"),disabled:!active,onClick:onRefine},
2599
+ :h(React.Fragment,null,"Save changes to your codebase",ttHint("A")))),
2600
+ h("span",{className:"t-tt-wrap"},
2601
+ h("button",{className:cx("tl-refine-btn t-tt-trigger",refineActive&&"is-active"),disabled:!active,onClick:onRefine},
2190
2602
  h(Ic,{name:"wand"}),
2191
2603
  h("span",{className:"tl-refine-sparks","aria-hidden":"true"},
2192
2604
  h("i",{style:{"--ox":"-1px","--oy":"-3px","--sx":"0px","--sy":"-11px","--sd":"900ms","--sdelay":"0ms"}}),
@@ -2200,35 +2612,57 @@
2200
2612
  h("i",{style:{"--ox":"-5px","--oy":"-2px","--sx":"-9px","--sy":"-9px","--sd":"930ms","--sdelay":"180ms"}}),
2201
2613
  h("i",{style:{"--ox":"3px","--oy":"1px","--sx":"12px","--sy":"1px","--sd":"980ms","--sdelay":"300ms"}})),
2202
2614
  h("span",null,"Refine")),
2203
- h("button",{className:"tl-icon-btn ghost",title:"Minimize",onClick:onMinimize},h(Ic,{name:"minimize"})),
2615
+ h("span",{className:"t-tt tl-tt-below",role:"tooltip"},"Refine",ttHint("R"))),
2616
+ h("span",{className:"t-tt-wrap"},
2617
+ h("button",{className:"tl-icon-btn ghost t-tt-trigger","aria-label":"Minimize",onClick:onMinimize},h(Ic,{name:"minimize"})),
2618
+ h("span",{className:"t-tt tl-tt-below tl-tt-right",role:"tooltip"},"Minimize",ttHint("."))),
2204
2619
  );
2205
2620
  }
2206
2621
 
2207
2622
  function PropTrack({property, member, delayMs, durationMs, selected, onSelect, onDelayChange, onDurationChange, snap, scaleMs, lockDuration, labelW}){
2208
2623
  const trackRef=useRef(null);
2209
- const grid = snap ? 25 : 1;
2210
- 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]);
2624
+ const [dragging,setDragging]=useState(false);
2625
+ // 1ms resolution while dragging so the bar moves smoothly; the snap grid
2626
+ // (25ms) is applied only on release when `snap` is on.
2627
+ const SNAP_GRID = 25;
2628
+ 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]);
2211
2629
 
2212
2630
  const startDrag=useCallback((mode,e)=>{
2213
2631
  e.preventDefault(); e.stopPropagation();
2214
2632
  onSelect();
2215
2633
  // a spring controls its own duration, so its bar can be moved (delay) but not resized
2216
2634
  if(lockDuration && mode!=="move") return;
2635
+ setDragging(true);
2217
2636
  const startX=e.clientX; const sd=delayMs; const sdur=durationMs;
2637
+ let lastDel=sd, lastDur=sdur;
2218
2638
  const onMove=e2=>{
2219
- const dx=e2.clientX-startX; const dMs=pxToMs(dx);
2639
+ const dx=e2.clientX-startX; const dMs=pxToMs(dx,1); // smooth: 1ms steps
2220
2640
  if(mode==="move"){
2221
- onDelayChange(Math.max(0,Math.min(sd+dMs,scaleMs-sdur)));
2641
+ lastDel=Math.max(0,Math.min(sd+dMs,scaleMs-sdur));
2642
+ onDelayChange(lastDel);
2222
2643
  } else if(mode==="left"){
2223
- const rawDel=sd+dMs; const clampedDel=Math.max(0,Math.min(rawDel,sd+sdur-grid));
2224
- onDelayChange(clampedDel); onDurationChange(sdur-(clampedDel-sd));
2644
+ const rawDel=sd+dMs; const clampedDel=Math.max(0,Math.min(rawDel,sd+sdur-1));
2645
+ lastDel=clampedDel; lastDur=sdur-(clampedDel-sd);
2646
+ onDelayChange(lastDel); onDurationChange(lastDur);
2225
2647
  } else {
2226
- onDurationChange(Math.max(grid,Math.min(sdur+dMs,scaleMs-sd)));
2648
+ lastDur=Math.max(1,Math.min(sdur+dMs,scaleMs-sd));
2649
+ onDurationChange(lastDur);
2227
2650
  }
2228
2651
  };
2229
- const onUp=()=>{window.removeEventListener("mousemove",onMove);window.removeEventListener("mouseup",onUp);};
2652
+ const onUp=()=>{
2653
+ setDragging(false);
2654
+ // snap to the grid once, on release (bar tweens there since the
2655
+ // is-dragging transition lock is now removed).
2656
+ if(snap){
2657
+ const g=SNAP_GRID, sn=v=>Math.round(v/g)*g;
2658
+ if(mode==="move"){ onDelayChange(Math.max(0,Math.min(sn(lastDel),scaleMs-sdur))); }
2659
+ else if(mode==="left"){ onDelayChange(Math.max(0,sn(lastDel))); onDurationChange(Math.max(g,sn(lastDur))); }
2660
+ else { onDurationChange(Math.max(g,Math.min(sn(lastDur),scaleMs-sd))); }
2661
+ }
2662
+ window.removeEventListener("mousemove",onMove);window.removeEventListener("mouseup",onUp);
2663
+ };
2230
2664
  window.addEventListener("mousemove",onMove);window.addEventListener("mouseup",onUp);
2231
- },[delayMs,durationMs,pxToMs,grid,scaleMs,onSelect,onDelayChange,onDurationChange,lockDuration]);
2665
+ },[delayMs,durationMs,pxToMs,snap,scaleMs,onSelect,onDelayChange,onDurationChange,lockDuration]);
2232
2666
 
2233
2667
  const delPct=(delayMs/scaleMs)*100; const durPct=(durationMs/scaleMs)*100;
2234
2668
  return h("div",{className:cx("tl-prop-row",selected&&"selected",lockDuration&&"is-spring"),onClick:onSelect},
@@ -2237,7 +2671,7 @@
2237
2671
  member&&h("span",{className:"tl-prop-member"},member),
2238
2672
  h("span",{className:"tl-prop-prop"},property))),
2239
2673
  h("div",{className:"tl-prop-track",ref:trackRef},
2240
- h("div",{className:"tl-bar",style:{left:delPct+"%",width:durPct+"%"},onMouseDown:e=>startDrag("move",e),
2674
+ h("div",{className:cx("tl-bar",dragging&&"is-dragging"),style:{left:delPct+"%",width:durPct+"%"},onMouseDown:e=>startDrag("move",e),
2241
2675
  title:lockDuration?"Spring duration is derived \u2014 drag to move, resize is locked":undefined},
2242
2676
  h("span",{className:"tl-bar-handle left"}),
2243
2677
  h("span",{className:"tl-bar-handle right"}),
@@ -2257,7 +2691,8 @@
2257
2691
  const[dragging,setDragging]=useState(false);
2258
2692
  const[menu,setMenu]=useState(false);
2259
2693
  const pct = Math.min(Math.max((value - min) / (max - min), 0), 1) * 100;
2260
- const grid = snap ? step : Math.max(1, Math.round(step/5));
2694
+ // fine grid while dragging (smooth); the snap `step` is applied on release.
2695
+ const dragGrid = Math.max(1, Math.round(step/5));
2261
2696
  const selectAll = useCallback(e=>{
2262
2697
  const el = e && e.target ? e.target : inputRef.current;
2263
2698
  if(!el) return;
@@ -2265,25 +2700,27 @@
2265
2700
  requestAnimationFrame(()=>el.select());
2266
2701
  },[]);
2267
2702
 
2268
- const setFromX = useCallback(clientX => {
2703
+ const setFromX = useCallback((clientX, g) => {
2269
2704
  if(!trackRef.current) return;
2270
2705
  const rect = trackRef.current.getBoundingClientRect();
2271
2706
  const ratio = Math.max(0, Math.min((clientX - rect.left) / rect.width, 1));
2272
2707
  const raw = min + ratio * (max - min);
2273
- const snapped = Math.round(raw / grid) * grid;
2708
+ const gg = g || 1;
2709
+ const snapped = Math.round(raw / gg) * gg;
2274
2710
  onChange(Math.max(min, Math.min(max, snapped)));
2275
- },[min,max,grid,onChange]);
2711
+ },[min,max,onChange]);
2276
2712
 
2277
2713
  const startDrag = useCallback(e=>{
2278
2714
  if(editing || readOnly) return;
2279
2715
  e.preventDefault();
2280
2716
  setDragging(true);
2281
- setFromX(e.clientX);
2282
- const onMove = e2 => setFromX(e2.clientX);
2283
- const onUp = () => { setDragging(false); window.removeEventListener("mousemove",onMove); window.removeEventListener("mouseup",onUp); };
2717
+ let lastX = e.clientX;
2718
+ setFromX(e.clientX, dragGrid); // smooth fine steps during drag
2719
+ const onMove = e2 => { lastX = e2.clientX; setFromX(e2.clientX, dragGrid); };
2720
+ const onUp = () => { setDragging(false); if(snap) setFromX(lastX, step); window.removeEventListener("mousemove",onMove); window.removeEventListener("mouseup",onUp); };
2284
2721
  window.addEventListener("mousemove",onMove);
2285
2722
  window.addEventListener("mouseup",onUp);
2286
- },[setFromX,editing]);
2723
+ },[setFromX,editing,readOnly,snap,step,dragGrid]);
2287
2724
 
2288
2725
  const beginEdit = useCallback(e=>{ e.stopPropagation(); if(readOnly) return; setDraft(String(value)); setEditing(true); },[value,readOnly]);
2289
2726
  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]);
@@ -2317,15 +2754,12 @@
2317
2754
  }
2318
2755
 
2319
2756
  // shared plot geometry for both the easing curve and the spring curve.
2320
- // Compact, landscape box: small horizontal padding (bezier x is clamped 0..1
2321
- // so there is no horizontal overshoot to reserve room for) makes the curve
2322
- // fill the width, while PAD_Y keeps the 0..1 band vertically centered with
2323
- // equal headroom above/below so overshoot/undershoot handles (clamped to the
2324
- // -0.5..1.5 range) stay visible instead of being clipped at the edges.
2325
- // square plot with equal padding on all sides — standard cubic-bezier
2326
- // graph ratio (easing.dev): the 0→1 region is a square, centered, with
2327
- // matching overshoot headroom above 1 and below 0.
2328
- const CURVE = { VBW:240, VBH:240, PAD_X:48, PAD_Y:48 };
2757
+ // Square plot with equal padding on all sides (standard cubic-bezier graph
2758
+ // ratio, easing.dev): the 0→1 region is centered with identical top/bottom
2759
+ // (and left/right) margins. The box no longer reserves room for overshoot
2760
+ // overshoot/undershoot handles overflow the box freely (the curve container
2761
+ // is overflow:visible) so dragging is never clamped to the rectangle.
2762
+ const CURVE = { VBW:240, VBH:240, PAD_X:24, PAD_Y:24 };
2329
2763
 
2330
2764
  // Unified "Preview" section (Figma node 580:11158) — collapsible, collapsed by
2331
2765
  // default (transitions.dev · 21-accordion). Holds BOTH the timing curve card
@@ -2828,6 +3262,9 @@
2828
3262
  const[panelHeight,setPanelHeight]=useState(440);
2829
3263
  const[resizing,setResizing]=useState(false);
2830
3264
  const[snap,setSnap]=useState(true);
3265
+ // transition picker open state — lifted out of Header so the panel-focused
3266
+ // Cmd/Ctrl+K shortcut can open it.
3267
+ const[pickOpen,setPickOpen]=useState(false);
2831
3268
  // ── refine ──
2832
3269
  const[refineOpen,setRefineOpen]=useState(false);
2833
3270
  const[refinePhase,setRefinePhase]=useState("idle"); // idle | scanning | done | error
@@ -2836,6 +3273,10 @@
2836
3273
  const[refineSuggestions,setRefineSuggestions]=useState([]);
2837
3274
  const[refineSummary,setRefineSummary]=useState(null);
2838
3275
  const[refineError,setRefineError]=useState(null);
3276
+ // deterministic scans answer almost instantly, so enforce a 2s floor on the
3277
+ // "scanning" phase for that mode only (LLM scans stay immediate).
3278
+ const scanStartedAtRef=useRef(0);
3279
+ const scanModeRef=useRef("llm");
2839
3280
  // ── accept (write to source) ──
2840
3281
  const[acceptState,setAcceptState]=useState("idle"); // idle | saving | done | error
2841
3282
  const[acceptError,setAcceptError]=useState(null);
@@ -2876,33 +3317,76 @@
2876
3317
  refreshHealth();
2877
3318
  },[active,refineOpen,refreshHealth]);
2878
3319
  // "Start scanning" resolves availability first, then posts the job.
2879
- const startScan=useCallback(async()=>{
3320
+ // per-(transition,type) result cache so switching tabs reuses a prior scan
3321
+ // instead of re-asking the agent. In llm mode each scan is scoped to the
3322
+ // active tab (Small = token tweaks, Replace = recipe) so the common Small
3323
+ // view skips the recipe selection + reference-file read. Deterministic stays
3324
+ // a single instant "both" pass that feeds both tabs.
3325
+ const refineCacheRef=useRef(new Map()); // `${id}::${type}` -> {suggestions,summary}
3326
+ const refineScanTypeRef=useRef("small"); // which type the in-flight job targets
3327
+ // results are not comparable across transitions — drop the cache when the
3328
+ // target transition changes.
3329
+ useEffect(()=>{refineCacheRef.current.clear();},[active&&active.id]);
3330
+ // recompute the displayed suggestions from whatever types are cached for a
3331
+ // transition (the panel filters by kind per tab, so a merged array is fine).
3332
+ const applyMerged=useCallback((id)=>{
3333
+ const c=refineCacheRef.current;
3334
+ const both=c.get(id+"::both");
3335
+ if(both){setRefineSuggestions(both.suggestions||[]);setRefineSummary(both.summary||null);return;}
3336
+ const small=c.get(id+"::small"), repl=c.get(id+"::replace");
3337
+ setRefineSuggestions([...(small?small.suggestions:[]),...(repl?repl.suggestions:[])]);
3338
+ setRefineSummary((small&&small.summary)||(repl&&repl.summary)||null);
3339
+ },[]);
3340
+ const startScan=useCallback(async(typeArg,opts)=>{
2880
3341
  if(!active)return;
2881
- setRefinePhase("scanning");setRefineLog([]);
2882
- setRefineSuggestions([]);setRefineSummary(null);setRefineError(null);
2883
- setAppliedIds({});setRefineLabel(active.label);setRefineJobId(null);
2884
- const j=await refreshHealth();
2885
- const avail=j?!!j.llmAvailable:false;
3342
+ opts=opts||{};
3343
+ // Resolve availability from the continuously-polled /health (2s cadence)
3344
+ // rather than a serial probe on the click path. Unknown (null) counts as
3345
+ // available so the first click isn't blocked; a dead relay surfaces via
3346
+ // the POST below (→ error state).
3347
+ const avail=llmAvailable!==false;
2886
3348
  const mode=(refineMode==="llm"&&!avail)?"deterministic":refineMode;
2887
3349
  if(mode!==refineMode)setRefineMode(mode);
3350
+ scanModeRef.current=mode;
3351
+ // deterministic → one "both" pass (instant, free). llm → scope to the
3352
+ // active tab so Small skips recipe work; Replace asks for it on demand.
3353
+ // NOTE: this is also wired as a button onClick, so typeArg may be a click
3354
+ // event — only honour it when it's an explicit "small"/"replace" string.
3355
+ const reqType=(typeArg==="small"||typeArg==="replace")?typeArg:refineType;
3356
+ const type=mode==="deterministic"?"both":reqType;
3357
+ refineScanTypeRef.current=type;
3358
+ scanStartedAtRef.current=Date.now();
3359
+ setRefinePhase("scanning");setRefineLog([]);setRefineError(null);
3360
+ setRefineLabel(active.label);setRefineJobId(null);
3361
+ if(!opts.keepApplied)setAppliedIds({});
3362
+ // re-scan only this type; keep the other tab's cached result for the merge.
3363
+ refineCacheRef.current.delete(active.id+"::"+type);
2888
3364
  try{
2889
3365
  const et=active.effectiveTimings||[];
2890
3366
  const timings=et.map(t=>({property:t.property,member:t.member||null,durationMs:t.durationMs,delayMs:t.delayMs,easing:t.easing}));
2891
3367
  const selector=(active.bindings&&active.bindings.selector)||(et[0]&&et[0].selector)||null;
2892
- // always scan for BOTH kinds (token tweaks + a whole-transition replacement)
2893
- // in one pass; the tabs just filter which kind they show.
2894
3368
  const{id}=await relayCreateJob({transitionId:active.id,label:active.label,selector,
2895
- phase:active.phase||null,group:active.groupLabel||null,timings,mode,refineType:"both"});
3369
+ phase:active.phase||null,group:active.groupLabel||null,timings,mode,refineType:type});
2896
3370
  setRefineJobId(id);
2897
3371
  }catch(e){
2898
3372
  setRefinePhase("error");
2899
3373
  setRefineError(h(React.Fragment,null,"Couldn't reach the refine relay. Start it with ",h("code",{className:"tl-code"},"npx transitions-refine live"),"."));
2900
3374
  }
2901
- },[active,refineMode,refineType,refreshHealth]);
2902
- const changeRefineMode=useCallback((mode)=>{setRefineMode(mode);setRefinePhase("idle");refreshHealth();},[refreshHealth]);
2903
- // switching tabs only changes which kind of suggestion is shown it never
2904
- // interrupts a scan or discards results, since one scan covers both kinds.
2905
- const changeRefineType=useCallback((t)=>{setRefineType(t);},[]);
3375
+ },[active,refineMode,refineType,llmAvailable]);
3376
+ const changeRefineMode=useCallback((mode)=>{setRefineMode(mode);setRefinePhase("idle");refineCacheRef.current.clear();refreshHealth();},[refreshHealth]);
3377
+ // Switching tabs reuses a cached scan when present; otherwise (llm only) it
3378
+ // scans the newly-selected type on demand the trade-off for not scanning
3379
+ // the recipe upfront. Deterministic feeds both tabs from one "both" pass, so
3380
+ // its tab switch is a pure filter. Before any scan (idle/error), tabs just
3381
+ // preview copy and never auto-scan.
3382
+ const changeRefineType=useCallback((t)=>{
3383
+ setRefineType(t);
3384
+ if(!active)return;
3385
+ if(scanModeRef.current==="deterministic")return;
3386
+ if(refinePhase!=="done"&&refinePhase!=="scanning")return;
3387
+ if(refineCacheRef.current.has(active.id+"::"+t)){applyMerged(active.id);setRefinePhase("done");return;}
3388
+ startScan(t,{keepApplied:true});
3389
+ },[active,refinePhase,applyMerged,startScan]);
2906
3390
  // poll the relay while a job is running
2907
3391
  useEffect(()=>{
2908
3392
  if(!refineJobId||refinePhase!=="scanning")return;
@@ -2912,12 +3396,27 @@
2912
3396
  const job=await relayGetJob(refineJobId);
2913
3397
  if(!live)return;
2914
3398
  if(Array.isArray(job.statusLog))setRefineLog(job.statusLog);
2915
- if(job.status==="done"){setRefineSuggestions((job.result&&job.result.suggestions)||[]);setRefineSummary(job.result&&job.result.summary);setRefinePhase("done");return;}
3399
+ if(job.status==="done"){
3400
+ try{console.log("[refine] "+refineScanTypeRef.current+" job done in "+(Date.now()-scanStartedAtRef.current)+"ms");}catch{}
3401
+ const finish=()=>{if(!live)return;
3402
+ // cache this scan's result under its type, then show the merged set
3403
+ // (so the other tab's cached suggestions stay visible).
3404
+ const type=refineScanTypeRef.current;
3405
+ const id=active?active.id:"";
3406
+ refineCacheRef.current.set(id+"::"+type,{suggestions:(job.result&&job.result.suggestions)||[],summary:(job.result&&job.result.summary)||null});
3407
+ if(active)applyMerged(active.id);else{setRefineSuggestions((job.result&&job.result.suggestions)||[]);setRefineSummary(job.result&&job.result.summary);}
3408
+ setRefinePhase("done");};
3409
+ // deterministic mode floors the scanning phase at 2s so it doesn't flash by.
3410
+ const minMs=scanModeRef.current==="deterministic"?2000:0;
3411
+ const wait=Math.max(0,minMs-(Date.now()-scanStartedAtRef.current));
3412
+ if(wait>0){to=setTimeout(finish,wait);}else{finish();}
3413
+ return;
3414
+ }
2916
3415
  if(job.status==="error"){setRefineError(job.error||"The agent reported an error.");setRefinePhase("error");return;}
2917
- to=setTimeout(tick,500);
3416
+ to=setTimeout(tick,200);
2918
3417
  }catch(e){if(live){setRefineError("Lost connection to the relay.");setRefinePhase("error");}}
2919
3418
  };
2920
- to=setTimeout(tick,400);
3419
+ to=setTimeout(tick,200);
2921
3420
  return()=>{live=false;if(to)clearTimeout(to);};
2922
3421
  },[refineJobId,refinePhase]);
2923
3422
  const applySuggestion=useCallback((s)=>{
@@ -2951,7 +3450,7 @@
2951
3450
  if(typeof document==="undefined")return;
2952
3451
  const applied=liveAppliedRef.current;
2953
3452
  const desired=new Map(); // el → css
2954
- const add=(els,css)=>{ for(const el of els){ if(!el||(el.closest&&el.closest("[data-timeline-panel]")))continue; desired.set(el,css); } };
3453
+ const add=(els,css)=>{ for(const el of els){ if(!el||(el.closest&&el.closest("[data-timeline-panel],[data-tl-ui]")))continue; desired.set(el,css); } };
2955
3454
  for(const item of entries){
2956
3455
  const ov=registry.getPropOverrides(item.id);
2957
3456
  if(!ov||!Object.keys(ov).length)continue; // only edited transitions go live
@@ -3019,8 +3518,29 @@
3019
3518
  }else{
3020
3519
  text="transition: "+et.map(decl).join(",\n ")+";";
3021
3520
  }
3022
- navigator.clipboard.writeText(text).then(()=>{setCopied(true);setTimeout(()=>setCopied(false),1500);}).catch(()=>{});
3023
- showToast("Values copied");
3521
+ const onCopied=()=>{setCopied(true);setTimeout(()=>setCopied(false),1500);showToast("Values copied");};
3522
+ const onCopyFail=()=>{showToast("Couldn't copy");};
3523
+ // navigator.clipboard needs a secure context (https/localhost); when the
3524
+ // host page is plain http — or Safari blocks the async API — fall back to
3525
+ // a hidden-textarea execCommand("copy") so copy still works everywhere.
3526
+ const legacyCopy=()=>{
3527
+ try{
3528
+ const ta=document.createElement("textarea");
3529
+ ta.value=text; ta.setAttribute("readonly","");
3530
+ ta.style.position="fixed"; ta.style.top="-9999px"; ta.style.opacity="0";
3531
+ document.body.appendChild(ta);
3532
+ ta.focus(); ta.select();
3533
+ try{ta.setSelectionRange(0,text.length);}catch(e){}
3534
+ const ok=document.execCommand("copy");
3535
+ document.body.removeChild(ta);
3536
+ ok?onCopied():onCopyFail();
3537
+ }catch(e){onCopyFail();}
3538
+ };
3539
+ try{
3540
+ if(navigator.clipboard&&navigator.clipboard.writeText){
3541
+ navigator.clipboard.writeText(text).then(onCopied).catch(legacyCopy);
3542
+ }else legacyCopy();
3543
+ }catch(e){legacyCopy();}
3024
3544
  },[active,showToast]);
3025
3545
  const resetOverrides=useCallback(()=>{if(active)registry.clearOverride(active.id);},[registry,active]);
3026
3546
  // Accept → send an "apply" job so the agent writes the edited timings into
@@ -3100,6 +3620,11 @@
3100
3620
  if(scanTokenRef.current!==token)return;
3101
3621
  const flat=registry.getAll().filter(e=>e.kind!=="phase");
3102
3622
  if(!flat.length){setGroupScanState("idle");return;}
3623
+ // Hardcoded grouping (window.__TX_SEED_GROUPS): a host/demo page can ship a
3624
+ // pre-computed agent grouping so the panel groups instantly with NO relay
3625
+ // or agent round-trip. demo.html uses this to bake in the agent's scan.
3626
+ const seed=typeof window!=="undefined"&&Array.isArray(window.__TX_SEED_GROUPS)?window.__TX_SEED_GROUPS:null;
3627
+ if(seed&&seed.length){registry.setGroups(seed);setGroupScanState("done");return;}
3103
3628
  // valid cache (same content signature) → apply instantly, skip the agent.
3104
3629
  // A stale cache (different sig, e.g. content changed) falls through to a
3105
3630
  // fresh scan and is overwritten below.
@@ -3148,7 +3673,12 @@
3148
3673
  // (signature match, no agent) or kicks off a fresh agent scan. Rescan is
3149
3674
  // manual after that.
3150
3675
  useEffect(()=>{
3151
- if(!live||didGroupScanRef.current)return;
3676
+ // A host/demo page can hardcode a grouping via window.__TX_SEED_GROUPS so
3677
+ // the panel shows it WITHOUT a live agent (demo.html bakes in the agent's
3678
+ // initial scan this way). Otherwise the agent group-scan stays gated behind
3679
+ // a live agent as usual.
3680
+ const seeded=typeof window!=="undefined"&&Array.isArray(window.__TX_SEED_GROUPS)&&window.__TX_SEED_GROUPS.length;
3681
+ if((!live&&!seeded)||didGroupScanRef.current)return;
3152
3682
  didGroupScanRef.current=true;
3153
3683
  runGroupScan();
3154
3684
  },[live,runGroupScan]);
@@ -3203,27 +3733,93 @@
3203
3733
  // small "Agent scanning transitions…" note in the header count
3204
3734
  // slot. A failed scan just leaves the flat list (Rescan in
3205
3735
  // Settings to retry) — it never traps the panel.
3736
+ // ── keyboard shortcuts (panel-focused, Cmd/Ctrl based) ──
3737
+ // Fire only when the panel is focused or hovered so we never hijack the
3738
+ // host app. A latest-values ref keeps the single window listener stable.
3739
+ const panelRootRef=useRef(null);
3740
+ const hoveredRef=useRef(false);
3741
+ const panelHotRef=useRef(false);
3742
+ const recomputeHot=useCallback(()=>{
3743
+ const el=panelRootRef.current;
3744
+ panelHotRef.current=hoveredRef.current||!!(el&&document.activeElement&&el.contains(document.activeElement));
3745
+ },[]);
3746
+ const kbdRef=useRef({});
3747
+ kbdRef.current={entries,active,acceptState,acceptDisabled:computeChanges(active).length===0,minimized,
3748
+ openRefine,resetOverrides,copyValues,onAccept,setActiveId,setMinimized,setPickOpen};
3749
+ useEffect(()=>{
3750
+ const onKey=e=>{
3751
+ const mod=e.metaKey||e.ctrlKey;
3752
+ if(!mod)return;
3753
+ const L=kbdRef.current;
3754
+ // Cmd+. always restores a minimized panel (panel root isn't in the DOM
3755
+ // then, so the hot/input gates can't apply). All other combos require
3756
+ // the panel to be focused/hovered and not typing in a field.
3757
+ const isRestore=e.key==="."&&L.minimized;
3758
+ recomputeHot();
3759
+ if(!isRestore){
3760
+ if(!panelHotRef.current)return;
3761
+ const t=e.target;
3762
+ if(t&&(t.tagName==="INPUT"||t.tagName==="TEXTAREA"||t.isContentEditable))return;
3763
+ }
3764
+ const handle=fn=>{e.preventDefault();e.stopPropagation();fn&&fn();};
3765
+ switch(e.key){
3766
+ case"a":case"A": // Accept changes (same enabled condition as the Accept button)
3767
+ handle(()=>{if(L.active&&!L.acceptDisabled&&L.acceptState!=="saving"&&L.acceptState!=="done")L.onAccept&&L.onAccept();});
3768
+ break;
3769
+ case"Backspace": // Reset values
3770
+ handle(()=>{if(L.active)L.resetOverrides&&L.resetOverrides();});
3771
+ break;
3772
+ case"c":case"C":{ // Copy values — only when no text is selected (native copy wins otherwise)
3773
+ const sel=window.getSelection();
3774
+ if(sel&&!sel.isCollapsed)return;
3775
+ handle(()=>{if(L.active)L.copyValues&&L.copyValues();});
3776
+ break;}
3777
+ case"r":case"R": // Toggle Refine panel
3778
+ handle(()=>{if(L.active)L.openRefine&&L.openRefine();});
3779
+ break;
3780
+ case"k":case"K": // Open transition picker
3781
+ handle(()=>{if(L.active)L.setPickOpen&&L.setPickOpen(true);});
3782
+ break;
3783
+ case"ArrowUp":
3784
+ case"ArrowDown":{ // Previous / next transition
3785
+ const list=L.entries||[];
3786
+ if(!list.length)break;
3787
+ const dir=e.key==="ArrowUp"?-1:1;
3788
+ const cur=L.active?list.findIndex(x=>x.id===L.active.id):-1;
3789
+ let ni=cur<0?(dir>0?0:list.length-1):cur+dir;
3790
+ ni=Math.max(0,Math.min(list.length-1,ni));
3791
+ if(list[ni])handle(()=>L.setActiveId&&L.setActiveId(list[ni].id));
3792
+ break;}
3793
+ case".": // Minimize / restore panel
3794
+ handle(()=>L.setMinimized&&L.setMinimized(v=>!v));
3795
+ break;
3796
+ }
3797
+ };
3798
+ window.addEventListener("keydown",onKey);
3799
+ return()=>window.removeEventListener("keydown",onKey);
3800
+ },[recomputeHot]);
3206
3801
  const gate = !live
3207
3802
  ? (llmAvailable===null ? "loading" : "blocked")
3208
3803
  : "ready";
3209
3804
  return h(React.Fragment,null,
3210
- render&&h("div",{className:"t-panel-slide","data-timeline-panel":true,
3805
+ render&&h("div",{className:"t-panel-slide","data-timeline-panel":true,ref:panelRootRef,
3806
+ onMouseEnter:()=>{hoveredRef.current=true;},onMouseLeave:()=>{hoveredRef.current=false;},
3211
3807
  "data-open":panelOpen?"true":"false","data-phase":phase,style:{height:panelHeight+"px"}},
3212
3808
  h("div",{className:cx("tl-resize-handle",resizing&&"dragging"),onMouseDown:startResize,title:"Drag to resize"}),
3213
3809
  h("div",{className:"tl-panel-body"},
3214
3810
  gate==="ready"
3215
3811
  ? h(React.Fragment,null,
3216
- h("div",{className:"tl-panel-main"},
3217
- h(Header,{entries,active,onSelect:setActiveId,onReset:resetOverrides,onCopy:copyValues,copied,
3218
- snap,setSnap,onMinimize:()=>setMinimized(true),onRefine:openRefine,refineActive:refineOpen,
3219
- onAccept,acceptState,acceptDisabled:computeChanges(active).length===0,acceptError,
3220
- scanning:groupScanState==="scanning",onRescan:rescanTransitions}),
3221
- active
3222
- ?h(Body,{entry:active,onPropChange:(prop,o)=>setPropOverride(prop,o),snap})
3223
- :h("div",{className:"tl-empty"},"Select a transition to inspect and edit it")),
3224
- h(RefinePanel,{open:refineOpen,onClose:()=>setRefineOpen(false),phase:refinePhase,label:refineLabel,
3225
- refineType,onType:changeRefineType,suggestions:refineSuggestions,summary:refineSummary,error:refineError,
3226
- appliedIds,onApply:applySuggestion,onApplyAll:applyAllSuggestions,
3812
+ h("div",{className:"tl-panel-main"},
3813
+ h(Header,{entries,active,onSelect:setActiveId,onReset:resetOverrides,onCopy:copyValues,copied,
3814
+ snap,setSnap,onMinimize:()=>setMinimized(true),onRefine:openRefine,refineActive:refineOpen,
3815
+ onAccept,acceptState,acceptDisabled:computeChanges(active).length===0,acceptError,
3816
+ scanning:groupScanState==="scanning",onRescan:rescanTransitions,pick:pickOpen,setPick:setPickOpen}),
3817
+ active
3818
+ ?h(Body,{entry:active,onPropChange:(prop,o)=>setPropOverride(prop,o),snap})
3819
+ :h("div",{className:"tl-empty"},"Select a transition to inspect and edit it")),
3820
+ h(RefinePanel,{open:refineOpen,onClose:()=>setRefineOpen(false),phase:refinePhase,label:refineLabel,
3821
+ refineType,onType:changeRefineType,suggestions:refineSuggestions,summary:refineSummary,error:refineError,
3822
+ appliedIds,onApply:applySuggestion,onApplyAll:applyAllSuggestions,
3227
3823
  mode:refineMode,onMode:changeRefineMode,llmAvailable,cliInstalled,onStart:startScan,
3228
3824
  lanes:active?.effectiveTimings||[]}))
3229
3825
  : h("div",{className:"tl-panel-main"},
@@ -3234,21 +3830,57 @@
3234
3830
  "Please run the ",h("code",{className:"tl-code"},"/refine live"),
3235
3831
  " command in your agent to enable live features, such as scanning and refining transitions.")))),
3236
3832
  toast&&createPortal(
3237
- h("div",{className:"tl-toast-wrap","aria-live":"polite"},
3833
+ h("div",{className:"tl-toast-wrap","data-tl-ui":"","aria-live":"polite"},
3238
3834
  h("div",{className:cx("tl-toast",toast.closing&&"is-closing")},
3239
3835
  h("span",{className:"tl-toast-ic"},
3240
3836
  toast.loader?h(DotmLoader):h(Ic,{name:"accept",size:14})),
3241
- h("span",null,toast.msg))),
3837
+ toast.loader
3838
+ ? h("span",{className:"t-shimmer tl-scan-shimmer","data-text":toast.msg},toast.msg)
3839
+ : h("span",null,toast.msg))),
3242
3840
  document.body),
3243
3841
  ),
3244
3842
  ),
3245
- minimized&&h("div",{className:"tl-pill",onClick:()=>setMinimized(false)},
3843
+ minimized&&h("div",{className:"tl-pill","data-tl-ui":"",onClick:()=>setMinimized(false)},
3246
3844
  h("span",{className:"tl-pill-label"},"Transitions"),
3247
3845
  h("span",{className:cx("tl-pill-count",String(entries.length).length===1&&"is-single")},entries.length)),
3248
3846
  );
3249
3847
  }
3250
3848
 
3251
3849
  // ── demo boxes ──
3850
+ // Hardcoded grouping — the transitions.dev agent's initial scan of this demo
3851
+ // (run once via the live relay), baked in so the panel groups the morph
3852
+ // instantly with NO relay/agent round-trip. Member labels are cleaned up from
3853
+ // the agent's output ("Morph container"→"Container", "Morph menu"→"Menu panel").
3854
+ // Edit here if the morph's transitions change, or delete window.__TX_SEED_GROUPS
3855
+ // to fall back to a live agent scan. Shape mirrors relay.mjs buildScanPrompt's
3856
+ // groups[] (the same structure TransitionRegistry.setGroups consumes). This is
3857
+ // below the inject CUT_MARKER, so it never ships in the injected/website build.
3858
+ const _mEO="cubic-bezier(0.22, 1, 0.36, 1)"; // smooth ease-out (open)
3859
+ const _mEM="cubic-bezier(0.34, 1.25, 0.64, 1)"; // morph ease (close settle)
3860
+ const _mpt=(property,durationMs,easing)=>({property,durationMs,delayMs:0,easing});
3861
+ const MORPH_SEED_GROUPS=[
3862
+ {id:"morph",label:"Plus \u2192 menu morph",component:null,phases:[
3863
+ {id:"morph:open",phase:"open",label:"Open",stateTarget:".t-morph",fromState:null,toState:'[data-open="true"]',members:[
3864
+ {id:"morph-container",label:"Container",selector:".t-morph",propertyTimings:[
3865
+ _mpt("width",250,_mEO),_mpt("height",250,_mEO),_mpt("border-radius",250,_mEO)]},
3866
+ {id:"morph-plus",label:"Plus button",selector:".t-morph .t-morph-plus",propertyTimings:[
3867
+ _mpt("opacity",200,_mEO),_mpt("transform",350,_mEO),_mpt("filter",200,_mEO)]},
3868
+ {id:"morph-menu",label:"Menu panel",selector:".t-morph .t-morph-menu",propertyTimings:[
3869
+ _mpt("opacity",200,_mEO),_mpt("transform",350,_mEO),_mpt("filter",200,_mEO)]}]},
3870
+ {id:"morph:close",phase:"close",label:"Close",stateTarget:".t-morph",fromState:'[data-open="true"]',toState:null,members:[
3871
+ {id:"morph-container",label:"Container",selector:".t-morph",propertyTimings:[
3872
+ _mpt("width",400,_mEM),_mpt("height",350,_mEM),_mpt("border-radius",350,_mEM)]},
3873
+ {id:"morph-plus",label:"Plus button",selector:".t-morph .t-morph-plus",propertyTimings:[
3874
+ _mpt("opacity",200,_mEO),_mpt("transform",350,_mEO),_mpt("filter",200,_mEO)]},
3875
+ {id:"morph-menu",label:"Menu panel",selector:".t-morph .t-morph-menu",propertyTimings:[
3876
+ _mpt("opacity",200,_mEO),_mpt("transform",350,_mEO),_mpt("filter",200,_mEO)]}]}]},
3877
+ {id:"morph-item",label:"Menu item hover",component:null,phases:[
3878
+ {id:"morph-item:hover",phase:"hover",label:"Hover",stateTarget:".t-morph-menu button.morph-item",fromState:null,toState:":hover",members:[
3879
+ {id:"morph-item-bg",label:"Menu item",selector:".t-morph-menu button.morph-item",propertyTimings:[
3880
+ _mpt("background",120,"ease")]}]}]},
3881
+ ];
3882
+ if(typeof window!=="undefined")window.__TX_SEED_GROUPS=MORPH_SEED_GROUPS;
3883
+
3252
3884
  // Plus → menu morph — transitions.dev (20-plus-menu-morph.md). The trigger
3253
3885
  // button morphs into the panel it opens; CSS owns the morph, JS only flips
3254
3886
  // data-open and mirrors it to aria-expanded, closing on outside-click / Escape.
@@ -3387,9 +4019,20 @@
3387
4019
  // to show them for testing. (This whole block is below the inject CUT_MARKER,
3388
4020
  // so it never ships in the injected build.)
3389
4021
  const showControls=(()=>{try{return new URLSearchParams(location.search).has("controls");}catch(e){return false;}})();
4022
+ // TESTING ONLY (not part of the tool): top-right toggle to hide the demo
4023
+ // page content so the panel can be inspected against a blank page.
4024
+ const[demoHidden,setDemoHidden]=useState(false);
3390
4025
  return h(TimelineCtx.Provider,{value:ctx},
3391
4026
  showControls&&h(PanelControls),
3392
- 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.")),
4027
+ h("button",{type:"button",onClick:()=>setDemoHidden(v=>!v),
4028
+ title:"Testing only: hide/show demo page content",
4029
+ style:{position:"fixed",top:"12px",right:"12px",zIndex:2147483000,
4030
+ height:"28px",padding:"0 10px",borderRadius:"8px",cursor:"pointer",
4031
+ border:"1px solid rgba(0,0,0,0.12)",background:"#fff",color:"#444",
4032
+ font:"500 12px/1 ui-sans-serif,system-ui,sans-serif",
4033
+ boxShadow:"0 1px 3px rgba(0,0,0,0.12)"}},
4034
+ demoHidden?"Show demo":"Hide demo"),
4035
+ h("div",{ref:rootRef,className:"demo-root",style:{display:demoHidden?"none":undefined}},h("div",{className:"demo-header"},h("h1",null,"Timeline Inspector \u2014 Demo"),h("p",null,"A single transitions.dev element: the plus \u2192 menu morph. Its transitions are auto-detected \u2014 select one below, drag the bars to edit timing, then interact with the component itself to preview the change.")),
3393
4036
  h("div",{className:"demo-grid"},h(BoxMorph))),
3394
4037
  h(TimelinePanel));
3395
4038
  }