transitions-refine 0.3.9 → 0.3.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/demo.html CHANGED
@@ -39,6 +39,9 @@
39
39
  --drop-btn: 0 1px 3px 0 rgba(4,41,117,0.08);
40
40
  /* secondary/raised white button (Reset, Settings, Copy, Accept) — exact spec */
41
41
  --shadow-btn: 0 1px 3px 0 rgba(0,0,0,0.04), inset 0 0 0 1px rgba(0,0,0,0.06), inset 0 -1px 0 0 rgba(0,0,0,0.10), inset 0 0 0 1px rgba(196,196,196,0.10);
42
+ /* inset-only portion of --shadow-btn — for overlay rings that must reproduce
43
+ the Reset button's visible border on top of a separate fill layer */
44
+ --shadow-btn-inset: inset 0 0 0 1px rgba(0,0,0,0.06), inset 0 -1px 0 0 rgba(0,0,0,0.10), inset 0 0 0 1px rgba(196,196,196,0.10);
42
45
  --drop-line: 0 1px 3px 0 rgba(0,0,0,0.08);
43
46
  --shadow-border: 0 0 0 1px rgba(0,0,0,0.06), 0 1px 2px -1px rgba(0,0,0,0.06), 0 2px 4px 0 rgba(0,0,0,0.04);
44
47
  --shadow-border-hover: 0 0 0 1px rgba(0,0,0,0.08), 0 1px 2px -1px rgba(0,0,0,0.08), 0 2px 4px 0 rgba(0,0,0,0.06);
@@ -327,7 +330,7 @@
327
330
  border: none; border-radius: 8px; background: transparent; color: var(--c-blue);
328
331
  box-shadow: var(--drop-btn);
329
332
  font: inherit; font-size: 13px; font-weight: 500; line-height: 14px;
330
- cursor: pointer; white-space: nowrap; transition: color 0.12s ease; }
333
+ cursor: pointer; white-space: nowrap; transition: color 0.12s ease, scale 0.12s ease; }
331
334
  .tl-refine-btn::before { content: ""; position: absolute; inset: 0; border-radius: inherit;
332
335
  background: var(--c-blue-bg);
333
336
  background: color(display-p3 0 0.451 0.898 / 0.06);
@@ -339,7 +342,7 @@
339
342
  /* Figma: wand icon stroke is #0073E5, distinct from the #0071e2 label */
340
343
  .tl-refine-btn > svg { color: var(--c-blue-pressed); }
341
344
  .tl-refine-btn:hover:not(:disabled)::before { background: var(--c-blue-bg-h); background: color(display-p3 0 0.451 0.898 / 0.10); }
342
- .tl-refine-btn:active:not(:disabled) { color: var(--c-blue-pressed); }
345
+ .tl-refine-btn:active:not(:disabled) { color: var(--c-blue-pressed); scale: 0.96; }
343
346
  .tl-refine-btn:active:not(:disabled)::before { background: var(--c-blue-bg-h); background: color(display-p3 0 0.451 0.898 / 0.10); }
344
347
  .tl-refine-btn:disabled { cursor: default; color: var(--c-blue); }
345
348
  .tl-refine-btn:disabled::before { background: var(--c-blue-bg); background: color(display-p3 0 0.451 0.898 / 0.06); }
@@ -412,7 +415,15 @@
412
415
  .tl-bar { position: absolute; top: 50%; height: 24px; transform: translateY(-50%); background: var(--c-track);
413
416
  border-radius: 8px; min-width: 24px; cursor: grab;
414
417
  box-shadow: 0 1px 3px 0 rgba(0,0,0,0.08), inset 0 0 0 1px rgba(0,0,0,0.06), inset 0 -1px 0 0 rgba(0,0,0,0.1), inset 0 0 0 1px rgba(196,196,196,0.1);
415
- transition: background 0.12s ease; }
418
+ /* card resize (transitions.dev #01): tween left/width when timing changes
419
+ programmatically (Apply, snap, value-field edit). Suppressed mid-drag
420
+ (.is-dragging) so the bar tracks the cursor 1:1. */
421
+ transition: background 0.12s ease,
422
+ left 220ms cubic-bezier(0.22, 1, 0.36, 1),
423
+ width 220ms cubic-bezier(0.22, 1, 0.36, 1); will-change: left, width; }
424
+ .tl-bar.is-dragging { transition: background 0.12s ease; }
425
+ @media (prefers-reduced-motion: reduce) {
426
+ .tl-bar { transition: background 0.12s ease !important; } }
416
427
  .tl-prop-row:hover .tl-bar, .tl-prop-row.selected .tl-bar { background: var(--c-track-h); }
417
428
  .tl-bar:active { cursor: grabbing; }
418
429
  /* resize handles — Figma node 13044:2625: 2×14px, #767676 @60%, centered, ~5px edge inset */
@@ -450,18 +461,23 @@
450
461
  .tl-field-fill { position: absolute; left: 0; top: 0; bottom: 0; min-width: 32px; border-radius: 8px;
451
462
  background: var(--c-fill);
452
463
  box-shadow: inset 0 0 0 1px rgba(0,0,0,0.02), inset 0 -1px 0 0 rgba(0,0,0,0.08), inset 0 0 0 1px rgba(191,191,191,0.10), 0 1px 3px 0 rgba(0,0,0,0.06);
453
- pointer-events: none; transition: background 0.12s ease, opacity 0.12s ease; z-index: 1; }
454
- .tl-field.is-dragging .tl-field-fill { background: var(--c-fill-a); }
464
+ /* card resize (#01): tween width on programmatic changes + release snap;
465
+ suppressed mid-drag so it tracks the cursor 1:1. */
466
+ pointer-events: none; transition: background 0.12s ease, opacity 0.12s ease,
467
+ width 220ms cubic-bezier(0.22, 1, 0.36, 1); z-index: 1; }
468
+ .tl-field.is-dragging .tl-field-fill { background: var(--c-fill-a); transition: background 0.12s ease, opacity 0.12s ease; }
469
+ @media (prefers-reduced-motion: reduce) {
470
+ .tl-field-fill { transition: background 0.12s ease, opacity 0.12s ease !important; } }
455
471
  .tl-field.is-dragging .tl-field-label { opacity: 0.7; }
456
472
  .tl-field.is-editing .tl-field-fill { opacity: 0; }
457
473
  .tl-field-track { position: absolute; inset: 0; cursor: ew-resize; z-index: 2; }
458
474
  .tl-field-label { position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
459
475
  font-size: 13px; font-weight: 500; color: var(--c-text-mut); pointer-events: none; z-index: 3; }
460
476
  .tl-field-value { position: absolute; right: 12px; top: 50%; transform: translateY(-50%);
461
- font-size: 13px; color: var(--c-text); font-weight: 500; cursor: text; z-index: 3;
477
+ font-size: 13px; color: var(--c-text); font-weight: 400; cursor: text; z-index: 3;
462
478
  font-variant-numeric: tabular-nums; }
463
479
  .tl-field-input { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); width: 64px;
464
- text-align: right; border: none; background: transparent; font: inherit; font-size: 13px; font-weight: 500;
480
+ text-align: right; border: none; background: transparent; font: inherit; font-size: 13px; font-weight: 400;
465
481
  color: var(--c-text); outline: none; z-index: 4; font-variant-numeric: tabular-nums; }
466
482
  /* thumb at the right edge of the fill (Figma #767676 2×20px, inset 8px, hover 60% / drag 100%) */
467
483
  .tl-field-thumb { position: absolute; right: 8px; top: 7px; height: 18px; width: 2px;
@@ -520,7 +536,7 @@
520
536
  /* select trigger — Logram design system (node 13064:2418): flat #f7f7f7 fill */
521
537
  .tl-select { display: flex; align-items: center; justify-content: space-between; width: 100%; height: 32px;
522
538
  border: none; border-radius: 8px; background: #f7f7f7; padding: 6px 10px 6px 12px;
523
- font: inherit; font-size: 13px; font-weight: 500; line-height: 16px; color: #1b1b1b; cursor: pointer; text-align: left;
539
+ font: inherit; font-size: 13px; font-weight: 400; line-height: 16px; color: #1b1b1b; cursor: pointer; text-align: left;
524
540
  transition: background 0.12s ease; }
525
541
  .tl-select:hover { background: var(--c-sec-h); }
526
542
  .tl-select.is-open { background: var(--c-sec-h); }
@@ -546,7 +562,7 @@
546
562
  .tl-cubic-cell:focus-within { box-shadow: inset 0 0 0 1px rgba(0,0,0,0.14); }
547
563
  .tl-cubic-row input { flex: 1; min-width: 0; width: 100%; height: 100%; border: none; background: transparent;
548
564
  box-sizing: border-box; padding: 0; text-align: left;
549
- font: inherit; font-size: 12px; font-weight: 500; line-height: 16px; color: #1b1b1b; outline: none;
565
+ font: inherit; font-size: 12px; font-weight: 400; line-height: 16px; color: #1b1b1b; outline: none;
550
566
  -moz-appearance: textfield; appearance: textfield; }
551
567
  .tl-cubic-row input::-webkit-outer-spin-button, .tl-cubic-row input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; width: 0; }
552
568
  .tl-cubic-row input::placeholder { color: #979797; }
@@ -709,6 +725,12 @@
709
725
  /* trailing chevron on link rows (Figma 581:2828 — #696969 right chevron) */
710
726
  .tl-menu-chevr { display: flex; flex: none; color: #696969; }
711
727
 
728
+ /* trailing slot on a menu row — current value + chevron (Figma: Theme row
729
+ shows "System" then a right chevron and opens the theme picker sub-page). */
730
+ .tl-menu-trail { display: flex; align-items: center; gap: 4px; }
731
+ .tl-menu-value { font-size: 11px; line-height: 14px; color: #8b8b8b;
732
+ font-variant-numeric: tabular-nums; }
733
+
712
734
  /* ── settings dropdown sub-page slider (transitions.dev · 08-page-side-by-side) ──
713
735
  One dropdown surface that pages internally: main settings ↔ keyboard
714
736
  shortcuts. Both pages stay mounted so they can cross-slide; the surface
@@ -716,6 +738,10 @@
716
738
  smoothly instead of jumping. Page 1 exits left, page 2 exits right. */
717
739
  .tl-set-slide.t-page-slide {
718
740
  position: relative;
741
+ /* bleed past the .tl-menu 6px padding so the clipped (overflow:hidden)
742
+ viewport reaches the menu edges — lets dividers go truly full-width.
743
+ The 6px row inset is restored on .tl-set-page below. */
744
+ margin-left: -6px; margin-right: -6px;
719
745
  --page-slide-dur: 250ms; /* Fast — page slide */
720
746
  --page-fade-dur: 250ms;
721
747
  --page-slide-distance: 8px; /* Base distance */
@@ -728,9 +754,11 @@
728
754
  overflow: hidden;
729
755
  }
730
756
  .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); }
757
+ .tl-set-slide .t-page[data-page-id="2"],
758
+ .tl-set-slide .t-page[data-page-id="3"] { --t-page-from-x: var(--page-slide-distance); }
732
759
  .tl-set-slide .t-page {
733
760
  position: absolute; top: 0; left: 0; right: 0;
761
+ box-sizing: border-box; padding-left: 6px; padding-right: 6px;
734
762
  opacity: 0; pointer-events: none;
735
763
  transform: translateX(calc(var(--t-page-from-x, 0px) * var(--page-exit-enabled)));
736
764
  filter: blur(calc(var(--page-blur) * var(--page-exit-enabled)));
@@ -741,7 +769,8 @@
741
769
  will-change: opacity, transform, filter;
742
770
  }
743
771
  .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"] {
772
+ .tl-set-slide[data-page="2"] .t-page[data-page-id="2"],
773
+ .tl-set-slide[data-page="3"] .t-page[data-page-id="3"] {
745
774
  opacity: 1; pointer-events: auto; transform: translateX(0); filter: blur(0);
746
775
  transition-delay: var(--page-stagger);
747
776
  }
@@ -887,6 +916,12 @@
887
916
  transform: translate(0, 0) scale(var(--tt-scale)); }
888
917
  .t-tt-wrap:hover .t-tt.tl-tt-msg,
889
918
  .t-tt-trigger:focus-visible + .t-tt.tl-tt-msg { transform: translate(0, 0) scale(1); }
919
+ /* right-anchored variant for the panel's rightmost trigger (Minimize) so the
920
+ bubble grows leftward from the right edge and never clips off the panel. */
921
+ .t-tt.tl-tt-right { left: auto; right: 0; transform-origin: 100% 0;
922
+ transform: translate(0, 0) scale(var(--tt-scale)); }
923
+ .t-tt-wrap:hover .t-tt.tl-tt-right,
924
+ .t-tt-trigger:focus-visible + .t-tt.tl-tt-right { transform: translate(0, 0) scale(1); }
890
925
 
891
926
  /* ═════ transitions.dev — panel reveal (per-phase distance/ease/scale) ═════ */
892
927
  /* base = closed / close-end state: resolved travel + scale come from the
@@ -1018,7 +1053,7 @@
1018
1053
  /* header: title block (left) + Agent/Deterministic mode dropdown (right) */
1019
1054
  /* match the timeline top bar exactly: 7.5px/14px padding, centered, 52px tall (36px row + 15 + 1px border) */
1020
1055
  .tl-refine-head { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between;
1021
- gap: 8px; box-sizing: border-box; padding: 7.5px 14px;
1056
+ gap: 8px; box-sizing: border-box; padding: 7.5px 7.5px 7.5px 14px;
1022
1057
  border-bottom: 1px solid #f0f0f0; }
1023
1058
  .tl-refine-titles { min-width: 0; }
1024
1059
  .tl-refine-titles h3 { font-size: 13px; font-weight: 500; line-height: 14px; color: #17181c; }
@@ -1047,6 +1082,49 @@
1047
1082
  .tl-refine-tab[aria-selected="true"] { background: rgba(170,170,170,0.10); color: #17181c; }
1048
1083
  .tl-refine-body { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column;
1049
1084
  padding: 0 16px; }
1085
+ /* ── refine-type tab content slider (transitions.dev · 08-page-side-by-side) ──
1086
+ Switching the type tabs (Small refinements ↔ Replace transition) slides the
1087
+ body content side-by-side: the outgoing page exits with blur+fade while the
1088
+ incoming page slides in from the opposite edge. Both pages stay mounted so
1089
+ they cross-slide. Page 1 (Small, tab index 0) exits left; page 2 (Replace,
1090
+ tab index 1) exits right — going to Replace brings new content in from the
1091
+ right, going back brings it from the left. Mirrors the settings dropdown's
1092
+ .tl-set-slide tokens exactly. Pages are absolutely stacked and fill the
1093
+ fixed body region, scrolling internally, so no height tween is needed. */
1094
+ .tl-refine-body.tl-refine-pages.t-page-slide {
1095
+ position: relative; overflow: hidden; padding: 0;
1096
+ --page-slide-dur: 250ms; /* Fast — page slide */
1097
+ --page-fade-dur: 250ms;
1098
+ --page-slide-distance: 8px; /* Base distance */
1099
+ --page-blur: 3px; /* Medium blur */
1100
+ --page-stagger: 0ms;
1101
+ --page-exit-enabled: 1;
1102
+ --page-slide-ease: cubic-bezier(0.22, 1, 0.36, 1); /* Smooth ease out */
1103
+ --page-fade-ease: cubic-bezier(0.22, 1, 0.36, 1);
1104
+ }
1105
+ .tl-refine-pages .t-page[data-page-id="1"] { --t-page-from-x: calc(var(--page-slide-distance) * -1); }
1106
+ .tl-refine-pages .t-page[data-page-id="2"] { --t-page-from-x: var(--page-slide-distance); }
1107
+ .tl-refine-pages .t-page {
1108
+ position: absolute; inset: 0; overflow-y: auto;
1109
+ display: flex; flex-direction: column; box-sizing: border-box; padding: 0 16px;
1110
+ opacity: 0; pointer-events: none;
1111
+ transform: translateX(calc(var(--t-page-from-x, 0px) * var(--page-exit-enabled)));
1112
+ filter: blur(calc(var(--page-blur) * var(--page-exit-enabled)));
1113
+ transition:
1114
+ opacity var(--page-fade-dur) var(--page-fade-ease),
1115
+ transform var(--page-slide-dur) var(--page-slide-ease),
1116
+ filter var(--page-slide-dur) var(--page-slide-ease);
1117
+ will-change: opacity, transform, filter;
1118
+ }
1119
+ .tl-refine-pages[data-page="1"] .t-page[data-page-id="1"],
1120
+ .tl-refine-pages[data-page="2"] .t-page[data-page-id="2"] {
1121
+ opacity: 1; pointer-events: auto; transform: translateX(0); filter: blur(0);
1122
+ transition-delay: var(--page-stagger);
1123
+ }
1124
+ @media (prefers-reduced-motion: reduce) {
1125
+ .tl-refine-pages.t-page-slide,
1126
+ .tl-refine-pages .t-page { transition: none !important; }
1127
+ }
1050
1128
  /* idle + unavailable share a vertically-centred column */
1051
1129
  .tl-refine-center { flex: 1; display: flex; flex-direction: column; align-items: center;
1052
1130
  justify-content: center; gap: 12px; text-align: center; padding: 16px 0; }
@@ -1092,7 +1170,8 @@
1092
1170
  height var(--resize-dur) var(--resize-ease),
1093
1171
  border-radius var(--resize-dur) var(--resize-ease),
1094
1172
  color var(--resize-dur) var(--resize-ease),
1095
- box-shadow var(--resize-dur) var(--resize-ease); }
1173
+ box-shadow var(--resize-dur) var(--resize-ease),
1174
+ scale 0.12s ease; }
1096
1175
  .tl-scan-morph::before { content: ""; position: absolute; inset: 0; border-radius: inherit;
1097
1176
  background: var(--c-blue-bg);
1098
1177
  background: color(display-p3 0 0.451 0.898 / 0.06);
@@ -1104,7 +1183,7 @@
1104
1183
  .tl-refine-foot .tl-scan-morph { margin-bottom: 20px; }
1105
1184
  .tl-scan-morph:hover:not(:disabled):not(.is-scanning)::before {
1106
1185
  background: var(--c-blue-bg-h); background: color(display-p3 0 0.451 0.898 / 0.10); }
1107
- .tl-scan-morph:active:not(.is-scanning):not(:disabled) { color: var(--c-blue-pressed); }
1186
+ .tl-scan-morph:active:not(.is-scanning):not(:disabled) { color: var(--c-blue-pressed); scale: 0.96; }
1108
1187
  .tl-scan-morph:active:not(.is-scanning):not(:disabled)::before {
1109
1188
  background: var(--c-blue-bg-h); background: color(display-p3 0 0.451 0.898 / 0.10); }
1110
1189
  .tl-scan-morph:disabled:not(.is-scanning) { cursor: default; color: var(--c-blue); }
@@ -1114,12 +1193,12 @@
1114
1193
  color: var(--c-blue-pressed); opacity: 0.5; }
1115
1194
  .tl-scan-morph.is-scanning { width: 100%; height: 49px; border-radius: 60px;
1116
1195
  padding: 8px 16px; cursor: default; color: #676767; background: transparent;
1117
- box-shadow: none; }
1196
+ box-shadow: var(--shadow-btn); }
1118
1197
  .tl-scan-morph.is-scanning::before {
1119
1198
  background: #fff;
1120
1199
  transition: background var(--resize-dur) var(--resize-ease); }
1121
1200
  .tl-scan-morph.is-scanning::after {
1122
- box-shadow: inset 0 0 0 1px #f0f0f0;
1201
+ box-shadow: var(--shadow-btn-inset);
1123
1202
  transition: box-shadow var(--resize-dur) var(--resize-ease); }
1124
1203
  /* Two faces share one grid cell and cross-blur (icon swap, 09) between the
1125
1204
  label and the icon+status; whichever is hidden fades out with blur+scale.
@@ -1242,7 +1321,7 @@
1242
1321
  font: inherit; transition: background 0.12s ease; }
1243
1322
  .tl-mode-row:hover { background: rgba(170,170,170,0.08); }
1244
1323
  .tl-mode-row-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
1245
- .tl-mode-row-title { font-size: 14px; font-weight: 500; line-height: 16px; color: #1b1b1b; }
1324
+ .tl-mode-row-title { font-size: 13px; font-weight: 500; line-height: 16px; color: #1b1b1b; }
1246
1325
  .tl-mode-row-desc { font-size: 12px; font-weight: 400; line-height: 18px; color: #676767; text-wrap: balance; }
1247
1326
  .tl-mode-row-check { flex: 0 0 auto; width: 16px; height: 16px; display: flex; color: #17181c; }
1248
1327
  @keyframes tl-fade-up { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
@@ -1289,10 +1368,15 @@
1289
1368
  transition: background 0.12s ease, scale 0.12s ease, opacity 0.12s ease; }
1290
1369
  .tl-pill-btn .tl-dim { color: #696969; }
1291
1370
  .tl-pill-btn:hover:not(:disabled) { background: #fafafa; }
1292
- .tl-pill-btn:active:not(:disabled) { scale: 0.98; }
1293
- .tl-pill-btn:disabled { opacity: 0.45; cursor: default; }
1294
- /* applied state — GRAY, never green (matches Accept icon --c-text #17181c) */
1295
- .tl-pill-btn.is-applied { color: var(--c-text); cursor: default; }
1371
+ .tl-pill-btn:active:not(:disabled) { scale: 0.96; }
1372
+ /* disabled flat grey fill matching the active segmented tab, no raised
1373
+ shadow, muted text (instead of a faded white button). */
1374
+ .tl-pill-btn:disabled { cursor: not-allowed; opacity: 1; color: var(--c-disabled);
1375
+ background: rgba(170,170,170,0.1); box-shadow: none; }
1376
+ /* applied state — GRAY, never green (matches Accept icon --c-text #17181c).
1377
+ Flat grey fill matching the active segmented tab, no raised shadow. */
1378
+ .tl-pill-btn.is-applied { color: var(--c-text); cursor: not-allowed; opacity: 1;
1379
+ background: rgba(170,170,170,0.1); box-shadow: none; }
1296
1380
  .tl-pill-btn.is-applied svg path { stroke: var(--c-text); }
1297
1381
  /* Apply ⟷ Applied: a LEADING check is revealed first and pushes the text to
1298
1382
  the right, then the differing SUFFIX cross-blurs. The shared stem "Appl"
@@ -1758,6 +1842,8 @@
1758
1842
  // accept checkmark (Figma node 580:9057) — slimmer 1.5px check centered in a
1759
1843
  // square 16-frame (the glyph spans ~50% width, like the source icon frame).
1760
1844
  ICONS.accept = {vb:"0 0 16 16", svg:`<path d="M4 8.4268L6.46155 11.19223L12 4.97001" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`};
1845
+ // external-link (Figma "link-external-01") — box with an arrow leaving the top-right
1846
+ ICONS.linkout = {vb:"0 0 16 16", svg:`<path d="M9.5 3.5H12.5V6.5M12.5 3.5L7.5 8.5M11.5 9.25V11.5C11.5 12.0523 11.0523 12.5 10.5 12.5H4.5C3.94772 12.5 3.5 12.0523 3.5 11.5V5.5C3.5 4.94772 3.94772 4.5 4.5 4.5H6.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`};
1761
1847
  function Ic({name, size=16}){
1762
1848
  const ic = ICONS[name];
1763
1849
  if(!ic) return null;
@@ -1860,41 +1946,147 @@
1860
1946
  // page so the one dropdown surface resizes smoothly between pages. Lives
1861
1947
  // inside the Dropdown portal, so its layout effect fires when the surface
1862
1948
  // 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);
1949
+ function SettingsSlide({view,mainPage,shortcutsPage,themePage,onBack,onClose}){
1950
+ const slideRef=useRef(null), p1=useRef(null), p2=useRef(null), p3=useRef(null);
1951
+ const pageNum=view==="shortcuts"?"2":view==="theme"?"3":"1";
1865
1952
  useLayoutEffect(()=>{
1866
1953
  const slide=slideRef.current; if(!slide) return;
1867
- const active=view==="shortcuts"?p2.current:p1.current;
1954
+ const active=view==="shortcuts"?p2.current:view==="theme"?p3.current:p1.current;
1868
1955
  if(active) slide.style.height=active.offsetHeight+"px";
1869
1956
  });
1870
- // Escape steps back from the sub-page first, then closes the dropdown —
1957
+ // Escape steps back from a sub-page first, then closes the dropdown —
1871
1958
  // mirrors the picker's nested-dismiss behaviour.
1872
1959
  useEffect(()=>{
1873
1960
  const onKey=e=>{ if(e.key!=="Escape")return;
1874
- if(view==="shortcuts"){onBack&&onBack();} else {onClose&&onClose();} };
1961
+ if(view!=="main"){onBack&&onBack();} else {onClose&&onClose();} };
1875
1962
  document.addEventListener("keydown",onKey);
1876
1963
  return()=>document.removeEventListener("keydown",onKey);
1877
1964
  },[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));
1965
+ return h("div",{ref:slideRef,className:"tl-set-slide t-page-slide","data-page":pageNum},
1966
+ h("div",{ref:p1,className:"tl-set-page t-page","data-page-id":"1","aria-hidden":view!=="main"},mainPage),
1967
+ h("div",{ref:p2,className:"tl-set-page t-page","data-page-id":"2","aria-hidden":view!=="shortcuts"},shortcutsPage),
1968
+ h("div",{ref:p3,className:"tl-set-page t-page","data-page-id":"3","aria-hidden":view!=="theme"},themePage));
1881
1969
  }
1882
1970
 
1883
1971
  // ── refine relay client ──
1884
1972
  // Talks to the local relay (server/relay.mjs). The browser posts a job and
1885
1973
  // polls; the relay answers each job with one agent run (one-shot).
1886
1974
  const RELAY_URL = (window.REFINE_RELAY_URL) || "http://localhost:7331";
1975
+
1976
+ // ── demo/mock agent (guarded — additive, OFF by default) ──
1977
+ // When `window.__TX_REFINE_MOCK` is set (the standalone website-demo.html),
1978
+ // the three relay fns below short-circuit to a built-in mock so the panel is
1979
+ // fully interactive with NO relay/LLM/CLI. When the flag is UNSET this whole
1980
+ // block is inert and every fn falls through to the real fetch path unchanged.
1981
+ const TX_MOCK = typeof window!=="undefined" && window.__TX_REFINE_MOCK;
1982
+ const _txMockJobs = new Map(); // id → { request, createdAt }
1983
+ const _txMockId = ()=>"mock-"+Date.now()+"-"+Math.random().toString(36).slice(2,8);
1984
+ const _txMs = (n)=>(n==null?"":Math.round(n)+"ms");
1985
+ const _TX_EASE = "cubic-bezier(0.22, 1, 0.36, 1)"; // transitions.dev standard ease-out
1986
+ // Build a "done" result shaped by the stored request kind (mirrors relay.mjs
1987
+ // parseScanOutput/parseApplyOutput/parseAgentOutput shapes).
1988
+ function _txMockResult(request){
1989
+ const kind = request && request.kind;
1990
+ if(kind==="scan") return { groups: _txMockGroups(request), summary: "Grouped 1 component — Menu dropdown (open + close)." };
1991
+ if(kind==="apply") return { applied: true, summary: "Demo mode — simulated writing the change to your source. (No files touched.)", files: [] };
1992
+ return _txMockSuggestions(request);
1993
+ }
1994
+ // scan → one "Menu dropdown" group with Open + Close phases. Members are
1995
+ // derived from the raw DOM-detected transitions so the timeline shows real
1996
+ // lanes; selectors come straight from the live scan so they resolve in the
1997
+ // DOM. Shape mirrors relay.mjs buildScanPrompt's groups[] (id/label/phases →
1998
+ // phase{id,phase,label,stateTarget,fromState,toState,members[{id,label,
1999
+ // selector,propertyTimings}]}) and what TransitionRegistry.setGroups expects.
2000
+ function _txMockGroups(request){
2001
+ const raw = (request && request.raw) || [];
2002
+ const base = raw.length ? raw : [{label:"Menu", selector:".wd-dd .wd-dd-menu", properties:["opacity","transform"]}];
2003
+ // OPEN phase matches the current DOM → copy the scanned timings verbatim
2004
+ // (so the timeline shows the element's real, possibly off-token values).
2005
+ const open = base.map((e,i)=>({
2006
+ id:"m"+i,
2007
+ label:e.label||("Element "+(i+1)),
2008
+ selector:e.selector||".wd-dd .wd-dd-menu",
2009
+ propertyTimings:((e.timings&&e.timings.length)?e.timings:(e.properties||["opacity","transform"]).map(p=>({property:p,durationMs:280,delayMs:0,easing:"ease"})))
2010
+ .map(t=>({property:t.property, durationMs:t.durationMs!=null?t.durationMs:280, delayMs:t.delayMs||0, easing:t.easing||"ease"})),
2011
+ }));
2012
+ // CLOSE phase lives on a state variant the DOM scan can't see; the recipe's
2013
+ // faster close (the authored 180ms) is recovered here, mirroring how the
2014
+ // real agent reads the opposite-phase timings from cssRules.
2015
+ const close = open.map(m=>({ ...m, propertyTimings:m.propertyTimings.map(t=>({property:t.property, durationMs:180, delayMs:0, easing:"ease"})) }));
2016
+ const phases=[
2017
+ {id:"menu-dropdown:open", phase:"open", label:"Open", stateTarget:".wd-dd", fromState:null, toState:".is-open", members:open},
2018
+ {id:"menu-dropdown:close",phase:"close", label:"Close", stateTarget:".wd-dd", fromState:".is-open", toState:null, members:close},
2019
+ ];
2020
+ return [{ id:"menu-dropdown", label:"Menu dropdown", component:null, phases }];
2021
+ }
2022
+ // refine → deterministic, transitions.dev-token-aligned suggestions. patches
2023
+ // derive property/member from request.timings so Apply changes a real lane.
2024
+ // Shape mirrors relay.mjs's suggestion contract (id/kind/property/title/from/
2025
+ // to/patch/reason, + reference on the replace card).
2026
+ function _txMockSuggestions(request){
2027
+ const t = (request && request.timings) || [];
2028
+ const p0 = (t[0]&&t[0].property) || "opacity";
2029
+ const m0 = (t[0]&&t[0].member) || null;
2030
+ const p1 = (t[1]&&t[1].property) || p0;
2031
+ const m1 = (t[1]&&t[1].member) || m0;
2032
+ const from0 = _txMs(t[0]&&t[0].durationMs) || "—";
2033
+ const fromE = (t[0]&&t[0].easing) || "ease";
2034
+ const mp = (member, rest)=> member ? {member, ...rest} : rest;
2035
+ const suggestions=[
2036
+ { id:"mock-dur-fast", kind:"duration", property:p0, member:m0,
2037
+ title:"Duration → Fast", from:from0, to:"250ms",
2038
+ patch:mp(m0,{property:p0, durationMs:250}),
2039
+ reason:"Open motion snaps to the 250ms “medium” token — standard UI motion on transitions.dev." },
2040
+ { id:"mock-dur-quick", kind:"duration", property:p1, member:m1,
2041
+ title:"Duration → Quick", from:_txMs(t[1]&&t[1].durationMs)||from0, to:"150ms",
2042
+ patch:mp(m1,{property:p1, durationMs:150}),
2043
+ reason:"Closing feels crisper at the 150ms “fast” token — quick state changes." },
2044
+ { id:"mock-ease-smooth", kind:"easing", property:p0, member:m0,
2045
+ title:"Easing → Smooth ease out", from:fromE, to:_TX_EASE,
2046
+ patch:mp(m0,{property:p0, easing:_TX_EASE}),
2047
+ reason:"transitions.dev standard ease-out — a gentle decelerate to rest." },
2048
+ { id:"mock-replace-menu", kind:"replace", property:p0, member:m0,
2049
+ title:"Menu dropdown", reference:"05-menu-dropdown.md",
2050
+ patch:mp(m0,{property:"all", durationMs:250, easing:_TX_EASE}),
2051
+ reason:"Best-fit transitions.dev recipe (#05 Menu dropdown): scale 0.97→1 + fade, 250ms open / 150ms close on cubic-bezier(0.22, 1, 0.36, 1)." },
2052
+ ];
2053
+ return { suggestions, summary:"3 motion-token tweaks and 1 transitions.dev recipe match for the Menu dropdown." };
2054
+ }
2055
+ // statusLog so the panel's shimmer status copy has something to show.
2056
+ function _txMockLog(request, createdAt){
2057
+ const kind = request && request.kind;
2058
+ const head = kind==="scan" ? "Grouping transitions from your source…"
2059
+ : kind==="apply" ? "Writing “"+((request&&request.label)||"transition")+"” to your code…"
2060
+ : "Scanning “"+((request&&request.label)||"transition")+"”…";
2061
+ return [{message:head, at:createdAt}, {message:"Reading the Transitions.dev skill…", at:createdAt+350}];
2062
+ }
2063
+
1887
2064
  async function relayCreateJob(request){
2065
+ if(TX_MOCK){
2066
+ const id=_txMockId();
2067
+ _txMockJobs.set(id,{request,createdAt:Date.now()});
2068
+ return { id, status:"pending" };
2069
+ }
1888
2070
  const r = await fetch(RELAY_URL+"/jobs",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({request})});
1889
2071
  if(!r.ok) throw new Error("relay POST /jobs failed ("+r.status+")");
1890
2072
  return r.json(); // { id, status }
1891
2073
  }
1892
2074
  async function relayGetJob(id){
2075
+ if(TX_MOCK){
2076
+ const rec=_txMockJobs.get(id);
2077
+ if(!rec) return { id, status:"error", statusLog:[], result:null, error:"mock: unknown job" };
2078
+ const elapsed=Date.now()-rec.createdAt;
2079
+ const log=_txMockLog(rec.request, rec.createdAt);
2080
+ // ~800ms simulated work: the first poll(s) see "running", then "done".
2081
+ if(elapsed<800) return { id, status:"running", statusLog:[log[0]], result:null, error:null };
2082
+ return { id, status:"done", statusLog:log, result:_txMockResult(rec.request), error:null };
2083
+ }
1893
2084
  const r = await fetch(RELAY_URL+"/jobs/"+id);
1894
2085
  if(!r.ok) throw new Error("relay GET /jobs/:id failed ("+r.status+")");
1895
2086
  return r.json();
1896
2087
  }
1897
2088
  async function relayHealth(){
2089
+ if(TX_MOCK) return { ok:true, auto:true, llmAvailable:true, cliInstalled:true, jobs:0 };
1898
2090
  const r = await fetch(RELAY_URL+"/health");
1899
2091
  if(!r.ok) throw new Error("relay /health failed ("+r.status+")");
1900
2092
  return r.json(); // { ok, auto, llmAvailable, jobs }
@@ -2012,14 +2204,21 @@
2012
2204
  h("span",{className:"tl-apply-seg tl-apply-y"},"y"),
2013
2205
  h("span",{className:"tl-apply-seg tl-apply-ied"},"ied"))));
2014
2206
  }
2015
- function RefineResults({suggestions,appliedIds,onApply,lanes}){
2207
+ function RefineResults({suggestions,appliedIds,onApply,lanes,revealedRef,active=true}){
2016
2208
  const ref=useRef(null);
2017
2209
  useEffect(()=>{
2018
2210
  const el=ref.current;if(!el)return;
2211
+ // Both type tabs keep their results mounted so they can cross-slide
2212
+ // (page-side-by-side). Only the visible page plays the staggered reveal,
2213
+ // and only once per scan; the hidden page just shows its final state so
2214
+ // switching tabs slides finished content in without re-staggering.
2215
+ if(!active){ el.classList.add("is-shown"); return; }
2216
+ if(revealedRef&&revealedRef.current){ el.classList.add("is-shown"); return; }
2019
2217
  el.classList.remove("is-shown");
2020
2218
  void el.offsetHeight; // reflow from the start state before playing
2021
2219
  el.classList.add("is-shown");
2022
- },[]);
2220
+ if(revealedRef) revealedRef.current=true;
2221
+ },[active]);
2023
2222
  const delay=(i)=>({transitionDelay:"calc(var(--stagger-stagger) * "+i+")"});
2024
2223
  // category chip label + color modifier, keyed by the suggestion kind
2025
2224
  const CAT={easing:["Easing","is-easing"],duration:["Duration","is-duration"],
@@ -2067,6 +2266,11 @@
2067
2266
  const[modeOpen,setModeOpen]=useState(false);
2068
2267
  const[statusIx,setStatusIx]=useState(0);
2069
2268
  const modeRef=useRef(null);
2269
+ // The results reveal should play once per scan, not on every tab switch.
2270
+ // This persists across RefineResults remounts; reset it when a new scan
2271
+ // starts (or the panel returns to idle) so fresh results animate again.
2272
+ const revealedRef=useRef(false);
2273
+ useEffect(()=>{ if(phase==="scanning"||phase==="idle") revealedRef.current=false; },[phase]);
2070
2274
  useEffect(()=>{
2071
2275
  let raf, to;
2072
2276
  if(open){
@@ -2107,14 +2311,11 @@
2107
2311
  // null (unknown / still probing) is treated as ready so the panel doesn't
2108
2312
  // flash the "unavailable" copy before /health resolves.
2109
2313
  const agentReady = !agentMode || llmAvailable!==false;
2110
- const typeDesc = (REFINE_TYPES.find(t=>t.key===refineType)||REFINE_TYPES[0]).desc;
2111
2314
  const modeLabel = (REFINE_MODES.find(m=>m.key===mode)||REFINE_MODES[0]).label;
2112
- // Idle empty-state copy is mode-aware: Agent (llm) keeps the type's
2113
- // agent-oriented desc; Deterministic has no agent, so it describes the
2114
- // math-snap behavior (and that "replace" needs Agent mode).
2115
- const idleDesc = agentMode ? typeDesc : (refineType==="replace"
2116
- ? "Deterministic mode can't pick a recipe to replace the transition — switch to Agent mode for replacements."
2117
- : "Deterministic mode snaps your transition's timing to the nearest Transitions.dev motion tokens — mathematically, with no agent or credits.");
2315
+ // Idle empty-state copy is mode-aware (computed per type in bodyFor):
2316
+ // Agent (llm) keeps the type's agent-oriented desc; Deterministic has no
2317
+ // agent, so it describes the math-snap behavior (and that "replace" needs
2318
+ // Agent mode).
2118
2319
  // One persistent control for the whole foot: in idle/done/error it's the
2119
2320
  // pill button; while scanning it carries `.is-scanning` and the same DOM
2120
2321
  // node morphs (card resize) into the loading rectangle with the border-beam.
@@ -2135,25 +2336,58 @@
2135
2336
  h(ShimmerSwapText,{text:REFINE_STATUS[statusIx]}))));
2136
2337
  };
2137
2338
 
2138
- let body, foot, footBar=false;
2339
+ // Body content for a given refine type. Both type tabs render their own
2340
+ // body so they can cross-slide (page-side-by-side). Phase is global, so
2341
+ // only the type-specific bits (filtered suggestions, empty/idle copy)
2342
+ // change per page. `isActive` tells the results list whether to play its
2343
+ // reveal (only the visible page does).
2344
+ const bodyFor=(rt,isActive)=>{
2345
+ const vis = suggestions.filter(s=> rt==="replace" ? s.kind==="replace" : s.kind!=="replace");
2346
+ if(phase==="scanning"){
2347
+ return h("div",{className:"tl-refine-center"});
2348
+ }
2349
+ if(phase==="error"){
2350
+ return h("div",{className:"tl-refine-center"},
2351
+ h("div",{className:"tl-refine-unavail-title"},"Something went wrong."),
2352
+ h("div",{className:"tl-refine-unavail-text"},error||"The agent reported an error."));
2353
+ }
2354
+ if(phase==="done"){
2355
+ if(vis.length===0){
2356
+ const emptyMsg = rt==="replace"
2357
+ ? "I didn't find any transition that would be a good fit as a replacement."
2358
+ : "Already aligned to the transitions.dev motion tokens. Nothing to refine.";
2359
+ return h("div",{className:"tl-refine-center"},
2360
+ h("p",{className:"tl-refine-idle-text"},emptyMsg));
2361
+ }
2362
+ return h(RefineResults,{suggestions:vis,appliedIds,onApply,lanes:lanes||[],revealedRef,active:isActive});
2363
+ }
2364
+ // idle
2365
+ if(!agentReady){
2366
+ const cliMissing = cliInstalled===false;
2367
+ return h("div",{className:"tl-refine-center"},
2368
+ h("div",{className:"tl-refine-unavail-title"},"Agent unavailable."),
2369
+ cliMissing
2370
+ ? h("p",{className:"tl-refine-unavail-text"},"Install Cursor CLI by ",h("code",{className:"tl-code"},"/refine live")," command in your agent to enable live functionality.")
2371
+ : h("p",{className:"tl-refine-unavail-text"},"Run the ",h("code",{className:"tl-code"},"/refine live")," command in your agent to enable live functionality."));
2372
+ }
2373
+ const idleDescR = agentMode ? (REFINE_TYPES.find(t=>t.key===rt)||REFINE_TYPES[0]).desc : (rt==="replace"
2374
+ ? "Deterministic mode can't pick a recipe to replace the transition — switch to Agent mode for replacements."
2375
+ : "Deterministic mode snaps your transition's timing to the nearest Transitions.dev motion tokens — mathematically, with no agent or credits.");
2376
+ return h("div",{className:"tl-refine-center"},
2377
+ h("p",{className:"tl-refine-idle-text"},idleDescR));
2378
+ };
2379
+
2380
+ // Foot tracks the active type (it lives below the sliding body, so it
2381
+ // just updates in place on a tab switch).
2382
+ let foot, footBar=false;
2139
2383
  if(phase==="scanning"){
2140
- body = h("div",{className:"tl-refine-center"});
2141
2384
  foot = startBtn();
2142
2385
  } else if(phase==="error"){
2143
- body = h("div",{className:"tl-refine-center"},
2144
- h("div",{className:"tl-refine-unavail-title"},"Something went wrong."),
2145
- h("div",{className:"tl-refine-unavail-text"},error||"The agent reported an error."));
2146
2386
  foot = startBtn({label:"Try again"});
2147
2387
  } else if(phase==="done"){
2148
2388
  if(visible.length===0){
2149
- const emptyMsg = refineType==="replace"
2150
- ? "I didn't find any transition that would be a good fit as a replacement."
2151
- : "Already aligned to the transitions.dev motion tokens. Nothing to refine.";
2152
- body = h("div",{className:"tl-refine-center"},
2153
- h("p",{className:"tl-refine-idle-text"},emptyMsg));
2154
2389
  foot = startBtn({label:"Scan again"});
2155
2390
  } else {
2156
- body = h(RefineResults,{suggestions:visible,appliedIds,onApply,lanes:lanes||[]});
2157
2391
  footBar = true;
2158
2392
  foot = h(React.Fragment,null,
2159
2393
  h("button",{className:"tl-pill-btn",disabled:pending.length===0,
@@ -2162,19 +2396,7 @@
2162
2396
  h("button",{className:"tl-pill-btn",onClick:onStart},"Scan again"));
2163
2397
  }
2164
2398
  } else { // idle
2165
- if(!agentReady){
2166
- const cliMissing = cliInstalled===false;
2167
- body = h("div",{className:"tl-refine-center"},
2168
- h("div",{className:"tl-refine-unavail-title"},"Agent unavailable."),
2169
- cliMissing
2170
- ? h("p",{className:"tl-refine-unavail-text"},"Install Cursor CLI by ",h("code",{className:"tl-code"},"/refine live")," command in your agent to enable live functionality.")
2171
- : h("p",{className:"tl-refine-unavail-text"},"Run the ",h("code",{className:"tl-code"},"/refine live")," command in your agent to enable live functionality."));
2172
- foot = startBtn({disabled:true});
2173
- } else {
2174
- body = h("div",{className:"tl-refine-center"},
2175
- h("p",{className:"tl-refine-idle-text"},idleDesc));
2176
- foot = startBtn({});
2177
- }
2399
+ foot = startBtn(agentReady?{}:{disabled:true});
2178
2400
  }
2179
2401
 
2180
2402
  return h("div",{className:"tl-refine-panel","data-open":panelOpen?"true":"false","data-phase":slidePhase,
@@ -2202,7 +2424,11 @@
2202
2424
  REFINE_TYPES.map(t=>h("button",{key:t.key,className:"tl-refine-tab",role:"tab",
2203
2425
  "aria-selected":refineType===t.key?"true":"false",
2204
2426
  onClick:()=>{if(t.key!==refineType)onType(t.key);}},t.label))),
2205
- h("div",{className:"tl-refine-body"},body),
2427
+ h("div",{className:"tl-refine-body tl-refine-pages t-page-slide","data-page":refineType==="replace"?"2":"1"},
2428
+ h("div",{className:"tl-refine-page t-page","data-page-id":"1","aria-hidden":refineType!=="small"},
2429
+ bodyFor("small",refineType==="small")),
2430
+ h("div",{className:"tl-refine-page t-page","data-page-id":"2","aria-hidden":refineType!=="replace"},
2431
+ bodyFor("replace",refineType==="replace"))),
2206
2432
  h("div",{className:cx("tl-refine-foot",footBar&&"is-bar")},foot)));
2207
2433
  }
2208
2434
 
@@ -2230,6 +2456,9 @@
2230
2456
  const[setg,setSetg]=useState(false);
2231
2457
  // settings dropdown sub-page (main menu ↔ keyboard-shortcuts cheat sheet)
2232
2458
  const[settingsView,setSettingsView]=useState("main"); // "main" | "shortcuts"
2459
+ // theme preference — UI only for now (dark theme not wired up yet)
2460
+ const[themePref,setThemePref]=useState("system"); // "light" | "dark" | "system"
2461
+ const THEME_OPTS=[{key:"light",label:"Light"},{key:"dark",label:"Dark"},{key:"system",label:"System"}];
2233
2462
  const isMac=typeof navigator!=="undefined"&&/mac/i.test((navigator.userAgentData&&navigator.userAgentData.platform)||navigator.platform||navigator.userAgent||"");
2234
2463
  const modKey=isMac?"Cmd":"Ctrl";
2235
2464
  // small gray shortcut hint appended inside a tooltip (e.g. "⌘A")
@@ -2308,23 +2537,38 @@
2308
2537
  h(SettingsSlide,{view:settingsView,
2309
2538
  onBack:()=>setSettingsView("main"),
2310
2539
  onClose:()=>{setSetg(false);setSettingsView("main");},
2311
- // ── main settings page ──
2540
+ // ── main settings page (Figma 580:4723) ──
2312
2541
  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
2542
  h(MenuItem,{disabled:!active,onClick:()=>{onCopy&&onCopy();setSetg(false);},
2318
2543
  left:h("span",{className:"tl-menu-icon"},h(Ic,{name:"copy"})),
2319
2544
  right:copied&&h("span",{className:"tl-menu-check"},h(Ic,{name:"accept"}))},copied?"Copied":"Copy values"),
2320
2545
  h(MenuItem,{disabled:scanning,onClick:()=>{setSetg(false);onRescan&&onRescan();},
2321
2546
  left:h("span",{className:"tl-menu-icon"},h(Ic,{name:"restart"}))},
2322
2547
  scanning?"Rescanning…":"Rescan transitions"),
2548
+ h("div",{className:"tl-menu-divider"}),
2549
+ // theme picker row — shows the current value + chevron; opens the sub-page
2550
+ h(MenuItem,{onClick:()=>setSettingsView("theme"),
2551
+ right:h("span",{className:"tl-menu-trail"},
2552
+ h("span",{className:"tl-menu-value"},(THEME_OPTS.find(o=>o.key===themePref)||{}).label),
2553
+ h("span",{className:"tl-menu-chevr"},h(Ic,{name:"chevronr",size:16})))},"Theme"),
2323
2554
  h(MenuItem,{onClick:()=>setSettingsView("shortcuts"),
2324
2555
  right:h("span",{className:"tl-menu-chevr"},h(Ic,{name:"chevronr",size:16}))},"Keyboard shortcuts"),
2325
- h("div",{className:"tl-menu-divider"}),
2326
2556
  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")),
2557
+ right:h("span",{className:"tl-menu-chevr"},h(Ic,{name:"linkout",size:16}))},"Send feedback"),
2558
+ h(MenuItem,{onClick:()=>{setSetg(false);window.open("https://transitions.dev","_blank","noopener");},
2559
+ right:h("span",{className:"tl-menu-chevr"},h(Ic,{name:"linkout",size:16}))},"Learn more"),
2560
+ h("div",{className:"tl-menu-divider"}),
2561
+ h("div",{className:"tl-menu-head"},
2562
+ h("span",null,"Transitions.dev ",h("span",{className:"tl-menu-head-name"},"Refine")),
2563
+ h("span",{className:"tl-menu-head-ver"},REFINE_VERSION))),
2564
+ // ── theme sub-page (Light / Dark / System — System default) ──
2565
+ themePage:h(React.Fragment,null,
2566
+ h("button",{type:"button",className:"tl-set-back",onClick:()=>setSettingsView("main")},
2567
+ h("span",{className:"tl-set-back-ic"},h(Ic,{name:"chevronr",size:16})),
2568
+ h("span",{className:"tl-set-back-title"},"Theme")),
2569
+ h("div",{className:"tl-menu-divider"}),
2570
+ ...THEME_OPTS.map(o=>h(MenuItem,{key:o.key,onClick:()=>{setThemePref(o.key);setSettingsView("main");},
2571
+ right:themePref===o.key&&h("span",{className:"tl-menu-check"},h(Ic,{name:"accept"}))},o.label))),
2328
2572
  // ── keyboard-shortcuts sub-page ──
2329
2573
  shortcutsPage:h(React.Fragment,null,
2330
2574
  h("button",{type:"button",className:"tl-set-back",onClick:()=>setSettingsView("main")},
@@ -2371,35 +2615,54 @@
2371
2615
  h("span",{className:"t-tt tl-tt-below",role:"tooltip"},"Refine",ttHint("R"))),
2372
2616
  h("span",{className:"t-tt-wrap"},
2373
2617
  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("."))),
2618
+ h("span",{className:"t-tt tl-tt-below tl-tt-right",role:"tooltip"},"Minimize",ttHint("."))),
2375
2619
  );
2376
2620
  }
2377
2621
 
2378
2622
  function PropTrack({property, member, delayMs, durationMs, selected, onSelect, onDelayChange, onDurationChange, snap, scaleMs, lockDuration, labelW}){
2379
2623
  const trackRef=useRef(null);
2380
- const grid = snap ? 25 : 1;
2381
- const pxToMs=useCallback(px=>{if(!trackRef.current)return 0;const w=trackRef.current.getBoundingClientRect().width;return Math.round((px/w)*scaleMs/grid)*grid;},[grid,scaleMs]);
2624
+ const [dragging,setDragging]=useState(false);
2625
+ // 1ms resolution while dragging so the bar moves smoothly; the snap grid
2626
+ // (25ms) is applied only on release when `snap` is on.
2627
+ const SNAP_GRID = 25;
2628
+ const pxToMs=useCallback((px,g)=>{if(!trackRef.current)return 0;const w=trackRef.current.getBoundingClientRect().width;const gg=g||1;return Math.round((px/w)*scaleMs/gg)*gg;},[scaleMs]);
2382
2629
 
2383
2630
  const startDrag=useCallback((mode,e)=>{
2384
2631
  e.preventDefault(); e.stopPropagation();
2385
2632
  onSelect();
2386
2633
  // a spring controls its own duration, so its bar can be moved (delay) but not resized
2387
2634
  if(lockDuration && mode!=="move") return;
2635
+ setDragging(true);
2388
2636
  const startX=e.clientX; const sd=delayMs; const sdur=durationMs;
2637
+ let lastDel=sd, lastDur=sdur;
2389
2638
  const onMove=e2=>{
2390
- const dx=e2.clientX-startX; const dMs=pxToMs(dx);
2639
+ const dx=e2.clientX-startX; const dMs=pxToMs(dx,1); // smooth: 1ms steps
2391
2640
  if(mode==="move"){
2392
- onDelayChange(Math.max(0,Math.min(sd+dMs,scaleMs-sdur)));
2641
+ lastDel=Math.max(0,Math.min(sd+dMs,scaleMs-sdur));
2642
+ onDelayChange(lastDel);
2393
2643
  } else if(mode==="left"){
2394
- const rawDel=sd+dMs; const clampedDel=Math.max(0,Math.min(rawDel,sd+sdur-grid));
2395
- onDelayChange(clampedDel); onDurationChange(sdur-(clampedDel-sd));
2644
+ const rawDel=sd+dMs; const clampedDel=Math.max(0,Math.min(rawDel,sd+sdur-1));
2645
+ lastDel=clampedDel; lastDur=sdur-(clampedDel-sd);
2646
+ onDelayChange(lastDel); onDurationChange(lastDur);
2396
2647
  } else {
2397
- onDurationChange(Math.max(grid,Math.min(sdur+dMs,scaleMs-sd)));
2648
+ lastDur=Math.max(1,Math.min(sdur+dMs,scaleMs-sd));
2649
+ onDurationChange(lastDur);
2398
2650
  }
2399
2651
  };
2400
- const onUp=()=>{window.removeEventListener("mousemove",onMove);window.removeEventListener("mouseup",onUp);};
2652
+ const onUp=()=>{
2653
+ setDragging(false);
2654
+ // snap to the grid once, on release (bar tweens there since the
2655
+ // is-dragging transition lock is now removed).
2656
+ if(snap){
2657
+ const g=SNAP_GRID, sn=v=>Math.round(v/g)*g;
2658
+ if(mode==="move"){ onDelayChange(Math.max(0,Math.min(sn(lastDel),scaleMs-sdur))); }
2659
+ else if(mode==="left"){ onDelayChange(Math.max(0,sn(lastDel))); onDurationChange(Math.max(g,sn(lastDur))); }
2660
+ else { onDurationChange(Math.max(g,Math.min(sn(lastDur),scaleMs-sd))); }
2661
+ }
2662
+ window.removeEventListener("mousemove",onMove);window.removeEventListener("mouseup",onUp);
2663
+ };
2401
2664
  window.addEventListener("mousemove",onMove);window.addEventListener("mouseup",onUp);
2402
- },[delayMs,durationMs,pxToMs,grid,scaleMs,onSelect,onDelayChange,onDurationChange,lockDuration]);
2665
+ },[delayMs,durationMs,pxToMs,snap,scaleMs,onSelect,onDelayChange,onDurationChange,lockDuration]);
2403
2666
 
2404
2667
  const delPct=(delayMs/scaleMs)*100; const durPct=(durationMs/scaleMs)*100;
2405
2668
  return h("div",{className:cx("tl-prop-row",selected&&"selected",lockDuration&&"is-spring"),onClick:onSelect},
@@ -2408,7 +2671,7 @@
2408
2671
  member&&h("span",{className:"tl-prop-member"},member),
2409
2672
  h("span",{className:"tl-prop-prop"},property))),
2410
2673
  h("div",{className:"tl-prop-track",ref:trackRef},
2411
- h("div",{className:"tl-bar",style:{left:delPct+"%",width:durPct+"%"},onMouseDown:e=>startDrag("move",e),
2674
+ h("div",{className:cx("tl-bar",dragging&&"is-dragging"),style:{left:delPct+"%",width:durPct+"%"},onMouseDown:e=>startDrag("move",e),
2412
2675
  title:lockDuration?"Spring duration is derived \u2014 drag to move, resize is locked":undefined},
2413
2676
  h("span",{className:"tl-bar-handle left"}),
2414
2677
  h("span",{className:"tl-bar-handle right"}),
@@ -2428,7 +2691,8 @@
2428
2691
  const[dragging,setDragging]=useState(false);
2429
2692
  const[menu,setMenu]=useState(false);
2430
2693
  const pct = Math.min(Math.max((value - min) / (max - min), 0), 1) * 100;
2431
- const grid = snap ? step : Math.max(1, Math.round(step/5));
2694
+ // fine grid while dragging (smooth); the snap `step` is applied on release.
2695
+ const dragGrid = Math.max(1, Math.round(step/5));
2432
2696
  const selectAll = useCallback(e=>{
2433
2697
  const el = e && e.target ? e.target : inputRef.current;
2434
2698
  if(!el) return;
@@ -2436,25 +2700,27 @@
2436
2700
  requestAnimationFrame(()=>el.select());
2437
2701
  },[]);
2438
2702
 
2439
- const setFromX = useCallback(clientX => {
2703
+ const setFromX = useCallback((clientX, g) => {
2440
2704
  if(!trackRef.current) return;
2441
2705
  const rect = trackRef.current.getBoundingClientRect();
2442
2706
  const ratio = Math.max(0, Math.min((clientX - rect.left) / rect.width, 1));
2443
2707
  const raw = min + ratio * (max - min);
2444
- const snapped = Math.round(raw / grid) * grid;
2708
+ const gg = g || 1;
2709
+ const snapped = Math.round(raw / gg) * gg;
2445
2710
  onChange(Math.max(min, Math.min(max, snapped)));
2446
- },[min,max,grid,onChange]);
2711
+ },[min,max,onChange]);
2447
2712
 
2448
2713
  const startDrag = useCallback(e=>{
2449
2714
  if(editing || readOnly) return;
2450
2715
  e.preventDefault();
2451
2716
  setDragging(true);
2452
- setFromX(e.clientX);
2453
- const onMove = e2 => setFromX(e2.clientX);
2454
- const onUp = () => { setDragging(false); window.removeEventListener("mousemove",onMove); window.removeEventListener("mouseup",onUp); };
2717
+ let lastX = e.clientX;
2718
+ setFromX(e.clientX, dragGrid); // smooth fine steps during drag
2719
+ const onMove = e2 => { lastX = e2.clientX; setFromX(e2.clientX, dragGrid); };
2720
+ const onUp = () => { setDragging(false); if(snap) setFromX(lastX, step); window.removeEventListener("mousemove",onMove); window.removeEventListener("mouseup",onUp); };
2455
2721
  window.addEventListener("mousemove",onMove);
2456
2722
  window.addEventListener("mouseup",onUp);
2457
- },[setFromX,editing]);
2723
+ },[setFromX,editing,readOnly,snap,step,dragGrid]);
2458
2724
 
2459
2725
  const beginEdit = useCallback(e=>{ e.stopPropagation(); if(readOnly) return; setDraft(String(value)); setEditing(true); },[value,readOnly]);
2460
2726
  const commit = useCallback(()=>{ const n=parseFloat(draft); if(!isNaN(n)) onChange(Math.max(min,Math.min(max,Math.round(n)))); setEditing(false); },[draft,min,max,onChange]);
@@ -3051,35 +3317,76 @@
3051
3317
  refreshHealth();
3052
3318
  },[active,refineOpen,refreshHealth]);
3053
3319
  // "Start scanning" resolves availability first, then posts the job.
3054
- const startScan=useCallback(async()=>{
3320
+ // per-(transition,type) result cache so switching tabs reuses a prior scan
3321
+ // instead of re-asking the agent. In llm mode each scan is scoped to the
3322
+ // active tab (Small = token tweaks, Replace = recipe) so the common Small
3323
+ // view skips the recipe selection + reference-file read. Deterministic stays
3324
+ // a single instant "both" pass that feeds both tabs.
3325
+ const refineCacheRef=useRef(new Map()); // `${id}::${type}` -> {suggestions,summary}
3326
+ const refineScanTypeRef=useRef("small"); // which type the in-flight job targets
3327
+ // results are not comparable across transitions — drop the cache when the
3328
+ // target transition changes.
3329
+ useEffect(()=>{refineCacheRef.current.clear();},[active&&active.id]);
3330
+ // recompute the displayed suggestions from whatever types are cached for a
3331
+ // transition (the panel filters by kind per tab, so a merged array is fine).
3332
+ const applyMerged=useCallback((id)=>{
3333
+ const c=refineCacheRef.current;
3334
+ const both=c.get(id+"::both");
3335
+ if(both){setRefineSuggestions(both.suggestions||[]);setRefineSummary(both.summary||null);return;}
3336
+ const small=c.get(id+"::small"), repl=c.get(id+"::replace");
3337
+ setRefineSuggestions([...(small?small.suggestions:[]),...(repl?repl.suggestions:[])]);
3338
+ setRefineSummary((small&&small.summary)||(repl&&repl.summary)||null);
3339
+ },[]);
3340
+ const startScan=useCallback(async(typeArg,opts)=>{
3055
3341
  if(!active)return;
3056
- scanStartedAtRef.current=Date.now();
3057
- setRefinePhase("scanning");setRefineLog([]);
3058
- setRefineSuggestions([]);setRefineSummary(null);setRefineError(null);
3059
- setAppliedIds({});setRefineLabel(active.label);setRefineJobId(null);
3060
- const j=await refreshHealth();
3061
- const avail=j?!!j.llmAvailable:false;
3342
+ opts=opts||{};
3343
+ // Resolve availability from the continuously-polled /health (2s cadence)
3344
+ // rather than a serial probe on the click path. Unknown (null) counts as
3345
+ // available so the first click isn't blocked; a dead relay surfaces via
3346
+ // the POST below (→ error state).
3347
+ const avail=llmAvailable!==false;
3062
3348
  const mode=(refineMode==="llm"&&!avail)?"deterministic":refineMode;
3063
- scanModeRef.current=mode;
3064
3349
  if(mode!==refineMode)setRefineMode(mode);
3350
+ scanModeRef.current=mode;
3351
+ // deterministic → one "both" pass (instant, free). llm → scope to the
3352
+ // active tab so Small skips recipe work; Replace asks for it on demand.
3353
+ // NOTE: this is also wired as a button onClick, so typeArg may be a click
3354
+ // event — only honour it when it's an explicit "small"/"replace" string.
3355
+ const reqType=(typeArg==="small"||typeArg==="replace")?typeArg:refineType;
3356
+ const type=mode==="deterministic"?"both":reqType;
3357
+ refineScanTypeRef.current=type;
3358
+ scanStartedAtRef.current=Date.now();
3359
+ setRefinePhase("scanning");setRefineLog([]);setRefineError(null);
3360
+ setRefineLabel(active.label);setRefineJobId(null);
3361
+ if(!opts.keepApplied)setAppliedIds({});
3362
+ // re-scan only this type; keep the other tab's cached result for the merge.
3363
+ refineCacheRef.current.delete(active.id+"::"+type);
3065
3364
  try{
3066
3365
  const et=active.effectiveTimings||[];
3067
3366
  const timings=et.map(t=>({property:t.property,member:t.member||null,durationMs:t.durationMs,delayMs:t.delayMs,easing:t.easing}));
3068
3367
  const selector=(active.bindings&&active.bindings.selector)||(et[0]&&et[0].selector)||null;
3069
- // always scan for BOTH kinds (token tweaks + a whole-transition replacement)
3070
- // in one pass; the tabs just filter which kind they show.
3071
3368
  const{id}=await relayCreateJob({transitionId:active.id,label:active.label,selector,
3072
- phase:active.phase||null,group:active.groupLabel||null,timings,mode,refineType:"both"});
3369
+ phase:active.phase||null,group:active.groupLabel||null,timings,mode,refineType:type});
3073
3370
  setRefineJobId(id);
3074
3371
  }catch(e){
3075
3372
  setRefinePhase("error");
3076
3373
  setRefineError(h(React.Fragment,null,"Couldn't reach the refine relay. Start it with ",h("code",{className:"tl-code"},"npx transitions-refine live"),"."));
3077
3374
  }
3078
- },[active,refineMode,refineType,refreshHealth]);
3079
- const changeRefineMode=useCallback((mode)=>{setRefineMode(mode);setRefinePhase("idle");refreshHealth();},[refreshHealth]);
3080
- // switching tabs only changes which kind of suggestion is shown it never
3081
- // interrupts a scan or discards results, since one scan covers both kinds.
3082
- const changeRefineType=useCallback((t)=>{setRefineType(t);},[]);
3375
+ },[active,refineMode,refineType,llmAvailable]);
3376
+ const changeRefineMode=useCallback((mode)=>{setRefineMode(mode);setRefinePhase("idle");refineCacheRef.current.clear();refreshHealth();},[refreshHealth]);
3377
+ // Switching tabs reuses a cached scan when present; otherwise (llm only) it
3378
+ // scans the newly-selected type on demand the trade-off for not scanning
3379
+ // the recipe upfront. Deterministic feeds both tabs from one "both" pass, so
3380
+ // its tab switch is a pure filter. Before any scan (idle/error), tabs just
3381
+ // preview copy and never auto-scan.
3382
+ const changeRefineType=useCallback((t)=>{
3383
+ setRefineType(t);
3384
+ if(!active)return;
3385
+ if(scanModeRef.current==="deterministic")return;
3386
+ if(refinePhase!=="done"&&refinePhase!=="scanning")return;
3387
+ if(refineCacheRef.current.has(active.id+"::"+t)){applyMerged(active.id);setRefinePhase("done");return;}
3388
+ startScan(t,{keepApplied:true});
3389
+ },[active,refinePhase,applyMerged,startScan]);
3083
3390
  // poll the relay while a job is running
3084
3391
  useEffect(()=>{
3085
3392
  if(!refineJobId||refinePhase!=="scanning")return;
@@ -3090,7 +3397,15 @@
3090
3397
  if(!live)return;
3091
3398
  if(Array.isArray(job.statusLog))setRefineLog(job.statusLog);
3092
3399
  if(job.status==="done"){
3093
- const finish=()=>{if(!live)return;setRefineSuggestions((job.result&&job.result.suggestions)||[]);setRefineSummary(job.result&&job.result.summary);setRefinePhase("done");};
3400
+ try{console.log("[refine] "+refineScanTypeRef.current+" job done in "+(Date.now()-scanStartedAtRef.current)+"ms");}catch{}
3401
+ const finish=()=>{if(!live)return;
3402
+ // cache this scan's result under its type, then show the merged set
3403
+ // (so the other tab's cached suggestions stay visible).
3404
+ const type=refineScanTypeRef.current;
3405
+ const id=active?active.id:"";
3406
+ refineCacheRef.current.set(id+"::"+type,{suggestions:(job.result&&job.result.suggestions)||[],summary:(job.result&&job.result.summary)||null});
3407
+ if(active)applyMerged(active.id);else{setRefineSuggestions((job.result&&job.result.suggestions)||[]);setRefineSummary(job.result&&job.result.summary);}
3408
+ setRefinePhase("done");};
3094
3409
  // deterministic mode floors the scanning phase at 2s so it doesn't flash by.
3095
3410
  const minMs=scanModeRef.current==="deterministic"?2000:0;
3096
3411
  const wait=Math.max(0,minMs-(Date.now()-scanStartedAtRef.current));
@@ -3098,10 +3413,10 @@
3098
3413
  return;
3099
3414
  }
3100
3415
  if(job.status==="error"){setRefineError(job.error||"The agent reported an error.");setRefinePhase("error");return;}
3101
- to=setTimeout(tick,500);
3416
+ to=setTimeout(tick,200);
3102
3417
  }catch(e){if(live){setRefineError("Lost connection to the relay.");setRefinePhase("error");}}
3103
3418
  };
3104
- to=setTimeout(tick,400);
3419
+ to=setTimeout(tick,200);
3105
3420
  return()=>{live=false;if(to)clearTimeout(to);};
3106
3421
  },[refineJobId,refinePhase]);
3107
3422
  const applySuggestion=useCallback((s)=>{
@@ -3305,6 +3620,11 @@
3305
3620
  if(scanTokenRef.current!==token)return;
3306
3621
  const flat=registry.getAll().filter(e=>e.kind!=="phase");
3307
3622
  if(!flat.length){setGroupScanState("idle");return;}
3623
+ // Hardcoded grouping (window.__TX_SEED_GROUPS): a host/demo page can ship a
3624
+ // pre-computed agent grouping so the panel groups instantly with NO relay
3625
+ // or agent round-trip. demo.html uses this to bake in the agent's scan.
3626
+ const seed=typeof window!=="undefined"&&Array.isArray(window.__TX_SEED_GROUPS)?window.__TX_SEED_GROUPS:null;
3627
+ if(seed&&seed.length){registry.setGroups(seed);setGroupScanState("done");return;}
3308
3628
  // valid cache (same content signature) → apply instantly, skip the agent.
3309
3629
  // A stale cache (different sig, e.g. content changed) falls through to a
3310
3630
  // fresh scan and is overwritten below.
@@ -3353,7 +3673,12 @@
3353
3673
  // (signature match, no agent) or kicks off a fresh agent scan. Rescan is
3354
3674
  // manual after that.
3355
3675
  useEffect(()=>{
3356
- if(!live||didGroupScanRef.current)return;
3676
+ // A host/demo page can hardcode a grouping via window.__TX_SEED_GROUPS so
3677
+ // the panel shows it WITHOUT a live agent (demo.html bakes in the agent's
3678
+ // initial scan this way). Otherwise the agent group-scan stays gated behind
3679
+ // a live agent as usual.
3680
+ const seeded=typeof window!=="undefined"&&Array.isArray(window.__TX_SEED_GROUPS)&&window.__TX_SEED_GROUPS.length;
3681
+ if((!live&&!seeded)||didGroupScanRef.current)return;
3357
3682
  didGroupScanRef.current=true;
3358
3683
  runGroupScan();
3359
3684
  },[live,runGroupScan]);
@@ -3522,6 +3847,40 @@
3522
3847
  }
3523
3848
 
3524
3849
  // ── demo boxes ──
3850
+ // Hardcoded grouping — the transitions.dev agent's initial scan of this demo
3851
+ // (run once via the live relay), baked in so the panel groups the morph
3852
+ // instantly with NO relay/agent round-trip. Member labels are cleaned up from
3853
+ // the agent's output ("Morph container"→"Container", "Morph menu"→"Menu panel").
3854
+ // Edit here if the morph's transitions change, or delete window.__TX_SEED_GROUPS
3855
+ // to fall back to a live agent scan. Shape mirrors relay.mjs buildScanPrompt's
3856
+ // groups[] (the same structure TransitionRegistry.setGroups consumes). This is
3857
+ // below the inject CUT_MARKER, so it never ships in the injected/website build.
3858
+ const _mEO="cubic-bezier(0.22, 1, 0.36, 1)"; // smooth ease-out (open)
3859
+ const _mEM="cubic-bezier(0.34, 1.25, 0.64, 1)"; // morph ease (close settle)
3860
+ const _mpt=(property,durationMs,easing)=>({property,durationMs,delayMs:0,easing});
3861
+ const MORPH_SEED_GROUPS=[
3862
+ {id:"morph",label:"Plus \u2192 menu morph",component:null,phases:[
3863
+ {id:"morph:open",phase:"open",label:"Open",stateTarget:".t-morph",fromState:null,toState:'[data-open="true"]',members:[
3864
+ {id:"morph-container",label:"Container",selector:".t-morph",propertyTimings:[
3865
+ _mpt("width",250,_mEO),_mpt("height",250,_mEO),_mpt("border-radius",250,_mEO)]},
3866
+ {id:"morph-plus",label:"Plus button",selector:".t-morph .t-morph-plus",propertyTimings:[
3867
+ _mpt("opacity",200,_mEO),_mpt("transform",350,_mEO),_mpt("filter",200,_mEO)]},
3868
+ {id:"morph-menu",label:"Menu panel",selector:".t-morph .t-morph-menu",propertyTimings:[
3869
+ _mpt("opacity",200,_mEO),_mpt("transform",350,_mEO),_mpt("filter",200,_mEO)]}]},
3870
+ {id:"morph:close",phase:"close",label:"Close",stateTarget:".t-morph",fromState:'[data-open="true"]',toState:null,members:[
3871
+ {id:"morph-container",label:"Container",selector:".t-morph",propertyTimings:[
3872
+ _mpt("width",400,_mEM),_mpt("height",350,_mEM),_mpt("border-radius",350,_mEM)]},
3873
+ {id:"morph-plus",label:"Plus button",selector:".t-morph .t-morph-plus",propertyTimings:[
3874
+ _mpt("opacity",200,_mEO),_mpt("transform",350,_mEO),_mpt("filter",200,_mEO)]},
3875
+ {id:"morph-menu",label:"Menu panel",selector:".t-morph .t-morph-menu",propertyTimings:[
3876
+ _mpt("opacity",200,_mEO),_mpt("transform",350,_mEO),_mpt("filter",200,_mEO)]}]}]},
3877
+ {id:"morph-item",label:"Menu item hover",component:null,phases:[
3878
+ {id:"morph-item:hover",phase:"hover",label:"Hover",stateTarget:".t-morph-menu button.morph-item",fromState:null,toState:":hover",members:[
3879
+ {id:"morph-item-bg",label:"Menu item",selector:".t-morph-menu button.morph-item",propertyTimings:[
3880
+ _mpt("background",120,"ease")]}]}]},
3881
+ ];
3882
+ if(typeof window!=="undefined")window.__TX_SEED_GROUPS=MORPH_SEED_GROUPS;
3883
+
3525
3884
  // Plus → menu morph — transitions.dev (20-plus-menu-morph.md). The trigger
3526
3885
  // button morphs into the panel it opens; CSS owns the morph, JS only flips
3527
3886
  // data-open and mirrors it to aria-expanded, closing on outside-click / Escape.
@@ -3660,9 +4019,20 @@
3660
4019
  // to show them for testing. (This whole block is below the inject CUT_MARKER,
3661
4020
  // so it never ships in the injected build.)
3662
4021
  const showControls=(()=>{try{return new URLSearchParams(location.search).has("controls");}catch(e){return false;}})();
4022
+ // TESTING ONLY (not part of the tool): top-right toggle to hide the demo
4023
+ // page content so the panel can be inspected against a blank page.
4024
+ const[demoHidden,setDemoHidden]=useState(false);
3663
4025
  return h(TimelineCtx.Provider,{value:ctx},
3664
4026
  showControls&&h(PanelControls),
3665
- h("div",{ref:rootRef,className:"demo-root"},h("div",{className:"demo-header"},h("h1",null,"Timeline Inspector \u2014 Demo"),h("p",null,"A single transitions.dev element: the plus \u2192 menu morph. Its transitions are auto-detected \u2014 select one below, drag the bars to edit timing, then interact with the component itself to preview the change.")),
4027
+ h("button",{type:"button",onClick:()=>setDemoHidden(v=>!v),
4028
+ title:"Testing only: hide/show demo page content",
4029
+ style:{position:"fixed",top:"12px",right:"12px",zIndex:2147483000,
4030
+ height:"28px",padding:"0 10px",borderRadius:"8px",cursor:"pointer",
4031
+ border:"1px solid rgba(0,0,0,0.12)",background:"#fff",color:"#444",
4032
+ font:"500 12px/1 ui-sans-serif,system-ui,sans-serif",
4033
+ boxShadow:"0 1px 3px rgba(0,0,0,0.12)"}},
4034
+ demoHidden?"Show demo":"Hide demo"),
4035
+ h("div",{ref:rootRef,className:"demo-root",style:{display:demoHidden?"none":undefined}},h("div",{className:"demo-header"},h("h1",null,"Timeline Inspector \u2014 Demo"),h("p",null,"A single transitions.dev element: the plus \u2192 menu morph. Its transitions are auto-detected \u2014 select one below, drag the bars to edit timing, then interact with the component itself to preview the change.")),
3666
4036
  h("div",{className:"demo-grid"},h(BoxMorph))),
3667
4037
  h(TimelinePanel));
3668
4038
  }