transitions-refine 0.3.23 → 0.3.25

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
@@ -96,6 +96,38 @@
96
96
  min-height: 100vh;
97
97
  -webkit-font-smoothing: antialiased;
98
98
  -moz-osx-font-smoothing: grayscale;
99
+ /* Smooth ease out (transitions.dev) for the demo-page bg toggle */
100
+ transition: background-color 250ms cubic-bezier(0.22, 1, 0.36, 1);
101
+ }
102
+ /* demo-page-only dark background — independent of the panel's data-tl-theme */
103
+ body[data-demo-bg="dark"] { background: #121212; }
104
+ /* the only unavoidable legibility tweak: the bare H1 sits directly on the page bg */
105
+ body[data-demo-bg="dark"] .demo-header h1 { color: #f5f5f5; }
106
+
107
+ /* demo-chrome background toggle — sits beside the Hide-demo button */
108
+ .demo-bg-toggle {
109
+ position: fixed; top: 12px; right: 104px; z-index: 2147483000;
110
+ display: inline-flex; align-items: center; gap: 6px;
111
+ height: 28px; padding: 0 10px; border-radius: 8px; cursor: pointer;
112
+ border: 1px solid rgba(0,0,0,0.12); background: #fff; color: #444;
113
+ font: 500 12px/1 ui-sans-serif, system-ui, sans-serif;
114
+ box-shadow: 0 1px 3px rgba(0,0,0,0.12);
115
+ transition: background-color 250ms cubic-bezier(0.22, 1, 0.36, 1),
116
+ color 250ms cubic-bezier(0.22, 1, 0.36, 1),
117
+ border-color 250ms cubic-bezier(0.22, 1, 0.36, 1);
118
+ }
119
+ .demo-bg-toggle:hover { border-color: rgba(0,0,0,0.22); }
120
+ .demo-bg-toggle[aria-pressed="true"] {
121
+ background: #121212; color: #ededed; border-color: rgba(255,255,255,0.18);
122
+ box-shadow: 0 1px 3px rgba(0,0,0,0.4);
123
+ }
124
+ .demo-bg-toggle[aria-pressed="true"]:hover { border-color: rgba(255,255,255,0.3); }
125
+ /* small swatch previewing the bg this toggle controls */
126
+ .demo-bg-toggle .demo-bg-dot {
127
+ width: 10px; height: 10px; border-radius: 50%; background: #121212;
128
+ box-shadow: inset 0 0 0 1px rgba(255,255,255,0.25); }
129
+ @media (prefers-reduced-motion: reduce) {
130
+ body, .demo-bg-toggle { transition: none; }
99
131
  }
100
132
 
101
133
  .demo-root { max-width: 900px; margin: 0 auto; padding: 24px 24px 120px; }
@@ -494,9 +526,10 @@
494
526
  .tl-refine-btn:disabled::before { background: var(--c-blue-bg); background: color(display-p3 0 0.451 0.898 / 0.06); }
495
527
  .tl-refine-btn:disabled span { color: var(--c-blue-pressed); opacity: 0.5; }
496
528
  /* tiny 1.5px sparks flying out along each of the wand's spark rays on hover */
497
- .tl-refine-sparks { position: absolute; left: 23px; top: 15px; width: 0; height: 0; z-index: 3; pointer-events: none; }
529
+ .tl-refine-sparks { position: absolute; left: 23px; top: 15px; width: 0; height: 0; z-index: 3; pointer-events: none;
530
+ color: var(--c-blue-pressed); }
498
531
  .tl-refine-sparks i { position: absolute; left: 0; top: 0; width: 1.5px; height: 1.5px; border-radius: 50%;
499
- background: var(--c-blue); opacity: 0; }
532
+ background: currentColor; opacity: 0; }
500
533
  @keyframes tl-refine-spark {
501
534
  0% { opacity: 0; transform: translate(var(--ox, 0), var(--oy, 0)) scale(0.4); }
502
535
  20% { opacity: 1; transform: translate(calc(var(--ox, 0) + var(--sx, 0) * 0.2), calc(var(--oy, 0) + var(--sy, -12px) * 0.2)) scale(1); }
@@ -513,6 +546,20 @@
513
546
  .tl-main { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
514
547
  .tl-inspector { flex: 0 0 280px; padding: 16px 16px; display: flex; flex-direction: column;
515
548
  border-left: 1px solid var(--c-line); min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
549
+ /* thin overlay-style scrollbars — scoped to the panel scroll containers so the
550
+ host page's own scrollbar is never touched */
551
+ .tl-inspector, .tl-rows, .tl-refine-body, .tl-refine-pages .t-page {
552
+ scrollbar-width: thin; scrollbar-color: rgba(0,0,0,0.18) transparent; }
553
+ .tl-inspector::-webkit-scrollbar, .tl-rows::-webkit-scrollbar,
554
+ .tl-refine-body::-webkit-scrollbar, .tl-refine-pages .t-page::-webkit-scrollbar { width: 6px; height: 6px; }
555
+ .tl-inspector::-webkit-scrollbar-track, .tl-rows::-webkit-scrollbar-track,
556
+ .tl-refine-body::-webkit-scrollbar-track, .tl-refine-pages .t-page::-webkit-scrollbar-track { background: transparent; }
557
+ .tl-inspector::-webkit-scrollbar-thumb, .tl-rows::-webkit-scrollbar-thumb,
558
+ .tl-refine-body::-webkit-scrollbar-thumb, .tl-refine-pages .t-page::-webkit-scrollbar-thumb {
559
+ background: rgba(0,0,0,0.18); border-radius: 8px; border: 1px solid transparent; background-clip: padding-box; }
560
+ .tl-inspector::-webkit-scrollbar-thumb:hover, .tl-rows::-webkit-scrollbar-thumb:hover,
561
+ .tl-refine-body::-webkit-scrollbar-thumb:hover, .tl-refine-pages .t-page::-webkit-scrollbar-thumb:hover {
562
+ background: rgba(0,0,0,0.28); background-clip: padding-box; }
516
563
  /* title sits centered in the ~52px band between the Refine toolbar and the
517
564
  Duration field: 16px above + 20px title + 16px below = 52px */
518
565
  .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; }
@@ -557,26 +604,48 @@
557
604
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
558
605
  /* 16px left inset of the plot area (kept in sync with the ruler) */
559
606
  .tl-prop-track { position: relative; flex: 1; user-select: none; margin-left: 16px; }
560
- /* timeline line — capsule (fully rounded), Figma #eeeeef default / #e9e9e9 hover */
561
- .tl-bar { position: absolute; top: 50%; height: 24px; transform: translateY(-50%); background: var(--c-track);
562
- border-radius: 8px; min-width: 24px; cursor: grab;
563
- box-shadow: 0 1px 3px 0 rgba(0,0,0,0.08), inset 0 0 0 1px rgba(0,0,0,0.06), inset 0 -1px 0 0 rgba(0,0,0,0.1), inset 0 0 0 1px rgba(196,196,196,0.1);
564
- /* card resize (transitions.dev #01): tween left/width when timing changes
565
- programmatically (Apply, snap, value-field edit). Suppressed mid-drag
566
- (.is-dragging) so the bar tracks the cursor 1:1. */
567
- transition: background 0.12s ease,
607
+ /* timeline line — rubber-band drag physics. card resize tweens left/width
608
+ when timing changes programmatically; suppressed mid-drag (.is-dragging)
609
+ so the bar tracks the cursor 1:1. */
610
+ .tl-bar { position: absolute; top: 50%;
611
+ height: 24px; min-width: 24px; cursor: grab;
612
+ transform: translateY(-50%) skewX(var(--rb-skew,0deg)) scale(var(--rb-sx,1),var(--rb-sy,1));
613
+ transform-origin: var(--rb-origin,50%) 50%;
614
+ transition: transform 70ms linear,
568
615
  left 220ms cubic-bezier(0.22, 1, 0.36, 1),
569
616
  width 220ms cubic-bezier(0.22, 1, 0.36, 1); will-change: left, width; }
570
- .tl-bar.is-dragging { transition: background 0.12s ease; }
617
+ .tl-bar.is-dragging { transition: transform 70ms linear; }
618
+ /* one-shot guard for move-mouseup: keeps left/width transition disabled
619
+ until the final released position has fully flushed to React state */
620
+ .tl-bar.no-geom-tween { transition: transform 70ms linear !important; }
621
+ /* keep release geometry stable: clearing rubber vars should not nudge the
622
+ dragged edge after mouseup, so transform resets instantly on release */
623
+ .tl-bar.rb-release { transition: left 220ms cubic-bezier(0.22,1,0.36,1),
624
+ width 220ms cubic-bezier(0.22,1,0.36,1); }
571
625
  @media (prefers-reduced-motion: reduce) {
572
- .tl-bar { transition: background 0.12s ease !important; } }
573
- .tl-prop-row:hover .tl-bar, .tl-prop-row.selected .tl-bar { background: var(--c-track-h); }
626
+ .tl-bar { transition: left 220ms cubic-bezier(0.22,1,0.36,1), width 220ms cubic-bezier(0.22,1,0.36,1) !important; } }
627
+ /* Bent capsule is a real SVG path: the 1px ring is a gradient stroke that
628
+ follows the bend exactly, the drop-shadow filter follows the painted path
629
+ (exact Figma shadow 580:8961), and the outline is re-synced to the bar's
630
+ real size by a ResizeObserver so it can never detach from the handles. */
631
+ .tl-bar-body { position: absolute; inset: 0; overflow: visible; pointer-events: none;
632
+ filter: drop-shadow(0 1px 3px rgba(0,0,0,0.08)); }
633
+ .tl-bar-body path { fill: var(--c-track); stroke: url(#rb-ring); stroke-width: 1;
634
+ transition: fill 0.12s ease; }
635
+ .tl-bar.rb-release .tl-bar-body path { transition: fill 0.12s ease, d 200ms cubic-bezier(0.22,1,0.36,1); }
636
+ .tl-prop-row:hover .tl-bar-body path, .tl-prop-row.selected .tl-bar-body path { fill: var(--c-track-h); }
574
637
  .tl-bar:active { cursor: grabbing; }
575
- /* resize handles — Figma node 13044:2625: 2×14px, #767676 @60%, centered, ~5px edge inset */
576
- .tl-bar-handle { position: absolute; top: 50%; transform: translateY(-50%); width: 2px; height: 14px; border-radius: 20px;
577
- background: var(--c-thumb); opacity: 0; transition: opacity 0.12s ease; pointer-events: none; }
578
- .tl-bar-handle.left { left: 5px; }
579
- .tl-bar-handle.right { right: 5px; }
638
+ /* resize handles — Figma 625:1959 ")" crescent stroke on the leading edge */
639
+ .tl-bar-handle { position: absolute; top: 50%; width: 8px; height: 14px;
640
+ transform: translateY(-50%) translateX(var(--rb-hshift,0px));
641
+ opacity: 0; transition: opacity 0.12s ease, transform 70ms linear; pointer-events: none; z-index: 1; }
642
+ .tl-bar.rb-release .tl-bar-handle { transition: opacity 0.12s ease, transform 200ms cubic-bezier(0.22,1,0.36,1); }
643
+ .tl-bar-handle svg { display: block; width: 100%; height: 100%; overflow: visible; }
644
+ .tl-bar-handle path { fill: none; stroke: var(--c-thumb); stroke-width: 2; stroke-linecap: round;
645
+ opacity: 0.6; transition: d 70ms linear; }
646
+ .tl-bar.rb-release .tl-bar-handle path { transition: d 200ms cubic-bezier(0.22,1,0.36,1); }
647
+ .tl-bar-handle.left { left: 2px; }
648
+ .tl-bar-handle.right { right: 2px; }
580
649
  .tl-prop-row:hover .tl-bar-handle { opacity: 0.6; }
581
650
  .tl-bar:active .tl-bar-handle,
582
651
  .tl-bar:has(.tl-bar-grip:active) .tl-bar-handle { opacity: 1; }
@@ -737,9 +806,10 @@
737
806
  transition: color 0.12s ease; }
738
807
  .tl-preview-head:hover .tl-preview-title,
739
808
  .tl-preview-head:hover .tl-preview-chevron { color: #000; }
740
- .tl-preview-title { font-size: 13px; font-weight: 500; color: #171717; letter-spacing: 0; line-height: 18px; }
809
+ .tl-preview-title { font-size: 13px; font-weight: 500; color: #171717; letter-spacing: 0; line-height: 18px;
810
+ transition: color 0.12s ease; }
741
811
  .tl-preview-chevron { display: inline-flex; color: #171717;
742
- transform: scaleY(1); transform-origin: center; transition: transform var(--acc-dur) var(--acc-ease); }
812
+ transform: scaleY(1); transform-origin: center; transition: transform var(--acc-dur) var(--acc-ease), color 0.12s ease; }
743
813
  .tl-preview[data-open="true"] .tl-preview-chevron { transform: scaleY(-1); }
744
814
  .tl-preview-panel { display: grid; grid-template-rows: 0fr;
745
815
  transition: grid-template-rows var(--acc-dur) var(--acc-ease); }
@@ -1192,7 +1262,7 @@
1192
1262
  }
1193
1263
  .tl-refine-inner {
1194
1264
  width: var(--refine-w, 360px); height: 100%; box-sizing: border-box;
1195
- display: flex; flex-direction: column;
1265
+ display: flex; flex-direction: column; position: relative;
1196
1266
  background: #fff; color: var(--c-text);
1197
1267
  font-family: "Inter", system-ui, -apple-system, sans-serif;
1198
1268
  }
@@ -1223,7 +1293,7 @@
1223
1293
  /* refine-type tabs — toolbar strip (Figma 587:9258): 48px white bar, hairline
1224
1294
  bottom divider, segments inset 16px and packed gap:0 like a segmented control */
1225
1295
  .tl-refine-tabs { flex: 0 0 auto; box-sizing: border-box; display: flex; align-items: center; gap: 0;
1226
- height: 48px; padding: 0 16px; background: #fff; border-bottom: 1px solid #f0f0f0; }
1296
+ height: 48px; padding: 0 16px 0 10px; background: #fff; border-bottom: 1px solid #f0f0f0; }
1227
1297
  .tl-refine-tab { height: 32px; padding: 6px 12px; border: none; background: transparent; cursor: pointer;
1228
1298
  border-radius: 8px; font: inherit; font-size: 13px; font-weight: 500; line-height: 14px; color: #676767;
1229
1299
  transition: background 0.14s ease, color 0.14s ease; }
@@ -1370,6 +1440,39 @@
1370
1440
  /* scanning: cross-blur to the status face */
1371
1441
  .tl-scan-morph.is-scanning .tl-scan-face-label { opacity: 0; filter: blur(var(--icon-swap-blur)); transform: scale(0.98); }
1372
1442
  .tl-scan-morph.is-scanning .tl-scan-face-status { opacity: 1; filter: blur(0); transform: scale(1); }
1443
+ /* exit (scan done → liquid split): pill stays put on the stage while status
1444
+ text cross-blurs out and the chrome/beam fade so the goo blobs underneath
1445
+ can take over without a position jump. */
1446
+ /* The exiting pill lives inside the overlay and collapses along the SAME
1447
+ 300ms curve and box values as the blobs' vertical travel — its centered
1448
+ label + loader ride down with the goo in perfect lockstep, and it can
1449
+ never disagree with the blobs about height. The stage below is just a
1450
+ spacer driving the foot's height tween. */
1451
+ .tl-scan-morph.is-exiting { position: absolute;
1452
+ width: auto !important; height: auto !important;
1453
+ margin: 0; pointer-events: none; z-index: 2;
1454
+ box-shadow: none !important; transition: none !important;
1455
+ animation: tl-scan-pill-exit 300ms cubic-bezier(0.22, 1, 0.36, 1) both; }
1456
+ @keyframes tl-scan-pill-exit {
1457
+ from { left: 4px; right: 4px; top: -41px; bottom: 24px; }
1458
+ to { left: 0; right: 0; top: 0; bottom: 0; } }
1459
+ /* the goo layer owns the pill's outline (fill + shadow) during exit — the
1460
+ exiting pill drops its own shadow and inset ring so they don't stack into
1461
+ a doubled outline. Only its white fill (::before) lingers, cross-fading to
1462
+ reveal the identical white blobs underneath, plus the fading text. */
1463
+ .tl-scan-morph.is-exiting::after { opacity: 0 !important; animation: none !important; }
1464
+ .tl-scan-morph.is-exiting .tl-scan-beam { animation: tl-scan-chrome-out var(--icon-swap-dur) var(--icon-swap-ease) forwards; }
1465
+ /* fill drops fast (150ms) so the blobs own the surface before they start
1466
+ visibly shrinking away from the pill's box; text follows at 250ms */
1467
+ .tl-scan-morph.is-exiting::before { animation: tl-scan-chrome-out 150ms ease-out forwards; }
1468
+ @keyframes tl-scan-chrome-out {
1469
+ from { opacity: 1; }
1470
+ to { opacity: 0; } }
1471
+ .tl-scan-morph.is-exiting .tl-scan-face-status {
1472
+ animation: tl-scan-status-out var(--icon-swap-dur) var(--icon-swap-ease) forwards; }
1473
+ @keyframes tl-scan-status-out {
1474
+ from { opacity: 1; filter: blur(0); transform: scale(1); }
1475
+ to { opacity: 0; filter: blur(var(--icon-swap-blur)); transform: scale(0.98); } }
1373
1476
  .tl-scan-label { white-space: nowrap; }
1374
1477
  /* The BorderBeam library injects its own stylesheet that forces
1375
1478
  position:relative on its [data-beam] container (and it lands later in the
@@ -1412,10 +1515,10 @@
1412
1515
  transition: background 150ms ease-out, border-color 150ms ease-out, opacity 150ms ease-out; }
1413
1516
  .tl-gate-recheck:hover { background: rgba(0, 0, 0, 0.07); border-color: rgba(0, 0, 0, 0.12); }
1414
1517
  .tl-gate-recheck:disabled { opacity: 0.55; cursor: default; }
1415
- html[data-theme="dark"] .tl-gate-reason { color: #7e7e7e; }
1416
- html[data-theme="dark"] .tl-gate-recheck { color: #d4d4d4; background: rgba(255, 255, 255, 0.06);
1518
+ html[data-tl-theme="dark"] .tl-gate-reason { color: #7e7e7e; }
1519
+ html[data-tl-theme="dark"] .tl-gate-recheck { color: #d4d4d4; background: rgba(255, 255, 255, 0.06);
1417
1520
  border-color: rgba(255, 255, 255, 0.12); }
1418
- html[data-theme="dark"] .tl-gate-recheck:hover { background: rgba(255, 255, 255, 0.1);
1521
+ html[data-tl-theme="dark"] .tl-gate-recheck:hover { background: rgba(255, 255, 255, 0.1);
1419
1522
  border-color: rgba(255, 255, 255, 0.16); }
1420
1523
  @media (prefers-reduced-motion: reduce) { .tl-gate-recheck { transition: none; } }
1421
1524
  @media (prefers-reduced-motion: reduce) {
@@ -1471,7 +1574,7 @@
1471
1574
  animation: tl-live-in 250ms cubic-bezier(0.22,1,0.36,1) both; }
1472
1575
  .tl-live-main { display: inline-flex; align-items: center; gap: 5px; flex: none; }
1473
1576
  .tl-live-label { font-size: 12px; font-weight: 400; line-height: 14px; color: #18a32f; white-space: nowrap; }
1474
- html[data-theme="dark"] .tl-live-label { color: #4ade80; }
1577
+ html[data-tl-theme="dark"] .tl-live-label { color: #70e984; }
1475
1578
  .tl-live-dot { width: 8px; height: 8px; border-radius: 50%; flex: none;
1476
1579
  background: #34CC20; box-shadow: inset 0 0 0 0.5px rgba(0,182,30,0.55);
1477
1580
  animation: tl-live-pulse 1600ms linear infinite; }
@@ -1486,9 +1589,9 @@
1486
1589
  color 150ms cubic-bezier(0.22,1,0.36,1); }
1487
1590
  .tl-live-stop:hover,
1488
1591
  .tl-live-stop:active { background: rgba(68,167,84,0.2); color: #057217; }
1489
- html[data-theme="dark"] .tl-live-stop { color: #4ade80; }
1490
- html[data-theme="dark"] .tl-live-stop:hover,
1491
- html[data-theme="dark"] .tl-live-stop:active { color: #86efac; }
1592
+ html[data-tl-theme="dark"] .tl-live-stop { color: #70e984; }
1593
+ html[data-tl-theme="dark"] .tl-live-stop:hover,
1594
+ html[data-tl-theme="dark"] .tl-live-stop:active { color: #8ef09e; }
1492
1595
  .tl-live-stop:focus-visible { outline: 2px solid #118c25; outline-offset: 1px; }
1493
1596
  /* LIVE OFF — node 594:22912 (gray, no button) */
1494
1597
  .tl-live-chip.is-stopped { width: auto; justify-content: flex-start; padding: 0 9px;
@@ -1498,7 +1601,7 @@
1498
1601
  .tl-live-chip.is-stopped .tl-live-dot { background: #A1A1A1;
1499
1602
  box-shadow: inset 0 0 0 0.5px rgba(146,146,146,0.6); animation: none; }
1500
1603
  .tl-live-chip.is-stopped .tl-live-label { color: #525252; }
1501
- html[data-theme="dark"] .tl-live-chip.is-stopped .tl-live-label { color: #a1a1a1; }
1604
+ html[data-tl-theme="dark"] .tl-live-chip.is-stopped .tl-live-label { color: #848484; }
1502
1605
  @keyframes tl-live-in { from { opacity: 0; transform: scale(0.97); } to { opacity: 1; transform: scale(1); } }
1503
1606
  @keyframes tl-live-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }
1504
1607
  @media (prefers-reduced-motion: reduce) {
@@ -1668,6 +1771,86 @@
1668
1771
  .tl-refine-foot.is-bar { padding: 8px 16px; gap: 8px; justify-content: stretch;
1669
1772
  background: #fff; border-top: 1px solid #f0f0f0; }
1670
1773
  .tl-refine-foot.is-bar .tl-pill-btn { flex: 1; align-self: stretch; }
1774
+ /* Loading pill → two footer buttons: gooey/liquid split. During exit the
1775
+ scanning pill stays mounted (is-exiting) while status text cross-blurs out;
1776
+ goo blobs start inset-0 on the same 49px stage, then the stage shrinks to
1777
+ 32px as the blobs split — no footer/pill position jump. */
1778
+ /* vertical geometry (stage height + foot padding) settles FAST (220ms) while
1779
+ the horizontal split runs the full 300ms — the box is already at its final
1780
+ 32px height while the blobs finish sliding apart, so the ending is calm. */
1781
+ .tl-liquid-stage { position: relative; width: 100%; height: 49px; margin-bottom: 20px;
1782
+ animation: tl-liquid-stage-shrink 220ms cubic-bezier(0.22, 1, 0.36, 1) forwards; }
1783
+ @keyframes tl-liquid-stage-shrink {
1784
+ to { height: 32px; margin-bottom: 0; } }
1785
+ /* The overlay is anchored to the FOOT's bottom at the bar's exact resting
1786
+ geometry (bottom 8 / sides 16 / height 32) — NOT to the shrinking stage.
1787
+ The foot's bottom edge never moves, so blobs, chrome and labels live at
1788
+ the final position/size for the entire morph; only the fading pill rides
1789
+ the stage. Handoff to the real bar is then pixel-identical. */
1790
+ .tl-liquid-morph { position: absolute; left: 16px; right: 16px; bottom: 8px; height: 32px; }
1791
+ .tl-liquid-goo-svg { position: absolute; width: 0; height: 0; }
1792
+ .tl-liquid-goo { position: absolute; inset: 0; pointer-events: none; z-index: 1;
1793
+ filter: url(#tl-goo) drop-shadow(0 1px 3px rgba(0,0,0,0.08)) drop-shadow(0 0 1px rgba(0,0,0,0.14));
1794
+ /* hand-off: goo cross-fades OUT (250–430ms) in the same window the real
1795
+ buttons cross-fade IN — the surface swaps ownership gradually, so the
1796
+ overlay unmount at 470ms removes an already-invisible layer */
1797
+ animation: tl-liquid-goo-out 180ms ease 250ms both; }
1798
+ @keyframes tl-liquid-goo-out {
1799
+ from { opacity: 1; }
1800
+ to { opacity: 0; } }
1801
+ /* split runs at the transitions.dev "Smooth ease out" token (300ms) — kept
1802
+ in sync with the stage shrink above so blobs land as the box reaches 32px */
1803
+ .tl-liquid-blob { position: absolute; background: #fff; border-radius: 36px;
1804
+ animation-duration: 300ms; animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
1805
+ animation-fill-mode: both; }
1806
+ .tl-liquid-blob-a { animation-name: tl-liquid-a; }
1807
+ .tl-liquid-blob-b { animation-name: tl-liquid-b; }
1808
+ /* from = the scanning pill's box expressed in the overlay's (final-row)
1809
+ coordinates: pill top sits 41px above the row, its bottom 24px above the
1810
+ row's bottom, and it's 4px wider each side (foot pad 20 vs bar pad 16). */
1811
+ @keyframes tl-liquid-a {
1812
+ from { left: 4px; right: 4px; top: -41px; bottom: 24px; border-radius: 60px; }
1813
+ to { left: 0; right: calc(50% + 4px); top: 0; bottom: 0; border-radius: 36px; } }
1814
+ @keyframes tl-liquid-b {
1815
+ from { left: 4px; right: 4px; top: -41px; bottom: 24px; border-radius: 60px; }
1816
+ to { left: calc(50% + 4px); right: 0; top: 0; bottom: 0; border-radius: 36px; } }
1817
+ .tl-liquid-btns { position: absolute; inset: 0; display: flex; gap: 8px; z-index: 3; }
1818
+ /* Two independent fades on the real buttons:
1819
+ — LABELS cross-blur in immediately (0–250ms), mirroring the pill label
1820
+ cross-blurring out — the icon-swap pattern, per the pill's own faces.
1821
+ — CHROME (fill + ring + shadow) fades in at 250–430ms, the exact window
1822
+ the goo fades out, so the surface swaps ownership gradually. */
1823
+ .tl-liquid-btns .tl-pill-btn { flex: 1; align-self: stretch;
1824
+ background: transparent; box-shadow: none;
1825
+ animation: tl-liquid-btn-chrome 180ms ease 250ms both; }
1826
+ .tl-liquid-btns .tl-pill-btn > span {
1827
+ animation: tl-liquid-label-in 250ms ease-in-out both; }
1828
+ @keyframes tl-liquid-label-in {
1829
+ from { opacity: 0; filter: blur(var(--icon-swap-blur, 2px)); }
1830
+ to { opacity: 1; filter: blur(0); } }
1831
+ @keyframes tl-liquid-btn-chrome {
1832
+ from { background: transparent; box-shadow: none; }
1833
+ to { background: #fff;
1834
+ box-shadow: 0 1px 3px 0 rgba(0,0,0,0.04),
1835
+ inset 0 0 0 1px rgba(0,0,0,0.06),
1836
+ inset 0 -1px 0 0 rgba(0,0,0,0.1),
1837
+ inset 0 0 0 1px rgba(196,196,196,0.1); } }
1838
+ /* the bar's background, hairline and geometry all converge on is-bar's
1839
+ exact resting values DURING the 300ms split (padding 12/20→8/16, border
1840
+ 0→1px, colors fade in), so swapping classes at the end is a visual no-op */
1841
+ .tl-refine-foot.is-exiting-scan { position: relative;
1842
+ animation: tl-foot-to-bar 220ms cubic-bezier(0.22, 1, 0.36, 1) forwards; }
1843
+ @keyframes tl-foot-to-bar {
1844
+ from { padding: 12px 20px; border-top-width: 0; }
1845
+ to { padding: 8px 16px; border-top-width: 1px; justify-content: stretch; } }
1846
+ .tl-refine-foot.is-exiting-scan.is-morphing { --bar-bg: #fff; --bar-line: #f0f0f0;
1847
+ border-top: 0 solid var(--bar-line);
1848
+ animation: tl-liquid-bar-in 260ms ease both, tl-foot-to-bar 220ms cubic-bezier(0.22, 1, 0.36, 1) forwards; }
1849
+ .tl-refine-foot.is-bar.is-morphing { --bar-bg: #fff; --bar-line: #f0f0f0;
1850
+ animation: tl-liquid-bar-in 280ms ease both; }
1851
+ @keyframes tl-liquid-bar-in {
1852
+ from { background: transparent; border-top-color: transparent; }
1853
+ to { background: var(--bar-bg); border-top-color: var(--bar-line); } }
1671
1854
  @media (prefers-reduced-motion: reduce) {
1672
1855
  .tl-refine-panel { transition: none; }
1673
1856
  .tl-dotm-dot { animation: none !important; }
@@ -1676,6 +1859,21 @@
1676
1859
  .t-text-swap { transition: none !important; }
1677
1860
  .tl-refine-results .t-stagger-line { transition: none !important; }
1678
1861
  .t-resize, .tl-scan-morph { transition: none !important; }
1862
+ .tl-liquid-goo { display: none !important; }
1863
+ .tl-liquid-stage { animation: none !important; height: 32px; margin-bottom: 0; }
1864
+ .tl-refine-foot.is-exiting-scan { animation: none !important; padding: 8px 16px;
1865
+ border-top-width: 1px !important; background: var(--bar-bg, #fff) !important;
1866
+ border-top-color: var(--bar-line, #f0f0f0) !important; }
1867
+ .tl-scan-morph.is-exiting { animation: none !important; inset: 0; }
1868
+ .tl-scan-morph.is-exiting .tl-scan-beam,
1869
+ .tl-scan-morph.is-exiting::before,
1870
+ .tl-scan-morph.is-exiting::after,
1871
+ .tl-scan-morph.is-exiting .tl-scan-face-status { animation: none !important; opacity: 0; }
1872
+ .tl-liquid-btns .tl-pill-btn { animation: none !important; background: #fff;
1873
+ box-shadow: 0 1px 3px 0 rgba(0,0,0,0.04), inset 0 0 0 1px rgba(0,0,0,0.06),
1874
+ inset 0 -1px 0 0 rgba(0,0,0,0.1), inset 0 0 0 1px rgba(196,196,196,0.1); }
1875
+ .tl-liquid-btns .tl-pill-btn > span { animation: none !important; }
1876
+ .tl-refine-foot.is-bar.is-morphing { animation: none !important; }
1679
1877
  .tl-pill-btn.tl-apply-btn { transition: background 0.12s ease, scale 0.12s ease, opacity 0.12s ease !important; }
1680
1878
  .tl-apply-check { transition: none !important; }
1681
1879
  .tl-sug-check { animation: none !important; opacity: 1; }
@@ -1684,6 +1882,305 @@
1684
1882
  .tl-scan-beam { animation: none !important; }
1685
1883
  .tl-pill { animation: none !important; }
1686
1884
  }
1885
+
1886
+ /* ═════════════════════════════════════════════════════════════════════
1887
+ DARK THEME — html[data-tl-theme="dark"]
1888
+ System-default, overridable from the 3-dot menu Theme row. Scoped to
1889
+ .tl-* / .t-* so a host page (which has no such classes) is never touched.
1890
+ Values are the exact Figma dark tokens; missing ones are derived. Purely
1891
+ additive — light mode stays byte-for-byte unchanged.
1892
+ ═════════════════════════════════════════════════════════════════════ */
1893
+
1894
+ /* — token overrides: auto-themes everything token-driven — */
1895
+ html[data-tl-theme="dark"] {
1896
+ --c-text: #fbfbfb; --c-text-strong: #fbfbfb; --c-text-mut: #979797;
1897
+ --c-text-mut2: #979797; --c-text-faint: #8b8b8b; --c-count: #8b8b8b;
1898
+ /* surfaces / overlay system (Figma node 580:652) */
1899
+ --c-sec: rgba(255,255,255,0.06); --c-sec-h: rgba(255,255,255,0.1); --c-sec-a: rgba(255,255,255,0.12);
1900
+ --c-circle: rgba(255,255,255,0.06); --c-circle-h: rgba(255,255,255,0.1); --c-circle-a: rgba(255,255,255,0.12);
1901
+ --c-ghost-h: rgba(255,255,255,0.04); --c-ghost-a: rgba(255,255,255,0.06);
1902
+ --c-field-bg: rgba(255,255,255,0.04); --c-fill: rgba(255,255,255,0.1); --c-fill-a: rgba(255,255,255,0.12);
1903
+ --c-track: rgba(255,255,255,0.1); --c-track-h: rgba(255,255,255,0.14); --c-thumb: #767676;
1904
+ --c-line: #262626; --c-hairline: rgba(255,255,255,0.04); --c-hairline2: rgba(255,255,255,0.06);
1905
+ /* blue accents */
1906
+ --c-blue: #3b9bff; --c-blue-pressed: #5cb0ff; --c-blue-bg: rgba(0,128,255,0.17);
1907
+ --c-blue-bg-h: rgba(0,128,255,0.24); --c-blue-bg-a: rgba(0,128,255,0.3); --c-blue-ring: rgba(85,207,255,0.06);
1908
+ /* tooltip surface */
1909
+ --tt-bg: #262626; --tt-fg: #fbfbfb;
1910
+ /* shadow / ring recipes — drop 0 1px1px rgba(0,0,0,.24) + white insets */
1911
+ --ring: inset 0 0 0 1px rgba(255,255,255,0.04), inset 0 1px 0 0 rgba(255,255,255,0.06);
1912
+ --ring-fill: inset 0 0 0 1px rgba(255,255,255,0.04), inset 0 1px 0 0 rgba(255,255,255,0.06);
1913
+ --ring-blue: inset 0 0 0 1px rgba(85,207,255,0.04), inset 0 1px 0 0 rgba(85,207,255,0.06);
1914
+ --drop: 0 1px 1px 0 rgba(0,0,0,0.24);
1915
+ --drop-btn: 0 1px 1px 0 rgba(0,0,0,0.24);
1916
+ --drop-line: 0 1px 1px 0 rgba(0,0,0,0.24);
1917
+ --shadow-btn: 0 1px 1px 0 rgba(0,0,0,0.24), inset 0 0 0 1px rgba(255,255,255,0.04), inset 0 1px 0 0 rgba(255,255,255,0.06);
1918
+ --shadow-btn-inset: inset 0 0 0 1px rgba(255,255,255,0.04), inset 0 1px 0 0 rgba(255,255,255,0.06);
1919
+ --menu-shadow: 0 0 0 1px rgba(0,0,0,0.06), 0 2px 6px rgba(0,0,0,0.05), 0 4px 42px rgba(0,0,0,0.24);
1920
+ --panel-shadow: 0 2px 6px 0 rgba(0,0,0,0.05), 0 4px 42px 0 rgba(0,0,0,0.24), 0 0 0 1px rgba(255,255,255,0.04) inset, 0 1px 0 0 rgba(255,255,255,0.02) inset;
1921
+ --card-shadow: 0 0 0 1px rgba(255,255,255,0.06), 0 1px 2px rgba(0,0,0,0.4), 0 8px 24px rgba(0,0,0,0.5);
1922
+ }
1923
+
1924
+ /* — panel surfaces — */
1925
+ html[data-tl-theme="dark"] .tl-panel-body { background: #1b1b1b;
1926
+ box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.05), 0 4px 42px 0 rgba(0, 0, 0, 0.24), 0 0 0 1px rgba(255, 255, 255, 0.04) inset, 0 1px 0 0 rgba(255, 255, 255, 0.02) inset; }
1927
+ html[data-tl-theme="dark"] .tl-refine-inner { background: #1b1b1b; }
1928
+ /* draw the 4% white inset ring as an overlay above all children (header,
1929
+ tab nav, body) so it stays continuous on the left edge where child
1930
+ backgrounds would otherwise paint over an element-level inset shadow. */
1931
+ html[data-tl-theme="dark"] .tl-refine-inner::after {
1932
+ content: ""; position: absolute; inset: 0; pointer-events: none;
1933
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04); }
1934
+ /* Refine panel: the parent .tl-panel-body has overflow:hidden, so a full
1935
+ surrounding shadow is clipped on every side except the internal left
1936
+ edge. Cast a directional LEFT shadow (negative x) so the panel reads as a
1937
+ clear divider over the dark timeline, plus a left-edge highlight + ring
1938
+ inset for the surface lift. */
1939
+ html[data-tl-theme="dark"] .tl-refine-panel {
1940
+ box-shadow:
1941
+ -4px 0 12px 0 rgba(0, 0, 0, 0.35),
1942
+ -16px 0 42px 0 rgba(0, 0, 0, 0.3); }
1943
+ html[data-tl-theme="dark"] .tl-empty { background: #202020; }
1944
+
1945
+ /* — timeline rows / ruler / bars / member chips — */
1946
+ html[data-tl-theme="dark"] .tl-prop-row:hover { background: rgba(255,255,255,0.02); }
1947
+ html[data-tl-theme="dark"] .tl-prop-row.selected { background: rgba(255,255,255,0.04); border-bottom-color: #2a2a2a; }
1948
+ /* top hairline of the selected row = bottom border of the row above (no 1px shift) */
1949
+ html[data-tl-theme="dark"] .tl-prop-row:has(+ .tl-prop-row.selected) { border-bottom-color: #2a2a2a; }
1950
+ /* first row selected: recolor the ruler-row divider that sits directly above it */
1951
+ html[data-tl-theme="dark"] .tl-ruler-row:has(+ .tl-rows .tl-prop-row:first-child.selected) { border-bottom-color: #2a2a2a; }
1952
+ html[data-tl-theme="dark"] .tl-prop-row.selected .tl-prop-label { color: #fbfbfb; }
1953
+ html[data-tl-theme="dark"] .tl-prop-member,
1954
+ html[data-tl-theme="dark"] .tl-prop-row.selected .tl-prop-member,
1955
+ html[data-tl-theme="dark"] .tl-insp-member { background: rgba(255,255,255,0.04); color: #bdbdbd; box-shadow: 0 0 0 1px rgba(255,255,255,0.08); }
1956
+ html[data-tl-theme="dark"] .tl-insp-title { color: #fbfbfb; }
1957
+ html[data-tl-theme="dark"] .tl-insp-label { color: #8b8b8b; }
1958
+ html[data-tl-theme="dark"] .tl-bar-body { filter: drop-shadow(0 1px 2px rgba(0,0,0,0.32)); }
1959
+ html[data-tl-theme="dark"] .tl-bar-body path { stroke: url(#rb-ring-dark); }
1960
+ html[data-tl-theme="dark"] #rb-ring stop:first-child { stop-opacity: 0.04; }
1961
+ html[data-tl-theme="dark"] #rb-ring stop:last-child { stop-opacity: 0.08; }
1962
+ html[data-tl-theme="dark"] .tl-ruler .tick { background: rgba(255,255,255,0.18); }
1963
+ /* dark thin scrollbars */
1964
+ html[data-tl-theme="dark"] .tl-inspector,
1965
+ html[data-tl-theme="dark"] .tl-rows,
1966
+ html[data-tl-theme="dark"] .tl-refine-body,
1967
+ html[data-tl-theme="dark"] .tl-refine-pages .t-page { scrollbar-color: rgba(255,255,255,0.14) transparent; }
1968
+ html[data-tl-theme="dark"] .tl-inspector::-webkit-scrollbar-thumb,
1969
+ html[data-tl-theme="dark"] .tl-rows::-webkit-scrollbar-thumb,
1970
+ html[data-tl-theme="dark"] .tl-refine-body::-webkit-scrollbar-thumb,
1971
+ html[data-tl-theme="dark"] .tl-refine-pages .t-page::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.14); background-clip: padding-box; }
1972
+ html[data-tl-theme="dark"] .tl-inspector::-webkit-scrollbar-thumb:hover,
1973
+ html[data-tl-theme="dark"] .tl-rows::-webkit-scrollbar-thumb:hover,
1974
+ html[data-tl-theme="dark"] .tl-refine-body::-webkit-scrollbar-thumb:hover,
1975
+ html[data-tl-theme="dark"] .tl-refine-pages .t-page::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.24); background-clip: padding-box; }
1976
+
1977
+ /* — refine header / tabs / titles — */
1978
+ html[data-tl-theme="dark"] .tl-refine-head { border-bottom-color: #262626; }
1979
+ html[data-tl-theme="dark"] .tl-refine-tabs { background: #1b1b1b; border-bottom-color: #262626; }
1980
+ html[data-tl-theme="dark"] .tl-refine-titles h3 { color: #fbfbfb; }
1981
+ html[data-tl-theme="dark"] .tl-refine-titles p { color: #979797; }
1982
+ html[data-tl-theme="dark"] .tl-refine-tab { color: #8b8b8b; }
1983
+ html[data-tl-theme="dark"] .tl-refine-tab:hover:not([aria-selected="true"]) { color: #fbfbfb; }
1984
+ html[data-tl-theme="dark"] .tl-refine-tab[aria-selected="true"] { background: rgba(255,255,255,0.06); color: #fbfbfb; }
1985
+ html[data-tl-theme="dark"] .tl-refine-mode { color: #fbfbfb; }
1986
+ html[data-tl-theme="dark"] .tl-refine-mode:hover,
1987
+ html[data-tl-theme="dark"] .tl-refine-mode.is-open { background: rgba(255,255,255,0.06); }
1988
+ html[data-tl-theme="dark"] .tl-refine-close { color: #979797; }
1989
+ html[data-tl-theme="dark"] .tl-refine-close:hover { background: rgba(255,255,255,0.06); color: #fbfbfb; }
1990
+
1991
+ /* — idle / unavailable / gate / inline code — */
1992
+ html[data-tl-theme="dark"] .tl-refine-idle-text,
1993
+ html[data-tl-theme="dark"] .tl-refine-unavail-text,
1994
+ html[data-tl-theme="dark"] .tl-gate-text { color: #979797; }
1995
+ html[data-tl-theme="dark"] .tl-refine-unavail-title,
1996
+ html[data-tl-theme="dark"] .tl-gate-title { color: #fbfbfb; }
1997
+ html[data-tl-theme="dark"] .tl-gate-text .tl-code { color: #cfcfcf; }
1998
+ html[data-tl-theme="dark"] code.tl-code { color: #fbfbfb; background: rgba(255,255,255,0.08); }
1999
+ html[data-tl-theme="dark"] .tl-refine-empty { color: #979797; }
2000
+ html[data-tl-theme="dark"] .tl-refine-error { color: #ff8a7a; background: rgba(192,57,43,0.14); }
2001
+
2002
+ /* — header buttons that hardcode white — */
2003
+ html[data-tl-theme="dark"] .tl-accept-btn { background: rgba(255,255,255,0.06); color: #fbfbfb; }
2004
+ html[data-tl-theme="dark"] .tl-accept-btn > svg { color: #fbfbfb; }
2005
+ html[data-tl-theme="dark"] .tl-accept-btn:hover:not(:disabled),
2006
+ html[data-tl-theme="dark"] .tl-accept-btn:active:not(:disabled) { background: rgba(255,255,255,0.1); }
2007
+ html[data-tl-theme="dark"] .tl-accept-spin { border-color: rgba(255,255,255,0.2); border-top-color: #fbfbfb; }
2008
+ html[data-tl-theme="dark"] .tl-header .tl-icon-btn:not(.ghost) { background: rgba(255,255,255,0.06); }
2009
+ html[data-tl-theme="dark"] .tl-header .tl-icon-btn:not(.ghost):hover:not(:disabled),
2010
+ html[data-tl-theme="dark"] .tl-header .tl-icon-btn:not(.ghost).is-active,
2011
+ html[data-tl-theme="dark"] .tl-header .tl-icon-btn:not(.ghost):active:not(:disabled) { background: rgba(255,255,255,0.1); }
2012
+ html[data-tl-theme="dark"] .tl-sec-btn { background: rgba(255,255,255,0.06); color: #fbfbfb; }
2013
+ html[data-tl-theme="dark"] .tl-sec-btn:hover:not(:disabled),
2014
+ html[data-tl-theme="dark"] .tl-sec-btn:active:not(:disabled) { background: rgba(255,255,255,0.1); }
2015
+
2016
+ /* — Refine button + scan-morph (layered blue fill) — */
2017
+ html[data-tl-theme="dark"] .tl-refine-btn,
2018
+ html[data-tl-theme="dark"] .tl-refine-btn > svg { color: #90dcff; }
2019
+ html[data-tl-theme="dark"] .tl-refine-sparks { color: #90dcff; }
2020
+ html[data-tl-theme="dark"] .tl-refine-btn::before { background: rgba(0,128,255,0.17); }
2021
+ /* Figma 580:758 hover variant: fill brightens 0.17 -> 0.25, text/icon #90dcff,
2022
+ ring + drop shadow unchanged. Override base p3 hover (0.10) which would dim it. */
2023
+ html[data-tl-theme="dark"] .tl-refine-btn:hover:not(:disabled)::before { background: rgba(0,128,255,0.25); }
2024
+ html[data-tl-theme="dark"] .tl-refine-btn.is-active { color: #b3e6ff; }
2025
+ html[data-tl-theme="dark"] .tl-refine-btn.is-active::before { background: rgba(0,128,255,0.3); }
2026
+ html[data-tl-theme="dark"] .tl-scan-morph { color: #90dcff; }
2027
+ html[data-tl-theme="dark"] .tl-scan-morph::before { background: rgba(0,128,255,0.17); }
2028
+ html[data-tl-theme="dark"] .tl-scan-morph:hover:not(:disabled):not(.is-scanning)::before,
2029
+ html[data-tl-theme="dark"] .tl-scan-morph:active:not(.is-scanning):not(:disabled)::before { background: rgba(0,128,255,0.24); }
2030
+ html[data-tl-theme="dark"] .tl-scan-morph:disabled:not(.is-scanning)::before { background: rgba(0,128,255,0.17); }
2031
+ html[data-tl-theme="dark"] .tl-scan-morph.is-scanning { color: #979797; }
2032
+ html[data-tl-theme="dark"] .tl-scan-morph.is-scanning::before { background: #202020; }
2033
+
2034
+ /* — resize handle — */
2035
+ html[data-tl-theme="dark"] .tl-resize-handle::after { background: rgba(255,255,255,0.14); }
2036
+
2037
+ /* — toast / minimized pill / transitions count badge — */
2038
+ /* toast + tooltip share the dropdown's dark shadow recipe (menu-shadow +
2039
+ white inset ring) so all floating surfaces read consistently. */
2040
+ html[data-tl-theme="dark"] .tl-toast { background: #202020; color: #fbfbfb;
2041
+ box-shadow: var(--menu-shadow), inset 0 0 0 1px rgba(255,255,255,0.04), inset 0 1px 0 0 rgba(255,255,255,0.03); }
2042
+ html[data-tl-theme="dark"] .t-tt {
2043
+ box-shadow: var(--menu-shadow), inset 0 0 0 1px rgba(255,255,255,0.04), inset 0 1px 0 0 rgba(255,255,255,0.03); }
2044
+ html[data-tl-theme="dark"] .tl-toast-ic { color: #fbfbfb; }
2045
+ html[data-tl-theme="dark"] .tl-pill { background: #1b1b1b; }
2046
+ html[data-tl-theme="dark"] .tl-pill:hover,
2047
+ html[data-tl-theme="dark"] .tl-pill:active { background: #262626; }
2048
+ html[data-tl-theme="dark"] .tl-pill-label { color: #fbfbfb; }
2049
+ html[data-tl-theme="dark"] .tl-pill-count { background: #fbfbfb; color: #1b1b1b;
2050
+ box-shadow: 0 1px 3px 0 rgba(0,0,0,0.3), inset 0 0 0 1px rgba(0,0,0,0.06), inset 0 -1px 0 0 rgba(0,0,0,0.06); }
2051
+
2052
+ /* — dropdown menu surface + rows (Figma node 620:976) — */
2053
+ html[data-tl-theme="dark"] .tl-menu { background: #202020;
2054
+ box-shadow: var(--menu-shadow), inset 0 0 0 1px rgba(255,255,255,0.04), inset 0 1px 0 0 rgba(255,255,255,0.03); }
2055
+ html[data-tl-theme="dark"] .tl-menu-search { border-bottom-color: #262626; }
2056
+ html[data-tl-theme="dark"] .tl-menu-search-input { color: #fbfbfb; }
2057
+ html[data-tl-theme="dark"] .tl-menu-search-input::placeholder { color: #8b8b8b; }
2058
+ html[data-tl-theme="dark"] .tl-menu-item { color: #fbfbfb; }
2059
+ html[data-tl-theme="dark"] .tl-menu-item:hover { background: rgba(255,255,255,0.04); }
2060
+ html[data-tl-theme="dark"] .tl-menu-item:active { background: rgba(255,255,255,0.06); }
2061
+ html[data-tl-theme="dark"] .tl-menu-item:focus-visible { background: transparent; box-shadow: inset 0 0 0 1px rgba(85,207,255,0.5); }
2062
+ html[data-tl-theme="dark"] .tl-menu-dim,
2063
+ html[data-tl-theme="dark"] .tl-menu-value,
2064
+ html[data-tl-theme="dark"] .tl-menu-section,
2065
+ html[data-tl-theme="dark"] .tl-menu-head { color: #8b8b8b; }
2066
+ html[data-tl-theme="dark"] .tl-menu-head-name { color: #4c4c4c; }
2067
+ html[data-tl-theme="dark"] .tl-menu-check { color: #fbfbfb; }
2068
+ html[data-tl-theme="dark"] .tl-menu-divider { background: #262626; }
2069
+ html[data-tl-theme="dark"] .tl-menu-icon,
2070
+ html[data-tl-theme="dark"] .tl-menu-chevr,
2071
+ html[data-tl-theme="dark"] .tl-set-back-ic { color: #8b8b8b; }
2072
+ html[data-tl-theme="dark"] .tl-set-back { color: #fbfbfb; }
2073
+ html[data-tl-theme="dark"] .tl-set-back:hover { background: rgba(255,255,255,0.04); }
2074
+ html[data-tl-theme="dark"] .tl-set-back:active { background: rgba(255,255,255,0.06); }
2075
+ html[data-tl-theme="dark"] .tl-set-back:focus-visible { background: transparent; box-shadow: inset 0 0 0 1px rgba(85,207,255,0.5); }
2076
+ html[data-tl-theme="dark"] .tl-kbd-row { color: #fbfbfb; }
2077
+ html[data-tl-theme="dark"] .tl-kbd { background: rgba(255,255,255,0.06); box-shadow: inset 0 0 0 1px rgba(255,255,255,0.1); color: #cfcfcf; }
2078
+ html[data-tl-theme="dark"] .tl-kbd-plus { color: #6b6b6b; }
2079
+
2080
+ /* — mode dropdown rows — */
2081
+ html[data-tl-theme="dark"] .tl-mode-row:hover { background: rgba(255,255,255,0.06); }
2082
+ html[data-tl-theme="dark"] .tl-mode-row-title { color: #fbfbfb; }
2083
+ html[data-tl-theme="dark"] .tl-mode-row-desc { color: #979797; }
2084
+ html[data-tl-theme="dark"] .tl-mode-row-check { color: #fbfbfb; }
2085
+
2086
+ /* — fields / selects / steppers / tabs / cubic inputs — */
2087
+ html[data-tl-theme="dark"] .tl-field.is-editing { box-shadow: inset 0 0 0 1px rgba(255,255,255,0.14), inset 0 0 0 0.5px rgba(255,255,255,0.06); }
2088
+ html[data-tl-theme="dark"] .tl-field-fill { box-shadow: 0 1px 1px 0 rgba(0,0,0,0.24), inset 0 0 0 1px rgba(255,255,255,0.04), inset 0 1px 0 0 rgba(255,255,255,0.06); }
2089
+ html[data-tl-theme="dark"] .tl-select { background: rgba(255,255,255,0.04); color: #fbfbfb; }
2090
+ html[data-tl-theme="dark"] .tl-select:hover,
2091
+ html[data-tl-theme="dark"] .tl-select.is-open,
2092
+ html[data-tl-theme="dark"] .tl-field:hover:not(.is-editing),
2093
+ html[data-tl-theme="dark"] .tl-field-chevron:hover,
2094
+ html[data-tl-theme="dark"] .tl-cubic-cell:hover { background: rgba(255,255,255,0.06); }
2095
+ html[data-tl-theme="dark"] .tl-cubic-row input { color: #fbfbfb; }
2096
+ html[data-tl-theme="dark"] .tl-cubic-row input::placeholder { color: #8b8b8b; }
2097
+ html[data-tl-theme="dark"] .tl-cubic-cell:focus-within { box-shadow: inset 0 0 0 1px rgba(255,255,255,0.1); }
2098
+ html[data-tl-theme="dark"] .tl-custom-input { box-shadow: inset 0 0 0 1px rgba(255,255,255,0.06); }
2099
+ html[data-tl-theme="dark"] .tl-seg-btn { color: #8b8b8b; }
2100
+ html[data-tl-theme="dark"] .tl-seg-btn:hover:not(.is-active) { background: rgba(255,255,255,0.04); color: #fbfbfb; }
2101
+ html[data-tl-theme="dark"] .tl-seg-btn.is-active { background: rgba(255,255,255,0.1); color: #fbfbfb; }
2102
+
2103
+ /* — bezier preview (curve stays light; surfaces darken) — */
2104
+ html[data-tl-theme="dark"] .tl-curve { background: rgba(255,255,255,0.04); }
2105
+ html[data-tl-theme="dark"] .tl-preview-curve svg path { stroke: #fbfbfb; }
2106
+ html[data-tl-theme="dark"] .tl-preview-title,
2107
+ html[data-tl-theme="dark"] .tl-preview-sub-label { color: #fbfbfb; }
2108
+ html[data-tl-theme="dark"] .tl-preview-chevron { color: var(--c-ruler); }
2109
+ html[data-tl-theme="dark"] .tl-preview-head:hover .tl-preview-title,
2110
+ html[data-tl-theme="dark"] .tl-preview-head:hover .tl-preview-chevron { color: #fff; }
2111
+ html[data-tl-theme="dark"] .tl-preview-play:hover { color: #fbfbfb; }
2112
+ html[data-tl-theme="dark"] .tl-preview-track { background: rgba(255,255,255,0.04); }
2113
+ html[data-tl-theme="dark"] .tl-preview-rail { border-top-color: rgba(255,255,255,0.14); }
2114
+
2115
+ /* — suggestion cards — */
2116
+ html[data-tl-theme="dark"] .tl-sug { background: #202020; box-shadow: 0 0 0 1px rgba(255,255,255,0.06); }
2117
+ html[data-tl-theme="dark"] .tl-sug-member { background: rgba(255,255,255,0.04); box-shadow: 0 0 0 1px rgba(255,255,255,0.08); color: #bdbdbd; }
2118
+ html[data-tl-theme="dark"] .tl-sug-prop { color: #fbfbfb; }
2119
+ html[data-tl-theme="dark"] .tl-sug-divider { background: #262626; }
2120
+ html[data-tl-theme="dark"] .tl-sug-title { color: #fbfbfb; }
2121
+ html[data-tl-theme="dark"] .tl-sug-from { color: #8b8b8b; }
2122
+ html[data-tl-theme="dark"] .tl-sug-to { color: #fbfbfb; }
2123
+ html[data-tl-theme="dark"] .tl-sug-reason,
2124
+ html[data-tl-theme="dark"] .tl-sug-phases { color: #979797; }
2125
+ html[data-tl-theme="dark"] .tl-sug-phase { background: rgba(255,255,255,0.06); color: #cfcfcf; }
2126
+ html[data-tl-theme="dark"] .tl-sug-phase-dur { color: #fbfbfb; }
2127
+
2128
+ /* — colorful category pills: brighten text only (bg/ring kept) — */
2129
+ html[data-tl-theme="dark"] .tl-sug-cat.is-easing { color: #c471ff; }
2130
+ html[data-tl-theme="dark"] .tl-sug-cat.is-duration { color: #50a7ff; }
2131
+ html[data-tl-theme="dark"] .tl-sug-cat.is-scale { color: #64d378; }
2132
+ html[data-tl-theme="dark"] .tl-sug-cat.is-blur { color: #3ec9d6; }
2133
+ html[data-tl-theme="dark"] .tl-sug-cat.is-distance,
2134
+ html[data-tl-theme="dark"] .tl-sug-cat.is-delay { color: #f99032; }
2135
+ html[data-tl-theme="dark"] .tl-sug-cat.is-replace { color: #50a7ff; }
2136
+
2137
+ /* — white pill buttons (card Apply + footer bar) — */
2138
+ html[data-tl-theme="dark"] .tl-pill-btn { background: rgba(255,255,255,0.06); color: #fbfbfb;
2139
+ box-shadow: 0 1px 1px 0 rgba(0,0,0,0.24), inset 0 0 0 1px rgba(255,255,255,0.04), inset 0 1px 0 0 rgba(255,255,255,0.06); }
2140
+ html[data-tl-theme="dark"] .tl-pill-btn .tl-dim { color: #8b8b8b; }
2141
+ html[data-tl-theme="dark"] .tl-pill-btn:hover:not(:disabled) { background: rgba(255,255,255,0.1); }
2142
+ html[data-tl-theme="dark"] .tl-pill-btn:disabled { color: #6b6b6b; background: rgba(255,255,255,0.06); box-shadow: none; }
2143
+ html[data-tl-theme="dark"] .tl-pill-btn.is-applied { color: #fbfbfb; background: rgba(255,255,255,0.06); box-shadow: none; }
2144
+ html[data-tl-theme="dark"] .tl-pill-btn.is-applied svg path { stroke: #fbfbfb; }
2145
+ html[data-tl-theme="dark"] .tl-refine-foot.is-bar { background: #1b1b1b; border-top-color: #262626; }
2146
+ /* liquid morph blobs need an OPAQUE fill (the goo filter hard-steps alpha,
2147
+ so a translucent fill would clip to nothing): flatten the dark pill's
2148
+ rgba(255,255,255,0.06)-over-#1b1b1b to its composite. */
2149
+ html[data-tl-theme="dark"] .tl-liquid-blob { background: #292929; }
2150
+ html[data-tl-theme="dark"] .tl-liquid-goo {
2151
+ filter: url(#tl-goo) drop-shadow(0 1px 1px rgba(0,0,0,0.24)); }
2152
+ /* dark chrome fade — same 250–430ms window as light */
2153
+ @keyframes tl-liquid-btn-chrome-dark {
2154
+ from { background: transparent; box-shadow: none; }
2155
+ to { background: rgba(255,255,255,0.06);
2156
+ box-shadow: 0 1px 1px 0 rgba(0,0,0,0.24),
2157
+ inset 0 0 0 1px rgba(255,255,255,0.04),
2158
+ inset 0 1px 0 0 rgba(255,255,255,0.06); } }
2159
+ html[data-tl-theme="dark"] .tl-liquid-btns .tl-pill-btn {
2160
+ animation: tl-liquid-btn-chrome-dark 180ms ease 250ms both; }
2161
+ html[data-tl-theme="dark"] .tl-refine-foot.is-bar.is-morphing,
2162
+ html[data-tl-theme="dark"] .tl-refine-foot.is-exiting-scan.is-morphing {
2163
+ --bar-bg: #1b1b1b; --bar-line: #262626; }
2164
+
2165
+ /* — Apply-all footer buttons (.pc-btn, shared into the build) — */
2166
+ html[data-tl-theme="dark"] .pc-btn { background: rgba(255,255,255,0.06); color: #fbfbfb; border-color: rgba(255,255,255,0.12); }
2167
+ html[data-tl-theme="dark"] .pc-btn:hover { background: rgba(255,255,255,0.1); }
2168
+ html[data-tl-theme="dark"] .pc-btn.primary { background: #fbfbfb; color: #1b1b1b; border-color: #fbfbfb; }
2169
+ html[data-tl-theme="dark"] .pc-btn.primary:hover { background: #fff; }
2170
+
2171
+ /* — shimmer / scan status highlights — */
2172
+ html[data-tl-theme="dark"] .tl-scan-shimmer { --shimmer-highlight: #fbfbfb; }
2173
+ html[data-tl-theme="dark"] .tl-refine-status-text { --shimmer-base: #6b6b6b; --shimmer-highlight: #fbfbfb; }
2174
+ html[data-tl-theme="dark"] .tl-scan-error { color: #ff8a7a; }
2175
+
2176
+ /* — live chip surfaces (Figma node 580:652) — */
2177
+ html[data-tl-theme="dark"] .tl-live-chip { background: rgba(151,251,142,0.1);
2178
+ box-shadow: 0 1px 3px 0 rgba(0,0,0,0.24), inset 0 0 0 1px rgba(126,224,90,0.06), inset 0 -1px 0 0 rgba(86,218,99,0.08); }
2179
+ html[data-tl-theme="dark"] .tl-live-stop { background: rgba(68,167,84,0.1); box-shadow: inset 0 0 0 1px rgba(112,233,132,0.08); }
2180
+ html[data-tl-theme="dark"] .tl-live-stop:hover,
2181
+ html[data-tl-theme="dark"] .tl-live-stop:active { background: rgba(68,167,84,0.2); }
2182
+ html[data-tl-theme="dark"] .tl-live-chip.is-stopped { background: rgba(180,180,180,0.04);
2183
+ box-shadow: 0 1px 3px 0 rgba(0,0,0,0.24), inset 0 0 0 1px rgba(255,255,255,0.1), inset 0 -1px 0 0 rgba(0,0,0,0.2); }
1687
2184
  </style>
1688
2185
  </head>
1689
2186
  <body>
@@ -1711,6 +2208,19 @@
1711
2208
  // standalone demo.html.
1712
2209
  const REFINE_VERSION = (typeof window !== "undefined" && window.__TX_REFINE_VERSION) || "0.3.5";
1713
2210
 
2211
+ // Apply the stored theme to <html data-tl-theme> synchronously, before the
2212
+ // panel mounts, so a returning dark-mode user never flashes the light panel.
2213
+ // Runs in both the demo and the injected build (this whole script ships via
2214
+ // inject.mjs). TimelinePanel re-applies + keeps it in sync afterwards.
2215
+ (function applyStoredTheme(){
2216
+ try{
2217
+ const pref=localStorage.getItem("tl-theme-pref")||"system";
2218
+ const sysDark=window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches;
2219
+ const r=pref==="dark"||(pref==="system"&&sysDark)?"dark":"light";
2220
+ document.documentElement.setAttribute("data-tl-theme",r);
2221
+ }catch(e){}
2222
+ })();
2223
+
1714
2224
  // ── helpers ──
1715
2225
  function parseCssTime(v) { const t=v.trim(); if(t.endsWith("ms")) return parseFloat(t); if(t.endsWith("s")) return parseFloat(t)*1000; const n=parseFloat(t); return isNaN(n)?0:n; }
1716
2226
  function formatCssTime(ms) { if(ms>=1000&&ms%1000===0) return `${ms/1000}s`; if(ms>=100) return `${ms/1000}s`; return `${ms}ms`; }
@@ -2311,7 +2821,8 @@
2311
2821
  // the three relay fns below short-circuit to a built-in mock so the panel is
2312
2822
  // fully interactive with NO relay/LLM/CLI. When the flag is UNSET this whole
2313
2823
  // block is inert and every fn falls through to the real fetch path unchanged.
2314
- const TX_MOCK = typeof window!=="undefined" && window.__TX_REFINE_MOCK;
2824
+ const TX_MOCK = typeof window!=="undefined" &&
2825
+ (window.__TX_REFINE_MOCK || /[?&]txmock\b/.test(window.location.search));
2315
2826
  const _txMockJobs = new Map(); // id → { request, createdAt }
2316
2827
  const _txMockId = ()=>"mock-"+Date.now()+"-"+Math.random().toString(36).slice(2,8);
2317
2828
  const _txMs = (n)=>(n==null?"":Math.round(n)+"ms");
@@ -2380,10 +2891,16 @@
2380
2891
  // Token-faithful tweaks for one lane list (mirrors refineTimings()).
2381
2892
  function _txTokenTweaks(timings, ctx){
2382
2893
  const out=[]; const c=ctx||{};
2894
+ const blurByMember=new Map(); // memberKey -> {member,hint,sawBlur,varName}
2383
2895
  for(const t of (timings||[])){
2384
2896
  const prop=t.property||"all";
2385
2897
  const member=t.member||null;
2386
2898
  const hint=[c.label,c.selector,c.phase,t.property,t.member].filter(Boolean).join(" ");
2899
+ const memberKey=member||"__all__";
2900
+ if(!blurByMember.has(memberKey))blurByMember.set(memberKey,{member,hint,sawBlur:false,varName:null});
2901
+ const bm=blurByMember.get(memberKey);
2902
+ bm.hint=[bm.hint,hint].filter(Boolean).join(" ");
2903
+ if(t.varName&&(!bm.varName||prop==="filter"))bm.varName=t.varName;
2387
2904
  const mp=(rest)=> member ? {member, ...rest} : rest;
2388
2905
  if(Number.isFinite(t.durationMs)){const {token,delta}=_txNearest(_TX_DUR_TOKENS,t.durationMs);
2389
2906
  if(delta>10)out.push({id:"mock-"+prop+"-duration",kind:"duration",property:prop,member,
@@ -2400,11 +2917,23 @@
2400
2917
  patch:mp({property:prop,scale:token.v,...(t.varName?{varName:t.varName}:{})}),
2401
2918
  reason:token.name+" ("+token.v+") is the transitions.dev scale token for "+token.usage+". "+t.scale+" is "+(usage?"off the usage token":"off-grid")+"."});}
2402
2919
  if(Number.isFinite(t.blur)&&t.blur>1e-4){const usage=_txBlurUsage(hint);const {token:near,delta}=_txNearest(_TX_BLUR_TOKENS,t.blur);const token=usage||near;
2920
+ bm.sawBlur=true;
2403
2921
  if(token&&(usage||delta>0.5)&&Math.abs(token.v-t.blur)>1e-4)out.push({id:"mock-"+prop+"-blur",kind:"blur",property:prop,member,
2404
2922
  title:"Blur → "+token.name,from:t.blur+"px",to:token.v+"px",
2405
2923
  patch:mp({property:prop,blur:token.v,...(t.varName?{varName:t.varName}:{})}),
2406
2924
  reason:token.name+" ("+token.v+"px) is the transitions.dev blur token for "+token.usage+". "+t.blur+"px is "+(usage?"off the usage token":"off-grid")+"."});}
2407
2925
  }
2926
+ // Missing-blur detection (demo parity with server token engine).
2927
+ for(const [memberKey,info] of blurByMember){
2928
+ if(info.sawBlur)continue;
2929
+ const token=_txBlurUsage(info.hint);
2930
+ if(!token)continue;
2931
+ const member=info.member||null;
2932
+ out.push({id:"mock-"+memberKey+"-blur-add",kind:"blur",property:"filter",member,
2933
+ title:"Blur → "+token.name,from:"0px",to:token.v+"px",
2934
+ patch:{property:"filter",blur:token.v,...(member?{member}:{}),...(info.varName?{varName:info.varName}:{})},
2935
+ reason:token.name+" ("+token.v+"px) is the transitions.dev blur token for "+token.usage+". This transition currently has no blur."});
2936
+ }
2408
2937
  return out;
2409
2938
  }
2410
2939
  // refine → token-faithful suggestions (same verdict the shipped agent reaches)
@@ -2420,15 +2949,50 @@
2420
2949
  // motion-token tweaks: ONLY off-token values, exactly like the real engine.
2421
2950
  const tweaks = _txTokenTweaks(t, ctx);
2422
2951
  // related phases (open + close) → a recipe swap updates them together: one
2423
- // `patches` entry per phase, open slower than close (250ms / 150ms tokens).
2952
+ // group of patches per phase, open slower than close (250ms / 150ms tokens).
2953
+ // Timing rides on a property:"all" patch; the scale value rides on dedicated
2954
+ // per-lane patches that carry the transform lane's BACKING VAR. A bare
2955
+ // property:"all" scale can't go live — the live preview writes a CSS custom
2956
+ // property and needs the var name, which only the scanned timings know.
2424
2957
  const reqPhases = (request && Array.isArray(request.phases)) ? request.phases.filter(p=>p&&p.phase) : [];
2425
- const replacePatches = reqPhases.length>1
2426
- ? reqPhases.map(p=>({phase:p.phase, property:"all", durationMs:/close/i.test(p.phase)?150:250, easing:_TX_EASE, scale:/close/i.test(p.phase)?0.99:0.97}))
2427
- : null;
2958
+ const recipeScale = ph => /close/i.test(ph)?0.99:0.97;
2959
+ const recipeBlur = ph => /close/i.test(ph)?2:2;
2960
+ const recipeDur = ph => /close/i.test(ph)?150:250;
2961
+ // one scale patch per transform/filter lane that has a backing var, so the
2962
+ // applied override carries {scale, varName} and the live var gets written.
2963
+ const scalePatchesFor = (phase, timings) => (timings||[])
2964
+ .filter(tt=>tt.varName && tt.scale!=null)
2965
+ .map(tt=>({phase, property:tt.property, member:tt.member||null, varName:tt.varName, scale:recipeScale(phase)}));
2966
+ const blurPatchesFor = (phase, timings) => (timings||[])
2967
+ .filter(tt=>tt.varName && tt.blur!=null)
2968
+ .map(tt=>({phase, property:tt.property, member:tt.member||null, varName:tt.varName, blur:recipeBlur(phase)}));
2969
+ const phasePatches = (phase, timings) => [
2970
+ {phase, property:"all", durationMs:recipeDur(phase), easing:_TX_EASE},
2971
+ ...scalePatchesFor(phase, timings),
2972
+ ...blurPatchesFor(phase, timings)];
2973
+ let replacePatches = reqPhases.length>1
2974
+ ? reqPhases.flatMap(p=>phasePatches(p.phase, p.timings))
2975
+ : phasePatches((request&&request.phase)||null, t);
2976
+ // If several phases resolve to the SAME backing var (a demo that reuses one
2977
+ // scale var for open + close), collapse to the most prominent value so the
2978
+ // single var lands on the recipe's open scale (0.97) instead of the subtler
2979
+ // close value (0.99). Real components use distinct vars → no-op here.
2980
+ {
2981
+ const seenVar=new Map(); const collapsed=[];
2982
+ for(const p of replacePatches){
2983
+ if(p.varName&&p.scale!=null){
2984
+ const prev=seenVar.get(p.varName);
2985
+ if(prev){ if(p.scale<prev.scale)prev.scale=p.scale; continue; }
2986
+ seenVar.set(p.varName,p);
2987
+ }
2988
+ collapsed.push(p);
2989
+ }
2990
+ replacePatches=collapsed;
2991
+ }
2428
2992
  const replace = { id:"mock-replace-menu", kind:"replace", property:p0, member:m0,
2429
2993
  title:"Menu dropdown", reference:"05-menu-dropdown.md",
2430
2994
  patch:mp(m0,{property:"all", durationMs:250, easing:_TX_EASE, scale:0.97}),
2431
- ...(replacePatches?{patches:replacePatches}:{}),
2995
+ ...(replacePatches&&replacePatches.length?{patches:replacePatches}:{}),
2432
2996
  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)." };
2433
2997
  const suggestions=[...tweaks, replace];
2434
2998
  // Scope to the requested refineType so per-tab caches merge cleanly.
@@ -2722,7 +3286,7 @@
2722
3286
  onReconnect&&h("button",{className:"tl-gate-recheck",disabled:rechecking,onClick:recheck},
2723
3287
  rechecking?(relayDown?"Retrying…":"Reconnecting…"):(relayDown?"Retry connection":"Reconnect agent")));
2724
3288
  }
2725
- function RefinePanel({open,onClose,phase,label,refineType,onType,suggestions,summary,error,appliedIds,onApply,onApplyAll,mode,onMode,llmAvailable,cliInstalled,relayDown,agentReason,onReconnect,onStart,lanes}){
3289
+ function RefinePanel({open,onClose,phase,label,refineType,onType,suggestions,summary,error,appliedIds,onApply,onApplyAll,mode,onMode,llmAvailable,cliInstalled,relayDown,agentReason,onReconnect,onStart,lanes,resolvedTheme}){
2726
3290
  // mount-on-open; keep mounted through the panel-reveal slide-out, then unmount
2727
3291
  const[render,setRender]=useState(open);
2728
3292
  const[panelOpen,setPanelOpen]=useState(false);
@@ -2765,6 +3329,26 @@
2765
3329
  const id=setInterval(()=>setStatusIx(i=>(i+1)%REFINE_STATUS.length),2800);
2766
3330
  return()=>clearInterval(id);
2767
3331
  },[phase]);
3332
+ // liquid morph: when the scan finishes WITH results, the loading pill
3333
+ // splits into the two bar buttons via a gooey overlay. One-shot per scan;
3334
+ // cleared after the blob keyframes + button cross-blur settle.
3335
+ // useLayoutEffect (not useEffect): the flag must flip BEFORE the "done"
3336
+ // frame paints, otherwise the bar buttons flash for a frame, then the
3337
+ // exiting pill re-mounts on top of them — visible pill/buttons flicker.
3338
+ const[liquidMorph,setLiquidMorph]=useState(false);
3339
+ const prevPhaseRef=useRef(phase);
3340
+ useLayoutEffect(()=>{
3341
+ const prev=prevPhaseRef.current;
3342
+ prevPhaseRef.current=phase;
3343
+ if(prev==="scanning"&&phase==="done"){
3344
+ setLiquidMorph(true);
3345
+ // 300ms split → 300–430ms goo/chrome hand-off; unmount at 470ms once
3346
+ // the buttons are fully self-sufficient (small buffer past 430ms).
3347
+ const to=setTimeout(()=>setLiquidMorph(false),470);
3348
+ return()=>clearTimeout(to);
3349
+ }
3350
+ if(phase!=="done")setLiquidMorph(false);
3351
+ },[phase]);
2768
3352
  if(!render)return null;
2769
3353
 
2770
3354
  // one scan returns both kinds; each tab shows only its own:
@@ -2785,17 +3369,19 @@
2785
3369
  // node morphs (card resize) into the loading rectangle with the border-beam.
2786
3370
  const scanning = phase==="scanning";
2787
3371
  const startBtn=(o)=>{o=o||{};
3372
+ const exiting = !!o.exiting;
3373
+ const showScanning = scanning || exiting;
2788
3374
  // Both faces stay mounted and stacked; the morph cross-blurs (icon swap)
2789
3375
  // between the label and the icon+status instead of swapping the subtree.
2790
- return h("button",{className:cx("tl-scan-morph","t-resize",scanning&&"is-scanning"),
2791
- disabled:(!!o.disabled)||scanning,"aria-busy":scanning?"true":undefined,
2792
- onClick:(o.disabled||scanning)?undefined:onStart},
2793
- scanning&&h(BorderBeam,{size:"md",colorVariant:"ocean",theme:"light",duration:2.6,saturation:2,brightness:1.6,className:"tl-scan-beam"},
3376
+ return h("button",{className:cx("tl-scan-morph","t-resize",showScanning&&"is-scanning",exiting&&"is-exiting"),
3377
+ disabled:(!!o.disabled)||showScanning,"aria-busy":scanning?"true":undefined,
3378
+ onClick:(o.disabled||showScanning)?undefined:onStart},
3379
+ scanning&&h(BorderBeam,{size:"md",colorVariant:"ocean",theme:resolvedTheme==="dark"?"dark":"light",duration:2.6,saturation:2,brightness:1.6,className:"tl-scan-beam"},
2794
3380
  h("span",{className:"tl-scan-beam-fill"})),
2795
3381
  h("span",{className:"tl-scan-content"},
2796
- h("span",{className:"tl-scan-face tl-scan-face-label","aria-hidden":scanning?"true":undefined},
3382
+ h("span",{className:"tl-scan-face tl-scan-face-label","aria-hidden":showScanning?"true":undefined},
2797
3383
  h("span",{className:"tl-scan-label"},o.label||"Start scanning")),
2798
- h("span",{className:"tl-scan-face tl-scan-face-status","aria-hidden":scanning?undefined:"true"},
3384
+ h("span",{className:"tl-scan-face tl-scan-face-status","aria-hidden":showScanning&&!exiting?undefined:"true"},
2799
3385
  h(DotmLoader),
2800
3386
  h(ShimmerSwapText,{text:REFINE_STATUS[statusIx]}))));
2801
3387
  };
@@ -2843,6 +3429,10 @@
2843
3429
 
2844
3430
  // Foot tracks the active type (it lives below the sliding body, so it
2845
3431
  // just updates in place on a tab switch).
3432
+ // The liquid split only plays when the finished scan actually shows the
3433
+ // two-button bar — with zero results the foot is a plain pill and must
3434
+ // not pick up the morphing footer classes.
3435
+ const morphing = liquidMorph && phase==="done" && visible.length>0;
2846
3436
  let foot, footBar=false;
2847
3437
  if(phase==="scanning"){
2848
3438
  foot = startBtn();
@@ -2852,12 +3442,38 @@
2852
3442
  if(visible.length===0){
2853
3443
  foot = startBtn({label:"Scan again"});
2854
3444
  } else {
2855
- footBar = true;
2856
- foot = h(React.Fragment,null,
3445
+ footBar = !morphing;
3446
+ const barBtns = h(React.Fragment,null,
2857
3447
  h("button",{className:"tl-pill-btn",disabled:pending.length===0,
2858
3448
  onClick:()=>pending.forEach(onApply)},
2859
3449
  h("span",null,"Apply all ",h("span",{className:"tl-dim"},"("+pending.length+")"))),
2860
- h("button",{className:"tl-pill-btn",onClick:onStart},"Scan again"));
3450
+ h("button",{className:"tl-pill-btn",onClick:onStart},h("span",null,"Scan again")));
3451
+ foot = morphing
3452
+ // gooey split: the exiting pill fades out on the shrinking stage
3453
+ // (which drives the foot's height transition), while the overlay —
3454
+ // blobs, chrome, labels — is a foot-anchored SIBLING pinned to the
3455
+ // bar's final geometry, so it never moves or resizes at handoff.
3456
+ ? h(React.Fragment,null,
3457
+ h("div",{className:"tl-liquid-stage"}),
3458
+ h("div",{className:"tl-liquid-morph"},
3459
+ startBtn({exiting:true}),
3460
+ h("svg",{className:"tl-liquid-goo-svg","aria-hidden":"true",focusable:"false"},
3461
+ h("defs",null,
3462
+ h("filter",{id:"tl-goo"},
3463
+ // alpha threshold must be exactly 0.5 (20a - 10): a blurred
3464
+ // straight edge crosses 0.5 precisely AT the true geometry
3465
+ // edge, so goo pills render at the real 32px button height.
3466
+ // Lower thresholds (e.g. 22a - 9 = 0.41) dilate every edge
3467
+ // ~1.5px outward — the goo state looked ~4px taller.
3468
+ h("feGaussianBlur",{in:"SourceGraphic",stdDeviation:"7",result:"blur"}),
3469
+ h("feColorMatrix",{in:"blur",mode:"matrix",
3470
+ values:"1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 20 -10",result:"goo"}),
3471
+ h("feComposite",{in:"SourceGraphic",in2:"goo",operator:"atop"})))),
3472
+ h("div",{className:"tl-liquid-goo"},
3473
+ h("div",{className:"tl-liquid-blob tl-liquid-blob-a"}),
3474
+ h("div",{className:"tl-liquid-blob tl-liquid-blob-b"})),
3475
+ h("div",{className:"tl-liquid-btns"},barBtns)))
3476
+ : barBtns;
2861
3477
  }
2862
3478
  } else { // idle
2863
3479
  foot = startBtn(agentReady?{}:{disabled:true});
@@ -2893,7 +3509,7 @@
2893
3509
  bodyFor("small",refineType==="small")),
2894
3510
  h("div",{className:"tl-refine-page t-page","data-page-id":"2","aria-hidden":refineType!=="replace"},
2895
3511
  bodyFor("replace",refineType==="replace"))),
2896
- h("div",{className:cx("tl-refine-foot",footBar&&"is-bar")},foot)));
3512
+ h("div",{className:cx("tl-refine-foot",footBar&&"is-bar",morphing&&"is-exiting-scan",morphing&&"is-morphing")},foot)));
2897
3513
  }
2898
3514
 
2899
3515
  // Diff the active item's effective (edited/refined) lanes against their
@@ -2937,12 +3553,12 @@
2937
3553
  return out;
2938
3554
  }
2939
3555
 
2940
- function Header({entries,active,onSelect,onReset,onCopy,copied,snap,setSnap,onMinimize,onRefine,refineActive,onAccept,acceptState,acceptDisabled,acceptError,scanning,scanError,onRescan,pick,setPick,chatLoop,liveStopped,onStopLive}){
3556
+ function Header({entries,active,onSelect,onReset,onCopy,copied,snap,setSnap,onMinimize,onRefine,refineActive,onAccept,acceptState,acceptDisabled,acceptError,scanning,scanError,onRescan,pick,setPick,chatLoop,liveStopped,onStopLive,themePref,setThemePref}){
2941
3557
  const[setg,setSetg]=useState(false);
2942
3558
  // settings dropdown sub-page (main menu ↔ keyboard-shortcuts cheat sheet)
2943
3559
  const[settingsView,setSettingsView]=useState("main"); // "main" | "shortcuts"
2944
- // theme preference UI only for now (dark theme not wired up yet)
2945
- const[themePref,setThemePref]=useState("system"); // "light" | "dark" | "system"
3560
+ // theme preference is owned by TimelinePanel (persisted + applied to <html>);
3561
+ // Header just renders the picker and reports changes up.
2946
3562
  const THEME_OPTS=[{key:"light",label:"Light"},{key:"dark",label:"Dark"},{key:"system",label:"System"}];
2947
3563
  const isMac=typeof navigator!=="undefined"&&/mac/i.test((navigator.userAgentData&&navigator.userAgentData.platform)||navigator.platform||navigator.userAgent||"");
2948
3564
  const modKey=isMac?"Cmd":"Ctrl";
@@ -3065,9 +3681,9 @@
3065
3681
  h("span",{className:"tl-menu-chevr"},h(Ic,{name:"chevronr",size:16})))},"Theme"),
3066
3682
  h(MenuItem,{onClick:()=>setSettingsView("shortcuts"),
3067
3683
  right:h("span",{className:"tl-menu-chevr"},h(Ic,{name:"chevronr",size:16}))},"Keyboard shortcuts"),
3068
- h(MenuItem,{onClick:()=>{setSetg(false);window.open("https://transitions.dev","_blank","noopener");},
3684
+ h(MenuItem,{onClick:()=>{setSetg(false);window.open("mailto:jakubja@gmail.com","_blank","noopener");},
3069
3685
  right:h("span",{className:"tl-menu-chevr"},h(Ic,{name:"linkout",size:16}))},"Send feedback"),
3070
- h(MenuItem,{onClick:()=>{setSetg(false);window.open("https://transitions.dev","_blank","noopener");},
3686
+ h(MenuItem,{onClick:()=>{setSetg(false);window.open("https://transitions.dev/refine","_blank","noopener");},
3071
3687
  right:h("span",{className:"tl-menu-chevr"},h(Ic,{name:"linkout",size:16}))},"Learn more"),
3072
3688
  h("div",{className:"tl-menu-divider"}),
3073
3689
  h("div",{className:"tl-menu-head"},
@@ -3124,54 +3740,188 @@
3124
3740
  h("i",{style:{"--ox":"-5px","--oy":"-2px","--sx":"-9px","--sy":"-9px","--sd":"930ms","--sdelay":"180ms"}}),
3125
3741
  h("i",{style:{"--ox":"3px","--oy":"1px","--sx":"12px","--sy":"1px","--sd":"980ms","--sdelay":"300ms"}})),
3126
3742
  h("span",{className:"tl-btn-label"},"Refine")),
3127
- h("span",{className:"t-tt tl-tt-below",role:"tooltip"},"Refine",ttHint("R"))),
3743
+ h("span",{className:"t-tt tl-tt-below",role:"tooltip"},"Refine",ttHint("E"))),
3128
3744
  h("span",{className:"t-tt-wrap"},
3129
3745
  h("button",{className:"tl-icon-btn ghost t-tt-trigger","aria-label":"Minimize",onClick:onMinimize},h(Ic,{name:"minimize"})),
3130
3746
  h("span",{className:"t-tt tl-tt-below tl-tt-right",role:"tooltip"},"Minimize",ttHint("."))),
3131
3747
  );
3132
3748
  }
3133
3749
 
3750
+ // capsule outline: right-drag pinches inward (neck), left-drag bows outward
3751
+ // (belly, 70% depth). `amt` > 0 = outward, < 0 = inward. Same command
3752
+ // structure at every value so the CSS `d` transition interpolates smoothly.
3753
+ const RB_H=24, RB_R=8;
3754
+ function rbPath(w, amt){
3755
+ const mx=(w/2).toFixed(1), t=(-2*amt).toFixed(2), b=(RB_H+2*amt).toFixed(2);
3756
+ return `path("M 0 ${RB_R} Q 0 0 ${RB_R} 0 Q ${mx} ${t} ${w-RB_R} 0 Q ${w} 0 ${w} ${RB_R} L ${w} ${RB_H-RB_R} Q ${w} ${RB_H} ${w-RB_R} ${RB_H} Q ${mx} ${b} ${RB_R} ${RB_H} Q 0 ${RB_H} 0 ${RB_H-RB_R} Z")`;
3757
+ }
3758
+
3134
3759
  function PropTrack({property, member, delayMs, durationMs, selected, onSelect, onDelayChange, onDurationChange, snap, scaleMs, lockDuration, labelW}){
3135
3760
  const trackRef=useRef(null);
3761
+ const barRef=useRef(null);
3136
3762
  const [dragging,setDragging]=useState(false);
3137
- // 1ms resolution while dragging so the bar moves smoothly; the snap grid
3138
- // (25ms) is applied only on release when `snap` is on.
3763
+ // Drag math stays at 1ms resolution for smooth motion. Any coarse grid
3764
+ // quantization is avoided here because it reads as frame snapping.
3139
3765
  const SNAP_GRID = 25;
3140
3766
  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]);
3141
3767
 
3768
+ // keep the SVG body outline in sync with the bar's real size
3769
+ useLayoutEffect(()=>{
3770
+ const bar=barRef.current; if(!bar) return;
3771
+ const p=bar.querySelector(".tl-bar-body path");
3772
+ const sync=()=>{
3773
+ if(bar.classList.contains("rb-release")) return;
3774
+ if(bar.classList.contains("is-dragging")){
3775
+ p.style.d=rbPath(bar.offsetWidth,bar.__rbBulge||0);
3776
+ return;
3777
+ }
3778
+ p.style.transition="none";
3779
+ p.style.d=rbPath(bar.offsetWidth,0);
3780
+ requestAnimationFrame(()=>p.style.removeProperty("transition"));
3781
+ };
3782
+ sync();
3783
+ const ro=new ResizeObserver(sync); ro.observe(bar);
3784
+ return ()=>ro.disconnect();
3785
+ },[]);
3786
+
3142
3787
  const startDrag=useCallback((mode,e)=>{
3143
3788
  e.preventDefault(); e.stopPropagation();
3144
3789
  onSelect();
3145
- // a spring controls its own duration, so its bar can be moved (delay) but not resized
3146
3790
  if(lockDuration && mode!=="move") return;
3147
3791
  setDragging(true);
3148
3792
  const startX=e.clientX; const sd=delayMs; const sdur=durationMs;
3149
3793
  let lastDel=sd, lastDur=sdur;
3794
+
3795
+ // ── rubber-band physics ──
3796
+ const bar=barRef.current;
3797
+ const bodyPath=bar?bar.querySelector(".tl-bar-body path"):null;
3798
+ let lastX=e.clientX, lastT=performance.now();
3799
+ let vel=0, dir=1, over=0, raf=0;
3800
+ if(bar){
3801
+ clearTimeout(bar.__rbCleanup);
3802
+ bar.classList.remove("rb-release");
3803
+ bar.classList.add("is-dragging");
3804
+ }
3805
+ const STRAIGHT_HANDLE='path("M 4 1 L 4 13")';
3806
+ const leftPath=bar?bar.querySelector(".tl-bar-handle.left path"):null;
3807
+ const rightPath=bar?bar.querySelector(".tl-bar-handle.right path"):null;
3808
+ const bentHandle=bow=>{
3809
+ const tip=(4+bow*0.15).toFixed(2), mid=(4+bow).toFixed(2);
3810
+ return `path("M ${tip} 1 Q ${mid} 7 ${tip} 13")`;
3811
+ };
3812
+ const setHandleBend=(bow,leading)=>{
3813
+ if(leftPath) leftPath.style.d=leading==="left"?bentHandle(bow):STRAIGHT_HANDLE;
3814
+ if(rightPath) rightPath.style.d=leading==="right"?bentHandle(bow):STRAIGHT_HANDLE;
3815
+ };
3816
+ let env=0;
3817
+ const setVars=()=>{
3818
+ if(!bar||!bodyPath) return;
3819
+ const sV=Math.abs(vel)/(Math.abs(vel)+0.15);
3820
+ const sO=over/(over+40);
3821
+ const s=Math.min(1,Math.max(sV,sO)*1.05);
3822
+ env=s>env?env+(s-env)*0.35:env*0.90;
3823
+ const w=bar.offsetWidth;
3824
+ if(mode==="move"){
3825
+ // Whole-bar drag should be pure translation only: no rubber bend.
3826
+ bar.__rbBulge=0;
3827
+ for(const v of ["--rb-origin","--rb-sx","--rb-sy","--rb-skew","--rb-hshift"]) bar.style.removeProperty(v);
3828
+ bodyPath.style.d=rbPath(w,0);
3829
+ setHandleBend(0,"none");
3830
+ return;
3831
+ }
3832
+ // Left-edge resize should mirror the bend direction (user expectation:
3833
+ // same pointer motion on the left edge bends opposite to the right edge).
3834
+ const bendDir = mode==="left" ? -dir : dir;
3835
+ const origin=mode==="move"?(dir>0?"0%":"100%"):(mode==="right"?"0%":"100%");
3836
+ bar.style.setProperty("--rb-origin",origin);
3837
+ bar.style.setProperty("--rb-sx",(1+(8*env)/Math.max(w,24)).toFixed(4));
3838
+ bar.style.setProperty("--rb-sy",(1-0.1*env).toFixed(4));
3839
+ bar.style.setProperty("--rb-skew",(bendDir*-3*env).toFixed(2)+"deg");
3840
+ bar.style.setProperty("--rb-hshift",(bendDir*1.5*env).toFixed(2)+"px");
3841
+ const bend=Math.min(4,(w-16)/8)*env;
3842
+ bar.__rbBulge=bendDir<0?bend*0.7:-bend;
3843
+ bodyPath.style.d=rbPath(w,bar.__rbBulge);
3844
+ const leading=mode==="move"?(dir>0?"right":"left"):mode;
3845
+ setHandleBend(bendDir*8*env,leading);
3846
+ };
3847
+ let lastTick=performance.now();
3848
+ const tick=()=>{ lastTick=performance.now(); vel*=0.86; setVars(); raf=requestAnimationFrame(tick); };
3849
+ raf=requestAnimationFrame(tick);
3850
+ const safety=setInterval(()=>{
3851
+ if(performance.now()-lastTick<40) return;
3852
+ vel*=0.86; setVars();
3853
+ },50);
3854
+
3150
3855
  const onMove=e2=>{
3151
- const dx=e2.clientX-startX; const dMs=pxToMs(dx,1); // smooth: 1ms steps
3856
+ const now=performance.now(); const dt=Math.max(1,now-lastT);
3857
+ const iv=(e2.clientX-lastX)/dt;
3858
+ vel+=(iv-vel)*0.3;
3859
+ if(Math.abs(vel)>0.05) dir=vel>0?1:-1;
3860
+ lastX=e2.clientX; lastT=now;
3861
+ const w=trackRef.current?trackRef.current.getBoundingClientRect().width:1;
3862
+ const msToPx=ms=>Math.abs(ms)/scaleMs*w;
3863
+ const grid=1;
3864
+ const dx=e2.clientX-startX; const dMs=pxToMs(dx,grid);
3152
3865
  if(mode==="move"){
3153
- lastDel=Math.max(0,Math.min(sd+dMs,scaleMs-sdur));
3154
- onDelayChange(lastDel);
3866
+ const raw=sd+dMs; const next=Math.max(0,Math.min(raw,scaleMs-sdur));
3867
+ over=msToPx(raw-next);
3868
+ lastDel=next; onDelayChange(next);
3155
3869
  } else if(mode==="left"){
3156
- const rawDel=sd+dMs; const clampedDel=Math.max(0,Math.min(rawDel,sd+sdur-1));
3870
+ const rawDel=sd+dMs; const clampedDel=Math.max(0,Math.min(rawDel,sd+sdur-grid));
3871
+ over=msToPx(rawDel-clampedDel);
3157
3872
  lastDel=clampedDel; lastDur=sdur-(clampedDel-sd);
3158
3873
  onDelayChange(lastDel); onDurationChange(lastDur);
3159
3874
  } else {
3160
- lastDur=Math.max(1,Math.min(sdur+dMs,scaleMs-sd));
3161
- onDurationChange(lastDur);
3875
+ const rawDur=sdur+dMs; const next=Math.max(grid,Math.min(rawDur,scaleMs-sd));
3876
+ over=msToPx(rawDur-next);
3877
+ lastDur=next; onDurationChange(next);
3162
3878
  }
3879
+ setVars();
3163
3880
  };
3164
3881
  const onUp=()=>{
3165
- setDragging(false);
3166
- // snap to the grid once, on release (bar tweens there since the
3167
- // is-dragging transition lock is now removed).
3168
- if(snap){
3169
- const g=SNAP_GRID, sn=v=>Math.round(v/g)*g;
3170
- if(mode==="move"){ onDelayChange(Math.max(0,Math.min(sn(lastDel),scaleMs-sdur))); }
3171
- else if(mode==="left"){ onDelayChange(Math.max(0,sn(lastDel))); onDurationChange(Math.max(g,sn(lastDur))); }
3172
- else { onDurationChange(Math.max(g,Math.min(sn(lastDur),scaleMs-sd))); }
3173
- }
3174
3882
  window.removeEventListener("mousemove",onMove);window.removeEventListener("mouseup",onUp);
3883
+ cancelAnimationFrame(raf); clearInterval(safety);
3884
+ // Freeze the bar exactly where it is RENDERED at mouseup. On a fast
3885
+ // drag React's last state flush can still be in flight — the DOM lags
3886
+ // the pointer — and letting that flush land after release reads as
3887
+ // the bar drifting on its own. Committing the visible layout position
3888
+ // (offsetLeft/offsetWidth → ms) overrides any in-flight value with
3889
+ // what the user actually sees, so the bar cannot move after release.
3890
+ if(bar&&trackRef.current){
3891
+ const grid=1;
3892
+ const tw=trackRef.current.getBoundingClientRect().width||1;
3893
+ const px2ms=px=>Math.round(px/tw*scaleMs/grid)*grid;
3894
+ const visDel=Math.max(0,px2ms(bar.offsetLeft));
3895
+ if(mode==="move"){
3896
+ onDelayChange(Math.min(visDel,Math.max(0,scaleMs-sdur)));
3897
+ } else if(bar.offsetWidth>24){ // min-width clamp would corrupt px→ms
3898
+ const visDur=Math.max(grid,px2ms(bar.offsetWidth));
3899
+ if(mode==="left"){ onDelayChange(visDel); onDurationChange(visDur); }
3900
+ else { onDurationChange(Math.min(visDur,scaleMs-sd)); }
3901
+ }
3902
+ }
3903
+ if(bar&&bodyPath){
3904
+ bar.__rbBulge=0;
3905
+ for(const v of ["--rb-sx","--rb-sy","--rb-skew","--rb-hshift"]) bar.style.removeProperty(v);
3906
+ bodyPath.style.d=rbPath(bar.offsetWidth,0);
3907
+ setHandleBend(0,"none");
3908
+ // geometry tweens stay hard-disabled until the release commit has
3909
+ // rendered — position must land instantly (it equals what's already
3910
+ // on screen), never animate.
3911
+ bar.classList.add("no-geom-tween");
3912
+ if(mode!=="move") bar.classList.add("rb-release");
3913
+ requestAnimationFrame(()=>requestAnimationFrame(()=>{
3914
+ setDragging(false);
3915
+ bar.classList.remove("no-geom-tween");
3916
+ }));
3917
+ bar.__rbCleanup=setTimeout(()=>{
3918
+ bar.classList.remove("rb-release");
3919
+ bar.style.removeProperty("--rb-origin");
3920
+ bodyPath.style.d=rbPath(bar.offsetWidth,0);
3921
+ if(leftPath) leftPath.style.removeProperty("d");
3922
+ if(rightPath) rightPath.style.removeProperty("d");
3923
+ },220);
3924
+ }
3175
3925
  };
3176
3926
  window.addEventListener("mousemove",onMove);window.addEventListener("mouseup",onUp);
3177
3927
  },[delayMs,durationMs,pxToMs,snap,scaleMs,onSelect,onDelayChange,onDurationChange,lockDuration]);
@@ -3183,10 +3933,15 @@
3183
3933
  member&&h("span",{className:"tl-prop-member"},member),
3184
3934
  h("span",{className:"tl-prop-prop"},property))),
3185
3935
  h("div",{className:"tl-prop-track",ref:trackRef},
3186
- h("div",{className:cx("tl-bar",dragging&&"is-dragging"),style:{left:delPct+"%",width:durPct+"%"},onMouseDown:e=>startDrag("move",e),
3936
+ h("div",{className:cx("tl-bar",dragging&&"is-dragging"),ref:barRef,style:{left:delPct+"%",width:durPct+"%"},onMouseDown:e=>startDrag("move",e),
3187
3937
  title:lockDuration?"Spring duration is derived \u2014 drag to move, resize is locked":undefined},
3188
- h("span",{className:"tl-bar-handle left"}),
3189
- h("span",{className:"tl-bar-handle right"}),
3938
+ h("svg",{className:"tl-bar-body","aria-hidden":true},
3939
+ h("defs",null,h("linearGradient",{id:"rb-ring",x1:0,y1:0,x2:0,y2:1},
3940
+ h("stop",{offset:"0",stopColor:"#000",stopOpacity:0.06}),
3941
+ h("stop",{offset:"1",stopColor:"#000",stopOpacity:0.15}))),
3942
+ h("path",null)),
3943
+ h("span",{className:"tl-bar-handle left"},h("svg",{viewBox:"0 0 8 14"},h("path",{d:"M 4 1 L 4 13"}))),
3944
+ h("span",{className:"tl-bar-handle right"},h("svg",{viewBox:"0 0 8 14"},h("path",{d:"M 4 1 L 4 13"}))),
3190
3945
  !lockDuration&&h("div",{className:"tl-bar-grip left",onMouseDown:e=>startDrag("left",e)}),
3191
3946
  !lockDuration&&h("div",{className:"tl-bar-grip right",onMouseDown:e=>startDrag("right",e)}),
3192
3947
  ),
@@ -3774,6 +4529,33 @@
3774
4529
  const[panelHeight,setPanelHeight]=useState(440);
3775
4530
  const[resizing,setResizing]=useState(false);
3776
4531
  const[snap,setSnap]=useState(true);
4532
+ // ── theme (light / dark / system) ──
4533
+ // Preference persists in localStorage; "system" follows the OS via
4534
+ // matchMedia. The resolved value is mirrored onto <html data-tl-theme> so the
4535
+ // panel AND its body-portaled menus/toasts theme together, and is handed to
4536
+ // BorderBeam to pick its dark beam. A dedicated attribute (not "data-theme")
4537
+ // avoids clobbering a host prototype's own theming in the injected build.
4538
+ const[themePref,setThemePref]=useState(()=>{
4539
+ try{const v=localStorage.getItem("tl-theme-pref");return v==="light"||v==="dark"||v==="system"?v:"system";}catch{return "system";}
4540
+ });
4541
+ const systemDark=()=>{try{return window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches;}catch{return false;}};
4542
+ const[resolvedTheme,setResolvedTheme]=useState(()=>themePref==="system"?(systemDark()?"dark":"light"):themePref);
4543
+ useEffect(()=>{
4544
+ try{localStorage.setItem("tl-theme-pref",themePref);}catch{}
4545
+ const apply=()=>{
4546
+ const r=themePref==="system"?(systemDark()?"dark":"light"):themePref;
4547
+ setResolvedTheme(r);
4548
+ try{document.documentElement.setAttribute("data-tl-theme",r);}catch{}
4549
+ };
4550
+ apply();
4551
+ if(themePref!=="system")return;
4552
+ // Follow live OS changes only while on "system".
4553
+ let mq;try{mq=window.matchMedia("(prefers-color-scheme: dark)");}catch{return;}
4554
+ if(!mq)return;
4555
+ const onChange=()=>apply();
4556
+ if(mq.addEventListener)mq.addEventListener("change",onChange);else if(mq.addListener)mq.addListener(onChange);
4557
+ return()=>{if(mq.removeEventListener)mq.removeEventListener("change",onChange);else if(mq.removeListener)mq.removeListener(onChange);};
4558
+ },[themePref]);
3777
4559
  // transition picker open state — lifted out of Header so the panel-focused
3778
4560
  // Cmd/Ctrl+K shortcut can open it.
3779
4561
  const[pickOpen,setPickOpen]=useState(false);
@@ -4038,15 +4820,10 @@
4038
4820
  const groupId=active&&active.groupId;
4039
4821
  for(const p of patches){
4040
4822
  if(!p.property)continue;
4041
- const o={};
4042
- if(p.durationMs!=null)o.durationMs=p.durationMs;
4043
- if(p.delayMs!=null)o.delayMs=p.delayMs;
4044
- if(p.easing!=null)o.easing=p.easing;
4045
- // value edits: keep the new value, the backing var, and the from-value
4046
- // (parsed off the suggestion) so computeChanges can write source on Accept.
4047
- if(p.scale!=null){o.scale=p.scale;o.scaleFrom=(s.from!=null?parseFloat(s.from):undefined);}
4048
- if(p.blur!=null){o.blur=p.blur;o.blurFrom=(s.from!=null?parseFloat(s.from):undefined);}
4049
- if(p.varName!=null)o.varName=p.varName;
4823
+ const timingOverride={};
4824
+ if(p.durationMs!=null)timingOverride.durationMs=p.durationMs;
4825
+ if(p.delayMs!=null)timingOverride.delayMs=p.delayMs;
4826
+ if(p.easing!=null)timingOverride.easing=p.easing;
4050
4827
  // resolve the phase entry this patch targets (its sibling open/close
4051
4828
  // lives in the same group); default to the active entry.
4052
4829
  let target=active;
@@ -4061,7 +4838,35 @@
4061
4838
  let lanes=p.property==="all"?et:et.filter(t=>t.property===p.property);
4062
4839
  if(p.member)lanes=lanes.filter(t=>(t.member===p.member||t.memberId===p.member));
4063
4840
  const ids=lanes.length?lanes.map(t=>t.laneId):[p.property];
4064
- for(const id of ids)registry.setPropOverride(target.id,id,o);
4841
+ if(Object.keys(timingOverride).length){
4842
+ for(const id of ids)registry.setPropOverride(target.id,id,timingOverride);
4843
+ }
4844
+
4845
+ // Value edits (scale/blur) must land on concrete lanes so live preview
4846
+ // can write the right backing var. For coarse recipe patches that use
4847
+ // property:"all" and omit varName, fan out by lane type.
4848
+ if(p.scale!=null){
4849
+ let scaleLanes=lanes.filter(t=>t.property==="transform"||t.scale!=null);
4850
+ if(!scaleLanes.length&&p.property==="all")scaleLanes=et.filter(t=>t.property==="transform"||t.scale!=null);
4851
+ const scaleFrom=(s.from!=null?parseFloat(s.from):undefined);
4852
+ for(const lane of scaleLanes){
4853
+ const ov={scale:p.scale,scaleFrom};
4854
+ const vn=p.varName??lane.varName;
4855
+ if(vn!=null)ov.varName=vn;
4856
+ registry.setPropOverride(target.id,lane.laneId,ov);
4857
+ }
4858
+ }
4859
+ if(p.blur!=null){
4860
+ let blurLanes=lanes.filter(t=>t.property==="filter"||t.blur!=null);
4861
+ if(!blurLanes.length&&p.property==="all")blurLanes=et.filter(t=>t.property==="filter"||t.blur!=null);
4862
+ const blurFrom=(s.from!=null?parseFloat(s.from):undefined);
4863
+ for(const lane of blurLanes){
4864
+ const ov={blur:p.blur,blurFrom};
4865
+ const vn=p.varName??lane.varName;
4866
+ if(vn!=null)ov.varName=vn;
4867
+ registry.setPropOverride(target.id,lane.laneId,ov);
4868
+ }
4869
+ }
4065
4870
  }
4066
4871
  setAppliedIds(prev=>({...prev,[s.id]:true}));
4067
4872
  },[registry,active,entries]);
@@ -4581,7 +5386,7 @@
4581
5386
  if(sel&&!sel.isCollapsed)return;
4582
5387
  handle(()=>{if(L.active)L.copyValues&&L.copyValues();});
4583
5388
  break;}
4584
- case"r":case"R": // Toggle Refine panel
5389
+ case"e":case"E": // Toggle Refine panel
4585
5390
  handle(()=>{if(L.active)L.openRefine&&L.openRefine();});
4586
5391
  break;
4587
5392
  case"k":case"K": // Open transition picker
@@ -4625,7 +5430,7 @@
4625
5430
  onAccept,acceptState,acceptDisabled:computeChanges(active,entries).length===0,acceptError,
4626
5431
  scanning:groupScanState==="scanning",scanError:groupScanState==="error"?groupScanErr:null,
4627
5432
  onRescan:rescanTransitions,pick:pickOpen,setPick:setPickOpen,
4628
- chatLoop,liveStopped,onStopLive:stopLive}),
5433
+ chatLoop,liveStopped,onStopLive:stopLive,themePref,setThemePref}),
4629
5434
  active
4630
5435
  ?h(Body,{entry:active,onPropChange:(prop,o)=>setPropOverride(prop,o),snap})
4631
5436
  :h("div",{className:"tl-empty"},"Select a transition to inspect and edit it")),
@@ -4633,7 +5438,7 @@
4633
5438
  refineType,onType:changeRefineType,suggestions:refineSuggestions,summary:refineSummary,error:refineError,
4634
5439
  appliedIds,onApply:applySuggestion,onApplyAll:applyAllSuggestions,
4635
5440
  mode:refineMode,onMode:changeRefineMode,llmAvailable,cliInstalled,relayDown,agentReason,onReconnect:reconnectAgent,onStart:startScan,
4636
- lanes:active?.effectiveTimings||[]}))
5441
+ lanes:active?.effectiveTimings||[],resolvedTheme}))
4637
5442
  : h("div",{className:"tl-panel-main"},
4638
5443
  gate==="blocked" && h("div",{className:"tl-gate"},
4639
5444
  h("div",{className:"tl-gate-col"},
@@ -4974,9 +5779,26 @@
4974
5779
  // TESTING ONLY (not part of the tool): top-right toggle to hide the demo
4975
5780
  // page content so the panel can be inspected against a blank page.
4976
5781
  const[demoHidden,setDemoHidden]=useState(false);
5782
+ // Demo-page background toggle. Flips ONLY the demo page bg (#EEEEEE <-> #121212).
5783
+ // Deliberately separate from the panel's own theme (data-tl-theme) — it sets
5784
+ // data-demo-bg on <body> only and never touches the panel.
5785
+ const[darkBg,setDarkBg]=useState(false);
5786
+ useEffect(()=>{
5787
+ try{
5788
+ if(darkBg)document.body.setAttribute("data-demo-bg","dark");
5789
+ else document.body.removeAttribute("data-demo-bg");
5790
+ }catch(e){}
5791
+ },[darkBg]);
4977
5792
  const active=PROTOS.find(p=>p.key===proto)||PROTOS[0];
4978
5793
  return h(TimelineCtx.Provider,{value:ctx},
4979
5794
  showControls&&h(PanelControls),
5795
+ h("button",{type:"button",className:"demo-bg-toggle",
5796
+ onClick:()=>setDarkBg(v=>!v),
5797
+ "aria-label":"Toggle demo page background",
5798
+ "aria-pressed":darkBg,
5799
+ title:"Demo page background (independent of the panel theme)"},
5800
+ h("span",{className:"demo-bg-dot","aria-hidden":"true"}),
5801
+ "Dark bg"),
4980
5802
  h("button",{type:"button",onClick:()=>setDemoHidden(v=>!v),
4981
5803
  title:"Testing only: hide/show demo page content",
4982
5804
  style:{position:"fixed",top:"12px",right:"12px",zIndex:2147483000,