transitions-refine 0.3.8 → 0.3.9

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.
Files changed (2) hide show
  1. package/demo.html +375 -102
  2. package/package.json +1 -1
package/demo.html CHANGED
@@ -244,7 +244,7 @@
244
244
  padding: 6px 10px 6px 16px;
245
245
  font-family: "Inter", system-ui, -apple-system, sans-serif;
246
246
  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;
247
+ box-shadow: var(--shadow-btn); transition: background 0.15s, scale 0.12s ease;
248
248
  animation: tl-pill-in 220ms cubic-bezier(0.22, 1, 0.36, 1) both; }
249
249
  @keyframes tl-pill-in { from { opacity: 0; transform: translateY(6px) scale(0.96); }
250
250
  to { opacity: 1; transform: none; } }
@@ -369,7 +369,7 @@
369
369
  .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
370
  /* full-bleed divider between the Delay field and the Easing/Springs tabs
371
371
  (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; }
372
+ .tl-inspector-divider { height: 1px; background: var(--c-line); margin: 4px -16px; }
373
373
  .tl-insp-label { font-size: 12px; line-height: 18px; color: #737373; margin: 10px 0 6px; }
374
374
 
375
375
  /* ── tracks / ruler ── */
@@ -528,9 +528,11 @@
528
528
  .tl-select-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
529
529
  .tl-select-chev { display: flex; color: var(--c-ruler); flex: none; }
530
530
  /* easing curve — exact Figma (node 580:11158): #f6f6f7, 8px radius, no grid */
531
+ /* overflow:visible so overshoot handles/lines spill past the box while
532
+ dragging instead of being clipped to the rectangle. */
531
533
  .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; }
534
+ padding: 0; position: relative; overflow: visible; }
535
+ .tl-curve svg { display: block; cursor: crosshair; width: 100%; height: auto; overflow: visible; }
534
536
  .tl-curve-handle { cursor: grab; }
535
537
  .tl-curve-handle:active { cursor: grabbing; }
536
538
  .tl-cubic-row { display: flex; gap: 4px; }
@@ -538,9 +540,9 @@
538
540
  32px #f7f7f7 pill, hover overlay + inset focus ring. */
539
541
  .tl-cubic-cell { flex: 1; min-width: 0; display: flex; flex-direction: row; align-items: center;
540
542
  gap: 4px; height: 32px; box-sizing: border-box;
541
- padding: 0 8px; border-radius: 8px; background: #f7f7f7;
543
+ padding: 0 8px; border-radius: 8px; background: var(--c-field-bg);
542
544
  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; }
545
+ .tl-cubic-cell:hover { background: var(--c-sec-h); }
544
546
  .tl-cubic-cell:focus-within { box-shadow: inset 0 0 0 1px rgba(0,0,0,0.14); }
545
547
  .tl-cubic-row input { flex: 1; min-width: 0; width: 100%; height: 100%; border: none; background: transparent;
546
548
  box-sizing: border-box; padding: 0; text-align: left;
@@ -682,9 +684,10 @@
682
684
  .tl-menu-item:focus-visible { outline: none; background: #fff; box-shadow: inset 0 0 0 1px rgba(0,115,229,0.4); }
683
685
  .tl-menu-item.disabled { color: var(--c-disabled); pointer-events: none; }
684
686
  .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; }
687
+ .tl-menu-text { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
688
+ .tl-menu-stack { flex: 1; min-width: 0; display: flex; flex-direction: row; align-items: baseline; gap: 6px; }
689
+ .tl-menu-stack .tl-menu-text { flex: 0 1 auto; }
690
+ .tl-menu-sub { flex: 0 0 auto; font-size: 13px; line-height: 18px; }
688
691
  .tl-menu-dim { color: #979797; }
689
692
  .tl-menu-check { display: flex; color: var(--c-text-strong); flex: none; }
690
693
  .tl-menu-empty { padding: 10px 8px; color: var(--c-disabled); font-size: 13px; }
@@ -706,6 +709,70 @@
706
709
  /* trailing chevron on link rows (Figma 581:2828 — #696969 right chevron) */
707
710
  .tl-menu-chevr { display: flex; flex: none; color: #696969; }
708
711
 
712
+ /* ── settings dropdown sub-page slider (transitions.dev · 08-page-side-by-side) ──
713
+ One dropdown surface that pages internally: main settings ↔ keyboard
714
+ shortcuts. Both pages stay mounted so they can cross-slide; the surface
715
+ height follows the active page (card-resize-style) so it grows/shrinks
716
+ smoothly instead of jumping. Page 1 exits left, page 2 exits right. */
717
+ .tl-set-slide.t-page-slide {
718
+ position: relative;
719
+ --page-slide-dur: 250ms; /* Fast — page slide */
720
+ --page-fade-dur: 250ms;
721
+ --page-slide-distance: 8px; /* Base distance */
722
+ --page-blur: 3px; /* Medium blur */
723
+ --page-stagger: 0ms;
724
+ --page-exit-enabled: 1;
725
+ --page-slide-ease: cubic-bezier(0.22, 1, 0.36, 1); /* Smooth ease out */
726
+ --page-fade-ease: cubic-bezier(0.22, 1, 0.36, 1);
727
+ transition: height var(--page-slide-dur) var(--page-slide-ease);
728
+ overflow: hidden;
729
+ }
730
+ .tl-set-slide .t-page[data-page-id="1"] { --t-page-from-x: calc(var(--page-slide-distance) * -1); }
731
+ .tl-set-slide .t-page[data-page-id="2"] { --t-page-from-x: var(--page-slide-distance); }
732
+ .tl-set-slide .t-page {
733
+ position: absolute; top: 0; left: 0; right: 0;
734
+ opacity: 0; pointer-events: none;
735
+ transform: translateX(calc(var(--t-page-from-x, 0px) * var(--page-exit-enabled)));
736
+ filter: blur(calc(var(--page-blur) * var(--page-exit-enabled)));
737
+ transition:
738
+ opacity var(--page-fade-dur) var(--page-fade-ease),
739
+ transform var(--page-slide-dur) var(--page-slide-ease),
740
+ filter var(--page-slide-dur) var(--page-slide-ease);
741
+ will-change: opacity, transform, filter;
742
+ }
743
+ .tl-set-slide[data-page="1"] .t-page[data-page-id="1"],
744
+ .tl-set-slide[data-page="2"] .t-page[data-page-id="2"] {
745
+ opacity: 1; pointer-events: auto; transform: translateX(0); filter: blur(0);
746
+ transition-delay: var(--page-stagger);
747
+ }
748
+ @media (prefers-reduced-motion: reduce) {
749
+ .tl-set-slide.t-page-slide,
750
+ .tl-set-slide .t-page { transition: none !important; }
751
+ }
752
+ /* sub-page back header — mirrors the menu-item row metrics (h-32, rounded-8,
753
+ 13/16 label) so the surface feels continuous between pages. */
754
+ .tl-set-back { display: flex; align-items: center; gap: 4px; width: 100%; box-sizing: border-box;
755
+ min-height: 32px; padding: 0 8px 0 4px; border: none; background: transparent; border-radius: 8px;
756
+ font: inherit; font-size: 13px; font-weight: 500; line-height: 16px; color: #1b1b1b;
757
+ cursor: pointer; text-align: left; transition: background 0.1s ease, box-shadow 0.1s ease; }
758
+ .tl-set-back:hover { background: #f4f4f5; }
759
+ .tl-set-back:active { background: #ededee; }
760
+ .tl-set-back:focus-visible { outline: none; background: #fff; box-shadow: inset 0 0 0 1px rgba(0,115,229,0.4); }
761
+ /* reuse the right chevron flipped to point back; the menu chevron color */
762
+ .tl-set-back-ic { display: flex; flex: none; color: #696969; transform: scaleX(-1); }
763
+ .tl-set-back-title { flex: 1; min-width: 0; }
764
+ /* shortcut row — label left, keycap cluster right; same h-32 rhythm as rows */
765
+ .tl-kbd-row { display: flex; align-items: center; justify-content: space-between; gap: 12px;
766
+ min-height: 32px; padding: 0 8px; font-size: 13px; line-height: 16px; color: #1b1b1b; }
767
+ .tl-kbd-row-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
768
+ .tl-kbd-cluster { display: flex; align-items: center; gap: 4px; flex: none; }
769
+ .tl-kbd-plus { color: #b8b8b8; font-size: 11px; line-height: 1; }
770
+ /* keycap — hairline via inset shadow (shadows over borders), tabular monospace */
771
+ .tl-kbd { display: inline-flex; align-items: center; justify-content: center; min-width: 18px; height: 18px;
772
+ padding: 0 5px; box-sizing: border-box; border-radius: 5px; background: #f4f4f5;
773
+ box-shadow: inset 0 0 0 1px #ececed; font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
774
+ font-size: 11px; line-height: 1; font-weight: 500; color: #4c4c4c; font-variant-numeric: tabular-nums; }
775
+
709
776
  /* ═════ transitions.dev — menu dropdown (verbatim) ═════ */
710
777
  .t-dropdown {
711
778
  transform-origin: top left;
@@ -808,6 +875,8 @@
808
875
  }
809
876
  /* below-variant for triggers near the panel top edge */
810
877
  .t-tt.tl-tt-below { bottom: auto; top: calc(100% + 8px); transform-origin: 50% 0; font-size: 12px; }
878
+ /* small gray shortcut hint inside a tooltip (matches the cheat-sheet keys) */
879
+ .tl-tt-kbd { margin-left: 8px; opacity: 0.4; }
811
880
  /* multi-line message variant (e.g. Accept error) — long text wraps to a
812
881
  readable measure instead of one overflowing line. Right-anchored so the
813
882
  wide bubble grows leftward and never clips off the panel edge. Mirrors the
@@ -899,7 +968,7 @@
899
968
  .pc-btn.primary:hover { background: #000; }
900
969
 
901
970
  .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); }
971
+ .tl-refine-btn.is-active::before { background: var(--c-blue-bg-a); background: color(display-p3 0 0.451 0.898 / 0.12); }
903
972
 
904
973
  /* refine panel slides in from the right using transitions.dev panel reveal
905
974
  tokens (translate + opacity + cross-blur, per-phase open/close dur/ease). */
@@ -967,8 +1036,10 @@
967
1036
  width: 36px; height: 36px; border: none; background: transparent; border-radius: 60px; cursor: pointer;
968
1037
  color: #676767; transition: background 0.14s ease, color 0.14s ease; }
969
1038
  .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; }
1039
+ /* refine-type tabs toolbar strip (Figma 587:9258): 48px white bar, hairline
1040
+ bottom divider, segments inset 16px and packed gap:0 like a segmented control */
1041
+ .tl-refine-tabs { flex: 0 0 auto; box-sizing: border-box; display: flex; align-items: center; gap: 0;
1042
+ height: 48px; padding: 0 16px; background: #fff; border-bottom: 1px solid #f0f0f0; }
972
1043
  .tl-refine-tab { height: 32px; padding: 6px 12px; border: none; background: transparent; cursor: pointer;
973
1044
  border-radius: 8px; font: inherit; font-size: 13px; font-weight: 500; line-height: 14px; color: #676767;
974
1045
  transition: background 0.14s ease, color 0.14s ease; }
@@ -1131,10 +1202,16 @@
1131
1202
  @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
1203
  @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
1204
  @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. */
1205
+ /* background-scan note in the header count slot — dot-matrix loader + shimmer
1206
+ text (transitions.dev shimmer text 15). Hover shows the tooltip. */
1207
+ /* the tooltip wrap fills the count slot's height; center its content so the
1208
+ loader + text sit on the slot's vertical midline instead of its bottom. */
1209
+ .tl-header-count .t-tt-wrap { display: inline-flex; align-items: center; align-self: center; }
1136
1210
  .tl-scan-note { cursor: help; color: var(--c-count);
1137
- text-decoration: underline dotted rgba(0,0,0,0.28); text-underline-offset: 2px; }
1211
+ display: inline-flex; align-items: center; gap: 6px; }
1212
+ .tl-scan-note .tl-dotm { width: 14px; height: 14px; }
1213
+ .tl-scan-shimmer { --shimmer-dur: 2000ms; --shimmer-base: var(--c-count); --shimmer-highlight: #17181c;
1214
+ --shimmer-band: 400%; --shimmer-ease: linear; }
1138
1215
  /* loading status text — transitions.dev shimmer text (15) + text states swap (04) */
1139
1216
  .tl-refine-status-text {
1140
1217
  --shimmer-dur: 2000ms; --shimmer-base: #9a9a9a; --shimmer-highlight: #17181c;
@@ -1217,32 +1294,46 @@
1217
1294
  /* applied state — GRAY, never green (matches Accept icon --c-text #17181c) */
1218
1295
  .tl-pill-btn.is-applied { color: var(--c-text); cursor: default; }
1219
1296
  .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;
1297
+ /* Apply ⟷ Applied: a LEADING check is revealed first and pushes the text to
1298
+ the right, then the differing SUFFIX cross-blurs. The shared stem "Appl"
1299
+ stays put while "y" "ied" cross-blur in a slot whose width tweens between
1300
+ the two measured widths. Both the leading check space AND the suffix slot
1301
+ use the SAME resize token (--apply-resize-*: card resize, smooth ease out),
1302
+ so the whole button grows in one synchronized motion. The check lives in its
1303
+ own overflow:hidden box whose width tweens 0 -> (icon + gap), shoving the
1304
+ text rightward the check itself stroke-draws IN PLACE (no translate). */
1305
+ .tl-apply-swap { position: relative; display: inline-flex; align-items: center;
1306
+ vertical-align: middle; line-height: 14px;
1307
+ --apply-resize-dur: 400ms; --apply-resize-ease: cubic-bezier(0.22, 1, 0.36, 1); }
1308
+ /* leading check slot: collapsed to width 0 at rest; expands to the measured
1309
+ icon+gap width (--chk-w) when applied, using the same token as the suffix
1310
+ resize. overflow:hidden clips the pinned-left check so it reveals in place
1311
+ (left edge fixed) rather than translating. */
1312
+ .tl-apply-check { display: inline-flex; align-items: center; overflow: hidden;
1313
+ flex: 0 0 auto; width: 0;
1314
+ transition: width var(--apply-resize-dur) var(--apply-resize-ease); }
1315
+ .tl-apply-swap[data-state="applied"] .tl-apply-check { width: var(--chk-w, 19px); }
1316
+ .tl-apply-stem { display: inline-block; }
1317
+ .tl-apply-suffix { position: relative; display: inline-block; width: var(--sfx-y, auto);
1318
+ transition: width var(--apply-resize-dur) var(--apply-resize-ease); }
1319
+ .tl-apply-swap[data-state="applied"] .tl-apply-suffix { width: var(--sfx-ied, auto); }
1320
+ .tl-apply-seg { white-space: pre;
1230
1321
  transition: opacity 180ms cubic-bezier(0.22, 1, 0.36, 1),
1231
1322
  filter 180ms cubic-bezier(0.22, 1, 0.36, 1);
1232
1323
  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); }
1324
+ .tl-apply-y { display: inline-block; }
1325
+ .tl-apply-ied { position: absolute; top: 0; left: 0; display: inline-block; }
1326
+ .tl-apply-swap .tl-apply-y { opacity: 1; filter: blur(0); }
1327
+ .tl-apply-swap .tl-apply-ied { opacity: 0; filter: blur(2px); }
1328
+ .tl-apply-swap[data-state="applied"] .tl-apply-y { opacity: 0; filter: blur(2px); }
1329
+ .tl-apply-swap[data-state="applied"] .tl-apply-ied { opacity: 1; filter: blur(0); }
1240
1330
  @media (prefers-reduced-motion: reduce) {
1241
- .tl-apply-swap, .tl-apply-label { transition: none !important; }
1331
+ .tl-apply-suffix, .tl-apply-seg { transition: none !important; }
1242
1332
  }
1243
1333
  /* success check (transitions.dev 10-success-check): stroke-draw on the path with
1244
1334
  a plain opacity fade — IN PLACE, no rotate / Y-bob / scale, so the check never
1245
- moves while it draws. GRAY stroke. */
1335
+ moves while it draws. It is the leading element, unclipped by the .tl-apply-check
1336
+ width tween that pushes the text. GRAY stroke. */
1246
1337
  .tl-sug-check { display: inline-flex; opacity: 0; will-change: opacity; }
1247
1338
  .tl-sug-check svg { display: block; overflow: visible; }
1248
1339
  /* stroke-dasharray = path.getTotalLength() of ICONS.accept "M4 8.4268 L6.46155
@@ -1278,6 +1369,7 @@
1278
1369
  .tl-refine-results .t-stagger-line { transition: none !important; }
1279
1370
  .t-resize, .tl-scan-morph { transition: none !important; }
1280
1371
  .tl-pill-btn.tl-apply-btn { transition: background 0.12s ease, scale 0.12s ease, opacity 0.12s ease !important; }
1372
+ .tl-apply-check { transition: none !important; }
1281
1373
  .tl-sug-check { animation: none !important; opacity: 1; }
1282
1374
  .tl-sug-check svg path { animation: none !important; stroke-dashoffset: 0 !important; }
1283
1375
  .tl-scan-face { transition: none !important; }
@@ -1530,7 +1622,10 @@
1530
1622
  _sched(){if(this.rafId!==null)return;this.rafId=requestAnimationFrame(()=>{this.rafId=null;if(this.running&&!this.paused)this.scan();});}
1531
1623
  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
1624
  _proc(el,seen){
1533
- if(el.closest("[data-timeline-panel]"))return;
1625
+ // Skip the Refine UI itself: the panel ([data-timeline-panel]) plus any of
1626
+ // our surfaces that portal OUT of it (dropdown menus, toasts) — those carry
1627
+ // [data-tl-ui]. Without this the scanner lists the tool's own transitions.
1628
+ if(el.closest("[data-timeline-panel],[data-tl-ui]"))return;
1534
1629
  const s=getComputedStyle(el); const rp=s.transitionProperty;
1535
1630
  if(!rp||rp==="none"||rp==="all")return;
1536
1631
  const props=rp.split(",").map(p=>p.trim());
@@ -1748,7 +1843,7 @@
1748
1843
  const origin=pos?pos.origin:"top-left";
1749
1844
  const cls=closing?"is-closing":shown?"is-open":"";
1750
1845
  return createPortal(
1751
- h("div",{ref,className:cx("t-dropdown","tl-menu",cls),"data-origin":origin,style},children),
1846
+ h("div",{ref,className:cx("t-dropdown","tl-menu",cls),"data-origin":origin,"data-tl-ui":"",style},children),
1752
1847
  target);
1753
1848
  }
1754
1849
 
@@ -1759,6 +1854,32 @@
1759
1854
  right);
1760
1855
  }
1761
1856
 
1857
+ // Settings dropdown body that pages between the main menu and the keyboard-
1858
+ // shortcuts cheat-sheet (transitions.dev · 08-page-side-by-side). Both pages
1859
+ // stay mounted so they cross-slide; the container height follows the active
1860
+ // page so the one dropdown surface resizes smoothly between pages. Lives
1861
+ // inside the Dropdown portal, so its layout effect fires when the surface
1862
+ // mounts (the parent Header doesn't re-render on the portal's own mount).
1863
+ function SettingsSlide({view,mainPage,shortcutsPage,onBack,onClose}){
1864
+ const slideRef=useRef(null), p1=useRef(null), p2=useRef(null);
1865
+ useLayoutEffect(()=>{
1866
+ const slide=slideRef.current; if(!slide) return;
1867
+ const active=view==="shortcuts"?p2.current:p1.current;
1868
+ if(active) slide.style.height=active.offsetHeight+"px";
1869
+ });
1870
+ // Escape steps back from the sub-page first, then closes the dropdown —
1871
+ // mirrors the picker's nested-dismiss behaviour.
1872
+ useEffect(()=>{
1873
+ const onKey=e=>{ if(e.key!=="Escape")return;
1874
+ if(view==="shortcuts"){onBack&&onBack();} else {onClose&&onClose();} };
1875
+ document.addEventListener("keydown",onKey);
1876
+ return()=>document.removeEventListener("keydown",onKey);
1877
+ },[view,onBack,onClose]);
1878
+ return h("div",{ref:slideRef,className:"tl-set-slide t-page-slide","data-page":view==="shortcuts"?"2":"1"},
1879
+ h("div",{ref:p1,className:"tl-set-page t-page","data-page-id":"1","aria-hidden":view==="shortcuts"},mainPage),
1880
+ h("div",{ref:p2,className:"tl-set-page t-page","data-page-id":"2","aria-hidden":view!=="shortcuts"},shortcutsPage));
1881
+ }
1882
+
1762
1883
  // ── refine relay client ──
1763
1884
  // Talks to the local relay (server/relay.mjs). The browser posts a job and
1764
1885
  // polls; the relay answers each job with one agent run (one-shot).
@@ -1825,25 +1946,32 @@
1825
1946
  const names=[...new Set(matches.map(t=>t.member))];
1826
1947
  return names.length===1?names[0]:matches[0].member;
1827
1948
  }
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.
1949
+ // Apply button — "Apply" ⟷ "Applied". On apply, a LEADING gray check is
1950
+ // revealed first and pushes the text to the right: .tl-apply-check tweens its
1951
+ // width 0 -> (icon + 6px gap) using the SAME resize token as the suffix slot
1952
+ // (--apply-resize-*, smooth ease out), so the check expansion + the "y" -> "ied"
1953
+ // suffix resize grow the button in one synchronized motion. The stem "Appl"
1954
+ // stays put and "y" "ied" cross-blur (transitions.dev 04). The check itself
1955
+ // stroke-draws IN PLACE (transitions.dev 10, no rotate/bob/scale/translate)
1956
+ // it is pinned to the slot's left edge and the overflow clip reveals it, so it
1957
+ // never translates. The same <button> node is reused across states.
1835
1958
  function ApplyBtn({applied,onApply}){
1836
1959
  const ref=useRef(null);
1837
1960
  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".
1961
+ // measure both suffixes once (after fonts/layout) and pin the two slot widths
1962
+ // so the suffix can tween between "y" and the checked "ied".
1840
1963
  useLayoutEffect(()=>{
1841
1964
  const btn=ref.current;if(!btn)return;
1842
1965
  const sw=btn.querySelector(".tl-apply-swap"); if(!sw) return;
1966
+ const CHK_GAP=6; // space between the leading check and "Applied"
1843
1967
  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");
1968
+ const y=sw.querySelector(".tl-apply-y"), ied=sw.querySelector(".tl-apply-ied");
1969
+ if(y) sw.style.setProperty("--sfx-y", Math.ceil(y.getBoundingClientRect().width)+"px");
1970
+ if(ied) sw.style.setProperty("--sfx-ied", Math.ceil(ied.getBoundingClientRect().width)+"px");
1971
+ // leading check slot target width = icon natural width + gap; it stays
1972
+ // measurable even while clipped (the inner .tl-sug-check keeps its size).
1973
+ const chk=sw.querySelector(".tl-sug-check");
1974
+ if(chk) sw.style.setProperty("--chk-w", (Math.ceil(chk.getBoundingClientRect().width)+CHK_GAP)+"px");
1847
1975
  };
1848
1976
  measure();
1849
1977
  if(document.fonts&&document.fonts.ready) document.fonts.ready.then(measure).catch(()=>{});
@@ -1873,14 +2001,16 @@
1873
2001
  className:cx("tl-pill-btn tl-apply-btn",applied&&"is-applied"),
1874
2002
  disabled:applied,onClick:applied?undefined:onApply},
1875
2003
  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"},
2004
+ h("span",{className:"tl-apply-check"},
1878
2005
  h("span",{className:"tl-sug-check","data-state":"out","aria-hidden":"true"},
1879
2006
  h("svg",{width:13,height:13,viewBox:"0 0 16 16",fill:"none",
1880
2007
  xmlns:"http://www.w3.org/2000/svg",style:{display:"block"}},
1881
2008
  h("path",{d:"M4 8.4268L6.46155 11.19223L12 4.97001",stroke:"currentColor",
1882
- strokeWidth:1.5,strokeLinecap:"round",strokeLinejoin:"round"}))),
1883
- "Applied")));
2009
+ strokeWidth:1.5,strokeLinecap:"round",strokeLinejoin:"round"})))),
2010
+ h("span",{className:"tl-apply-stem"},"Appl"),
2011
+ h("span",{className:"tl-apply-suffix"},
2012
+ h("span",{className:"tl-apply-seg tl-apply-y"},"y"),
2013
+ h("span",{className:"tl-apply-seg tl-apply-ied"},"ied"))));
1884
2014
  }
1885
2015
  function RefineResults({suggestions,appliedIds,onApply,lanes}){
1886
2016
  const ref=useRef(null);
@@ -1923,7 +2053,7 @@
1923
2053
  : h(React.Fragment,null,
1924
2054
  s.title&&h("div",{className:"tl-sug-title"},s.title),
1925
2055
  (s.from||s.to)&&h("div",{className:"tl-sug-delta"},
1926
- s.from&&h("span",{className:"tl-sug-from"},s.from),
2056
+ s.from&&h("span",{className:"tl-sug-from"},s.from),
1927
2057
  s.to&&h("span",{className:"tl-sug-to"},s.to))),
1928
2058
  s.reason&&h("div",{className:"tl-sug-reason"},s.reason)),
1929
2059
  applyBtn));
@@ -2096,9 +2226,24 @@
2096
2226
  return out;
2097
2227
  }
2098
2228
 
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);
2229
+ function Header({entries,active,onSelect,onReset,onCopy,copied,snap,setSnap,onMinimize,onRefine,refineActive,onAccept,acceptState,acceptDisabled,acceptError,scanning,onRescan,pick,setPick}){
2101
2230
  const[setg,setSetg]=useState(false);
2231
+ // settings dropdown sub-page (main menu ↔ keyboard-shortcuts cheat sheet)
2232
+ const[settingsView,setSettingsView]=useState("main"); // "main" | "shortcuts"
2233
+ const isMac=typeof navigator!=="undefined"&&/mac/i.test((navigator.userAgentData&&navigator.userAgentData.platform)||navigator.platform||navigator.userAgent||"");
2234
+ const modKey=isMac?"Cmd":"Ctrl";
2235
+ // small gray shortcut hint appended inside a tooltip (e.g. "⌘A")
2236
+ const ttHint=(k)=>h("span",{className:"tl-tt-kbd"},(isMac?"\u2318":"Ctrl ")+k);
2237
+ // ⌫ = Backspace, ↑/↓ = arrows; letters/Enter shown verbatim
2238
+ const SHORTCUTS=[
2239
+ {label:"Accept changes",k:"Enter"},
2240
+ {label:"Reset values",k:"\u232B"},
2241
+ {label:"Copy values",k:"C"},
2242
+ {label:"Toggle Refine panel",k:"E"},
2243
+ {label:"Open transition picker",k:"K"},
2244
+ {label:"Previous transition",k:"\u2191"},
2245
+ {label:"Next transition",k:"\u2193"},
2246
+ ];
2102
2247
  const pickRef=useRef(null), gearRef=useRef(null);
2103
2248
  const[q,setQ]=useState("");
2104
2249
  useEffect(()=>{if(!pick)setQ("");},[pick]); // reset search when the picker closes
@@ -2152,28 +2297,50 @@
2152
2297
  h("span",{className:"tl-header-count"},
2153
2298
  scanning
2154
2299
  ? h("span",{className:"t-tt-wrap"},
2155
- h("span",{className:"tl-scan-note",tabIndex:0},"Agent scanning transitions…"),
2300
+ h("span",{className:"tl-scan-note",tabIndex:0},
2301
+ h(DotmLoader),
2302
+ h("span",{className:"t-shimmer tl-scan-shimmer","data-text":"Agent scanning transitions…"},"Agent scanning transitions…")),
2156
2303
  h("span",{className:"t-tt tl-tt-below tl-tt-msg",role:"tooltip"},
2157
2304
  "Agent is working on understanding your transitions and creating grouping and naming. This usually takes a few seconds to a minute."))
2158
2305
  : entries.length+" transition"+(entries.length===1?"":"s")+" found"),
2159
2306
  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")),
2307
+ h(Dropdown,{open:setg,onClose:()=>{setSetg(false);setSettingsView("main");},triggerRef:gearRef,width:240,align:"right"},
2308
+ h(SettingsSlide,{view:settingsView,
2309
+ onBack:()=>setSettingsView("main"),
2310
+ onClose:()=>{setSetg(false);setSettingsView("main");},
2311
+ // ── main settings page ──
2312
+ mainPage:h(React.Fragment,null,
2313
+ h("div",{className:"tl-menu-head"},
2314
+ h("span",null,"Transitions.dev ",h("span",{className:"tl-menu-head-name"},"Refine")),
2315
+ h("span",{className:"tl-menu-head-ver"},REFINE_VERSION)),
2316
+ h("div",{className:"tl-menu-divider"}),
2317
+ h(MenuItem,{disabled:!active,onClick:()=>{onCopy&&onCopy();setSetg(false);},
2318
+ left:h("span",{className:"tl-menu-icon"},h(Ic,{name:"copy"})),
2319
+ right:copied&&h("span",{className:"tl-menu-check"},h(Ic,{name:"accept"}))},copied?"Copied":"Copy values"),
2320
+ h(MenuItem,{disabled:scanning,onClick:()=>{setSetg(false);onRescan&&onRescan();},
2321
+ left:h("span",{className:"tl-menu-icon"},h(Ic,{name:"restart"}))},
2322
+ scanning?"Rescanning…":"Rescan transitions"),
2323
+ h(MenuItem,{onClick:()=>setSettingsView("shortcuts"),
2324
+ right:h("span",{className:"tl-menu-chevr"},h(Ic,{name:"chevronr",size:16}))},"Keyboard shortcuts"),
2325
+ h("div",{className:"tl-menu-divider"}),
2326
+ h(MenuItem,{onClick:()=>{setSetg(false);window.open("https://transitions.dev","_blank","noopener");},
2327
+ right:h("span",{className:"tl-menu-chevr"},h(Ic,{name:"chevronr",size:16}))},"Learn more")),
2328
+ // ── keyboard-shortcuts sub-page ──
2329
+ shortcutsPage:h(React.Fragment,null,
2330
+ h("button",{type:"button",className:"tl-set-back",onClick:()=>setSettingsView("main")},
2331
+ h("span",{className:"tl-set-back-ic"},h(Ic,{name:"chevronr",size:16})),
2332
+ h("span",{className:"tl-set-back-title"},"Keyboard shortcuts")),
2333
+ h("div",{className:"tl-menu-divider"}),
2334
+ ...SHORTCUTS.map((s,i)=>h("div",{key:i,className:"tl-kbd-row"},
2335
+ h("span",{className:"tl-kbd-row-label"},s.label),
2336
+ h("span",{className:"tl-kbd-cluster"},
2337
+ h("kbd",{className:"tl-kbd"},modKey),
2338
+ h("span",{className:"tl-kbd-plus"},"+"),
2339
+ h("kbd",{className:"tl-kbd"},s.k)))))}),
2340
+ ),
2174
2341
  h("span",{className:"t-tt-wrap"},
2175
2342
  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")),
2343
+ h("span",{className:"t-tt tl-tt-below",role:"tooltip"},"Reset values",ttHint("\u232B"))),
2177
2344
  h("span",{className:"t-tt-wrap"},
2178
2345
  h("button",{className:cx("tl-accept-btn",acceptState==="saving"&&"is-saving",acceptState==="done"&&"is-done"),
2179
2346
  disabled:!active||acceptDisabled||acceptState==="saving"||acceptState==="done",onClick:onAccept,"aria-label":"Accept changes to your code"},
@@ -2185,8 +2352,9 @@
2185
2352
  acceptState==="error"&&acceptError?acceptError
2186
2353
  :acceptState==="done"?"Saved to your code"
2187
2354
  :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},
2355
+ :h(React.Fragment,null,"Save changes to your codebase",ttHint("A")))),
2356
+ h("span",{className:"t-tt-wrap"},
2357
+ h("button",{className:cx("tl-refine-btn t-tt-trigger",refineActive&&"is-active"),disabled:!active,onClick:onRefine},
2190
2358
  h(Ic,{name:"wand"}),
2191
2359
  h("span",{className:"tl-refine-sparks","aria-hidden":"true"},
2192
2360
  h("i",{style:{"--ox":"-1px","--oy":"-3px","--sx":"0px","--sy":"-11px","--sd":"900ms","--sdelay":"0ms"}}),
@@ -2200,7 +2368,10 @@
2200
2368
  h("i",{style:{"--ox":"-5px","--oy":"-2px","--sx":"-9px","--sy":"-9px","--sd":"930ms","--sdelay":"180ms"}}),
2201
2369
  h("i",{style:{"--ox":"3px","--oy":"1px","--sx":"12px","--sy":"1px","--sd":"980ms","--sdelay":"300ms"}})),
2202
2370
  h("span",null,"Refine")),
2203
- h("button",{className:"tl-icon-btn ghost",title:"Minimize",onClick:onMinimize},h(Ic,{name:"minimize"})),
2371
+ h("span",{className:"t-tt tl-tt-below",role:"tooltip"},"Refine",ttHint("R"))),
2372
+ h("span",{className:"t-tt-wrap"},
2373
+ h("button",{className:"tl-icon-btn ghost t-tt-trigger","aria-label":"Minimize",onClick:onMinimize},h(Ic,{name:"minimize"})),
2374
+ h("span",{className:"t-tt tl-tt-below",role:"tooltip"},"Minimize",ttHint("."))),
2204
2375
  );
2205
2376
  }
2206
2377
 
@@ -2317,15 +2488,12 @@
2317
2488
  }
2318
2489
 
2319
2490
  // 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 };
2491
+ // Square plot with equal padding on all sides (standard cubic-bezier graph
2492
+ // ratio, easing.dev): the 0→1 region is centered with identical top/bottom
2493
+ // (and left/right) margins. The box no longer reserves room for overshoot
2494
+ // overshoot/undershoot handles overflow the box freely (the curve container
2495
+ // is overflow:visible) so dragging is never clamped to the rectangle.
2496
+ const CURVE = { VBW:240, VBH:240, PAD_X:24, PAD_Y:24 };
2329
2497
 
2330
2498
  // Unified "Preview" section (Figma node 580:11158) — collapsible, collapsed by
2331
2499
  // default (transitions.dev · 21-accordion). Holds BOTH the timing curve card
@@ -2828,6 +2996,9 @@
2828
2996
  const[panelHeight,setPanelHeight]=useState(440);
2829
2997
  const[resizing,setResizing]=useState(false);
2830
2998
  const[snap,setSnap]=useState(true);
2999
+ // transition picker open state — lifted out of Header so the panel-focused
3000
+ // Cmd/Ctrl+K shortcut can open it.
3001
+ const[pickOpen,setPickOpen]=useState(false);
2831
3002
  // ── refine ──
2832
3003
  const[refineOpen,setRefineOpen]=useState(false);
2833
3004
  const[refinePhase,setRefinePhase]=useState("idle"); // idle | scanning | done | error
@@ -2836,6 +3007,10 @@
2836
3007
  const[refineSuggestions,setRefineSuggestions]=useState([]);
2837
3008
  const[refineSummary,setRefineSummary]=useState(null);
2838
3009
  const[refineError,setRefineError]=useState(null);
3010
+ // deterministic scans answer almost instantly, so enforce a 2s floor on the
3011
+ // "scanning" phase for that mode only (LLM scans stay immediate).
3012
+ const scanStartedAtRef=useRef(0);
3013
+ const scanModeRef=useRef("llm");
2839
3014
  // ── accept (write to source) ──
2840
3015
  const[acceptState,setAcceptState]=useState("idle"); // idle | saving | done | error
2841
3016
  const[acceptError,setAcceptError]=useState(null);
@@ -2878,12 +3053,14 @@
2878
3053
  // "Start scanning" resolves availability first, then posts the job.
2879
3054
  const startScan=useCallback(async()=>{
2880
3055
  if(!active)return;
3056
+ scanStartedAtRef.current=Date.now();
2881
3057
  setRefinePhase("scanning");setRefineLog([]);
2882
3058
  setRefineSuggestions([]);setRefineSummary(null);setRefineError(null);
2883
3059
  setAppliedIds({});setRefineLabel(active.label);setRefineJobId(null);
2884
3060
  const j=await refreshHealth();
2885
3061
  const avail=j?!!j.llmAvailable:false;
2886
3062
  const mode=(refineMode==="llm"&&!avail)?"deterministic":refineMode;
3063
+ scanModeRef.current=mode;
2887
3064
  if(mode!==refineMode)setRefineMode(mode);
2888
3065
  try{
2889
3066
  const et=active.effectiveTimings||[];
@@ -2912,7 +3089,14 @@
2912
3089
  const job=await relayGetJob(refineJobId);
2913
3090
  if(!live)return;
2914
3091
  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;}
3092
+ if(job.status==="done"){
3093
+ const finish=()=>{if(!live)return;setRefineSuggestions((job.result&&job.result.suggestions)||[]);setRefineSummary(job.result&&job.result.summary);setRefinePhase("done");};
3094
+ // deterministic mode floors the scanning phase at 2s so it doesn't flash by.
3095
+ const minMs=scanModeRef.current==="deterministic"?2000:0;
3096
+ const wait=Math.max(0,minMs-(Date.now()-scanStartedAtRef.current));
3097
+ if(wait>0){to=setTimeout(finish,wait);}else{finish();}
3098
+ return;
3099
+ }
2916
3100
  if(job.status==="error"){setRefineError(job.error||"The agent reported an error.");setRefinePhase("error");return;}
2917
3101
  to=setTimeout(tick,500);
2918
3102
  }catch(e){if(live){setRefineError("Lost connection to the relay.");setRefinePhase("error");}}
@@ -2951,7 +3135,7 @@
2951
3135
  if(typeof document==="undefined")return;
2952
3136
  const applied=liveAppliedRef.current;
2953
3137
  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); } };
3138
+ const add=(els,css)=>{ for(const el of els){ if(!el||(el.closest&&el.closest("[data-timeline-panel],[data-tl-ui]")))continue; desired.set(el,css); } };
2955
3139
  for(const item of entries){
2956
3140
  const ov=registry.getPropOverrides(item.id);
2957
3141
  if(!ov||!Object.keys(ov).length)continue; // only edited transitions go live
@@ -3019,8 +3203,29 @@
3019
3203
  }else{
3020
3204
  text="transition: "+et.map(decl).join(",\n ")+";";
3021
3205
  }
3022
- navigator.clipboard.writeText(text).then(()=>{setCopied(true);setTimeout(()=>setCopied(false),1500);}).catch(()=>{});
3023
- showToast("Values copied");
3206
+ const onCopied=()=>{setCopied(true);setTimeout(()=>setCopied(false),1500);showToast("Values copied");};
3207
+ const onCopyFail=()=>{showToast("Couldn't copy");};
3208
+ // navigator.clipboard needs a secure context (https/localhost); when the
3209
+ // host page is plain http — or Safari blocks the async API — fall back to
3210
+ // a hidden-textarea execCommand("copy") so copy still works everywhere.
3211
+ const legacyCopy=()=>{
3212
+ try{
3213
+ const ta=document.createElement("textarea");
3214
+ ta.value=text; ta.setAttribute("readonly","");
3215
+ ta.style.position="fixed"; ta.style.top="-9999px"; ta.style.opacity="0";
3216
+ document.body.appendChild(ta);
3217
+ ta.focus(); ta.select();
3218
+ try{ta.setSelectionRange(0,text.length);}catch(e){}
3219
+ const ok=document.execCommand("copy");
3220
+ document.body.removeChild(ta);
3221
+ ok?onCopied():onCopyFail();
3222
+ }catch(e){onCopyFail();}
3223
+ };
3224
+ try{
3225
+ if(navigator.clipboard&&navigator.clipboard.writeText){
3226
+ navigator.clipboard.writeText(text).then(onCopied).catch(legacyCopy);
3227
+ }else legacyCopy();
3228
+ }catch(e){legacyCopy();}
3024
3229
  },[active,showToast]);
3025
3230
  const resetOverrides=useCallback(()=>{if(active)registry.clearOverride(active.id);},[registry,active]);
3026
3231
  // Accept → send an "apply" job so the agent writes the edited timings into
@@ -3203,27 +3408,93 @@
3203
3408
  // small "Agent scanning transitions…" note in the header count
3204
3409
  // slot. A failed scan just leaves the flat list (Rescan in
3205
3410
  // Settings to retry) — it never traps the panel.
3411
+ // ── keyboard shortcuts (panel-focused, Cmd/Ctrl based) ──
3412
+ // Fire only when the panel is focused or hovered so we never hijack the
3413
+ // host app. A latest-values ref keeps the single window listener stable.
3414
+ const panelRootRef=useRef(null);
3415
+ const hoveredRef=useRef(false);
3416
+ const panelHotRef=useRef(false);
3417
+ const recomputeHot=useCallback(()=>{
3418
+ const el=panelRootRef.current;
3419
+ panelHotRef.current=hoveredRef.current||!!(el&&document.activeElement&&el.contains(document.activeElement));
3420
+ },[]);
3421
+ const kbdRef=useRef({});
3422
+ kbdRef.current={entries,active,acceptState,acceptDisabled:computeChanges(active).length===0,minimized,
3423
+ openRefine,resetOverrides,copyValues,onAccept,setActiveId,setMinimized,setPickOpen};
3424
+ useEffect(()=>{
3425
+ const onKey=e=>{
3426
+ const mod=e.metaKey||e.ctrlKey;
3427
+ if(!mod)return;
3428
+ const L=kbdRef.current;
3429
+ // Cmd+. always restores a minimized panel (panel root isn't in the DOM
3430
+ // then, so the hot/input gates can't apply). All other combos require
3431
+ // the panel to be focused/hovered and not typing in a field.
3432
+ const isRestore=e.key==="."&&L.minimized;
3433
+ recomputeHot();
3434
+ if(!isRestore){
3435
+ if(!panelHotRef.current)return;
3436
+ const t=e.target;
3437
+ if(t&&(t.tagName==="INPUT"||t.tagName==="TEXTAREA"||t.isContentEditable))return;
3438
+ }
3439
+ const handle=fn=>{e.preventDefault();e.stopPropagation();fn&&fn();};
3440
+ switch(e.key){
3441
+ case"a":case"A": // Accept changes (same enabled condition as the Accept button)
3442
+ handle(()=>{if(L.active&&!L.acceptDisabled&&L.acceptState!=="saving"&&L.acceptState!=="done")L.onAccept&&L.onAccept();});
3443
+ break;
3444
+ case"Backspace": // Reset values
3445
+ handle(()=>{if(L.active)L.resetOverrides&&L.resetOverrides();});
3446
+ break;
3447
+ case"c":case"C":{ // Copy values — only when no text is selected (native copy wins otherwise)
3448
+ const sel=window.getSelection();
3449
+ if(sel&&!sel.isCollapsed)return;
3450
+ handle(()=>{if(L.active)L.copyValues&&L.copyValues();});
3451
+ break;}
3452
+ case"r":case"R": // Toggle Refine panel
3453
+ handle(()=>{if(L.active)L.openRefine&&L.openRefine();});
3454
+ break;
3455
+ case"k":case"K": // Open transition picker
3456
+ handle(()=>{if(L.active)L.setPickOpen&&L.setPickOpen(true);});
3457
+ break;
3458
+ case"ArrowUp":
3459
+ case"ArrowDown":{ // Previous / next transition
3460
+ const list=L.entries||[];
3461
+ if(!list.length)break;
3462
+ const dir=e.key==="ArrowUp"?-1:1;
3463
+ const cur=L.active?list.findIndex(x=>x.id===L.active.id):-1;
3464
+ let ni=cur<0?(dir>0?0:list.length-1):cur+dir;
3465
+ ni=Math.max(0,Math.min(list.length-1,ni));
3466
+ if(list[ni])handle(()=>L.setActiveId&&L.setActiveId(list[ni].id));
3467
+ break;}
3468
+ case".": // Minimize / restore panel
3469
+ handle(()=>L.setMinimized&&L.setMinimized(v=>!v));
3470
+ break;
3471
+ }
3472
+ };
3473
+ window.addEventListener("keydown",onKey);
3474
+ return()=>window.removeEventListener("keydown",onKey);
3475
+ },[recomputeHot]);
3206
3476
  const gate = !live
3207
3477
  ? (llmAvailable===null ? "loading" : "blocked")
3208
3478
  : "ready";
3209
3479
  return h(React.Fragment,null,
3210
- render&&h("div",{className:"t-panel-slide","data-timeline-panel":true,
3480
+ render&&h("div",{className:"t-panel-slide","data-timeline-panel":true,ref:panelRootRef,
3481
+ onMouseEnter:()=>{hoveredRef.current=true;},onMouseLeave:()=>{hoveredRef.current=false;},
3211
3482
  "data-open":panelOpen?"true":"false","data-phase":phase,style:{height:panelHeight+"px"}},
3212
3483
  h("div",{className:cx("tl-resize-handle",resizing&&"dragging"),onMouseDown:startResize,title:"Drag to resize"}),
3213
3484
  h("div",{className:"tl-panel-body"},
3214
3485
  gate==="ready"
3215
3486
  ? 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,
3487
+ h("div",{className:"tl-panel-main"},
3488
+ h(Header,{entries,active,onSelect:setActiveId,onReset:resetOverrides,onCopy:copyValues,copied,
3489
+ snap,setSnap,onMinimize:()=>setMinimized(true),onRefine:openRefine,refineActive:refineOpen,
3490
+ onAccept,acceptState,acceptDisabled:computeChanges(active).length===0,acceptError,
3491
+ scanning:groupScanState==="scanning",onRescan:rescanTransitions,pick:pickOpen,setPick:setPickOpen}),
3492
+ active
3493
+ ?h(Body,{entry:active,onPropChange:(prop,o)=>setPropOverride(prop,o),snap})
3494
+ :h("div",{className:"tl-empty"},"Select a transition to inspect and edit it")),
3495
+ h(RefinePanel,{open:refineOpen,onClose:()=>setRefineOpen(false),phase:refinePhase,label:refineLabel,
3496
+ refineType,onType:changeRefineType,suggestions:refineSuggestions,summary:refineSummary,error:refineError,
3497
+ appliedIds,onApply:applySuggestion,onApplyAll:applyAllSuggestions,
3227
3498
  mode:refineMode,onMode:changeRefineMode,llmAvailable,cliInstalled,onStart:startScan,
3228
3499
  lanes:active?.effectiveTimings||[]}))
3229
3500
  : h("div",{className:"tl-panel-main"},
@@ -3234,15 +3505,17 @@
3234
3505
  "Please run the ",h("code",{className:"tl-code"},"/refine live"),
3235
3506
  " command in your agent to enable live features, such as scanning and refining transitions.")))),
3236
3507
  toast&&createPortal(
3237
- h("div",{className:"tl-toast-wrap","aria-live":"polite"},
3508
+ h("div",{className:"tl-toast-wrap","data-tl-ui":"","aria-live":"polite"},
3238
3509
  h("div",{className:cx("tl-toast",toast.closing&&"is-closing")},
3239
3510
  h("span",{className:"tl-toast-ic"},
3240
3511
  toast.loader?h(DotmLoader):h(Ic,{name:"accept",size:14})),
3241
- h("span",null,toast.msg))),
3512
+ toast.loader
3513
+ ? h("span",{className:"t-shimmer tl-scan-shimmer","data-text":toast.msg},toast.msg)
3514
+ : h("span",null,toast.msg))),
3242
3515
  document.body),
3243
3516
  ),
3244
3517
  ),
3245
- minimized&&h("div",{className:"tl-pill",onClick:()=>setMinimized(false)},
3518
+ minimized&&h("div",{className:"tl-pill","data-tl-ui":"",onClick:()=>setMinimized(false)},
3246
3519
  h("span",{className:"tl-pill-label"},"Transitions"),
3247
3520
  h("span",{className:cx("tl-pill-count",String(entries.length).length===1&&"is-single")},entries.length)),
3248
3521
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "transitions-refine",
3
- "version": "0.3.8",
3
+ "version": "0.3.9",
4
4
  "description": "Live, agent-driven Refine panel for CSS/Motion transitions — injects a timeline + Refine UI and runs transitions.dev suggestions via your coding agent.",
5
5
  "type": "module",
6
6
  "bin": {