transitions-refine 0.2.0 → 0.3.1

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
@@ -31,9 +31,14 @@
31
31
  --c-line: #ededf0; --c-hairline: rgba(0,0,0,0.04); --c-hairline2: rgba(0,0,0,0.06);
32
32
  /* shadow recipes (exact Figma) */
33
33
  --ring: inset 0 0 0 1px rgba(0,0,0,0.06), inset 0 -1px 0 0 rgba(0,0,0,0.06), inset 0 0 0 1px rgba(196,196,196,0.1);
34
- --ring-blue: inset 0 0 0 1px rgba(0,102,215,0.1), inset 0 -1px 0 0 rgba(0,0,0,0.06), inset 0 0 0 1px rgba(196,196,196,0.1);
34
+ /* blue button inset ring (exact Figma node 580:753) */
35
+ --ring-blue: inset 0 0 0 1px rgba(0,101,208,0.1), inset 0 -1px 0 0 rgba(3,66,142,0.15);
35
36
  --ring-fill: inset 0 0 0 1px rgba(0,0,0,0.02), inset 0 -1px 0 0 rgba(0,0,0,0.06), inset 0 0 0 1px rgba(196,196,196,0.1);
36
37
  --drop: 0 1px 3px 0 rgba(0,0,0,0.04);
38
+ /* topbar button drop shadow — exact Figma node 580:753 (navy-tinted) */
39
+ --drop-btn: 0 1px 3px 0 rgba(4,41,117,0.08);
40
+ /* secondary/raised white button (Reset, Settings, Copy, Accept) — exact spec */
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);
37
42
  --drop-line: 0 1px 3px 0 rgba(0,0,0,0.08);
38
43
  --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);
39
44
  --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);
@@ -53,6 +58,16 @@
53
58
  /* transitions.dev — easing motion tokens (verbatim from skill _root.css) */
54
59
  --panel-ease: cubic-bezier(0.22, 1, 0.36, 1); /* shared standard ease-out */
55
60
  --morph-ease: cubic-bezier(0.34, 1.25, 0.64, 1);
61
+ --morph-open-dur: 350ms;
62
+ --morph-close-dur: 250ms;
63
+ --morph-close-ease: cubic-bezier(0.22, 1, 0.36, 1);
64
+ --morph-r-closed: 40px;
65
+ --morph-r-open: 20px;
66
+ --morph-fade-dur: 200ms;
67
+ --morph-slide: 40px;
68
+ --morph-rotate: 45deg;
69
+ --morph-scale: 0.97;
70
+ --morph-blur: 2px;
56
71
  --check-ease-bob: cubic-bezier(0.34, 1.35, 0.64, 1);
57
72
  --badge-pop-ease: cubic-bezier(0.34, 1.36, 0.64, 1);
58
73
  --digit-ease: cubic-bezier(0.34, 1.45, 0.64, 1);
@@ -89,23 +104,93 @@
89
104
  .card h2 { font-size: 15px; font-weight: 600; margin-bottom: 4px; color: #171717; }
90
105
  .card p { font-size: 12px; color: var(--c-ruler); margin-bottom: 16px; line-height: 1.5; }
91
106
 
92
- .box-resize { width: 80px; height: 80px; background: #ef6c6c; border-radius: 8px; cursor: pointer;
93
- transition: width 0.4s ease-out, height 0.4s ease-out, background 0.4s ease-out, border-radius 0.3s ease; }
94
- .box-resize.expanded { width: 180px; height: 140px; background: #6c8eef; border-radius: 24px; }
107
+ /* Anchor sized to the OPEN footprint so the box grows up-and-left out of the corner. */
108
+ .morph-stage { position: relative; width: 183px; height: 172px; margin: 8px auto 0; }
95
109
 
96
- .box-opacity { width: 100px; height: 80px; background: #38c172; border-radius: 10px; cursor: pointer;
97
- transition: opacity 0.6s ease-in-out, transform 0.6s ease-in-out; }
98
- .box-opacity.faded { opacity: 0.15; transform: scale(0.85); }
99
-
100
- .box-slide { width: 60px; height: 60px; background: #ef9c6c; border-radius: 50%; cursor: pointer;
101
- transition: transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1), background 0.5s ease; }
102
- .box-slide.moved { transform: translateX(160px); background: #c96cef; }
110
+ /* Plus menu morph verbatim from transitions.dev skill (20-plus-menu-morph.md).
111
+ Closed: a small circular button. Open: a rounded panel.
112
+ Width/height/border-radius animate; the open state uses a
113
+ bouncier ease than the close. */
114
+ .t-morph {
115
+ position: absolute;
116
+ inset: auto 0 0 auto;
117
+ width: 40px;
118
+ height: 40px;
119
+ border-radius: var(--morph-r-closed);
120
+ overflow: hidden;
121
+ background: #fff;
122
+ box-shadow: var(--card-shadow);
123
+ transition:
124
+ width var(--morph-close-dur) var(--morph-close-ease),
125
+ height var(--morph-close-dur) var(--morph-close-ease),
126
+ border-radius var(--morph-close-dur) var(--morph-close-ease);
127
+ }
128
+ .t-morph[data-open="true"] {
129
+ width: 183px;
130
+ height: 172px;
131
+ border-radius: var(--morph-r-open);
132
+ transition:
133
+ width 400ms var(--morph-ease),
134
+ height var(--morph-open-dur) var(--morph-ease),
135
+ border-radius var(--morph-open-dur) var(--morph-ease);
136
+ }
137
+ /* Plus fades + slides out and the icon rotates into an ×. */
138
+ .t-morph-plus {
139
+ position: absolute;
140
+ inset: auto 0 0 auto;
141
+ width: 40px; height: 40px;
142
+ display: grid; place-items: center;
143
+ border: 0; background: transparent; cursor: pointer;
144
+ color: var(--c-text);
145
+ transition:
146
+ opacity var(--morph-fade-dur) var(--morph-close-ease),
147
+ transform var(--morph-open-dur) var(--morph-close-ease),
148
+ filter var(--morph-fade-dur) var(--morph-close-ease);
149
+ }
150
+ .t-morph-plus svg {
151
+ transition: transform var(--morph-open-dur) var(--morph-close-ease);
152
+ }
153
+ .t-morph[data-open="true"] .t-morph-plus {
154
+ opacity: 0;
155
+ transform: translateX(calc(-1 * var(--morph-slide)));
156
+ filter: blur(var(--morph-blur));
157
+ pointer-events: none;
158
+ }
159
+ .t-morph[data-open="true"] .t-morph-plus svg {
160
+ transform: scale(var(--morph-scale)) rotate(var(--morph-rotate));
161
+ }
162
+ /* Menu starts slid in + scaled + blurred; reveals on open. */
163
+ .t-morph-menu {
164
+ position: absolute;
165
+ inset: 0;
166
+ opacity: 0;
167
+ transform: translateX(var(--morph-slide)) scale(var(--morph-scale));
168
+ filter: blur(var(--morph-blur));
169
+ pointer-events: none;
170
+ transition:
171
+ opacity var(--morph-fade-dur) var(--morph-close-ease),
172
+ transform var(--morph-open-dur) var(--morph-close-ease),
173
+ filter var(--morph-fade-dur) var(--morph-close-ease);
174
+ }
175
+ .t-morph[data-open="true"] .t-morph-menu {
176
+ opacity: 1;
177
+ transform: translateX(0) scale(1);
178
+ filter: blur(0);
179
+ pointer-events: auto;
180
+ }
181
+ /* menu contents */
182
+ .t-morph-menu { padding: 12px; display: flex; flex-direction: column; }
183
+ .t-morph-menu .morph-title { font-size: 12px; font-weight: 600; color: var(--c-text-mut);
184
+ padding: 4px 8px 8px; }
185
+ .t-morph-menu button.morph-item { display: flex; align-items: center; gap: 10px;
186
+ width: 100%; padding: 9px 8px; border: 0; background: transparent; border-radius: 8px;
187
+ font: inherit; font-size: 13px; color: var(--c-text); text-align: left; cursor: pointer;
188
+ transition: background 120ms ease; }
189
+ .t-morph-menu button.morph-item:hover { background: var(--c-ghost-h); }
103
190
 
104
- .box-color { width: 100%; height: 60px; border-radius: 10px; cursor: pointer;
105
- background: linear-gradient(135deg, #e9e9ef, #d6d6e0);
106
- transition: background 0.8s ease, box-shadow 0.8s ease; }
107
- .box-color.lit { background: linear-gradient(135deg, #6c8eef, #ef6c9c);
108
- box-shadow: 0 0 30px rgba(108, 142, 239, 0.4); }
191
+ @media (prefers-reduced-motion: reduce) {
192
+ .t-morph, .t-morph-plus, .t-morph-menu { transition: none !important; }
193
+ }
109
194
  /* @inject-skip-end */
110
195
 
111
196
  /* ───────────────────── timeline panel ───────────────────── */
@@ -154,7 +239,7 @@
154
239
  .tl-pill-count.is-single { width: 20px; padding: 0; }
155
240
 
156
241
  /* ── header row ── */
157
- .tl-header { flex: none; display: flex; align-items: center; gap: 8px; padding: 10px 14px;
242
+ .tl-header { flex: none; position: relative; z-index: 40; display: flex; align-items: center; gap: 8px; padding: 7.5px 14px;
158
243
  border-bottom: 1px solid var(--c-line); }
159
244
  .tl-header-label { font-size: 13px; font-weight: 500; line-height: 18px; color: var(--c-text-mut2); white-space: nowrap; }
160
245
  .tl-header-count { margin-left: auto; font-size: 13px; line-height: 18px; color: var(--c-count); white-space: nowrap; }
@@ -166,10 +251,9 @@
166
251
  .tl-header .tl-sec-btn,
167
252
  .tl-header .tl-accept-btn,
168
253
  .tl-header .tl-refine-btn { border-radius: 60px; }
169
- .tl-accept-btn { display: inline-flex; align-items: center; gap: 8px; height: 36px; padding: 0 14px;
170
- border: none; cursor: pointer; font-weight: 500; font-size: 13px; line-height: 14px; color: #17181C;
171
- background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.04),
172
- inset 0 0 0 1px rgba(0,0,0,0.06), inset 0 -1px 0 0 rgba(0,0,0,0.06), inset 0 0 0 1px rgba(196,196,196,0.10);
254
+ .tl-accept-btn { display: inline-flex; align-items: center; gap: 6px; height: 36px; padding: 0 14px;
255
+ border: none; cursor: pointer; font: inherit; font-weight: 500; font-size: 13px; line-height: 14px; color: #17181C;
256
+ background: #fff; box-shadow: var(--shadow-btn);
173
257
  transition: background 140ms ease, scale 140ms ease, opacity 140ms ease; }
174
258
  .tl-accept-btn > svg { width: 16px; height: 16px; color: #17181C; flex: none; }
175
259
  .tl-accept-btn:hover:not(:disabled) { background: #f9f9f9; }
@@ -191,30 +275,38 @@
191
275
  .tl-ghost-btn .tl-dim { color: var(--c-text-faint); font-weight: 500; }
192
276
  .tl-ghost-chev { display: flex; color: var(--c-ruler); }
193
277
 
194
- /* icon button: flat secondary fill, no drop shadow (matches design-system secondary) */
278
+ /* icon button: secondary fill, raised with the shared Figma button shadow */
195
279
  .tl-icon-btn { position: relative; display: inline-flex; align-items: center; justify-content: center; flex: none;
196
280
  width: 36px; height: 36px; border: none; border-radius: 8px; background: var(--c-sec);
281
+ box-shadow: var(--shadow-btn);
197
282
  color: var(--c-text-mut2); cursor: pointer; transition: background 0.12s ease, color 0.12s ease, scale 0.12s ease; }
198
283
  .tl-icon-btn:hover:not(:disabled), .tl-icon-btn.is-active { background: var(--c-sec-h); }
199
284
  .tl-icon-btn:active:not(:disabled) { background: var(--c-sec-a); scale: 0.96; }
200
285
  .tl-icon-btn:disabled { opacity: 0.5; cursor: default; }
201
- .tl-icon-btn.ghost { background: transparent; }
286
+ .tl-icon-btn.ghost { background: transparent; box-shadow: none; }
202
287
  .tl-icon-btn.ghost:hover:not(:disabled) { background: var(--c-sec); }
203
-
204
- /* secondary button (Reset): flat grey fill, no shadow */
288
+ /* header Settings + Copy use the same white styling as the Reset button */
289
+ .tl-header .tl-icon-btn:not(.ghost) { background: #fff; }
290
+ .tl-header .tl-icon-btn:not(.ghost):hover:not(:disabled),
291
+ .tl-header .tl-icon-btn:not(.ghost).is-active { background: #f9f9f9; }
292
+ .tl-header .tl-icon-btn:not(.ghost):active:not(:disabled) { background: #f9f9f9; scale: 0.96; }
293
+
294
+ /* secondary button (Reset): Figma default is white, raised with the shared
295
+ button shadow; hover/pressed darken to #f9f9f9 (matches Accept states) */
205
296
  .tl-sec-btn { display: inline-flex; align-items: center; height: 36px; padding: 0 16px; border: none;
206
- border-radius: 8px; background: var(--c-sec); color: var(--c-text); font: inherit; font-size: 13px;
297
+ border-radius: 8px; background: #fff; box-shadow: var(--shadow-btn);
298
+ color: var(--c-text); font: inherit; font-size: 13px;
207
299
  font-weight: 500; cursor: pointer; transition: background 0.12s ease, scale 0.12s ease; white-space: nowrap; }
208
- .tl-sec-btn:hover:not(:disabled) { background: var(--c-sec-h); }
209
- .tl-sec-btn:active:not(:disabled) { background: var(--c-sec-a); scale: 0.96; }
300
+ .tl-sec-btn:hover:not(:disabled) { background: #f9f9f9; }
301
+ .tl-sec-btn:active:not(:disabled) { background: #f9f9f9; scale: 0.96; }
210
302
  .tl-sec-btn:disabled { opacity: 0.5; cursor: default; }
211
303
 
212
304
  /* blue button (Refine) — Figma Frame 427319678: layered fill + inset ring + drop shadow */
213
305
  .tl-refine-btn { position: relative; isolation: isolate;
214
306
  display: inline-flex; align-items: center; gap: 8px; height: 36px; padding: 0 12px;
215
307
  border: none; border-radius: 8px; background: transparent; color: var(--c-blue);
216
- box-shadow: var(--drop);
217
- box-shadow: 0 1px 3px 0 color(display-p3 0 0 0 / 0.04);
308
+ box-shadow: var(--drop-btn);
309
+ box-shadow: 0 1px 3px 0 color(display-p3 0.0157 0.1608 0.4588 / 0.08);
218
310
  font: inherit; font-size: 13px; font-weight: 500; line-height: 14px;
219
311
  cursor: pointer; white-space: nowrap; transition: color 0.12s ease; }
220
312
  .tl-refine-btn::before { content: ""; position: absolute; inset: 0; border-radius: inherit;
@@ -223,7 +315,7 @@
223
315
  transition: background 0.12s ease; z-index: 0; }
224
316
  .tl-refine-btn::after { content: ""; position: absolute; inset: 0; border-radius: inherit;
225
317
  box-shadow: var(--ring-blue);
226
- box-shadow: inset 0 0 0 1px color(display-p3 0 0.3942 0.8155 / 0.10), inset 0 -1px 0 0 color(display-p3 0 0 0 / 0.06), inset 0 0 0 1px color(display-p3 0.7674 0.7674 0.7674 / 0.10);
318
+ box-shadow: inset 0 0 0 1px color(display-p3 0 0.3961 0.8157 / 0.10), inset 0 -1px 0 0 color(display-p3 0.0118 0.2588 0.5569 / 0.15);
227
319
  pointer-events: none; z-index: 2; }
228
320
  .tl-refine-btn > * { position: relative; z-index: 1; }
229
321
  /* Figma: wand icon stroke is #0073E5, distinct from the #0071e2 label */
@@ -249,61 +341,17 @@
249
341
  @media (prefers-reduced-motion: reduce) {
250
342
  .tl-refine-btn:hover:not(:disabled) .tl-refine-sparks i { animation: none; } }
251
343
 
252
- /* ── transport row ── */
253
- .tl-transport { display: flex; align-items: center; gap: 8px; padding: 10px 16px; border-bottom: 1px solid var(--c-line); }
254
- .tl-transport-left { flex: 1; display: flex; align-items: center; gap: 10px; min-width: 0; }
255
- .tl-transport-center { flex: 0 0 auto; display: flex; justify-content: center; }
256
- .tl-transport-right { flex: 1; display: flex; align-items: center; justify-content: flex-end; gap: 12px; }
257
- .tl-transport .tl-ghost-btn,
258
- .tl-transport .tl-icon-btn,
259
- .tl-transport .tl-play-btn { border-radius: 60px; }
260
- .tl-timecode { font-variant-numeric: tabular-nums; font-size: 13px; font-weight: 500; color: var(--c-text); min-width: 70px; }
261
- .tl-speed { font-variant-numeric: tabular-nums; }
262
- .tl-zoom { position: relative; width: 72px; height: 15px; flex: none; }
263
- /* slider — Logram design system (node 13064:2539): 15px frame, track at y=5, 15px knob at y=0 */
264
- .tl-zoom-track { position: absolute; left: 0; right: 0; top: 5px; height: 5px; pointer-events: none;
265
- background: rgba(115,115,115,0.1); border: 1px solid rgba(0,0,0,0.08); border-radius: 7px; box-sizing: border-box; }
266
- .tl-zoom input[type="range"] { position: relative; z-index: 1; width: 100%; height: 15px; margin: 0;
267
- -webkit-appearance: none; appearance: none; background: transparent; outline: none; cursor: pointer; }
268
- .tl-zoom input[type="range"]::-webkit-slider-runnable-track { height: 15px; background: transparent; border: none; }
269
- .tl-zoom input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none;
270
- width: 15px; height: 15px; margin-top: 0; border-radius: 50%; background: #fff; cursor: pointer;
271
- box-shadow: 0 1px 3px rgba(0,0,0,0.08), inset 0 0 0 1px rgba(126,126,126,0.1), inset 0 -1px 0 rgba(0,0,0,0.1); }
272
- .tl-zoom input[type="range"]::-moz-range-track { height: 15px; background: transparent; border: none; }
273
- .tl-zoom input[type="range"]::-moz-range-thumb { width: 15px; height: 15px; border: none; border-radius: 50%;
274
- background: #fff; cursor: pointer;
275
- box-shadow: 0 1px 3px rgba(0,0,0,0.08), inset 0 0 0 1px rgba(126,126,126,0.1), inset 0 -1px 0 rgba(0,0,0,0.1); }
276
- /* play/pause circle — Figma: drop shadow + inset ring overlay */
277
- .tl-play-btn { position: relative; isolation: isolate; overflow: hidden;
278
- display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px;
279
- border: none; border-radius: 50%; background: var(--c-circle); color: #17181c;
280
- box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.04); cursor: pointer;
281
- transition: background 0.12s ease, scale 0.12s ease; }
282
- .tl-play-btn::after { content: ""; position: absolute; inset: 0; border-radius: inherit;
283
- box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.06), inset 0 -1px 0 0 rgba(0, 0, 0, 0.06),
284
- inset 0 0 0 1px rgba(196, 196, 196, 0.10); pointer-events: none; }
285
- .tl-play-btn > * { position: relative; z-index: 1; }
286
- .tl-play-btn:hover:not(:disabled) { background: var(--c-circle-h); }
287
- .tl-play-btn:active:not(:disabled) { background: var(--c-circle-a); scale: 0.96; }
288
- .tl-play-btn:disabled { opacity: 0.5; cursor: default; }
289
- /* center both icon layers in the swap cell so the differently-sized play (10×12)
290
- and pause (16²) svgs share one center; nudge ONLY the play triangle right for
291
- optical centering (skill: play-button triangles). left/position (not transform)
292
- so it doesn't clash with the icon-swap scale animation; pause stays dead-center. */
293
- .tl-play-btn .t-icon { place-self: center; }
294
- .tl-play-btn .t-icon[data-icon="a"] { position: relative; left: 1px; }
295
-
296
344
  /* ── body / floating cards row ── */
297
345
  .tl-body { display: flex; flex: 1; min-height: 0; gap: 0; }
298
- .tl-main { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden; }
346
+ .tl-main { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
299
347
  .tl-inspector { flex: 0 0 280px; padding: 14px 16px 18px; display: flex; flex-direction: column;
300
348
  border-left: 1px solid var(--c-line); min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
301
349
  .tl-insp-title { font-size: 13px; font-weight: 500; line-height: 18px; color: #171717; margin-bottom: 12px; text-transform: capitalize; }
302
350
  .tl-insp-label { font-size: 12px; line-height: 18px; color: #737373; margin: 10px 0 6px; }
303
351
 
304
352
  /* ── tracks / ruler ── */
305
- .tl-tracks { position: relative; flex: 1; }
306
- .tl-ruler-row { display: flex; height: 30px; border-bottom: 1px solid var(--c-line); }
353
+ .tl-tracks { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; }
354
+ .tl-ruler-row { flex: none; display: flex; height: 30px; border-bottom: 1px solid var(--c-line); }
307
355
  .tl-ruler-spacer { flex: 0 0 150px; border-right: 1px solid var(--c-line); }
308
356
  .tl-ruler { position: relative; flex: 1; margin-left: 16px; }
309
357
  .tl-ruler .maj { position: absolute; top: 9px; font-size: 12px; line-height: 14px; color: var(--c-ruler); transform: translateX(-50%); white-space: nowrap; }
@@ -316,23 +364,26 @@
316
364
  .tl-prop-row.selected { background: #fafafa; }
317
365
  .tl-prop-head { flex: 0 0 150px; display: flex; align-items: center; gap: 8px; padding-left: 12px;
318
366
  padding-right: 10px; overflow: hidden; border-right: 1px solid var(--c-line); }
319
- .tl-prop-grip { display: flex; color: #cccdd4; flex: none; cursor: grab; }
320
- .tl-prop-grip:active { cursor: grabbing; }
321
- .tl-rows { position: relative; }
322
- .tl-prop-row.reordering { background: #fff; box-shadow: 0 1px 2px rgba(0,0,0,0.06), 0 6px 16px rgba(0,0,0,0.10);
323
- position: relative; z-index: 5; cursor: grabbing; }
324
- .tl-prop-row.reordering .tl-prop-grip { color: var(--c-ruler); cursor: grabbing; }
325
- .tl-prop-label { font-size: 13px; font-weight: 500; line-height: 18px; color: var(--c-text-mut2); white-space: nowrap; overflow: hidden;
326
- text-overflow: ellipsis; text-transform: capitalize; display: flex; align-items: center; gap: 6px; min-width: 0; }
367
+ .tl-rows { position: relative; flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
368
+ /* no overflow:hidden here — the member chip's outset ring would get clipped
369
+ top/bottom; the property name's ellipsis is handled by .tl-prop-prop. */
370
+ .tl-prop-label { font-size: 13px; font-weight: 500; line-height: 18px; color: var(--c-text-mut2); white-space: nowrap;
371
+ text-transform: capitalize; display: flex; align-items: center; gap: 6px; min-width: 0; }
327
372
  .tl-prop-row.selected .tl-prop-label { color: #171717; }
328
- .tl-prop-member { flex: none; font-size: 11px; font-weight: 600; line-height: 14px; padding: 1px 6px; border-radius: 60px;
329
- background: var(--c-sec, rgba(0,0,0,0.05)); color: var(--c-text-faint); text-transform: none; max-width: 96px;
330
- overflow: hidden; text-overflow: ellipsis; }
331
- .tl-prop-row.selected .tl-prop-member { color: var(--c-text-mut2); }
373
+ /* member chip Figma node 580:3567: 63×20 white pill, 1px hairline ring, Inter Regular 11px #696969 (text centered, 1px above/below) */
374
+ .tl-prop-member { flex: none; box-sizing: border-box; display: inline-block; height: 20px; vertical-align: middle;
375
+ font-size: 11px; font-weight: 400; line-height: 20px; padding: 0 6px; border-radius: 50px;
376
+ background: #fff; box-shadow: 0 0 0 1px rgba(0,0,0,0.08); color: #696969; text-transform: none; max-width: 96px;
377
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
378
+ /* selection must NOT restyle the chip — single Figma style in every row state */
379
+ .tl-prop-row.selected .tl-prop-member { background: #fff; color: #696969; box-shadow: 0 0 0 1px rgba(0,0,0,0.08); }
332
380
  .tl-prop-prop { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
333
- .tl-insp-member { display: inline-block; font-size: 11px; font-weight: 600; line-height: 14px; padding: 1px 6px; border-radius: 60px;
334
- background: var(--c-sec, rgba(0,0,0,0.05)); color: var(--c-text-faint); text-transform: none; margin-right: 6px; vertical-align: middle; }
335
- /* 16px left inset of the plot area (kept in sync with ruler/playhead/scrub) */
381
+ /* inspector member chip mirrors right-column chip (Figma 580:3567) */
382
+ .tl-insp-member { box-sizing: border-box; display: inline-block; height: 20px; vertical-align: middle; margin-right: 6px;
383
+ font-size: 11px; font-weight: 400; line-height: 20px; padding: 0 6px; border-radius: 50px;
384
+ background: #fff; box-shadow: 0 0 0 1px rgba(0,0,0,0.08); color: #696969; text-transform: none;
385
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
386
+ /* 16px left inset of the plot area (kept in sync with the ruler) */
336
387
  .tl-prop-track { position: relative; flex: 1; user-select: none; margin-left: 16px; }
337
388
  /* timeline line — capsule (fully rounded), Figma #eeeeef default / #e9e9e9 hover */
338
389
  .tl-bar { position: absolute; top: 50%; height: 24px; transform: translateY(-50%); background: var(--c-track);
@@ -352,13 +403,16 @@
352
403
  .tl-bar-grip { position: absolute; top: 0; bottom: 0; width: 10px; cursor: ew-resize; z-index: 2; }
353
404
  .tl-bar-grip.left { left: -5px; } .tl-bar-grip.right { right: -5px; }
354
405
 
355
- .tl-playhead-layer { position: absolute; top: 14px; bottom: 0; left: 166px; right: 0;
356
- pointer-events: none; z-index: 4; }
357
- .tl-playhead { position: absolute; top: 0; bottom: 0; width: 2px; background: #1A7AFF; transform: translateX(-1px); }
358
- .tl-playhead-head { position: absolute; top: -6px; left: 50%; transform: translateX(-50%); width: 16.666px; height: 24px;
359
- overflow: visible; filter: drop-shadow(0 1px 3px rgba(0,0,0,.25)); }
360
- .tl-scrub-zone { position: absolute; top: 0; height: 30px; left: 166px; right: 0;
361
- z-index: 6; cursor: ew-resize; }
406
+ /* draggable divider between the label column and the bars column. The hit
407
+ area is wide for easy grabbing; the visible line is thin and only accents
408
+ on hover / while dragging (matches the panel's 0.12s hover house style). */
409
+ .tl-col-resizer { position: absolute; top: 0; bottom: 0; width: 11px; transform: translateX(-50%);
410
+ cursor: col-resize; z-index: 6; display: flex; justify-content: center; }
411
+ .tl-col-resizer-line { width: 1px; height: 100%; background: transparent;
412
+ transition: background 0.12s ease, width 0.12s ease; }
413
+ .tl-col-resizer:hover .tl-col-resizer-line,
414
+ .tl-col-resizer.dragging .tl-col-resizer-line { width: 2px; background: #1A7AFF; }
415
+ @media (prefers-reduced-motion: reduce) { .tl-col-resizer-line { transition: none; } }
362
416
 
363
417
  /* ── value field (slider + input) — exact Figma "Value slider and input" ── */
364
418
  .tl-field-wrap { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
@@ -369,7 +423,8 @@
369
423
  /* inner slider fill — Figma button/tiny: soft drop shadow + inset ring give the
370
424
  "raised block" look (shadow 0 1px 3px @4%, hairline border, bottom 1px). */
371
425
  .tl-field-fill { position: absolute; left: 0; top: 0; bottom: 0; min-width: 36px; border-radius: 8px;
372
- background: var(--c-fill); box-shadow: var(--drop), var(--ring-fill);
426
+ background: var(--c-fill);
427
+ 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);
373
428
  pointer-events: none; transition: background 0.12s ease, opacity 0.12s ease; z-index: 1; }
374
429
  .tl-field.is-dragging .tl-field-fill { background: var(--c-fill-a); }
375
430
  .tl-field.is-dragging .tl-field-label { opacity: 0.7; }
@@ -486,31 +541,9 @@
486
541
  .tl-seg-btn:hover:not(.is-active) { background: rgba(170,170,170,0.06); color: #17181c; }
487
542
  .tl-seg-btn.is-active { background: rgba(170,170,170,0.1); color: #17181c; }
488
543
 
489
- /* position preview (animated marker à la easing.dev) */
490
- .tl-preview { margin-top: 8px; background: #fff; border-radius: 8px; box-shadow: inset 0 0 0 1px var(--c-hairline);
491
- padding: 8px 12px 14px; }
492
- .tl-preview-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
493
- .tl-preview-title { font-size: 11px; font-weight: 600; color: var(--c-text-mut); }
494
- .tl-preview-btn { display: inline-flex; align-items: center; gap: 5px; height: 22px; padding: 0 8px 0 7px;
495
- border: none; border-radius: 6px; background: var(--c-field-bg); color: var(--c-text-mut2);
496
- font: inherit; font-size: 11px; font-weight: 500; cursor: pointer;
497
- transition: background 0.12s ease, scale 0.12s ease; }
498
- .tl-preview-btn:hover { background: var(--c-sec-h); }
499
- .tl-preview-btn:active { scale: 0.96; }
500
- .tl-preview-track { position: relative; height: 16px; }
501
- .tl-preview-rail { position: absolute; left: 0; right: 0; top: 50%; height: 2px; transform: translateY(-50%);
502
- background: var(--c-track); border-radius: 2px; }
503
- .tl-preview-end { position: absolute; top: 50%; width: 2px; height: 8px; transform: translateY(-50%);
504
- background: #d0d0d6; border-radius: 2px; }
505
- .tl-preview-end.left { left: 0; }
506
- .tl-preview-end.right { right: 0; }
507
- .tl-preview-dot { position: absolute; left: 0; top: 50%; width: 14px; height: 14px; margin-top: -7px;
508
- border-radius: 50%; background: var(--c-blue); box-shadow: 0 1px 3px rgba(0,0,0,0.25);
509
- will-change: transform; }
510
-
511
- /* menu section header (non-clickable) */
512
- .tl-menu-group { padding: 9px 10px 4px; font-size: 10.5px; font-weight: 600; letter-spacing: 0.04em;
513
- text-transform: uppercase; color: var(--c-text-faint); pointer-events: none; }
544
+ /* menu section header (non-clickable) Figma node 580:1696 dropdown */
545
+ .tl-menu-group { padding: 12px 8px 8px; font-size: 11px; font-weight: 400; line-height: 14px;
546
+ color: #8b8b8b; pointer-events: none; }
514
547
  .tl-menu-group:first-child { padding-top: 4px; }
515
548
 
516
549
  /* spring params box (reuses bounce row layout) */
@@ -538,10 +571,19 @@
538
571
  display: flex; align-items: center; justify-content: center;
539
572
  background: #fff; border-radius: 12px; box-shadow: var(--card-shadow); }
540
573
 
541
- /* ── shared dropdown surface ── */
574
+ /* ── shared dropdown surface (Figma node 580:1696) ──
575
+ container: rounded-12 p-8 + menu shadow; rows: h-40 rounded-8 px-12,
576
+ Inter Medium 13/16 #1b1b1b label with #979797 suffix; section headers:
577
+ Inter Regular 11/14 #8b8b8b, pt-12 pb-8 pl-12, no divider. */
542
578
  .tl-menu { background: #fff; border-radius: 12px; padding: 6px; box-shadow: var(--menu-shadow); }
543
- .tl-menu-item { display: flex; align-items: center; gap: 10px; min-height: 32px; padding: 6px 10px;
544
- border-radius: 7px; font-size: 13px; color: var(--c-text-strong); cursor: pointer;
579
+ /* full-bleed divider: negative side margins cancel the .tl-menu 6px padding so
580
+ the line spans the whole dropdown width; no top gap. */
581
+ .tl-menu-search { margin: 0 -6px 6px; padding: 0 6px 6px; border-bottom: 1px solid var(--c-line); }
582
+ .tl-menu-search-input { width: 100%; height: 32px; box-sizing: border-box; border: none; outline: none;
583
+ background: transparent; font: inherit; font-size: 13px; line-height: 16px; color: #1b1b1b; padding: 0 8px; }
584
+ .tl-menu-search-input::placeholder { color: #999; }
585
+ .tl-menu-item { display: flex; align-items: center; gap: 8px; min-height: 36px; padding: 0 8px;
586
+ border-radius: 8px; font-size: 13px; font-weight: 500; line-height: 16px; color: #1b1b1b; cursor: pointer;
545
587
  transition: background 0.1s ease, box-shadow 0.1s ease; }
546
588
  .tl-menu-item:hover { background: #f4f4f5; }
547
589
  .tl-menu-item:active { background: #ededee; }
@@ -549,15 +591,14 @@
549
591
  .tl-menu-item.disabled { color: var(--c-disabled); pointer-events: none; }
550
592
  .tl-menu-item-label { flex: 1; min-width: 0; display: flex; align-items: center; }
551
593
  .tl-menu-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
552
- .tl-menu-dim { color: var(--c-text-faint); }
594
+ .tl-menu-dim { color: #979797; }
553
595
  .tl-menu-help { color: #c4c4cc; margin-left: 10px; flex: none; cursor: help; }
554
596
  .tl-menu-help:hover { color: var(--c-ruler); }
555
597
  .tl-menu-help svg { display: block; }
556
598
  .tl-menu-check { display: flex; color: var(--c-text-strong); flex: none; }
557
- .tl-menu-empty { padding: 10px; color: var(--c-disabled); font-size: 13px; }
558
- .tl-menu-section { padding: 8px 10px 4px; font-size: 11px; font-weight: 600; line-height: 14px;
559
- letter-spacing: 0.02em; text-transform: uppercase; color: var(--c-text-faint); }
560
- .tl-menu-section:not(:first-child) { margin-top: 2px; border-top: 1px solid var(--c-border, rgba(0,0,0,0.06)); padding-top: 8px; }
599
+ .tl-menu-empty { padding: 10px 8px; color: var(--c-disabled); font-size: 13px; }
600
+ .tl-menu-section { padding: 12px 8px 8px; font-size: 11px; font-weight: 400; line-height: 14px;
601
+ color: #8b8b8b; }
561
602
 
562
603
  /* ═════ transitions.dev — menu dropdown (verbatim) ═════ */
563
604
  .t-dropdown {
@@ -637,6 +678,7 @@
637
678
  background: var(--tt-bg);
638
679
  color: var(--tt-fg);
639
680
  white-space: nowrap;
681
+ z-index: 100;
640
682
  box-shadow:
641
683
  0 0 0 1px rgba(0, 0, 0, 0.06),
642
684
  0 2px 6px 0 rgba(0, 0, 0, 0.05),
@@ -751,7 +793,8 @@
751
793
  /* refine panel slides in from the right using transitions.dev panel reveal
752
794
  tokens (translate + opacity + cross-blur, per-phase open/close dur/ease). */
753
795
  .tl-refine-panel {
754
- position: absolute; top: 0; right: 0; bottom: 0; z-index: 6;
796
+ /* must sit above the header (z-index:40) so it overlays the whole panel */
797
+ position: absolute; top: 0; right: 0; bottom: 0; z-index: 50;
755
798
  width: var(--refine-w, 360px); max-width: 100%; overflow: hidden;
756
799
  --panel-translate-x: var(--panel-close-distance);
757
800
  --panel-scale-now: var(--panel-scale-close);
@@ -859,10 +902,7 @@
859
902
  border: none; border-radius: 60px; cursor: pointer;
860
903
  font: inherit; font-size: 13px; font-weight: 500; line-height: 14px; color: #0073e5;
861
904
  background: rgba(0,115,229,0.04); text-shadow: 0 1px 3px rgba(0,0,0,0.04);
862
- box-shadow: 0 1px 3px rgba(0,0,0,0.04),
863
- inset 0 0 0 1px rgba(0,101,208,0.10),
864
- inset 0 -1px 0 0 rgba(0,0,0,0.06),
865
- inset 0 0 0 1px rgba(196,196,196,0.10);
905
+ box-shadow: var(--drop-btn), var(--ring-blue);
866
906
  --resize-dur: 300ms; --resize-ease: cubic-bezier(0.22, 1, 0.36, 1);
867
907
  transition: width var(--resize-dur) var(--resize-ease),
868
908
  height var(--resize-dur) var(--resize-ease),
@@ -1019,6 +1059,13 @@
1019
1059
  if(cur)parts.push(cur); return parts;
1020
1060
  }
1021
1061
  function transitionSignature(props,dur,del,ease) { return [...props].sort().join(",")+"|"+dur+"|"+del+"|"+ease; }
1062
+ // Serialise a set of effective lanes back into a CSS `transition` shorthand.
1063
+ function transitionLanesToCss(lanes){ return (lanes||[]).map(t=>`${t.property} ${formatCssTime(t.durationMs)} ${t.easing} ${formatCssTime(t.delayMs)}`).join(", "); }
1064
+ // Elements we've live-applied edited timings to. Maps element → its ORIGINAL
1065
+ // source lanes ({z}), so the DomScanner keeps the entry pinned to the source
1066
+ // signature/timings even while our inline `transition` overrides the DOM —
1067
+ // otherwise the timing-based id would churn and orphan the user's edits.
1068
+ const _TX_LIVE_BASE = new WeakMap();
1022
1069
 
1023
1070
  const EASING_PRESETS = [
1024
1071
  { keyword:"linear", cubic:[0,0,1,1] }, { keyword:"ease", cubic:[0.25,0.1,0.25,1] },
@@ -1032,12 +1079,6 @@
1032
1079
  // Motion tokens are the verbatim cubic-beziers shipped by the transitions.dev
1033
1080
  // skill (_root.css); the "Common" set are the standard easings.net curves.
1034
1081
  const EASING_LIBRARY = [
1035
- { group:"Standard" },
1036
- { label:"Linear", value:"linear" },
1037
- { label:"Ease", value:"ease" },
1038
- { label:"Ease in", value:"ease-in" },
1039
- { label:"Ease out", value:"ease-out" },
1040
- { label:"Ease in-out", value:"ease-in-out" },
1041
1082
  { group:"Motion tokens (transitions.dev)" },
1042
1083
  { label:"Smooth out", value:"cubic-bezier(0.22, 1, 0.36, 1)", usage:"The transitions.dev default — dropdown, modal, panel, tabs, page slides, accordion." },
1043
1084
  { label:"Standard close", value:"cubic-bezier(0.4, 0, 0.2, 1)", usage:"Material-style accelerate→decelerate. Used for badge close / calm exits." },
@@ -1046,25 +1087,6 @@
1046
1087
  { label:"Morph open", value:"cubic-bezier(0.34, 1.25, 0.64, 1)", usage:"Plus-to-menu morph open — gentle bouncy expand." },
1047
1088
  { label:"Check bob", value:"cubic-bezier(0.34, 1.35, 0.64, 1)", usage:"Success check bob — playful settle on confirmation." },
1048
1089
  { label:"Big overshoot", value:"cubic-bezier(0.34, 3.85, 0.64, 1)", usage:"Avatar group hover return — aggressive, spring-like overshoot." },
1049
- { group:"Common (easings.net)" },
1050
- { label:"Sine in", value:"cubic-bezier(0.12, 0, 0.39, 0)" },
1051
- { label:"Sine out", value:"cubic-bezier(0.61, 1, 0.88, 1)" },
1052
- { label:"Sine in-out", value:"cubic-bezier(0.37, 0, 0.63, 1)" },
1053
- { label:"Cubic in", value:"cubic-bezier(0.32, 0, 0.67, 0)" },
1054
- { label:"Cubic out", value:"cubic-bezier(0.33, 1, 0.68, 1)" },
1055
- { label:"Cubic in-out", value:"cubic-bezier(0.65, 0, 0.35, 1)" },
1056
- { label:"Quart in", value:"cubic-bezier(0.5, 0, 0.75, 0)" },
1057
- { label:"Quart out", value:"cubic-bezier(0.25, 1, 0.5, 1)" },
1058
- { label:"Quart in-out", value:"cubic-bezier(0.76, 0, 0.24, 1)" },
1059
- { label:"Expo in", value:"cubic-bezier(0.7, 0, 0.84, 0)" },
1060
- { label:"Expo out", value:"cubic-bezier(0.16, 1, 0.3, 1)" },
1061
- { label:"Expo in-out", value:"cubic-bezier(0.87, 0, 0.13, 1)" },
1062
- { label:"Circ in", value:"cubic-bezier(0.55, 0, 1, 0.45)" },
1063
- { label:"Circ out", value:"cubic-bezier(0, 0.55, 0.45, 1)" },
1064
- { label:"Circ in-out", value:"cubic-bezier(0.85, 0, 0.15, 1)" },
1065
- { label:"Back in", value:"cubic-bezier(0.36, 0, 0.66, -0.56)" },
1066
- { label:"Back out", value:"cubic-bezier(0.34, 1.56, 0.64, 1)" },
1067
- { label:"Back in-out", value:"cubic-bezier(0.68, -0.6, 0.32, 1.6)" },
1068
1090
  { group:"Custom" },
1069
1091
  { label:"cubic-bezier(\u2026)", value:"__cubic" },
1070
1092
  { label:"custom", value:"__custom" },
@@ -1205,6 +1227,7 @@
1205
1227
  });
1206
1228
  const item={id,kind:"phase",groupId:g.id,groupLabel:g.label||g.id,component:g.component||null,
1207
1229
  phase:ph.phase||null,phaseLabel:ph.label||ph.phase||"Phase",
1230
+ stateTarget:ph.stateTarget||null,fromState:(ph.fromState??null),toState:(ph.toState??null),
1208
1231
  label:(g.label||g.id)+" · "+(ph.label||ph.phase||"Phase"),durationMs:repDur,
1209
1232
  members,effectiveTimings:effTimings,baseLanes};
1210
1233
  this.effectiveCache.set(id,item);
@@ -1226,263 +1249,12 @@
1226
1249
  }
1227
1250
  }
1228
1251
 
1229
- // ── preview controller ──
1230
- // ── transition capture ──
1231
- // Real-world transitions are usually triggered by interaction (hover, a
1232
- // click on a *different* button, focus, JS state) — not by clicking the
1233
- // element that carries the transition. So we observe transitions as they
1234
- // actually run and remember each property's from/to computed values; the
1235
- // preview then replays them with no synthetic click required.
1236
- const _txCapture = new WeakMap(); // Element -> Map<prop,{from,to}>
1237
- let _txInstalled = false;
1238
- function _txCamel(p){return p.replace(/-([a-z])/g,(_,c)=>c.toUpperCase());}
1239
- function _txOnRun(e){
1240
- const el=e.target, prop=e.propertyName;
1241
- if(!el||!prop||typeof el.getAnimations!=="function")return;
1242
- if(el.closest&&el.closest("[data-timeline-panel]"))return;
1243
- try{
1244
- const ck=_txCamel(prop);
1245
- const anims=el.getAnimations();
1246
- let anim=anims.find(a=>a.transitionProperty===prop);
1247
- if(!anim)anim=anims.find(a=>{try{const k=a.effect&&a.effect.getKeyframes();return k&&k.length&&(ck in k[0]||ck in k[k.length-1]);}catch{return false;}});
1248
- if(!anim||!anim.effect)return;
1249
- const kf=anim.effect.getKeyframes();
1250
- if(!kf||kf.length<2)return;
1251
- const from=kf[0][ck], to=kf[kf.length-1][ck];
1252
- if(from==null||to==null)return;
1253
- let rec=_txCapture.get(el); if(!rec){rec=new Map();_txCapture.set(el,rec);}
1254
- rec.set(prop,{from:String(from),to:String(to)});
1255
- }catch{}
1256
- }
1257
- function _txInstallCapture(){
1258
- if(_txInstalled||typeof document==="undefined")return;
1259
- _txInstalled=true;
1260
- // capture phase + the bubbling transitionrun event reaches document
1261
- document.addEventListener("transitionrun",_txOnRun,true);
1262
- }
1263
- // from/to recovered from a previously observed real run
1264
- function _txCaptured(el,et){
1265
- const rec=_txCapture.get(el);if(!rec)return null;
1266
- const from={},to={};let n=0;
1267
- for(const t of et){const c=rec.get(t.property);if(c){from[t.property]=c.from;to[t.property]=c.to;n++;}}
1268
- return n?{from,to}:null;
1269
- }
1270
- // Discover the transition's end-state WITHOUT interaction by reading the
1271
- // stylesheets: find rules that set a transitioning prop and represent a
1272
- // *state* of the element (a :hover/:focus pseudo, or a toggled class/attr
1273
- // like `.modal.open`). from = current computed value, to = the rule's value.
1274
- const _TX_STATE_PSEUDO=/:{1,2}(hover|focus|focus-visible|focus-within|active|checked|enabled|disabled|target|visited|link|valid|invalid|placeholder-shown|default)\b(\([^)]*\))?/g;
1275
- function _txReduceLast(el,sel){
1276
- const parts=sel.split(/(\s*[>+~]\s*|\s+)/);
1277
- let i=parts.length-1;while(i>=0&&/^\s*[>+~]?\s*$/.test(parts[i]))i--;
1278
- if(i<0)return null;
1279
- const toks=parts[i].match(/[.#]?[\w-]+|\[[^\]]*\]|::?[\w-]+(?:\([^)]*\))?|\*/g)||[];
1280
- let dropped=false,hasId=false;
1281
- const kept=toks.filter(tk=>{
1282
- if(tk[0]==="."){const ok=el.classList.contains(tk.slice(1));if(ok)hasId=true;else dropped=true;return ok;}
1283
- if(tk[0]==="["){let ok;try{ok=el.matches(tk);}catch{ok=false;}if(ok)hasId=true;else dropped=true;return ok;}
1284
- if(tk[0]==="#"){const ok=el.id&&("#"+el.id)===tk;if(ok)hasId=true;else dropped=true;return ok;}
1285
- if(tk[0]===":"){dropped=true;return false;} // pseudo = the state delta
1286
- return true; // tag / *
1287
- });
1288
- // Only a real *variant of this element*: we must have removed a state token
1289
- // AND kept a class/id/attr that ties the rule to el. Dropping every class to
1290
- // land on a bare tag/`*` would falsely match unrelated rules.
1291
- if(!dropped||!hasId)return null;
1292
- parts[i]=kept.join("");
1293
- return parts.join("");
1294
- }
1295
- function _txIsStateOf(el,sel){
1296
- try{if(el.matches(sel))return false;}catch{return false;}
1297
- const noP=sel.replace(_TX_STATE_PSEUDO,"").trim();
1298
- if(noP&&noP!==sel){try{if(el.matches(noP))return true;}catch{}}
1299
- const red=_txReduceLast(el,sel);
1300
- if(red&&red!==sel){try{if(el.matches(red))return true;}catch{}}
1301
- return false;
1302
- }
1303
- function _txScanRules(rules,el,props,to){
1304
- for(const rule of rules){
1305
- // Read style rules directly. NOTE: in browsers with CSS Nesting a plain
1306
- // CSSStyleRule also has a (usually empty) .cssRules, so test selectorText
1307
- // first — don't treat every style rule as a grouping rule.
1308
- const decl=rule.style;
1309
- if(decl&&rule.selectorText){
1310
- let sets=false;for(const p of props){if(decl.getPropertyValue(p)){sets=true;break;}}
1311
- if(sets){
1312
- for(const sub of rule.selectorText.split(",")){
1313
- const s=sub.trim();if(!s)continue;
1314
- if(_txIsStateOf(el,s)){for(const p of props){const v=decl.getPropertyValue(p);if(v&&!(p in to))to[p]=v.trim();}}
1315
- }
1316
- }
1317
- }
1318
- // Recurse into @media / @supports / @layer and any nested rules.
1319
- if(rule.cssRules&&rule.cssRules.length){
1320
- try{if(rule.media&&!window.matchMedia(rule.media.mediaText).matches)continue;}catch{}
1321
- _txScanRules(rule.cssRules,el,props,to);
1322
- }
1323
- }
1324
- }
1325
- function _txDiscover(el,et){
1326
- if(typeof document==="undefined"||!el.matches)return null;
1327
- const props=et.map(t=>t.property).filter(p=>p&&p!=="all");
1328
- if(!props.length)return null;
1329
- const to={};
1330
- for(const sheet of Array.from(document.styleSheets||[])){
1331
- let rules;try{rules=sheet.cssRules;}catch{continue;} // cross-origin sheet
1332
- if(rules)_txScanRules(rules,el,props,to);
1333
- }
1334
- const keys=Object.keys(to);if(!keys.length)return null;
1335
- const cs=getComputedStyle(el);const from={};
1336
- for(const p of keys)from[p]=cs.getPropertyValue(p)||cs[_txCamel(p)]||"";
1337
- return {from,to};
1338
- }
1339
- // Toggle a phase's state class/attribute (e.g. ".is-open" or "[data-open]")
1340
- // and read the resulting computed values, so a phase plays its real end-state
1341
- // even when the source uses a toggled class the DOM isn't currently in.
1342
- function _txToState(el,et,toState){
1343
- if(typeof document==="undefined"||!el.matches||!toState)return null;
1344
- const props=et.map(t=>t.property).filter(p=>p&&p!=="all");
1345
- if(!props.length)return null;
1346
- const s=String(toState).trim();
1347
- let applied=null; // {undo}
1348
- try{
1349
- if(s[0]==="."){const c=s.slice(1);if(c&&!el.classList.contains(c)){el.classList.add(c);applied=()=>el.classList.remove(c);}}
1350
- else if(s[0]==="["){
1351
- const m=s.match(/^\[\s*([\w-]+)\s*(?:([~|^$*]?=)\s*"?([^"\]]*)"?\s*)?\]$/);
1352
- if(m){const name=m[1],val=m[3]!=null?m[3]:"";if(el.getAttribute(name)!==val){el.setAttribute(name,val);applied=()=>el.removeAttribute(name);}}
1353
- }
1354
- }catch{}
1355
- if(!applied)return null; // already in that state, or unparseable
1356
- const cs=getComputedStyle(el);const to={};
1357
- for(const p of props)to[p]=cs.getPropertyValue(p)||cs[_txCamel(p)]||"";
1358
- try{applied();}catch{}
1359
- const cs0=getComputedStyle(el);const from={};
1360
- for(const p of props)from[p]=cs0.getPropertyValue(p)||cs0[_txCamel(p)]||"";
1361
- const keys=Object.keys(to).filter(p=>to[p]!=null&&to[p]!==""&&to[p]!==from[p]);
1362
- if(!keys.length)return null;
1363
- const f={},t={};for(const p of keys){f[p]=from[p];t[p]=to[p];}
1364
- return {from:f,to:t};
1365
- }
1366
-
1367
- class PreviewController {
1368
- state="idle"; listeners=new Set(); cleanups=[]; animations=[]; progressListeners=new Set(); _rafId=null; scanner=null; _gen=0;
1369
- rate=1; loop=false; _current=null;
1370
- setScanner(s){this.scanner=s;} getState(){return this.state;}
1371
- setRate(r){this.rate=r;for(const a of this.animations){try{a.playbackRate=r;}catch{}}}
1372
- setLoop(v){this.loop=v;}
1373
- subscribe(fn){this.listeners.add(fn);return()=>this.listeners.delete(fn);}
1374
- onProgress(fn){this.progressListeners.add(fn);return()=>this.progressListeners.delete(fn);}
1375
- play(entry){this.stop();this._gen++;this._current=entry;
1376
- if(this.scanner)this.scanner.pause();this._playCss(entry,this._gen);this._setState("playing");}
1377
- // Resolve a selectable item to a flat list of {el, et, toState} arm targets.
1378
- // Phase items fan out to their members (selector may match several elements,
1379
- // e.g. staggered items); flat items use their bound DOM elements.
1380
- _targets(entry){
1381
- const out=[];
1382
- if(entry.kind==="phase"){
1383
- for(const m of (entry.members||[])){
1384
- if(!m.selector||!m.lanes||!m.lanes.length)continue;
1385
- let els=[];try{els=Array.from(document.querySelectorAll(m.selector));}catch{}
1386
- for(const el of els)out.push({el,et:m.lanes,toState:m.toState});
1387
- }
1388
- return out;
1389
- }
1390
- if(!entry.bindings||entry.bindings.type!=="css")return out;
1391
- const et=entry.effectiveTimings||entry.properties.map(p=>({property:p,durationMs:entry.durationMs,delayMs:entry.delayMs,easing:entry.easing}));
1392
- for(const wr of entry.bindings.elements){const el=wr.deref&&wr.deref();if(el)out.push({el,et,toState:null});}
1393
- return out;
1394
- }
1395
- pause(){if(this.state!=="playing")return;for(const a of this.animations){try{a.pause();}catch{}}this._stopPL();this._setState("paused");}
1396
- resume(){if(this.state!=="paused")return;for(const a of this.animations){try{a.play();}catch{}}this._startPL();this._setState("playing");}
1397
- stop(){this._stopPL();for(const a of this.animations){try{a.cancel();}catch{}}this.animations=[];for(const c of this.cleanups)c();this.cleanups=[];this._ep(0);if(this.scanner)this.scanner.unpause();this._setState("idle");}
1398
- restart(entry){this.stop();requestAnimationFrame(()=>this.play(entry));}
1399
- playPaused(entry,seekMs){
1400
- this.stop();this._gen++;const gen=this._gen;
1401
- this._pendingSeek=seekMs;
1402
- if(this.scanner)this.scanner.pause();
1403
- const targets=this._targets(entry);
1404
- if(!targets.length)return;
1405
- this.animations=[];
1406
- for(const {el,et,toState} of targets){
1407
- const restore=this._arm(el,et,toState);
1408
- requestAnimationFrame(()=>{if(this._gen!==gen)return;const running=el.getAnimations();
1409
- for(const a of running){a.pause();a.playbackRate=this.rate;this.animations.push(a);}
1410
- const t=this._pendingSeek??seekMs;
1411
- if(t!=null){for(const a of this.animations){try{a.currentTime=t;}catch{}}this._ep(t);}
1412
- });
1413
- this.cleanups.push(restore);}
1414
- this._setState("paused");
1415
- if(seekMs!=null)this._ep(seekMs);
1416
- }
1417
- seek(timeMs){
1418
- if(this.state==="idle")return;
1419
- if(this.state==="playing"){for(const a of this.animations){try{a.pause();}catch{}}this._stopPL();this._setState("paused");}
1420
- for(const a of this.animations){try{a.currentTime=timeMs;}catch{}}
1421
- this._ep(timeMs);
1422
- this._pendingSeek=timeMs;
1423
- }
1424
- _finish(){this._stopPL();if(this.animations.length>0){let end=0;for(const a of this.animations){const t=a.effect?.getTiming();const e=(t?.delay??0)+(Number(t?.duration)||0);if(e>end)end=e;}this._ep(end);}this.animations=[];for(const c of this.cleanups)c();this.cleanups=[];if(this.scanner)this.scanner.unpause();this._setState("idle");}
1425
- // Arm an element so its transition runs now, without needing the user to
1426
- // trigger it first. Priority:
1427
- // 1. a previously observed real run (exact),
1428
- // 2. end-state discovered from the stylesheets (hover/focus/toggled class),
1429
- // 3. a synthetic opacity/transform pulse to preview the timing,
1430
- // 4. click the element (last resort, may have side effects).
1431
- // Returns a restore fn.
1432
- _arm(el,et,toState){
1433
- const saved=el.style.cssText;
1434
- const tv=et.map(t=>`${t.property} ${formatCssTime(t.durationMs)} ${t.easing} ${formatCssTime(t.delayMs)}`).join(", ");
1435
- // toState (a phase's driving class/attr) is the most reliable source of
1436
- // the real end-state; fall back to a captured run, then CSSOM discovery.
1437
- const states=(toState&&_txToState(el,et,toState))||_txCaptured(el,et)||_txDiscover(el,et);
1438
- if(states){
1439
- const props=et.map(t=>t.property).filter(p=>states.from[p]!=null&&states.to[p]!=null);
1440
- if(props.length){
1441
- el.style.transition="none";
1442
- for(const p of props)el.style.setProperty(p,states.from[p]);
1443
- void el.offsetWidth; // commit the from-state before transitioning
1444
- el.style.transition=tv;
1445
- for(const p of props)el.style.setProperty(p,states.to[p]);
1446
- return ()=>{try{el.style.cssText=saved;}catch{}};
1447
- }
1448
- }
1449
- const synth=et.filter(t=>t.property==="opacity"||t.property==="transform"||t.property==="all");
1450
- if(synth.length){
1451
- const dur=Math.max(...et.map(t=>(t.durationMs||0)+(t.delayMs||0)))||et[0].durationMs||300;
1452
- const ease=et[0].easing||"ease";
1453
- const hasOp=synth.some(t=>t.property!=="transform"),hasTr=synth.some(t=>t.property!=="opacity");
1454
- const k0={},k1={},k2={};
1455
- if(hasOp){k0.opacity=1;k1.opacity=0.35;k2.opacity=1;}
1456
- if(hasTr){k0.transform="none";k1.transform="translateY(8px)";k2.transform="none";}
1457
- try{el.animate([k0,k1,k2],{duration:dur,easing:ease,fill:"none"});return ()=>{};}catch{}
1458
- }
1459
- el.style.transition=tv;el.click();
1460
- return ()=>{try{el.style.cssText=saved;}catch{}};
1461
- }
1462
- _playCss(entry,gen){
1463
- const targets=this._targets(entry);
1464
- this.animations=[];
1465
- if(!targets.length){this._finish();return;}
1466
- let pending=targets.length;
1467
- for(const {el,et,toState} of targets){
1468
- const restore=this._arm(el,et,toState);
1469
- requestAnimationFrame(()=>{if(this._gen!==gen)return;const running=el.getAnimations();for(const a of running){a.playbackRate=this.rate;this.animations.push(a);}this._startPL();
1470
- // resolve the whole group when every target's animations have settled
1471
- const onDone=()=>{if(this._gen!==gen||this.state!=="playing")return;pending--;if(pending>0)return;if(this.loop&&this._current){this.play(this._current);}else{this._finish();}};
1472
- if(running.length>0){Promise.allSettled(running.map(a=>a.finished)).then(onDone);}else{onDone();}});
1473
- this.cleanups.push(restore);}}
1474
- _startPL(){this._stopPL();const tick=()=>{if(this.animations.length>0){let cur=0;for(const a of this.animations){const c=Number(a.currentTime)||0;if(c>cur)cur=c;}this._ep(cur);}this._rafId=requestAnimationFrame(tick);};this._rafId=requestAnimationFrame(tick);}
1475
- _stopPL(){if(this._rafId!==null){cancelAnimationFrame(this._rafId);this._rafId=null;}}
1476
- _ep(p){for(const fn of this.progressListeners)fn(p);}
1477
- _setState(s){this.state=s;for(const fn of this.listeners)fn(s);}
1478
- }
1479
-
1480
1252
  // ── scanner ──
1481
1253
  class DomScanner {
1482
1254
  observer=null;rafId=null;running=false;paused=false;
1483
1255
  constructor(root,reg){this.root=root;this.registry=reg;}
1484
1256
  pause(){this.paused=true;} unpause(){this.paused=false;this._sched();}
1485
- start(){if(this.running)return;this.running=true;_txInstallCapture();this.scan();this.observer=new MutationObserver(()=>this._sched());this.observer.observe(this.root,{childList:true,subtree:true,attributes:true,attributeFilter:["class","style"]});}
1257
+ start(){if(this.running)return;this.running=true;this.scan();this.observer=new MutationObserver(()=>this._sched());this.observer.observe(this.root,{childList:true,subtree:true,attributes:true,attributeFilter:["class","style"]});}
1486
1258
  stop(){this.running=false;this.observer?.disconnect();this.observer=null;if(this.rafId!==null){cancelAnimationFrame(this.rafId);this.rafId=null;}}
1487
1259
  _sched(){if(this.rafId!==null)return;this.rafId=requestAnimationFrame(()=>{this.rafId=null;if(this.running&&!this.paused)this.scan();});}
1488
1260
  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()));}
@@ -1494,7 +1266,11 @@
1494
1266
  const durs=(s.transitionDuration||"0s").split(",");
1495
1267
  const dels=(s.transitionDelay||"0s").split(",");
1496
1268
  const eass=splitCssValues(s.transitionTimingFunction||"ease");
1497
- const z=zipTransitionLists(props,durs,dels,eass);
1269
+ let z=zipTransitionLists(props,durs,dels,eass);
1270
+ // If we've live-applied edited timings here, keep the entry pinned to the
1271
+ // ORIGINAL source lanes so its id/overrides stay stable across rescans.
1272
+ const _lb=_TX_LIVE_BASE.get(el);
1273
+ if(_lb){ if(_lb.z&&_lb.z.length) z=_lb.z; else return; }
1498
1274
  if(!z.length||z.every(x=>x.durationMs===0&&x.delayMs===0))return;
1499
1275
  const pDur=z[0].durationMs,pDel=z[0].delayMs,pEase=z[0].easing;
1500
1276
  const allP=z.map(x=>x.property);
@@ -1512,19 +1288,11 @@
1512
1288
  const TimelineCtx = createContext(null);
1513
1289
  function useReg(){const{registry}=useContext(TimelineCtx);return useSyncExternalStore(useCallback(cb=>registry.subscribe(cb),[registry]),useCallback(()=>registry.getAll(),[registry]),useCallback(()=>registry.getAll(),[registry]));}
1514
1290
  function useActive(){const{activeId,setActiveId,registry}=useContext(TimelineCtx);const active=useSyncExternalStore(useCallback(cb=>registry.subscribe(cb),[registry]),useCallback(()=>activeId?registry.getEffective(activeId):undefined,[registry,activeId]),useCallback(()=>activeId?registry.getEffective(activeId):undefined,[registry,activeId]));return{active,setActiveId};}
1515
- function usePlayback(){const{preview,registry,activeId}=useContext(TimelineCtx);const state=useSyncExternalStore(useCallback(cb=>preview.subscribe(cb),[preview]),useCallback(()=>preview.getState(),[preview]),useCallback(()=>preview.getState(),[preview]));
1516
- return{state,play:useCallback(()=>{if(!activeId)return;const e=registry.getEffective(activeId);if(e)preview.play(e);},[preview,registry,activeId]),pause:useCallback(()=>preview.pause(),[preview]),resume:useCallback(()=>preview.resume(),[preview]),restart:useCallback(()=>{if(!activeId)return;const e=registry.getEffective(activeId);if(e)preview.restart(e);},[preview,registry,activeId]),stop:useCallback(()=>preview.stop(),[preview])};}
1517
1291
  function usePropOverride(){const{registry,activeId}=useContext(TimelineCtx);return{setPropOverride:useCallback((prop,o)=>{if(activeId)registry.setPropOverride(activeId,prop,o);},[registry,activeId])};}
1518
1292
 
1519
1293
  // ── components ──
1520
- const BASE_SCALE_MS = 5000;
1521
- const ZOOM_MIN = 25;
1522
- const ZOOM_MAX = 400;
1523
- const ZOOM_DEFAULT = 100;
1524
- const scaleFromZoom = zoom => Math.round(BASE_SCALE_MS * 100 / zoom);
1525
- const LABEL_W = 150;
1294
+ const LABEL_W = 200;
1526
1295
  const CLOSE_MS = 150;
1527
- const SPEEDS = [0.25, 0.5, 1, 2];
1528
1296
  const DURATION_TOKENS = [
1529
1297
  {label:"Duration-fast", ms:150, usage:"Quick state changes — hovers, toggles, button presses, dropdown & modal close, text swaps."},
1530
1298
  {label:"Duration-medium", ms:250, usage:"Standard UI motion — icon swaps, dropdown & modal open, sliding tabs, page slides."},
@@ -1538,8 +1306,6 @@
1538
1306
  {label:"Long", ms:300, usage:"Pronounced wait — use sparingly to draw attention."},
1539
1307
  ];
1540
1308
  function cx(...a){ return a.filter(Boolean).join(" "); }
1541
- function fmtTimecode(ms){ const m=Math.floor(ms/60000); const s=Math.floor((ms%60000)/1000); const c=Math.floor((ms%1000)/10); const p=n=>String(n).padStart(2,"0"); return p(m)+":"+p(s)+":"+p(c); }
1542
- function fmtSpeed(s){ return s+"x"; }
1543
1309
 
1544
1310
  // ── icons ──
1545
1311
  // Exact SVGs exported from the Logram ❖ Design System Figma file (no recreations).
@@ -1563,6 +1329,8 @@
1563
1329
  };
1564
1330
  ICONS.minimize = ICONS.chevron;
1565
1331
  ICONS.close = {vb:"0 0 16 16", svg:`<path d="M4 4L12 12M12 4L4 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`};
1332
+ // dots-vertical (Figma node 580:4819) — vertical "⋮" overflow icon
1333
+ ICONS.dotsv = {vb:"0 0 16 16", svg:`<path fill-rule="evenodd" clip-rule="evenodd" d="M6.66667 8C6.66667 7.26362 7.26362 6.66667 8 6.66667C8.73638 6.66667 9.33333 7.26362 9.33333 8C9.33333 8.73638 8.73638 9.33333 8 9.33333C7.26362 9.33333 6.66667 8.73638 6.66667 8Z" fill="currentColor"/><path fill-rule="evenodd" clip-rule="evenodd" d="M6.66667 3.33333C6.66667 2.59695 7.26362 2 8 2C8.73638 2 9.33333 2.59695 9.33333 3.33333C9.33333 4.06971 8.73638 4.66667 8 4.66667C7.26362 4.66667 6.66667 4.06971 6.66667 3.33333Z" fill="currentColor"/><path fill-rule="evenodd" clip-rule="evenodd" d="M6.66667 12.6667C6.66667 11.9303 7.26362 11.3333 8 11.3333C8.73638 11.3333 9.33333 11.9303 9.33333 12.6667C9.33333 13.403 8.73638 14 8 14C7.26362 14 6.66667 13.403 6.66667 12.6667Z" fill="currentColor"/>`};
1566
1334
  function Ic({name, size=16}){
1567
1335
  const ic = ICONS[name];
1568
1336
  if(!ic) return null;
@@ -1574,13 +1342,6 @@
1574
1342
  style:{display:"block"},dangerouslySetInnerHTML:{__html:ic.svg}});
1575
1343
  }
1576
1344
 
1577
- function usePreviewTime(){
1578
- const{preview}=useContext(TimelineCtx);
1579
- const[ms,setMs]=useState(0);
1580
- useEffect(()=>preview.onProgress(setMs),[preview]);
1581
- return ms;
1582
- }
1583
-
1584
1345
  // portaled, origin-aware dropdown surface (transitions.dev menu-dropdown)
1585
1346
  function Dropdown({open,onClose,triggerRef,width,align,children}){
1586
1347
  const ref=useRef(null);
@@ -1782,7 +1543,10 @@
1782
1543
  },[phase]);
1783
1544
  if(!render)return null;
1784
1545
 
1785
- const pending = suggestions.filter(s=>!appliedIds[s.id]);
1546
+ // one scan returns both kinds; each tab shows only its own:
1547
+ // Replace transition → kind "replace"; Small refinements → token tweaks.
1548
+ const visible = suggestions.filter(s=> refineType==="replace" ? s.kind==="replace" : s.kind!=="replace");
1549
+ const pending = visible.filter(s=>!appliedIds[s.id]);
1786
1550
  const agentMode = mode==="llm";
1787
1551
  // null (unknown / still probing) is treated as ready so the panel doesn't
1788
1552
  // flash the "unavailable" copy before /health resolves.
@@ -1819,7 +1583,7 @@
1819
1583
  h("div",{className:"tl-refine-unavail-text"},error||"The agent reported an error."));
1820
1584
  foot = startBtn({label:"Try again"});
1821
1585
  } else if(phase==="done"){
1822
- if(suggestions.length===0){
1586
+ if(visible.length===0){
1823
1587
  const emptyMsg = refineType==="replace"
1824
1588
  ? "I didn't find any transition that would be a good fit as a replacement."
1825
1589
  : "Already aligned to the transitions.dev motion tokens. Nothing to refine.";
@@ -1827,9 +1591,9 @@
1827
1591
  h("p",{className:"tl-refine-idle-text"},emptyMsg));
1828
1592
  foot = startBtn({label:"Scan again"});
1829
1593
  } else {
1830
- body = h(RefineResults,{summary,suggestions,appliedIds,onApply});
1594
+ body = h(RefineResults,{summary:refineType==="replace"?null:summary,suggestions:visible,appliedIds,onApply});
1831
1595
  foot = pending.length>1
1832
- ? h("button",{className:"pc-btn primary",style:{flex:1},onClick:onApplyAll},"Apply all ("+pending.length+")")
1596
+ ? h("button",{className:"pc-btn primary",style:{flex:1},onClick:()=>pending.forEach(onApply)},"Apply all ("+pending.length+")")
1833
1597
  : startBtn({label:"Scan again"});
1834
1598
  }
1835
1599
  } else { // idle
@@ -1854,7 +1618,7 @@
1854
1618
  h("div",{className:"tl-refine-head"},
1855
1619
  h("div",{className:"tl-refine-titles"},
1856
1620
  h("h3",null,"Refine"),
1857
- h("p",null,label?("Tuning "+label):"Transitions review")),
1621
+ h("p",null,label||"Transitions review")),
1858
1622
  h("div",{className:"tl-refine-actions"},
1859
1623
  h("button",{ref:modeRef,className:cx("tl-refine-mode",modeOpen&&"is-open"),
1860
1624
  "aria-haspopup":"menu","aria-expanded":modeOpen?"true":"false",onClick:()=>setModeOpen(v=>!v)},
@@ -1897,10 +1661,12 @@
1897
1661
  return out;
1898
1662
  }
1899
1663
 
1900
- function Header({entries,active,onSelect,onReset,onCopy,copied,loop,setLoop,snap,setSnap,onMinimize,onRefine,refineActive,onAccept,acceptState,acceptDisabled,acceptError,scanning}){
1664
+ function Header({entries,active,onSelect,onReset,onCopy,copied,snap,setSnap,onMinimize,onRefine,refineActive,onAccept,acceptState,acceptDisabled,acceptError,scanning,onRescan}){
1901
1665
  const[pick,setPick]=useState(false);
1902
1666
  const[setg,setSetg]=useState(false);
1903
1667
  const pickRef=useRef(null), gearRef=useRef(null);
1668
+ const[q,setQ]=useState("");
1669
+ useEffect(()=>{if(!pick)setQ("");},[pick]); // reset search when the picker closes
1904
1670
  // Split selectable items into agent groups (component → phases) and the
1905
1671
  // flat, ungrouped DOM transitions, preserving order.
1906
1672
  const groupList=[]; const groupMap=new Map(); const flatItems=[];
@@ -1911,6 +1677,12 @@
1911
1677
  g.phases.push(e);
1912
1678
  }else flatItems.push(e);
1913
1679
  }
1680
+ // live filter for the search input (matches phase/group/flat labels)
1681
+ const ql=q.trim().toLowerCase();
1682
+ const fGroups=groupList.map(g=>({...g,phases:g.phases.filter(e=>!ql||
1683
+ ((e.phaseLabel||"")+" "+(e.groupLabel||"")+" "+(e.label||"")).toLowerCase().includes(ql))})).filter(g=>g.phases.length);
1684
+ const fFlat=flatItems.filter(e=>!ql||(e.label||"").toLowerCase().includes(ql));
1685
+ const noMatches=!!ql&&fGroups.length===0&&fFlat.length===0;
1914
1686
  const phaseItem=(e)=>h(MenuItem,{key:e.id,active:active&&e.id===active.id,onClick:()=>{onSelect(e.id);setPick(false);},
1915
1687
  right:active&&e.id===active.id&&h("span",{className:"tl-menu-check"},h(Ic,{name:"check"}))},
1916
1688
  h("span",null,e.phaseLabel,h("span",{className:"tl-menu-dim"}," "+e.durationMs+"ms")));
@@ -1924,29 +1696,32 @@
1924
1696
  :h("span",{className:"tl-dim"},"None"),
1925
1697
  h("span",{className:"tl-ghost-chev"},h(Ic,{name:"chevron"}))),
1926
1698
  h(Dropdown,{open:pick,onClose:()=>setPick(false),triggerRef:pickRef,width:Math.max(240,(pickRef.current&&pickRef.current.offsetWidth)||240),align:"left"},
1699
+ h("div",{className:"tl-menu-search"},
1700
+ h("input",{className:"tl-menu-search-input",type:"text",placeholder:"Search",value:q,autoFocus:true,
1701
+ onChange:e=>setQ(e.target.value),
1702
+ onKeyDown:e=>{e.stopPropagation();if(e.key==="Escape"){if(q)setQ("");else setPick(false);}}})),
1927
1703
  entries.length===0
1928
1704
  ? h("div",{className:"tl-menu-empty"},"No transitions found")
1929
- : h(React.Fragment,null,
1930
- ...groupList.map(g=>h(React.Fragment,{key:g.id},
1931
- h("div",{className:"tl-menu-section"},g.label),
1932
- ...g.phases.map(phaseItem))),
1933
- flatItems.length>0&&groupList.length>0&&h("div",{className:"tl-menu-section"},"Ungrouped"),
1934
- ...flatItems.map(flatItem))),
1705
+ : noMatches
1706
+ ? h("div",{className:"tl-menu-empty"},"No matches")
1707
+ : h(React.Fragment,null,
1708
+ ...fGroups.map(g=>h(React.Fragment,{key:g.id},
1709
+ h("div",{className:"tl-menu-section"},g.label),
1710
+ ...g.phases.map(phaseItem))),
1711
+ fFlat.length>0&&fGroups.length>0&&h("div",{className:"tl-menu-section"},"Ungrouped"),
1712
+ ...fFlat.map(flatItem))),
1935
1713
  h("span",{className:"tl-header-count"},
1936
1714
  scanning?"Grouping…":entries.length+" transition"+(entries.length===1?"":"s")+" found"),
1937
- h("button",{ref:gearRef,className:cx("tl-icon-btn",setg&&"is-active"),title:"Settings",onClick:()=>setSetg(v=>!v)},h(Ic,{name:"gear"})),
1715
+ h("button",{ref:gearRef,className:cx("tl-icon-btn",setg&&"is-active"),title:"Settings",onClick:()=>setSetg(v=>!v)},h(Ic,{name:"dotsv"})),
1938
1716
  h(Dropdown,{open:setg,onClose:()=>setSetg(false),triggerRef:gearRef,width:210,align:"right"},
1939
- h(MenuItem,{onClick:()=>setLoop(v=>!v),right:loop&&h("span",{className:"tl-menu-check"},h(Ic,{name:"check"}))},"Loop playback"),
1940
- h(MenuItem,{onClick:()=>setSnap(v=>!v),right:snap&&h("span",{className:"tl-menu-check"},h(Ic,{name:"check"}))},"Snap to grid")),
1717
+ h(MenuItem,{onClick:()=>setSnap(v=>!v),right:snap&&h("span",{className:"tl-menu-check"},h(Ic,{name:"check"}))},"Snap to grid"),
1718
+ h(MenuItem,{disabled:!active,onClick:()=>{onCopy&&onCopy();setSetg(false);},
1719
+ right:copied&&h("span",{className:"tl-menu-check"},h(Ic,{name:"check"}))},copied?"Copied":"Copy values"),
1720
+ h(MenuItem,{disabled:scanning,onClick:()=>{setSetg(false);onRescan&&onRescan();}},
1721
+ scanning?"Rescanning…":"Rescan transitions")),
1941
1722
  h("span",{className:"t-tt-wrap"},
1942
1723
  h("button",{className:"tl-sec-btn t-tt-trigger",disabled:!active,onClick:onReset},"Reset"),
1943
1724
  h("span",{className:"t-tt tl-tt-below",role:"tooltip"},"Reset values")),
1944
- h("span",{className:"t-tt-wrap"},
1945
- h("button",{className:"tl-icon-btn t-tt-trigger",disabled:!active,"aria-label":"Copy values",onClick:onCopy},
1946
- h("span",{className:"t-icon-swap","data-state":copied?"b":"a"},
1947
- h("span",{className:"t-icon","data-icon":"a"},h(Ic,{name:"copy"})),
1948
- h("span",{className:"t-icon","data-icon":"b"},h(Ic,{name:"check"})))),
1949
- h("span",{className:"t-tt tl-tt-below",role:"tooltip"},copied?"Copied":"Copy values")),
1950
1725
  h("span",{className:"t-tt-wrap"},
1951
1726
  h("button",{className:cx("tl-accept-btn",acceptState==="saving"&&"is-saving",acceptState==="done"&&"is-done"),
1952
1727
  disabled:!active||acceptDisabled||acceptState==="saving"||acceptState==="done",onClick:onAccept,"aria-label":"Accept changes to your code"},
@@ -1977,7 +1752,7 @@
1977
1752
  );
1978
1753
  }
1979
1754
 
1980
- function PropTrack({property, member, delayMs, durationMs, selected, dragging, onSelect, onReorder, onDelayChange, onDurationChange, snap, scaleMs, lockDuration}){
1755
+ function PropTrack({property, member, delayMs, durationMs, selected, onSelect, onDelayChange, onDurationChange, snap, scaleMs, lockDuration, labelW}){
1981
1756
  const trackRef=useRef(null);
1982
1757
  const grid = snap ? 25 : 1;
1983
1758
  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]);
@@ -2004,9 +1779,8 @@
2004
1779
  },[delayMs,durationMs,pxToMs,grid,scaleMs,onSelect,onDelayChange,onDurationChange,lockDuration]);
2005
1780
 
2006
1781
  const delPct=(delayMs/scaleMs)*100; const durPct=(durationMs/scaleMs)*100;
2007
- return h("div",{className:cx("tl-prop-row",selected&&"selected",dragging&&"reordering",lockDuration&&"is-spring"),onClick:onSelect},
2008
- h("div",{className:"tl-prop-head"},
2009
- h("span",{className:"tl-prop-grip",title:"Drag to reorder",onMouseDown:onReorder},h(Ic,{name:"dots"})),
1782
+ return h("div",{className:cx("tl-prop-row",selected&&"selected",lockDuration&&"is-spring"),onClick:onSelect},
1783
+ h("div",{className:"tl-prop-head",style:labelW!=null?{flexBasis:labelW+"px"}:undefined},
2010
1784
  h("span",{className:"tl-prop-label"},
2011
1785
  member&&h("span",{className:"tl-prop-member"},member),
2012
1786
  h("span",{className:"tl-prop-prop"},property))),
@@ -2095,18 +1869,25 @@
2095
1869
  }
2096
1870
 
2097
1871
  // shared plot geometry for both the easing curve and the spring curve.
2098
- // (Figma easing frame node 13044:2506: 269×162 with generous H margins + tight V margins.)
2099
- const CURVE = { VBW:269, VBH:162, PAD_X:60, PAD_Y:16 };
1872
+ // Compact, landscape box: small horizontal padding (bezier x is clamped 0..1
1873
+ // so there is no horizontal overshoot to reserve room for) makes the curve
1874
+ // fill the width, while PAD_Y keeps the 0..1 band vertically centered with
1875
+ // equal headroom above/below so overshoot/undershoot handles (clamped to the
1876
+ // -0.5..1.5 range) stay visible instead of being clipped at the edges.
1877
+ const CURVE = { VBW:269, VBH:150, PAD_X:30, PAD_Y:42 };
2100
1878
 
2101
1879
  function EasingEditor({easing, cubic, spring, durationMs, propKey, apply}){
2102
1880
  const [tab, setTab] = useState(spring ? "springs" : "easing");
2103
1881
  // when the user selects a different property, reflect that property's mode
2104
1882
  useEffect(()=>{ setTab(spring ? "springs" : "easing"); /* eslint-disable-next-line */ }, [propKey]);
2105
1883
 
2106
- // remember the last real (non-spring) easing so we can restore it when
2107
- // switching back from the Springs tab.
1884
+ // remember the last real (non-spring) easing + duration so we can restore
1885
+ // them when switching back from the Springs tab (the spring overwrites the
1886
+ // duration with its derived settle time).
2108
1887
  const lastEasingRef = useRef("ease");
1888
+ const lastEaseDurRef = useRef(durationMs);
2109
1889
  useEffect(()=>{ if(!spring && easing && !/^linear\(/.test(easing)) lastEasingRef.current = easing; }, [easing, spring]);
1890
+ useEffect(()=>{ if(!spring && durationMs!=null) lastEaseDurRef.current = durationMs; }, [durationMs, spring]);
2110
1891
 
2111
1892
  const applySpring = useCallback((stiffness,damping,mass,name)=>{
2112
1893
  const sim = simulateSpring(stiffness,damping,mass);
@@ -2119,7 +1900,7 @@
2119
1900
  if(!spring){ const p=SPRING_PRESETS[0]; applySpring(p.stiffness,p.damping,p.mass,p.label); }
2120
1901
  } else {
2121
1902
  setTab("easing");
2122
- if(spring) apply({ spring:null, easing:lastEasingRef.current||"ease" });
1903
+ if(spring) apply({ spring:null, easing:lastEasingRef.current||"ease", durationMs:lastEaseDurRef.current });
2123
1904
  }
2124
1905
  },[spring,apply,applySpring]);
2125
1906
 
@@ -2149,59 +1930,6 @@
2149
1930
  h("div",{className:"t-page",ref:springPageRef,"data-page-id":"2","aria-hidden":tab!=="springs"},
2150
1931
  h(SpringTab,{spring,applySpring})),
2151
1932
  ),
2152
- h(PositionPreview,{easing, durationMs}),
2153
- );
2154
- }
2155
-
2156
- // Animated position preview (à la easing.dev): a marker travels left→right
2157
- // using the live timing function + duration, pauses, returns, and loops.
2158
- // Works for any timing function — cubic-bezier keywords or a spring's linear().
2159
- function PositionPreview({easing, durationMs}){
2160
- const trackRef = useRef(null);
2161
- const dotRef = useRef(null);
2162
- const [playing, setPlaying] = useState(false);
2163
- const safeEasing = (!easing || easing.trim()==="") ? "linear" : easing;
2164
- const dur = Math.max(120, durationMs || 0);
2165
-
2166
- useEffect(()=>{
2167
- if(!playing) return;
2168
- const dot = dotRef.current, track = trackRef.current;
2169
- if(!dot || !track) return;
2170
- let cancelled = false, anim = null, timer = null, atRight = false;
2171
- const GAP = 480, DOT = 14;
2172
- const travel = ()=> Math.max(0, track.clientWidth - DOT - 8);
2173
- const step = ()=>{
2174
- if(cancelled) return;
2175
- const from = atRight ? travel() : 0;
2176
- const to = atRight ? 0 : travel();
2177
- try {
2178
- anim = dot.animate(
2179
- [{transform:`translateX(${from}px)`},{transform:`translateX(${to}px)`}],
2180
- {duration:dur, easing:safeEasing, fill:"forwards"});
2181
- } catch {
2182
- anim = dot.animate(
2183
- [{transform:`translateX(${from}px)`},{transform:`translateX(${to}px)`}],
2184
- {duration:dur, easing:"linear", fill:"forwards"});
2185
- }
2186
- anim.onfinish = ()=>{ if(cancelled) return; atRight = !atRight; timer = setTimeout(step, GAP); };
2187
- };
2188
- step();
2189
- return ()=>{ cancelled = true; if(anim){try{anim.cancel();}catch{}} if(timer)clearTimeout(timer); };
2190
- },[playing, safeEasing, dur]);
2191
-
2192
- return h("div",{className:"tl-preview"},
2193
- h("div",{className:"tl-preview-head"},
2194
- h("span",{className:"tl-preview-title"},"Position Preview"),
2195
- h("button",{className:"tl-preview-btn",onClick:()=>setPlaying(p=>!p)},
2196
- h(Ic,{name:playing?"pause":"play",size:11}),
2197
- h("span",null,playing?"Pause":"Play")),
2198
- ),
2199
- h("div",{className:"tl-preview-track",ref:trackRef},
2200
- h("span",{className:"tl-preview-rail"}),
2201
- h("span",{className:"tl-preview-end left"}),
2202
- h("span",{className:"tl-preview-end right"}),
2203
- h("span",{className:"tl-preview-dot",ref:dotRef}),
2204
- ),
2205
1933
  );
2206
1934
  }
2207
1935
 
@@ -2388,50 +2116,29 @@
2388
2116
  );
2389
2117
  }
2390
2118
 
2391
- function ScrubZone({scaleMs}){
2392
- const { preview, registry, activeId } = useContext(TimelineCtx);
2393
- const areaRef = useRef(null);
2394
- const startedRef = useRef(false);
2395
-
2396
- const pxToMs = useCallback(clientX=>{
2397
- if(!areaRef.current) return 0;
2398
- const rect = areaRef.current.getBoundingClientRect();
2399
- const ratio = Math.max(0, Math.min((clientX - rect.left) / rect.width, 1));
2400
- return ratio * scaleMs;
2401
- },[scaleMs]);
2402
-
2403
- const doSeek = useCallback(ms=>{
2404
- if(preview.getState()==="idle" && !startedRef.current){
2405
- if(!activeId) return;
2406
- const entry = registry.getEffective(activeId);
2407
- if(!entry) return;
2408
- startedRef.current = true;
2409
- preview.playPaused(entry, ms);
2410
- } else {
2411
- preview.seek(ms);
2412
- }
2413
- },[preview,registry,activeId]);
2414
-
2415
- const startScrub = useCallback(e=>{
2416
- e.preventDefault();
2417
- startedRef.current = false;
2418
- doSeek(pxToMs(e.clientX));
2419
- const onMove = e2 => doSeek(pxToMs(e2.clientX));
2420
- const onUp = () => { startedRef.current = false; window.removeEventListener("mousemove",onMove); window.removeEventListener("mouseup",onUp); };
2421
- window.addEventListener("mousemove",onMove);
2422
- window.addEventListener("mouseup",onUp);
2423
- },[doSeek,pxToMs]);
2424
-
2425
- return h("div",{className:"tl-scrub-zone",ref:areaRef,onMouseDown:startScrub});
2426
- }
2427
-
2428
2119
  function Tracks({et, selLane, setSelLane, onPropChange, snap, scaleMs}){
2429
- const t = usePreviewTime();
2430
- const ratio = Math.min(t / scaleMs, 1);
2431
- const ROW_H = 48;
2432
2120
  const rowsRef = useRef(null);
2121
+ const tracksRef = useRef(null);
2433
2122
  const [order, setOrder] = useState([]);
2434
- const [dragLane, setDragLane] = useState(null);
2123
+ // Width of the label column (shared by the ruler spacer + every prop-row
2124
+ // head). Draggable via the divider; the bars column is flex:1 so it just
2125
+ // takes the remaining space and the bars stay proportional to scaleMs.
2126
+ const LABEL_MIN = 96, LABEL_TRACK_MIN = 160;
2127
+ const [labelW, setLabelW] = useState(LABEL_W);
2128
+ const [colDragging, setColDragging] = useState(false);
2129
+ const startColResize = useCallback(e=>{
2130
+ e.preventDefault(); e.stopPropagation();
2131
+ const startX = e.clientX, startW = labelW;
2132
+ const maxW = tracksRef.current
2133
+ ? Math.max(LABEL_MIN, tracksRef.current.getBoundingClientRect().width - LABEL_TRACK_MIN)
2134
+ : 360;
2135
+ setColDragging(true);
2136
+ document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none";
2137
+ const onMove = e2 => setLabelW(Math.max(LABEL_MIN, Math.min(maxW, startW + (e2.clientX - startX))));
2138
+ const onUp = () => { setColDragging(false); document.body.style.cursor=""; document.body.style.userSelect="";
2139
+ window.removeEventListener("mousemove",onMove); window.removeEventListener("mouseup",onUp); };
2140
+ window.addEventListener("mousemove",onMove); window.addEventListener("mouseup",onUp);
2141
+ },[labelW]);
2435
2142
  const laneKey = et.map(x=>x.laneId).join("|");
2436
2143
  useEffect(()=>{
2437
2144
  const ids = et.map(x=>x.laneId);
@@ -2442,28 +2149,7 @@
2442
2149
  });
2443
2150
  },[laneKey]);
2444
2151
  const orderedRows = order.map(p=>et.find(x=>x.laneId===p)).filter(Boolean);
2445
- const startReorder = useCallback((laneId,e)=>{
2446
- e.preventDefault(); e.stopPropagation();
2447
- setSelLane(laneId);
2448
- setDragLane(laneId);
2449
- const top = rowsRef.current ? rowsRef.current.getBoundingClientRect().top : 0;
2450
- const onMove = e2=>{
2451
- const idx = Math.floor((e2.clientY - top) / ROW_H);
2452
- setOrder(prev=>{
2453
- const cur = prev.indexOf(laneId);
2454
- if(cur<0) return prev;
2455
- const target = Math.max(0, Math.min(prev.length-1, idx));
2456
- if(target===cur) return prev;
2457
- const next = prev.slice();
2458
- next.splice(cur,1);
2459
- next.splice(target,0,laneId);
2460
- return next;
2461
- });
2462
- };
2463
- const onUp = ()=>{ setDragLane(null); window.removeEventListener("mousemove",onMove); window.removeEventListener("mouseup",onUp); };
2464
- window.addEventListener("mousemove",onMove); window.addEventListener("mouseup",onUp);
2465
- },[setSelLane]);
2466
- const majorStep = scaleMs <= 10000 ? 1000 : scaleMs <= 25000 ? 5000 : 10000;
2152
+ const majorStep = scaleMs <= 2000 ? 500 : scaleMs <= 10000 ? 1000 : scaleMs <= 25000 ? 5000 : 10000;
2467
2153
  const minorStep = majorStep / 4;
2468
2154
  const ruler=[];
2469
2155
  for(let ms=0; ms<=scaleMs; ms+=majorStep){
@@ -2474,26 +2160,22 @@
2474
2160
  if(ms%majorStep===0) continue;
2475
2161
  ruler.push(h("span",{key:"t"+ms,className:"tick",style:{left:((ms/scaleMs)*100)+"%"}}));
2476
2162
  }
2477
- return h("div",{className:"tl-tracks"},
2163
+ return h("div",{className:"tl-tracks",ref:tracksRef},
2478
2164
  h("div",{className:"tl-ruler-row"},
2479
- h("div",{className:"tl-ruler-spacer"}),
2165
+ h("div",{className:"tl-ruler-spacer",style:{flexBasis:labelW+"px"}}),
2480
2166
  h("div",{className:"tl-ruler"},...ruler)),
2481
2167
  h("div",{className:"tl-rows",ref:rowsRef},
2482
2168
  ...orderedRows.map(row=>h(PropTrack,{
2483
2169
  key:row.laneId, property:row.property, member:row.member,
2484
2170
  delayMs:row.delayMs, durationMs:row.durationMs, lockDuration:!!row.spring,
2485
- selected:row.laneId===selLane, dragging:row.laneId===dragLane, snap, scaleMs,
2171
+ selected:row.laneId===selLane, snap, scaleMs, labelW,
2486
2172
  onSelect:()=>setSelLane(row.laneId),
2487
- onReorder:e=>startReorder(row.laneId,e),
2488
2173
  onDelayChange:ms=>onPropChange(row.laneId,{delayMs:ms}),
2489
2174
  onDurationChange:ms=>onPropChange(row.laneId,{durationMs:ms}),
2490
2175
  }))),
2491
- h(ScrubZone,{scaleMs}),
2492
- h("div",{className:"tl-playhead-layer"},
2493
- h("div",{className:"tl-playhead",style:{left:(ratio*100)+"%"}},
2494
- h("svg",{className:"tl-playhead-head",viewBox:"0 0 16.666 24",width:"16.666",height:"24",fill:"none"},
2495
- h("path",{d:"M13.666 7.333C13.666 11.609 9.333 15.666 9.333 19.942L9.333 24L7.333 24L7.333 19.942C7.333 15.666 3 11.609 3 7.333C3 4.387 5.387 2 8.333 2C11.279 2 13.666 4.387 13.666 7.333Z",fill:"#1A7AFF"}))),
2496
- ),
2176
+ h("div",{className:cx("tl-col-resizer",colDragging&&"dragging"),style:{left:labelW+"px"},
2177
+ onMouseDown:startColResize,title:"Drag to resize columns","aria-label":"Resize label column",role:"separator"},
2178
+ h("span",{className:"tl-col-resizer-line"})),
2497
2179
  );
2498
2180
  }
2499
2181
 
@@ -2518,65 +2200,34 @@
2518
2200
  );
2519
2201
  }
2520
2202
 
2521
- function Body({entry, onPropChange, state, play, pause, resume, restart, stop, speed, setSpeed, snap}){
2203
+ function Body({entry, onPropChange, snap}){
2522
2204
  const et = entry.effectiveTimings || [];
2523
2205
  const [selLane, setSelLane] = useState(et[0]?.laneId ?? null);
2524
- const [zoom, setZoom] = useState(ZOOM_DEFAULT);
2525
- const scaleMs = scaleFromZoom(zoom);
2206
+ // No playback: the real component is the preview. The track scale just
2207
+ // auto-fits the longest lane (duration+delay) with a little headroom so
2208
+ // the static bars stay readable across short and long transitions.
2209
+ const maxEnd = et.reduce((m,t)=>Math.max(m,(t.durationMs||0)+(t.delayMs||0)),0);
2210
+ const scaleMs = Math.max(500, Math.ceil((maxEnd*1.15)/250)*250);
2526
2211
  useEffect(()=>{
2527
2212
  if(et.length && !et.find(t=>t.laneId===selLane)) setSelLane(et[0]?.laneId);
2528
2213
  },[et,selLane]);
2529
2214
 
2530
2215
  return h("div",{className:"tl-body"},
2531
2216
  h("div",{className:"tl-main"},
2532
- h(Transport,{state,disabled:!entry,onPlay:play,onPause:pause,onResume:resume,onRestart:restart,onStop:stop,speed,setSpeed,zoom,setZoom}),
2533
2217
  h(Tracks,{et,selLane,setSelLane,onPropChange,snap,scaleMs}),
2534
2218
  ),
2535
2219
  h(Inspector,{entry,selLane,onPropChange,snap}),
2536
2220
  );
2537
2221
  }
2538
2222
 
2539
- function Transport({state,disabled,onPlay,onPause,onResume,onRestart,onStop,speed,setSpeed,zoom,setZoom}){
2540
- const t = usePreviewTime();
2541
- const spRef = useRef(null);
2542
- const [sp,setSp] = useState(false);
2543
- const playing = state==="playing";
2544
- const onMain = () => playing ? onPause() : state==="paused" ? onResume() : onPlay();
2545
- return h("div",{className:"tl-transport"},
2546
- h("div",{className:"tl-transport-left"},
2547
- h("span",{className:"tl-timecode"}, fmtTimecode(t)),
2548
- h("button",{ref:spRef,className:cx("tl-ghost-btn","tl-speed",sp&&"is-active"),onClick:()=>setSp(v=>!v)},
2549
- fmtSpeed(speed), h("span",{className:"tl-ghost-chev"},h(Ic,{name:"chevron"}))),
2550
- h(Dropdown,{open:sp,onClose:()=>setSp(false),triggerRef:spRef,width:120,align:"left"},
2551
- SPEEDS.map(s=>h(MenuItem,{key:s,active:s===speed,onClick:()=>{setSpeed(s);setSp(false);},
2552
- right:s===speed&&h("span",{className:"tl-menu-check"},h(Ic,{name:"check"}))},fmtSpeed(s)))) ),
2553
- h("div",{className:"tl-transport-center"},
2554
- h("button",{className:"tl-play-btn",disabled,onClick:onMain,title:playing?"Pause":"Play"},
2555
- h("span",{className:"t-icon-swap","data-state":playing?"b":"a"},
2556
- h("span",{className:"t-icon","data-icon":"a"},h(Ic,{name:"play",size:12})),
2557
- h("span",{className:"t-icon","data-icon":"b"},h(Ic,{name:"pause",size:16}))))),
2558
- h("div",{className:"tl-transport-right"},
2559
- h("div",{className:"tl-zoom",title:"Timeline zoom"},
2560
- h("div",{className:"tl-zoom-track","aria-hidden":true}),
2561
- h("input",{type:"range",min:ZOOM_MIN,max:ZOOM_MAX,value:zoom,
2562
- onChange:e=>setZoom(Number(e.target.value))})),
2563
- h("span",{className:"t-tt-wrap"},
2564
- h("button",{className:"tl-icon-btn ghost t-tt-trigger",disabled,"aria-label":"Replay",onClick:onRestart},h(Ic,{name:"restart"})),
2565
- h("span",{className:"t-tt tl-tt-below",role:"tooltip"},"Replay")),
2566
- ),
2567
- );
2568
- }
2569
-
2570
2223
  function TimelinePanel(){
2571
2224
  const entries=useReg(); const{active,setActiveId}=useActive();
2572
- const{state,play,pause,resume,restart,stop}=usePlayback(); const{setPropOverride}=usePropOverride();
2573
- const{registry,preview}=useContext(TimelineCtx);
2225
+ const{setPropOverride}=usePropOverride();
2226
+ const{registry}=useContext(TimelineCtx);
2574
2227
  const[copied,setCopied]=useState(false);
2575
2228
  const[minimized,setMinimized]=useState(false);
2576
2229
  const[panelHeight,setPanelHeight]=useState(440);
2577
2230
  const[resizing,setResizing]=useState(false);
2578
- const[speed,setSpeed]=useState(1);
2579
- const[loop,setLoop]=useState(false);
2580
2231
  const[snap,setSnap]=useState(true);
2581
2232
  // ── refine ──
2582
2233
  const[refineOpen,setRefineOpen]=useState(false);
@@ -2628,8 +2279,10 @@
2628
2279
  const et=active.effectiveTimings||[];
2629
2280
  const timings=et.map(t=>({property:t.property,durationMs:t.durationMs,delayMs:t.delayMs,easing:t.easing}));
2630
2281
  const selector=(active.bindings&&active.bindings.selector)||(et[0]&&et[0].selector)||null;
2282
+ // always scan for BOTH kinds (token tweaks + a whole-transition replacement)
2283
+ // in one pass; the tabs just filter which kind they show.
2631
2284
  const{id}=await relayCreateJob({transitionId:active.id,label:active.label,selector,
2632
- phase:active.phase||null,group:active.groupLabel||null,timings,mode,refineType});
2285
+ phase:active.phase||null,group:active.groupLabel||null,timings,mode,refineType:"both"});
2633
2286
  setRefineJobId(id);
2634
2287
  }catch(e){
2635
2288
  setRefinePhase("error");
@@ -2637,7 +2290,9 @@
2637
2290
  }
2638
2291
  },[active,refineMode,refineType,refreshHealth]);
2639
2292
  const changeRefineMode=useCallback((mode)=>{setRefineMode(mode);setRefinePhase("idle");refreshHealth();},[refreshHealth]);
2640
- const changeRefineType=useCallback((t)=>{setRefineType(t);setRefinePhase("idle");},[]);
2293
+ // switching tabs only changes which kind of suggestion is shown — it never
2294
+ // interrupts a scan or discards results, since one scan covers both kinds.
2295
+ const changeRefineType=useCallback((t)=>{setRefineType(t);},[]);
2641
2296
  // poll the relay while a job is running
2642
2297
  useEffect(()=>{
2643
2298
  if(!refineJobId||refinePhase!=="scanning")return;
@@ -2676,8 +2331,59 @@
2676
2331
  const panelMinH=200;
2677
2332
  const panelMaxH=useCallback(()=>Math.round(window.innerHeight*0.92),[]);
2678
2333
  useEffect(()=>{if(!active&&entries.length>0)setActiveId(entries[0].id);},[active,entries,setActiveId]);
2679
- useEffect(()=>{preview.setRate(speed);},[preview,speed]);
2680
- useEffect(()=>{preview.setLoop(loop);},[preview,loop]);
2334
+ // ── live styling ──────────────────────────────────────────────────────
2335
+ // With playback gone, the real component IS the preview. So whenever a
2336
+ // transition has edits, mirror its effective timings onto the live
2337
+ // element(s) as an inline `transition`, and revert when edits are cleared.
2338
+ // Interacting with the component then animates with the edited values.
2339
+ const liveAppliedRef=useRef(new Map()); // el → {prevInline, css}
2340
+ useEffect(()=>{
2341
+ if(typeof document==="undefined")return;
2342
+ const applied=liveAppliedRef.current;
2343
+ const desired=new Map(); // el → css
2344
+ const add=(els,css)=>{ for(const el of els){ if(!el||(el.closest&&el.closest("[data-timeline-panel]")))continue; desired.set(el,css); } };
2345
+ for(const item of entries){
2346
+ const ov=registry.getPropOverrides(item.id);
2347
+ if(!ov||!Object.keys(ov).length)continue; // only edited transitions go live
2348
+ if(item.kind==="phase"){
2349
+ for(const m of (item.members||[])){
2350
+ if(!m.selector||!m.lanes||!m.lanes.length)continue;
2351
+ let els=[];try{els=Array.from(document.querySelectorAll(m.selector));}catch{}
2352
+ add(els,transitionLanesToCss(m.lanes));
2353
+ }
2354
+ }else{
2355
+ const lanes=item.effectiveTimings||[];
2356
+ if(!lanes.length)continue;
2357
+ const els=((item.bindings&&item.bindings.elements)||[]).map(w=>w.deref&&w.deref()).filter(Boolean);
2358
+ add(els,transitionLanesToCss(lanes));
2359
+ }
2360
+ }
2361
+ // apply new / changed
2362
+ for(const [el,css] of desired){
2363
+ const cur=applied.get(el);
2364
+ if(cur&&cur.css===css)continue;
2365
+ if(!cur){
2366
+ const s=getComputedStyle(el);
2367
+ const z=zipTransitionLists((s.transitionProperty||"").split(","),(s.transitionDuration||"0s").split(","),(s.transitionDelay||"0s").split(","),splitCssValues(s.transitionTimingFunction||"ease"));
2368
+ _TX_LIVE_BASE.set(el,{z});
2369
+ applied.set(el,{prevInline:el.style.transition||"",css});
2370
+ }else applied.set(el,{prevInline:cur.prevInline,css});
2371
+ try{el.style.transition=css;}catch{}
2372
+ }
2373
+ // revert elements that no longer have edits
2374
+ for(const [el,rec] of Array.from(applied)){
2375
+ if(desired.has(el))continue;
2376
+ try{el.style.transition=rec.prevInline;}catch{}
2377
+ _TX_LIVE_BASE.delete(el);
2378
+ applied.delete(el);
2379
+ }
2380
+ },[entries,registry]);
2381
+ // restore everything on unmount
2382
+ useEffect(()=>()=>{
2383
+ const applied=liveAppliedRef.current;
2384
+ for(const [el,rec] of applied){ try{el.style.transition=rec.prevInline;}catch{} _TX_LIVE_BASE.delete(el); }
2385
+ applied.clear();
2386
+ },[]);
2681
2387
  const startResize=useCallback(e=>{
2682
2388
  e.preventDefault();
2683
2389
  const startY=e.clientY; const startH=panelHeight;
@@ -2737,33 +2443,81 @@
2737
2443
  },[active]);
2738
2444
  // reset Accept feedback when switching transitions
2739
2445
  useEffect(()=>{setAcceptState("idle");setAcceptError(null);},[active&&active.id]);
2740
- // Auto-run the grouped scan once, after the flat DOM scan has populated.
2741
- // The agent reads the source and returns Open/Close phases; if no agent is
2742
- // live (or the relay is down) we silently keep the flat DOM scan.
2446
+ // The grouped scan asks the agent to read the source and return Open/Close
2447
+ // phases an expensive LLM round-trip. So it runs ONLY:
2448
+ // 1. the first time this page is ever opened (nothing cached), or
2449
+ // 2. on demand via Settings → "Rescan transitions".
2450
+ // The result is cached in localStorage by page path. A plain reload
2451
+ // re-hydrates the groups instantly and NEVER touches the agent. Use
2452
+ // "Rescan transitions" after the page's transitions actually change.
2453
+ const GROUP_STORE_KEY="tlGroups:"+location.pathname;
2454
+ // content signature of the flat transitions — so cached groups invalidate
2455
+ // automatically when the page's transitions change (e.g. the demo content
2456
+ // was swapped under the same pathname). Computed from the SETTLED flat set.
2457
+ const flatSig=useCallback((flat)=>flat.map(e=>[e.label,e.bindings&&e.bindings.selector,(e.properties||[]).join(","),e.durationMs,e.delayMs,e.easing].join("|")).sort().join("\u00a7"),[]);
2458
+ const readGroupCache=useCallback(()=>{
2459
+ try{const c=JSON.parse(localStorage.getItem(GROUP_STORE_KEY)||"null");
2460
+ return c&&Array.isArray(c.groups)&&c.groups.length?c:null;}catch{return null;}
2461
+ },[GROUP_STORE_KEY]);
2462
+ // monotonic token so an unmount or a newer rescan supersedes an in-flight poll
2463
+ const scanTokenRef=useRef(0);
2464
+ const runGroupScan=useCallback(async()=>{
2465
+ const token=++scanTokenRef.current;
2466
+ setGroupScanState("scanning");
2467
+ // wait for the flat DOM scan to settle (count stable twice) so the agent
2468
+ // sees every transition, not a partial set discovered progressively.
2469
+ let prev=-1,stable=0;
2470
+ while(scanTokenRef.current===token){
2471
+ const n=registry.getAll().filter(e=>e.kind!=="phase").length;
2472
+ if(n>0&&n===prev){if(++stable>=2)break;}else stable=0;
2473
+ prev=n;
2474
+ await new Promise(r=>setTimeout(r,300));
2475
+ }
2476
+ if(scanTokenRef.current!==token)return;
2477
+ const flat=registry.getAll().filter(e=>e.kind!=="phase");
2478
+ if(!flat.length){setGroupScanState("idle");return;}
2479
+ // valid cache (same content signature) → apply instantly, skip the agent.
2480
+ // A stale cache (different sig, e.g. content changed) falls through to a
2481
+ // fresh scan and is overwritten below.
2482
+ const sig=flatSig(flat);
2483
+ const cached=readGroupCache();
2484
+ if(cached&&cached.sig===sig){registry.setGroups(cached.groups);setGroupScanState("done");return;}
2485
+ setGroupScanState("scanning");
2486
+ const raw=flat.map(e=>({label:e.label,selector:e.bindings&&e.bindings.selector,
2487
+ properties:e.properties,
2488
+ timings:(e.baseLanes||[]).map(l=>({property:l.property,durationMs:l.durationMs,delayMs:l.delayMs,easing:l.easing}))}));
2489
+ try{
2490
+ const{id}=await relayCreateJob({kind:"scan",url:location.href,raw});
2491
+ for(let i=0;i<520;i++){
2492
+ if(scanTokenRef.current!==token)return; // superseded / unmounted
2493
+ await new Promise(r=>setTimeout(r,500));
2494
+ if(scanTokenRef.current!==token)return;
2495
+ const job=await relayGetJob(id);
2496
+ if(job.status==="done"){
2497
+ const groups=(job.result&&job.result.groups)||[];
2498
+ if(groups.length){registry.setGroups(groups);
2499
+ try{localStorage.setItem(GROUP_STORE_KEY,JSON.stringify({groups,sig,ts:Date.now()}));}catch{}}
2500
+ setGroupScanState("done");return;}
2501
+ if(job.status==="error"){setGroupScanState("error");return;}
2502
+ }
2503
+ setGroupScanState("error");
2504
+ }catch(e){ setGroupScanState("error"); /* relay down → stay flat, retry next open */ }
2505
+ },[registry,GROUP_STORE_KEY,flatSig,readGroupCache]);
2506
+ // manual "Rescan transitions" — drop the cache and force a fresh agent scan
2507
+ const rescanTransitions=useCallback(()=>{
2508
+ try{localStorage.removeItem(GROUP_STORE_KEY);}catch{}
2509
+ runGroupScan();
2510
+ },[runGroupScan,GROUP_STORE_KEY]);
2511
+ // On mount, run the group resolver once. It waits for the flat scan to
2512
+ // settle, then either re-applies cached groups (when the content signature
2513
+ // matches — a pure cache read, no agent) or kicks off a fresh agent scan
2514
+ // (first open OR the page's transitions changed since last cache).
2743
2515
  useEffect(()=>{
2744
- if(didGroupScanRef.current||!entries.length)return;
2516
+ if(didGroupScanRef.current)return;
2745
2517
  didGroupScanRef.current=true;
2746
- let cancelled=false;
2747
- (async()=>{
2748
- setGroupScanState("scanning");
2749
- const flat=registry.getAll().filter(e=>e.kind!=="phase");
2750
- const raw=flat.map(e=>({label:e.label,selector:e.bindings&&e.bindings.selector,
2751
- properties:e.properties,
2752
- timings:(e.baseLanes||[]).map(l=>({property:l.property,durationMs:l.durationMs,delayMs:l.delayMs,easing:l.easing}))}));
2753
- try{
2754
- const{id}=await relayCreateJob({kind:"scan",url:location.href,raw});
2755
- for(let i=0;i<240&&!cancelled;i++){
2756
- await new Promise(r=>setTimeout(r,500));
2757
- const job=await relayGetJob(id);
2758
- if(cancelled)return;
2759
- if(job.status==="done"){const groups=(job.result&&job.result.groups)||[];if(groups.length)registry.setGroups(groups);setGroupScanState("done");return;}
2760
- if(job.status==="error"){setGroupScanState("error");return;}
2761
- }
2762
- if(!cancelled)setGroupScanState("error");
2763
- }catch(e){ if(!cancelled)setGroupScanState("error"); /* relay down → stay flat */ }
2764
- })();
2765
- return ()=>{cancelled=true;};
2766
- },[entries.length,registry]);
2518
+ runGroupScan();
2519
+ return ()=>{scanTokenRef.current++;};
2520
+ },[runGroupScan]);
2767
2521
 
2768
2522
  // whole-component open/close uses the transitions.dev panel reveal:
2769
2523
  // keep the panel mounted while it animates, flip data-open on the next
@@ -2812,12 +2566,11 @@
2812
2566
  h("div",{className:"tl-panel-body"},
2813
2567
  h("div",{className:"tl-panel-main"},
2814
2568
  h(Header,{entries,active,onSelect:setActiveId,onReset:resetOverrides,onCopy:copyValues,copied,
2815
- loop,setLoop,snap,setSnap,onMinimize:()=>setMinimized(true),onRefine:openRefine,refineActive:refineOpen,
2569
+ snap,setSnap,onMinimize:()=>setMinimized(true),onRefine:openRefine,refineActive:refineOpen,
2816
2570
  onAccept,acceptState,acceptDisabled:computeChanges(active).length===0,acceptError,
2817
- scanning:groupScanState==="scanning"}),
2571
+ scanning:groupScanState==="scanning",onRescan:rescanTransitions}),
2818
2572
  active
2819
- ?h(Body,{entry:active,onPropChange:(prop,o)=>setPropOverride(prop,o),
2820
- state,play,pause,resume,restart,stop,speed,setSpeed,snap})
2573
+ ?h(Body,{entry:active,onPropChange:(prop,o)=>setPropOverride(prop,o),snap})
2821
2574
  :h("div",{className:"tl-empty"},"Select a transition to inspect and edit it")),
2822
2575
  h(RefinePanel,{open:refineOpen,onClose:()=>setRefineOpen(false),phase:refinePhase,label:refineLabel,
2823
2576
  refineType,onType:changeRefineType,suggestions:refineSuggestions,summary:refineSummary,error:refineError,
@@ -2832,10 +2585,36 @@
2832
2585
  }
2833
2586
 
2834
2587
  // ── demo boxes ──
2835
- function BoxResize(){const[on,set]=useState(false);return h("div",{className:"card"},h("h2",null,"Resize + Color"),h("p",null,"Click to toggle. Transitions: width, height, background, border-radius."),h("div",{"data-testid":"resize-box",className:"box-resize"+(on?" expanded":""),onClick:()=>set(v=>!v)}));}
2836
- function BoxOpacity(){const[on,set]=useState(false);return h("div",{className:"card"},h("h2",null,"Opacity + Scale"),h("p",null,"Click to fade. Uses ease-in-out at 600ms."),h("div",{"data-testid":"opacity-box",className:"box-opacity"+(on?" faded":""),onClick:()=>set(v=>!v)}));}
2837
- function BoxSlide(){const[on,set]=useState(false);return h("div",{className:"card"},h("h2",null,"Slide + Bounce"),h("p",null,"Click to slide. Custom cubic-bezier with overshoot."),h("div",{"data-testid":"slide-box",className:"box-slide"+(on?" moved":""),onClick:()=>set(v=>!v)}));}
2838
- function BoxColor(){const[on,set]=useState(false);return h("div",{className:"card"},h("h2",null,"Gradient Glow"),h("p",null,"Click to light up. 800ms background + box-shadow."),h("div",{"data-testid":"glow-box",className:"box-color"+(on?" lit":""),onClick:()=>set(v=>!v)}));}
2588
+ // Plus menu morph transitions.dev (20-plus-menu-morph.md). The trigger
2589
+ // button morphs into the panel it opens; CSS owns the morph, JS only flips
2590
+ // data-open and mirrors it to aria-expanded, closing on outside-click / Escape.
2591
+ function BoxMorph(){
2592
+ const[open,setOpen]=useState(false);
2593
+ const ref=useRef(null);
2594
+ useEffect(()=>{
2595
+ const onDoc=e=>{if(ref.current&&!ref.current.contains(e.target))setOpen(false);};
2596
+ const onKey=e=>{if(e.key==="Escape")setOpen(false);};
2597
+ document.addEventListener("click",onDoc);
2598
+ document.addEventListener("keydown",onKey);
2599
+ return()=>{document.removeEventListener("click",onDoc);document.removeEventListener("keydown",onKey);};
2600
+ },[]);
2601
+ const item=(label,icon)=>h("button",{className:"morph-item",type:"button",onClick:()=>setOpen(false)},
2602
+ h(Ic,{name:icon,size:16}),label);
2603
+ return h("div",{className:"card"},
2604
+ h("h2",null,"Plus \u2192 Menu morph"),
2605
+ h("p",null,"Click the + button. The circular trigger morphs into the menu it opens \u2014 width, height & corner radius animate while the plus cross-fades into the panel."),
2606
+ h("div",{className:"morph-stage"},
2607
+ h("div",{className:"t-morph","data-open":String(open),ref},
2608
+ h("div",{className:"t-morph-menu","aria-hidden":!open},
2609
+ h("div",{className:"morph-title"},"Create"),
2610
+ item("New file","copy"),
2611
+ item("Upload","restart"),
2612
+ item("Settings","gear")),
2613
+ h("button",{className:"t-morph-plus",type:"button","aria-expanded":String(open),"aria-label":"Open menu",
2614
+ onClick:e=>{e.stopPropagation();setOpen(v=>!v);}},
2615
+ h("svg",{width:20,height:20,viewBox:"0 0 20 20",fill:"none"},
2616
+ h("path",{d:"M10 4.5v11M4.5 10h11",stroke:"currentColor","strokeWidth":1.8,"strokeLinecap":"round"}))))));
2617
+ }
2839
2618
 
2840
2619
  // ── live controls for the whole-panel reveal transition (demo harness) ──
2841
2620
  // transitions.dev motion tokens (values lifted verbatim from the skill's _root.css)
@@ -2937,17 +2716,17 @@
2937
2716
  }
2938
2717
 
2939
2718
  function App(){
2940
- const rootRef=useRef(null);const registry=useMemo(()=>new TransitionRegistry(),[]);const preview=useMemo(()=>new PreviewController(),[]);const[activeId,setActiveId]=useState(null);
2941
- useEffect(()=>{const root=rootRef.current??document.body;const scanner=new DomScanner(root,registry);preview.setScanner(scanner);scanner.start();return()=>{scanner.stop();preview.setScanner(null);};},[registry,preview]);
2942
- const ctx=useMemo(()=>({registry,preview,activeId,setActiveId}),[registry,preview,activeId]);
2719
+ const rootRef=useRef(null);const registry=useMemo(()=>new TransitionRegistry(),[]);const[activeId,setActiveId]=useState(null);
2720
+ useEffect(()=>{const root=rootRef.current??document.body;const scanner=new DomScanner(root,registry);scanner.start();return()=>{scanner.stop();};},[registry]);
2721
+ const ctx=useMemo(()=>({registry,activeId,setActiveId}),[registry,activeId]);
2943
2722
  // Demo-only tweak controls: hidden by default. Append ?controls to the URL
2944
2723
  // to show them for testing. (This whole block is below the inject CUT_MARKER,
2945
2724
  // so it never ships in the injected build.)
2946
2725
  const showControls=(()=>{try{return new URLSearchParams(location.search).has("controls");}catch(e){return false;}})();
2947
2726
  return h(TimelineCtx.Provider,{value:ctx},
2948
2727
  showControls&&h(PanelControls),
2949
- h("div",{ref:rootRef,className:"demo-root"},h("div",{className:"demo-header"},h("h1",null,"Timeline Inspector \u2014 Demo"),h("p",null,"CSS transitions are automatically detected. Select one below, drag the bars to edit timing, then Play.")),
2950
- h("div",{className:"demo-grid"},h(BoxResize),h(BoxOpacity),h(BoxSlide),h(BoxColor))),
2728
+ 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.")),
2729
+ h("div",{className:"demo-grid"},h(BoxMorph))),
2951
2730
  h(TimelinePanel));
2952
2731
  }
2953
2732