transitions-refine 0.3.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 */
@@ -251,15 +343,15 @@
251
343
 
252
344
  /* ── body / floating cards row ── */
253
345
  .tl-body { display: flex; flex: 1; min-height: 0; gap: 0; }
254
- .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; }
255
347
  .tl-inspector { flex: 0 0 280px; padding: 14px 16px 18px; display: flex; flex-direction: column;
256
348
  border-left: 1px solid var(--c-line); min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
257
349
  .tl-insp-title { font-size: 13px; font-weight: 500; line-height: 18px; color: #171717; margin-bottom: 12px; text-transform: capitalize; }
258
350
  .tl-insp-label { font-size: 12px; line-height: 18px; color: #737373; margin: 10px 0 6px; }
259
351
 
260
352
  /* ── tracks / ruler ── */
261
- .tl-tracks { position: relative; flex: 1; }
262
- .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); }
263
355
  .tl-ruler-spacer { flex: 0 0 150px; border-right: 1px solid var(--c-line); }
264
356
  .tl-ruler { position: relative; flex: 1; margin-left: 16px; }
265
357
  .tl-ruler .maj { position: absolute; top: 9px; font-size: 12px; line-height: 14px; color: var(--c-ruler); transform: translateX(-50%); white-space: nowrap; }
@@ -272,22 +364,25 @@
272
364
  .tl-prop-row.selected { background: #fafafa; }
273
365
  .tl-prop-head { flex: 0 0 150px; display: flex; align-items: center; gap: 8px; padding-left: 12px;
274
366
  padding-right: 10px; overflow: hidden; border-right: 1px solid var(--c-line); }
275
- .tl-prop-grip { display: flex; color: #cccdd4; flex: none; cursor: grab; }
276
- .tl-prop-grip:active { cursor: grabbing; }
277
- .tl-rows { position: relative; }
278
- .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);
279
- position: relative; z-index: 5; cursor: grabbing; }
280
- .tl-prop-row.reordering .tl-prop-grip { color: var(--c-ruler); cursor: grabbing; }
281
- .tl-prop-label { font-size: 13px; font-weight: 500; line-height: 18px; color: var(--c-text-mut2); white-space: nowrap; overflow: hidden;
282
- 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; }
283
372
  .tl-prop-row.selected .tl-prop-label { color: #171717; }
284
- .tl-prop-member { flex: none; font-size: 11px; font-weight: 600; line-height: 14px; padding: 1px 6px; border-radius: 60px;
285
- background: var(--c-sec, rgba(0,0,0,0.05)); color: var(--c-text-faint); text-transform: none; max-width: 96px;
286
- overflow: hidden; text-overflow: ellipsis; }
287
- .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); }
288
380
  .tl-prop-prop { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
289
- .tl-insp-member { display: inline-block; font-size: 11px; font-weight: 600; line-height: 14px; padding: 1px 6px; border-radius: 60px;
290
- background: var(--c-sec, rgba(0,0,0,0.05)); color: var(--c-text-faint); text-transform: none; margin-right: 6px; vertical-align: middle; }
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; }
291
386
  /* 16px left inset of the plot area (kept in sync with the ruler) */
292
387
  .tl-prop-track { position: relative; flex: 1; user-select: none; margin-left: 16px; }
293
388
  /* timeline line — capsule (fully rounded), Figma #eeeeef default / #e9e9e9 hover */
@@ -328,7 +423,8 @@
328
423
  /* inner slider fill — Figma button/tiny: soft drop shadow + inset ring give the
329
424
  "raised block" look (shadow 0 1px 3px @4%, hairline border, bottom 1px). */
330
425
  .tl-field-fill { position: absolute; left: 0; top: 0; bottom: 0; min-width: 36px; border-radius: 8px;
331
- 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);
332
428
  pointer-events: none; transition: background 0.12s ease, opacity 0.12s ease; z-index: 1; }
333
429
  .tl-field.is-dragging .tl-field-fill { background: var(--c-fill-a); }
334
430
  .tl-field.is-dragging .tl-field-label { opacity: 0.7; }
@@ -445,31 +541,9 @@
445
541
  .tl-seg-btn:hover:not(.is-active) { background: rgba(170,170,170,0.06); color: #17181c; }
446
542
  .tl-seg-btn.is-active { background: rgba(170,170,170,0.1); color: #17181c; }
447
543
 
448
- /* position preview (animated marker à la easing.dev) */
449
- .tl-preview { margin-top: 8px; background: #fff; border-radius: 8px; box-shadow: inset 0 0 0 1px var(--c-hairline);
450
- padding: 8px 12px 14px; }
451
- .tl-preview-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
452
- .tl-preview-title { font-size: 11px; font-weight: 600; color: var(--c-text-mut); }
453
- .tl-preview-btn { display: inline-flex; align-items: center; gap: 5px; height: 22px; padding: 0 8px 0 7px;
454
- border: none; border-radius: 6px; background: var(--c-field-bg); color: var(--c-text-mut2);
455
- font: inherit; font-size: 11px; font-weight: 500; cursor: pointer;
456
- transition: background 0.12s ease, scale 0.12s ease; }
457
- .tl-preview-btn:hover { background: var(--c-sec-h); }
458
- .tl-preview-btn:active { scale: 0.96; }
459
- .tl-preview-track { position: relative; height: 16px; }
460
- .tl-preview-rail { position: absolute; left: 0; right: 0; top: 50%; height: 2px; transform: translateY(-50%);
461
- background: var(--c-track); border-radius: 2px; }
462
- .tl-preview-end { position: absolute; top: 50%; width: 2px; height: 8px; transform: translateY(-50%);
463
- background: #d0d0d6; border-radius: 2px; }
464
- .tl-preview-end.left { left: 0; }
465
- .tl-preview-end.right { right: 0; }
466
- .tl-preview-dot { position: absolute; left: 0; top: 50%; width: 14px; height: 14px; margin-top: -7px;
467
- border-radius: 50%; background: var(--c-blue); box-shadow: 0 1px 3px rgba(0,0,0,0.25);
468
- will-change: transform; }
469
-
470
- /* menu section header (non-clickable) */
471
- .tl-menu-group { padding: 9px 10px 4px; font-size: 10.5px; font-weight: 600; letter-spacing: 0.04em;
472
- 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; }
473
547
  .tl-menu-group:first-child { padding-top: 4px; }
474
548
 
475
549
  /* spring params box (reuses bounce row layout) */
@@ -497,10 +571,19 @@
497
571
  display: flex; align-items: center; justify-content: center;
498
572
  background: #fff; border-radius: 12px; box-shadow: var(--card-shadow); }
499
573
 
500
- /* ── 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. */
501
578
  .tl-menu { background: #fff; border-radius: 12px; padding: 6px; box-shadow: var(--menu-shadow); }
502
- .tl-menu-item { display: flex; align-items: center; gap: 10px; min-height: 32px; padding: 6px 10px;
503
- 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;
504
587
  transition: background 0.1s ease, box-shadow 0.1s ease; }
505
588
  .tl-menu-item:hover { background: #f4f4f5; }
506
589
  .tl-menu-item:active { background: #ededee; }
@@ -508,15 +591,14 @@
508
591
  .tl-menu-item.disabled { color: var(--c-disabled); pointer-events: none; }
509
592
  .tl-menu-item-label { flex: 1; min-width: 0; display: flex; align-items: center; }
510
593
  .tl-menu-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
511
- .tl-menu-dim { color: var(--c-text-faint); }
594
+ .tl-menu-dim { color: #979797; }
512
595
  .tl-menu-help { color: #c4c4cc; margin-left: 10px; flex: none; cursor: help; }
513
596
  .tl-menu-help:hover { color: var(--c-ruler); }
514
597
  .tl-menu-help svg { display: block; }
515
598
  .tl-menu-check { display: flex; color: var(--c-text-strong); flex: none; }
516
- .tl-menu-empty { padding: 10px; color: var(--c-disabled); font-size: 13px; }
517
- .tl-menu-section { padding: 8px 10px 4px; font-size: 11px; font-weight: 600; line-height: 14px;
518
- letter-spacing: 0.02em; text-transform: uppercase; color: var(--c-text-faint); }
519
- .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; }
520
602
 
521
603
  /* ═════ transitions.dev — menu dropdown (verbatim) ═════ */
522
604
  .t-dropdown {
@@ -596,6 +678,7 @@
596
678
  background: var(--tt-bg);
597
679
  color: var(--tt-fg);
598
680
  white-space: nowrap;
681
+ z-index: 100;
599
682
  box-shadow:
600
683
  0 0 0 1px rgba(0, 0, 0, 0.06),
601
684
  0 2px 6px 0 rgba(0, 0, 0, 0.05),
@@ -710,7 +793,8 @@
710
793
  /* refine panel slides in from the right using transitions.dev panel reveal
711
794
  tokens (translate + opacity + cross-blur, per-phase open/close dur/ease). */
712
795
  .tl-refine-panel {
713
- 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;
714
798
  width: var(--refine-w, 360px); max-width: 100%; overflow: hidden;
715
799
  --panel-translate-x: var(--panel-close-distance);
716
800
  --panel-scale-now: var(--panel-scale-close);
@@ -818,10 +902,7 @@
818
902
  border: none; border-radius: 60px; cursor: pointer;
819
903
  font: inherit; font-size: 13px; font-weight: 500; line-height: 14px; color: #0073e5;
820
904
  background: rgba(0,115,229,0.04); text-shadow: 0 1px 3px rgba(0,0,0,0.04);
821
- box-shadow: 0 1px 3px rgba(0,0,0,0.04),
822
- inset 0 0 0 1px rgba(0,101,208,0.10),
823
- inset 0 -1px 0 0 rgba(0,0,0,0.06),
824
- inset 0 0 0 1px rgba(196,196,196,0.10);
905
+ box-shadow: var(--drop-btn), var(--ring-blue);
825
906
  --resize-dur: 300ms; --resize-ease: cubic-bezier(0.22, 1, 0.36, 1);
826
907
  transition: width var(--resize-dur) var(--resize-ease),
827
908
  height var(--resize-dur) var(--resize-ease),
@@ -998,12 +1079,6 @@
998
1079
  // Motion tokens are the verbatim cubic-beziers shipped by the transitions.dev
999
1080
  // skill (_root.css); the "Common" set are the standard easings.net curves.
1000
1081
  const EASING_LIBRARY = [
1001
- { group:"Standard" },
1002
- { label:"Linear", value:"linear" },
1003
- { label:"Ease", value:"ease" },
1004
- { label:"Ease in", value:"ease-in" },
1005
- { label:"Ease out", value:"ease-out" },
1006
- { label:"Ease in-out", value:"ease-in-out" },
1007
1082
  { group:"Motion tokens (transitions.dev)" },
1008
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." },
1009
1084
  { label:"Standard close", value:"cubic-bezier(0.4, 0, 0.2, 1)", usage:"Material-style accelerate→decelerate. Used for badge close / calm exits." },
@@ -1012,25 +1087,6 @@
1012
1087
  { label:"Morph open", value:"cubic-bezier(0.34, 1.25, 0.64, 1)", usage:"Plus-to-menu morph open — gentle bouncy expand." },
1013
1088
  { label:"Check bob", value:"cubic-bezier(0.34, 1.35, 0.64, 1)", usage:"Success check bob — playful settle on confirmation." },
1014
1089
  { label:"Big overshoot", value:"cubic-bezier(0.34, 3.85, 0.64, 1)", usage:"Avatar group hover return — aggressive, spring-like overshoot." },
1015
- { group:"Common (easings.net)" },
1016
- { label:"Sine in", value:"cubic-bezier(0.12, 0, 0.39, 0)" },
1017
- { label:"Sine out", value:"cubic-bezier(0.61, 1, 0.88, 1)" },
1018
- { label:"Sine in-out", value:"cubic-bezier(0.37, 0, 0.63, 1)" },
1019
- { label:"Cubic in", value:"cubic-bezier(0.32, 0, 0.67, 0)" },
1020
- { label:"Cubic out", value:"cubic-bezier(0.33, 1, 0.68, 1)" },
1021
- { label:"Cubic in-out", value:"cubic-bezier(0.65, 0, 0.35, 1)" },
1022
- { label:"Quart in", value:"cubic-bezier(0.5, 0, 0.75, 0)" },
1023
- { label:"Quart out", value:"cubic-bezier(0.25, 1, 0.5, 1)" },
1024
- { label:"Quart in-out", value:"cubic-bezier(0.76, 0, 0.24, 1)" },
1025
- { label:"Expo in", value:"cubic-bezier(0.7, 0, 0.84, 0)" },
1026
- { label:"Expo out", value:"cubic-bezier(0.16, 1, 0.3, 1)" },
1027
- { label:"Expo in-out", value:"cubic-bezier(0.87, 0, 0.13, 1)" },
1028
- { label:"Circ in", value:"cubic-bezier(0.55, 0, 1, 0.45)" },
1029
- { label:"Circ out", value:"cubic-bezier(0, 0.55, 0.45, 1)" },
1030
- { label:"Circ in-out", value:"cubic-bezier(0.85, 0, 0.15, 1)" },
1031
- { label:"Back in", value:"cubic-bezier(0.36, 0, 0.66, -0.56)" },
1032
- { label:"Back out", value:"cubic-bezier(0.34, 1.56, 0.64, 1)" },
1033
- { label:"Back in-out", value:"cubic-bezier(0.68, -0.6, 0.32, 1.6)" },
1034
1090
  { group:"Custom" },
1035
1091
  { label:"cubic-bezier(\u2026)", value:"__cubic" },
1036
1092
  { label:"custom", value:"__custom" },
@@ -1193,338 +1249,6 @@
1193
1249
  }
1194
1250
  }
1195
1251
 
1196
- // ── preview controller ──
1197
- // ── transition capture ──
1198
- // Real-world transitions are usually triggered by interaction (hover, a
1199
- // click on a *different* button, focus, JS state) — not by clicking the
1200
- // element that carries the transition. So we observe transitions as they
1201
- // actually run and remember each property's from/to computed values; the
1202
- // preview then replays them with no synthetic click required.
1203
- const _txCapture = new WeakMap(); // Element -> Map<prop,{from,to}>
1204
- let _txInstalled = false;
1205
- function _txCamel(p){return p.replace(/-([a-z])/g,(_,c)=>c.toUpperCase());}
1206
- function _txOnRun(e){
1207
- const el=e.target, prop=e.propertyName;
1208
- if(!el||!prop||typeof el.getAnimations!=="function")return;
1209
- if(el.closest&&el.closest("[data-timeline-panel]"))return;
1210
- try{
1211
- const ck=_txCamel(prop);
1212
- const anims=el.getAnimations();
1213
- let anim=anims.find(a=>a.transitionProperty===prop);
1214
- 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;}});
1215
- if(!anim||!anim.effect)return;
1216
- const kf=anim.effect.getKeyframes();
1217
- if(!kf||kf.length<2)return;
1218
- const from=kf[0][ck], to=kf[kf.length-1][ck];
1219
- if(from==null||to==null)return;
1220
- let rec=_txCapture.get(el); if(!rec){rec=new Map();_txCapture.set(el,rec);}
1221
- rec.set(prop,{from:String(from),to:String(to)});
1222
- }catch{}
1223
- }
1224
- function _txInstallCapture(){
1225
- if(_txInstalled||typeof document==="undefined")return;
1226
- _txInstalled=true;
1227
- // capture phase + the bubbling transitionrun event reaches document
1228
- document.addEventListener("transitionrun",_txOnRun,true);
1229
- }
1230
- // from/to recovered from a previously observed real run
1231
- function _txCaptured(el,et){
1232
- const rec=_txCapture.get(el);if(!rec)return null;
1233
- const from={},to={};let n=0;
1234
- for(const t of et){const c=rec.get(t.property);if(c){from[t.property]=c.from;to[t.property]=c.to;n++;}}
1235
- return n?{from,to}:null;
1236
- }
1237
- // Discover the transition's end-state WITHOUT interaction by reading the
1238
- // stylesheets: find rules that set a transitioning prop and represent a
1239
- // *state* of the element (a :hover/:focus pseudo, or a toggled class/attr
1240
- // like `.modal.open`). from = current computed value, to = the rule's value.
1241
- 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;
1242
- function _txReduceLast(el,sel){
1243
- const parts=sel.split(/(\s*[>+~]\s*|\s+)/);
1244
- let i=parts.length-1;while(i>=0&&/^\s*[>+~]?\s*$/.test(parts[i]))i--;
1245
- if(i<0)return null;
1246
- const toks=parts[i].match(/[.#]?[\w-]+|\[[^\]]*\]|::?[\w-]+(?:\([^)]*\))?|\*/g)||[];
1247
- let dropped=false,hasId=false;
1248
- const kept=toks.filter(tk=>{
1249
- if(tk[0]==="."){const ok=el.classList.contains(tk.slice(1));if(ok)hasId=true;else dropped=true;return ok;}
1250
- if(tk[0]==="["){let ok;try{ok=el.matches(tk);}catch{ok=false;}if(ok)hasId=true;else dropped=true;return ok;}
1251
- if(tk[0]==="#"){const ok=el.id&&("#"+el.id)===tk;if(ok)hasId=true;else dropped=true;return ok;}
1252
- if(tk[0]===":"){dropped=true;return false;} // pseudo = the state delta
1253
- return true; // tag / *
1254
- });
1255
- // Only a real *variant of this element*: we must have removed a state token
1256
- // AND kept a class/id/attr that ties the rule to el. Dropping every class to
1257
- // land on a bare tag/`*` would falsely match unrelated rules.
1258
- if(!dropped||!hasId)return null;
1259
- parts[i]=kept.join("");
1260
- return parts.join("");
1261
- }
1262
- function _txIsStateOf(el,sel){
1263
- try{if(el.matches(sel))return false;}catch{return false;}
1264
- const noP=sel.replace(_TX_STATE_PSEUDO,"").trim();
1265
- if(noP&&noP!==sel){try{if(el.matches(noP))return true;}catch{}}
1266
- const red=_txReduceLast(el,sel);
1267
- if(red&&red!==sel){try{if(el.matches(red))return true;}catch{}}
1268
- return false;
1269
- }
1270
- function _txScanRules(rules,el,props,to){
1271
- for(const rule of rules){
1272
- // Read style rules directly. NOTE: in browsers with CSS Nesting a plain
1273
- // CSSStyleRule also has a (usually empty) .cssRules, so test selectorText
1274
- // first — don't treat every style rule as a grouping rule.
1275
- const decl=rule.style;
1276
- if(decl&&rule.selectorText){
1277
- let sets=false;for(const p of props){if(decl.getPropertyValue(p)){sets=true;break;}}
1278
- if(sets){
1279
- for(const sub of rule.selectorText.split(",")){
1280
- const s=sub.trim();if(!s)continue;
1281
- if(_txIsStateOf(el,s)){for(const p of props){const v=decl.getPropertyValue(p);if(v&&!(p in to))to[p]=v.trim();}}
1282
- }
1283
- }
1284
- }
1285
- // Recurse into @media / @supports / @layer and any nested rules.
1286
- if(rule.cssRules&&rule.cssRules.length){
1287
- try{if(rule.media&&!window.matchMedia(rule.media.mediaText).matches)continue;}catch{}
1288
- _txScanRules(rule.cssRules,el,props,to);
1289
- }
1290
- }
1291
- }
1292
- function _txDiscover(el,et){
1293
- if(typeof document==="undefined"||!el.matches)return null;
1294
- const props=et.map(t=>t.property).filter(p=>p&&p!=="all");
1295
- if(!props.length)return null;
1296
- const to={};
1297
- for(const sheet of Array.from(document.styleSheets||[])){
1298
- let rules;try{rules=sheet.cssRules;}catch{continue;} // cross-origin sheet
1299
- if(rules)_txScanRules(rules,el,props,to);
1300
- }
1301
- const keys=Object.keys(to);if(!keys.length)return null;
1302
- const cs=getComputedStyle(el);const from={};
1303
- for(const p of keys)from[p]=cs.getPropertyValue(p)||cs[_txCamel(p)]||"";
1304
- return {from,to};
1305
- }
1306
- // Toggle a phase's state class/attribute (e.g. ".is-open" or "[data-open]")
1307
- // and read the resulting computed values, so a phase plays its real end-state
1308
- // even when the source uses a toggled class the DOM isn't currently in.
1309
- function _txToState(el,et,toState){
1310
- if(typeof document==="undefined"||!el.matches||!toState)return null;
1311
- const props=et.map(t=>t.property).filter(p=>p&&p!=="all");
1312
- if(!props.length)return null;
1313
- const s=String(toState).trim();
1314
- let applied=null; // {undo}
1315
- try{
1316
- if(s[0]==="."){const c=s.slice(1);if(c&&!el.classList.contains(c)){el.classList.add(c);applied=()=>el.classList.remove(c);}}
1317
- else if(s[0]==="["){
1318
- const m=s.match(/^\[\s*([\w-]+)\s*(?:([~|^$*]?=)\s*"?([^"\]]*)"?\s*)?\]$/);
1319
- 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);}}
1320
- }
1321
- }catch{}
1322
- if(!applied)return null; // already in that state, or unparseable
1323
- const cs=getComputedStyle(el);const to={};
1324
- for(const p of props)to[p]=cs.getPropertyValue(p)||cs[_txCamel(p)]||"";
1325
- try{applied();}catch{}
1326
- const cs0=getComputedStyle(el);const from={};
1327
- for(const p of props)from[p]=cs0.getPropertyValue(p)||cs0[_txCamel(p)]||"";
1328
- const keys=Object.keys(to).filter(p=>to[p]!=null&&to[p]!==""&&to[p]!==from[p]);
1329
- if(!keys.length)return null;
1330
- const f={},t={};for(const p of keys){f[p]=from[p];t[p]=to[p];}
1331
- return {from:f,to:t};
1332
- }
1333
-
1334
- // Add/remove a single state token (".class" or "[attr=val]"/"[attr]") on an
1335
- // element. Returns false for tokens we can't toggle (e.g. pseudo ":hover").
1336
- function _applyStateToken(el,token,add){
1337
- const s=String(token||"").trim();
1338
- if(!s)return true;
1339
- if(s[0]==="."){const c=s.slice(1);if(!c)return false;if(add)el.classList.add(c);else el.classList.remove(c);return true;}
1340
- if(s[0]==="["){
1341
- const m=s.match(/^\[\s*([\w-]+)\s*(?:[~|^$*]?=\s*"?([^"\]]*)"?\s*)?\]$/);
1342
- if(!m)return false;
1343
- const name=m[1],val=m[2]!=null?m[2]:"";
1344
- if(add)el.setAttribute(name,val);else el.removeAttribute(name);
1345
- return true;
1346
- }
1347
- return false; // pseudo (:hover/:focus) or unrecognised → not class-toggleable
1348
- }
1349
- function _tokenToggleable(token){
1350
- const s=String(token||"").trim();
1351
- return !s || s[0]==="."||s[0]==="[";
1352
- }
1353
-
1354
- class PreviewController {
1355
- state="idle"; listeners=new Set(); cleanups=[]; animations=[]; progressListeners=new Set(); _rafId=null; scanner=null; _gen=0;
1356
- rate=1; loop=false; _current=null;
1357
- setScanner(s){this.scanner=s;} getState(){return this.state;}
1358
- setRate(r){this.rate=r;for(const a of this.animations){try{a.playbackRate=r;}catch{}}}
1359
- setLoop(v){this.loop=v;}
1360
- subscribe(fn){this.listeners.add(fn);return()=>this.listeners.delete(fn);}
1361
- onProgress(fn){this.progressListeners.add(fn);return()=>this.progressListeners.delete(fn);}
1362
- play(entry){this.stop();this._gen++;this._current=entry;
1363
- if(this.scanner)this.scanner.pause();this._playCss(entry,this._gen);this._setState("playing");}
1364
- // Resolve a selectable item to a flat list of {el, et, toState} arm targets.
1365
- // Phase items fan out to their members (selector may match several elements,
1366
- // e.g. staggered items); flat items use their bound DOM elements.
1367
- _targets(entry){
1368
- const out=[];
1369
- if(entry.kind==="phase"){
1370
- for(const m of (entry.members||[])){
1371
- if(!m.selector||!m.lanes||!m.lanes.length)continue;
1372
- let els=[];try{els=Array.from(document.querySelectorAll(m.selector));}catch{}
1373
- for(const el of els)out.push({el,et:m.lanes,toState:m.toState});
1374
- }
1375
- return out;
1376
- }
1377
- if(!entry.bindings||entry.bindings.type!=="css")return out;
1378
- const et=entry.effectiveTimings||entry.properties.map(p=>({property:p,durationMs:entry.durationMs,delayMs:entry.delayMs,easing:entry.easing}));
1379
- for(const wr of entry.bindings.elements){const el=wr.deref&&wr.deref();if(el)out.push({el,et,toState:null});}
1380
- return out;
1381
- }
1382
- pause(){if(this.state!=="playing")return;for(const a of this.animations){try{a.pause();}catch{}}this._stopPL();this._setState("paused");}
1383
- resume(){if(this.state!=="paused")return;for(const a of this.animations){try{a.play();}catch{}}this._startPL();this._setState("playing");}
1384
- 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");}
1385
- restart(entry){this.stop();requestAnimationFrame(()=>this.play(entry));}
1386
- playPaused(entry,seekMs){
1387
- this.stop();this._gen++;const gen=this._gen;
1388
- this._pendingSeek=seekMs;
1389
- if(this.scanner)this.scanner.pause();
1390
- const armed=this._armAll(entry);
1391
- const els=armed.els;
1392
- if(!els.length){this._setState("paused");if(seekMs!=null)this._ep(seekMs);return;}
1393
- this.cleanups.push(armed.restore);
1394
- this.animations=[];
1395
- requestAnimationFrame(()=>{if(this._gen!==gen)return;
1396
- const running=[];for(const el of els){for(const a of el.getAnimations()){a.pause();a.playbackRate=this.rate;running.push(a);}}
1397
- this.animations=running;
1398
- const t=this._pendingSeek??seekMs;
1399
- if(t!=null){for(const a of this.animations){try{a.currentTime=t;}catch{}}this._ep(t);}
1400
- });
1401
- this._setState("paused");
1402
- if(seekMs!=null)this._ep(seekMs);
1403
- }
1404
- // Snapshot/restore the toggled state on a root element.
1405
- _snapshotState(el,toggleTokens){
1406
- const attrs={},classes={};
1407
- for(const t of toggleTokens){const s=String(t).trim();
1408
- if(s[0]===".")classes[s.slice(1)]=el.classList.contains(s.slice(1));
1409
- else if(s[0]==="["){const m=s.match(/^\[\s*([\w-]+)/);if(m)attrs[m[1]]=el.getAttribute(m[1]);}}
1410
- return {attrs,classes};
1411
- }
1412
- _restoreState(el,snap){
1413
- for(const c in snap.classes){if(snap.classes[c])el.classList.add(c);else el.classList.remove(c);}
1414
- for(const n in snap.attrs){const v=snap.attrs[n];if(v==null)el.removeAttribute(n);else el.setAttribute(n,v);}
1415
- }
1416
- _setRootState(el,target,toggleTokens){
1417
- for(const t of toggleTokens)_applyStateToken(el,t,false);
1418
- if(target)_applyStateToken(el,target,true);
1419
- }
1420
- // Arm a whole phase by toggling the REAL driving state on its stateTarget,
1421
- // so the actual CSS animates every member in the correct direction (open
1422
- // base→state, close state→base). Edited timings are applied inline on the
1423
- // member elements so they override the source. Returns {els, restore} or
1424
- // null to fall back to per-member arming.
1425
- _armPhase(entry,root){
1426
- const fromState=entry.fromState??null, toState=entry.toState??null;
1427
- if(![fromState,toState].every(_tokenToggleable))return null; // pseudo states → legacy
1428
- const toggleTokens=[fromState,toState].filter(Boolean);
1429
- if(!toggleTokens.length)return null;
1430
- const memberEls=[];
1431
- for(const m of (entry.members||[])){
1432
- if(!m.selector||!m.lanes||!m.lanes.length)continue;
1433
- let els=[];try{els=Array.from(document.querySelectorAll(m.selector));}catch{}
1434
- for(const el of els)memberEls.push({el,lanes:m.lanes});
1435
- }
1436
- if(!memberEls.length)return null;
1437
- const savedRoot=this._snapshotState(root,toggleTokens);
1438
- const savedStyles=memberEls.map(({el})=>el.style.cssText);
1439
- // 1. suppress transitions and commit the FROM state
1440
- for(const {el} of memberEls)el.style.transition="none";
1441
- this._setRootState(root,fromState,toggleTokens);
1442
- void root.offsetWidth;for(const {el} of memberEls)void el.offsetWidth;
1443
- // 2. apply the (possibly edited) per-member timings inline
1444
- for(const {el,lanes} of memberEls){
1445
- el.style.transition=lanes.map(t=>`${t.property} ${formatCssTime(t.durationMs)} ${t.easing} ${formatCssTime(t.delayMs)}`).join(", ");
1446
- }
1447
- // 3. toggle to the TO state → real CSS transitions fire on every member
1448
- this._setRootState(root,toState,toggleTokens);
1449
- const restore=()=>{try{this._restoreState(root,savedRoot);}catch{}
1450
- memberEls.forEach(({el},i)=>{try{el.style.cssText=savedStyles[i];}catch{}});};
1451
- return {els:memberEls.map(m=>m.el),restore};
1452
- }
1453
- // Resolve an entry to armed element(s) + a restore fn. Phase items with a
1454
- // toggleable stateTarget use _armPhase; everything else uses per-target arm.
1455
- _armAll(entry){
1456
- if(entry&&entry.kind==="phase"&&entry.stateTarget&&typeof document!=="undefined"){
1457
- let root=null;try{root=document.querySelector(entry.stateTarget);}catch{}
1458
- if(root){const r=this._armPhase(entry,root);if(r)return r;}
1459
- }
1460
- const targets=this._targets(entry);
1461
- const els=[],restores=[];
1462
- for(const {el,et,toState} of targets){restores.push(this._arm(el,et,toState));els.push(el);}
1463
- return {els,restore:()=>{for(const c of restores){try{c();}catch{}}}};
1464
- }
1465
- seek(timeMs){
1466
- if(this.state==="idle")return;
1467
- if(this.state==="playing"){for(const a of this.animations){try{a.pause();}catch{}}this._stopPL();this._setState("paused");}
1468
- for(const a of this.animations){try{a.currentTime=timeMs;}catch{}}
1469
- this._ep(timeMs);
1470
- this._pendingSeek=timeMs;
1471
- }
1472
- _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");}
1473
- // Arm an element so its transition runs now, without needing the user to
1474
- // trigger it first. Priority:
1475
- // 1. a previously observed real run (exact),
1476
- // 2. end-state discovered from the stylesheets (hover/focus/toggled class),
1477
- // 3. a synthetic opacity/transform pulse to preview the timing,
1478
- // 4. click the element (last resort, may have side effects).
1479
- // Returns a restore fn.
1480
- _arm(el,et,toState){
1481
- const saved=el.style.cssText;
1482
- const tv=et.map(t=>`${t.property} ${formatCssTime(t.durationMs)} ${t.easing} ${formatCssTime(t.delayMs)}`).join(", ");
1483
- // toState (a phase's driving class/attr) is the most reliable source of
1484
- // the real end-state; fall back to a captured run, then CSSOM discovery.
1485
- const states=(toState&&_txToState(el,et,toState))||_txCaptured(el,et)||_txDiscover(el,et);
1486
- if(states){
1487
- const props=et.map(t=>t.property).filter(p=>states.from[p]!=null&&states.to[p]!=null);
1488
- if(props.length){
1489
- el.style.transition="none";
1490
- for(const p of props)el.style.setProperty(p,states.from[p]);
1491
- void el.offsetWidth; // commit the from-state before transitioning
1492
- el.style.transition=tv;
1493
- for(const p of props)el.style.setProperty(p,states.to[p]);
1494
- return ()=>{try{el.style.cssText=saved;}catch{}};
1495
- }
1496
- }
1497
- const synth=et.filter(t=>t.property==="opacity"||t.property==="transform"||t.property==="all");
1498
- if(synth.length){
1499
- const dur=Math.max(...et.map(t=>(t.durationMs||0)+(t.delayMs||0)))||et[0].durationMs||300;
1500
- const ease=et[0].easing||"ease";
1501
- const hasOp=synth.some(t=>t.property!=="transform"),hasTr=synth.some(t=>t.property!=="opacity");
1502
- const k0={},k1={},k2={};
1503
- if(hasOp){k0.opacity=1;k1.opacity=0.35;k2.opacity=1;}
1504
- if(hasTr){k0.transform="none";k1.transform="translateY(8px)";k2.transform="none";}
1505
- try{el.animate([k0,k1,k2],{duration:dur,easing:ease,fill:"none"});return ()=>{};}catch{}
1506
- }
1507
- el.style.transition=tv;el.click();
1508
- return ()=>{try{el.style.cssText=saved;}catch{}};
1509
- }
1510
- _playCss(entry,gen){
1511
- const armed=this._armAll(entry);
1512
- const els=armed.els;
1513
- this.animations=[];
1514
- if(!els.length){this._finish();return;}
1515
- this.cleanups.push(armed.restore);
1516
- requestAnimationFrame(()=>{if(this._gen!==gen)return;
1517
- const running=[];for(const el of els){for(const a of el.getAnimations()){a.playbackRate=this.rate;running.push(a);}}
1518
- this.animations=running;this._startPL();
1519
- const onDone=()=>{if(this._gen!==gen||this.state!=="playing")return;if(this.loop&&this._current){this.play(this._current);}else{this._finish();}};
1520
- if(running.length>0){Promise.allSettled(running.map(a=>a.finished)).then(onDone);}else{onDone();}
1521
- });}
1522
- _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);}
1523
- _stopPL(){if(this._rafId!==null){cancelAnimationFrame(this._rafId);this._rafId=null;}}
1524
- _ep(p){for(const fn of this.progressListeners)fn(p);}
1525
- _setState(s){this.state=s;for(const fn of this.listeners)fn(s);}
1526
- }
1527
-
1528
1252
  // ── scanner ──
1529
1253
  class DomScanner {
1530
1254
  observer=null;rafId=null;running=false;paused=false;
@@ -1567,7 +1291,7 @@
1567
1291
  function usePropOverride(){const{registry,activeId}=useContext(TimelineCtx);return{setPropOverride:useCallback((prop,o)=>{if(activeId)registry.setPropOverride(activeId,prop,o);},[registry,activeId])};}
1568
1292
 
1569
1293
  // ── components ──
1570
- const LABEL_W = 150;
1294
+ const LABEL_W = 200;
1571
1295
  const CLOSE_MS = 150;
1572
1296
  const DURATION_TOKENS = [
1573
1297
  {label:"Duration-fast", ms:150, usage:"Quick state changes — hovers, toggles, button presses, dropdown & modal close, text swaps."},
@@ -1605,6 +1329,8 @@
1605
1329
  };
1606
1330
  ICONS.minimize = ICONS.chevron;
1607
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"/>`};
1608
1334
  function Ic({name, size=16}){
1609
1335
  const ic = ICONS[name];
1610
1336
  if(!ic) return null;
@@ -1817,7 +1543,10 @@
1817
1543
  },[phase]);
1818
1544
  if(!render)return null;
1819
1545
 
1820
- 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]);
1821
1550
  const agentMode = mode==="llm";
1822
1551
  // null (unknown / still probing) is treated as ready so the panel doesn't
1823
1552
  // flash the "unavailable" copy before /health resolves.
@@ -1854,7 +1583,7 @@
1854
1583
  h("div",{className:"tl-refine-unavail-text"},error||"The agent reported an error."));
1855
1584
  foot = startBtn({label:"Try again"});
1856
1585
  } else if(phase==="done"){
1857
- if(suggestions.length===0){
1586
+ if(visible.length===0){
1858
1587
  const emptyMsg = refineType==="replace"
1859
1588
  ? "I didn't find any transition that would be a good fit as a replacement."
1860
1589
  : "Already aligned to the transitions.dev motion tokens. Nothing to refine.";
@@ -1862,9 +1591,9 @@
1862
1591
  h("p",{className:"tl-refine-idle-text"},emptyMsg));
1863
1592
  foot = startBtn({label:"Scan again"});
1864
1593
  } else {
1865
- body = h(RefineResults,{summary,suggestions,appliedIds,onApply});
1594
+ body = h(RefineResults,{summary:refineType==="replace"?null:summary,suggestions:visible,appliedIds,onApply});
1866
1595
  foot = pending.length>1
1867
- ? 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+")")
1868
1597
  : startBtn({label:"Scan again"});
1869
1598
  }
1870
1599
  } else { // idle
@@ -1889,7 +1618,7 @@
1889
1618
  h("div",{className:"tl-refine-head"},
1890
1619
  h("div",{className:"tl-refine-titles"},
1891
1620
  h("h3",null,"Refine"),
1892
- h("p",null,label?("Tuning "+label):"Transitions review")),
1621
+ h("p",null,label||"Transitions review")),
1893
1622
  h("div",{className:"tl-refine-actions"},
1894
1623
  h("button",{ref:modeRef,className:cx("tl-refine-mode",modeOpen&&"is-open"),
1895
1624
  "aria-haspopup":"menu","aria-expanded":modeOpen?"true":"false",onClick:()=>setModeOpen(v=>!v)},
@@ -1932,10 +1661,12 @@
1932
1661
  return out;
1933
1662
  }
1934
1663
 
1935
- function Header({entries,active,onSelect,onReset,onCopy,copied,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}){
1936
1665
  const[pick,setPick]=useState(false);
1937
1666
  const[setg,setSetg]=useState(false);
1938
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
1939
1670
  // Split selectable items into agent groups (component → phases) and the
1940
1671
  // flat, ungrouped DOM transitions, preserving order.
1941
1672
  const groupList=[]; const groupMap=new Map(); const flatItems=[];
@@ -1946,6 +1677,12 @@
1946
1677
  g.phases.push(e);
1947
1678
  }else flatItems.push(e);
1948
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;
1949
1686
  const phaseItem=(e)=>h(MenuItem,{key:e.id,active:active&&e.id===active.id,onClick:()=>{onSelect(e.id);setPick(false);},
1950
1687
  right:active&&e.id===active.id&&h("span",{className:"tl-menu-check"},h(Ic,{name:"check"}))},
1951
1688
  h("span",null,e.phaseLabel,h("span",{className:"tl-menu-dim"}," "+e.durationMs+"ms")));
@@ -1959,28 +1696,32 @@
1959
1696
  :h("span",{className:"tl-dim"},"None"),
1960
1697
  h("span",{className:"tl-ghost-chev"},h(Ic,{name:"chevron"}))),
1961
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);}}})),
1962
1703
  entries.length===0
1963
1704
  ? h("div",{className:"tl-menu-empty"},"No transitions found")
1964
- : h(React.Fragment,null,
1965
- ...groupList.map(g=>h(React.Fragment,{key:g.id},
1966
- h("div",{className:"tl-menu-section"},g.label),
1967
- ...g.phases.map(phaseItem))),
1968
- flatItems.length>0&&groupList.length>0&&h("div",{className:"tl-menu-section"},"Ungrouped"),
1969
- ...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))),
1970
1713
  h("span",{className:"tl-header-count"},
1971
1714
  scanning?"Grouping…":entries.length+" transition"+(entries.length===1?"":"s")+" found"),
1972
- 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"})),
1973
1716
  h(Dropdown,{open:setg,onClose:()=>setSetg(false),triggerRef:gearRef,width:210,align:"right"},
1974
- 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")),
1975
1722
  h("span",{className:"t-tt-wrap"},
1976
1723
  h("button",{className:"tl-sec-btn t-tt-trigger",disabled:!active,onClick:onReset},"Reset"),
1977
1724
  h("span",{className:"t-tt tl-tt-below",role:"tooltip"},"Reset values")),
1978
- h("span",{className:"t-tt-wrap"},
1979
- h("button",{className:"tl-icon-btn t-tt-trigger",disabled:!active,"aria-label":"Copy values",onClick:onCopy},
1980
- h("span",{className:"t-icon-swap","data-state":copied?"b":"a"},
1981
- h("span",{className:"t-icon","data-icon":"a"},h(Ic,{name:"copy"})),
1982
- h("span",{className:"t-icon","data-icon":"b"},h(Ic,{name:"check"})))),
1983
- h("span",{className:"t-tt tl-tt-below",role:"tooltip"},copied?"Copied":"Copy values")),
1984
1725
  h("span",{className:"t-tt-wrap"},
1985
1726
  h("button",{className:cx("tl-accept-btn",acceptState==="saving"&&"is-saving",acceptState==="done"&&"is-done"),
1986
1727
  disabled:!active||acceptDisabled||acceptState==="saving"||acceptState==="done",onClick:onAccept,"aria-label":"Accept changes to your code"},
@@ -2011,7 +1752,7 @@
2011
1752
  );
2012
1753
  }
2013
1754
 
2014
- function PropTrack({property, member, delayMs, durationMs, selected, dragging, onSelect, onReorder, onDelayChange, onDurationChange, snap, scaleMs, lockDuration, labelW}){
1755
+ function PropTrack({property, member, delayMs, durationMs, selected, onSelect, onDelayChange, onDurationChange, snap, scaleMs, lockDuration, labelW}){
2015
1756
  const trackRef=useRef(null);
2016
1757
  const grid = snap ? 25 : 1;
2017
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]);
@@ -2038,9 +1779,8 @@
2038
1779
  },[delayMs,durationMs,pxToMs,grid,scaleMs,onSelect,onDelayChange,onDurationChange,lockDuration]);
2039
1780
 
2040
1781
  const delPct=(delayMs/scaleMs)*100; const durPct=(durationMs/scaleMs)*100;
2041
- return h("div",{className:cx("tl-prop-row",selected&&"selected",dragging&&"reordering",lockDuration&&"is-spring"),onClick:onSelect},
1782
+ return h("div",{className:cx("tl-prop-row",selected&&"selected",lockDuration&&"is-spring"),onClick:onSelect},
2042
1783
  h("div",{className:"tl-prop-head",style:labelW!=null?{flexBasis:labelW+"px"}:undefined},
2043
- h("span",{className:"tl-prop-grip",title:"Drag to reorder",onMouseDown:onReorder},h(Ic,{name:"dots"})),
2044
1784
  h("span",{className:"tl-prop-label"},
2045
1785
  member&&h("span",{className:"tl-prop-member"},member),
2046
1786
  h("span",{className:"tl-prop-prop"},property))),
@@ -2129,18 +1869,25 @@
2129
1869
  }
2130
1870
 
2131
1871
  // shared plot geometry for both the easing curve and the spring curve.
2132
- // (Figma easing frame node 13044:2506: 269×162 with generous H margins + tight V margins.)
2133
- 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 };
2134
1878
 
2135
1879
  function EasingEditor({easing, cubic, spring, durationMs, propKey, apply}){
2136
1880
  const [tab, setTab] = useState(spring ? "springs" : "easing");
2137
1881
  // when the user selects a different property, reflect that property's mode
2138
1882
  useEffect(()=>{ setTab(spring ? "springs" : "easing"); /* eslint-disable-next-line */ }, [propKey]);
2139
1883
 
2140
- // remember the last real (non-spring) easing so we can restore it when
2141
- // 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).
2142
1887
  const lastEasingRef = useRef("ease");
1888
+ const lastEaseDurRef = useRef(durationMs);
2143
1889
  useEffect(()=>{ if(!spring && easing && !/^linear\(/.test(easing)) lastEasingRef.current = easing; }, [easing, spring]);
1890
+ useEffect(()=>{ if(!spring && durationMs!=null) lastEaseDurRef.current = durationMs; }, [durationMs, spring]);
2144
1891
 
2145
1892
  const applySpring = useCallback((stiffness,damping,mass,name)=>{
2146
1893
  const sim = simulateSpring(stiffness,damping,mass);
@@ -2153,7 +1900,7 @@
2153
1900
  if(!spring){ const p=SPRING_PRESETS[0]; applySpring(p.stiffness,p.damping,p.mass,p.label); }
2154
1901
  } else {
2155
1902
  setTab("easing");
2156
- if(spring) apply({ spring:null, easing:lastEasingRef.current||"ease" });
1903
+ if(spring) apply({ spring:null, easing:lastEasingRef.current||"ease", durationMs:lastEaseDurRef.current });
2157
1904
  }
2158
1905
  },[spring,apply,applySpring]);
2159
1906
 
@@ -2183,59 +1930,6 @@
2183
1930
  h("div",{className:"t-page",ref:springPageRef,"data-page-id":"2","aria-hidden":tab!=="springs"},
2184
1931
  h(SpringTab,{spring,applySpring})),
2185
1932
  ),
2186
- h(PositionPreview,{easing, durationMs}),
2187
- );
2188
- }
2189
-
2190
- // Animated position preview (à la easing.dev): a marker travels left→right
2191
- // using the live timing function + duration, pauses, returns, and loops.
2192
- // Works for any timing function — cubic-bezier keywords or a spring's linear().
2193
- function PositionPreview({easing, durationMs}){
2194
- const trackRef = useRef(null);
2195
- const dotRef = useRef(null);
2196
- const [playing, setPlaying] = useState(false);
2197
- const safeEasing = (!easing || easing.trim()==="") ? "linear" : easing;
2198
- const dur = Math.max(120, durationMs || 0);
2199
-
2200
- useEffect(()=>{
2201
- if(!playing) return;
2202
- const dot = dotRef.current, track = trackRef.current;
2203
- if(!dot || !track) return;
2204
- let cancelled = false, anim = null, timer = null, atRight = false;
2205
- const GAP = 480, DOT = 14;
2206
- const travel = ()=> Math.max(0, track.clientWidth - DOT - 8);
2207
- const step = ()=>{
2208
- if(cancelled) return;
2209
- const from = atRight ? travel() : 0;
2210
- const to = atRight ? 0 : travel();
2211
- try {
2212
- anim = dot.animate(
2213
- [{transform:`translateX(${from}px)`},{transform:`translateX(${to}px)`}],
2214
- {duration:dur, easing:safeEasing, fill:"forwards"});
2215
- } catch {
2216
- anim = dot.animate(
2217
- [{transform:`translateX(${from}px)`},{transform:`translateX(${to}px)`}],
2218
- {duration:dur, easing:"linear", fill:"forwards"});
2219
- }
2220
- anim.onfinish = ()=>{ if(cancelled) return; atRight = !atRight; timer = setTimeout(step, GAP); };
2221
- };
2222
- step();
2223
- return ()=>{ cancelled = true; if(anim){try{anim.cancel();}catch{}} if(timer)clearTimeout(timer); };
2224
- },[playing, safeEasing, dur]);
2225
-
2226
- return h("div",{className:"tl-preview"},
2227
- h("div",{className:"tl-preview-head"},
2228
- h("span",{className:"tl-preview-title"},"Position Preview"),
2229
- h("button",{className:"tl-preview-btn",onClick:()=>setPlaying(p=>!p)},
2230
- h(Ic,{name:playing?"pause":"play",size:11}),
2231
- h("span",null,playing?"Pause":"Play")),
2232
- ),
2233
- h("div",{className:"tl-preview-track",ref:trackRef},
2234
- h("span",{className:"tl-preview-rail"}),
2235
- h("span",{className:"tl-preview-end left"}),
2236
- h("span",{className:"tl-preview-end right"}),
2237
- h("span",{className:"tl-preview-dot",ref:dotRef}),
2238
- ),
2239
1933
  );
2240
1934
  }
2241
1935
 
@@ -2423,11 +2117,9 @@
2423
2117
  }
2424
2118
 
2425
2119
  function Tracks({et, selLane, setSelLane, onPropChange, snap, scaleMs}){
2426
- const ROW_H = 48;
2427
2120
  const rowsRef = useRef(null);
2428
2121
  const tracksRef = useRef(null);
2429
2122
  const [order, setOrder] = useState([]);
2430
- const [dragLane, setDragLane] = useState(null);
2431
2123
  // Width of the label column (shared by the ruler spacer + every prop-row
2432
2124
  // head). Draggable via the divider; the bars column is flex:1 so it just
2433
2125
  // takes the remaining space and the bars stay proportional to scaleMs.
@@ -2457,27 +2149,6 @@
2457
2149
  });
2458
2150
  },[laneKey]);
2459
2151
  const orderedRows = order.map(p=>et.find(x=>x.laneId===p)).filter(Boolean);
2460
- const startReorder = useCallback((laneId,e)=>{
2461
- e.preventDefault(); e.stopPropagation();
2462
- setSelLane(laneId);
2463
- setDragLane(laneId);
2464
- const top = rowsRef.current ? rowsRef.current.getBoundingClientRect().top : 0;
2465
- const onMove = e2=>{
2466
- const idx = Math.floor((e2.clientY - top) / ROW_H);
2467
- setOrder(prev=>{
2468
- const cur = prev.indexOf(laneId);
2469
- if(cur<0) return prev;
2470
- const target = Math.max(0, Math.min(prev.length-1, idx));
2471
- if(target===cur) return prev;
2472
- const next = prev.slice();
2473
- next.splice(cur,1);
2474
- next.splice(target,0,laneId);
2475
- return next;
2476
- });
2477
- };
2478
- const onUp = ()=>{ setDragLane(null); window.removeEventListener("mousemove",onMove); window.removeEventListener("mouseup",onUp); };
2479
- window.addEventListener("mousemove",onMove); window.addEventListener("mouseup",onUp);
2480
- },[setSelLane]);
2481
2152
  const majorStep = scaleMs <= 2000 ? 500 : scaleMs <= 10000 ? 1000 : scaleMs <= 25000 ? 5000 : 10000;
2482
2153
  const minorStep = majorStep / 4;
2483
2154
  const ruler=[];
@@ -2497,9 +2168,8 @@
2497
2168
  ...orderedRows.map(row=>h(PropTrack,{
2498
2169
  key:row.laneId, property:row.property, member:row.member,
2499
2170
  delayMs:row.delayMs, durationMs:row.durationMs, lockDuration:!!row.spring,
2500
- selected:row.laneId===selLane, dragging:row.laneId===dragLane, snap, scaleMs, labelW,
2171
+ selected:row.laneId===selLane, snap, scaleMs, labelW,
2501
2172
  onSelect:()=>setSelLane(row.laneId),
2502
- onReorder:e=>startReorder(row.laneId,e),
2503
2173
  onDelayChange:ms=>onPropChange(row.laneId,{delayMs:ms}),
2504
2174
  onDurationChange:ms=>onPropChange(row.laneId,{durationMs:ms}),
2505
2175
  }))),
@@ -2609,8 +2279,10 @@
2609
2279
  const et=active.effectiveTimings||[];
2610
2280
  const timings=et.map(t=>({property:t.property,durationMs:t.durationMs,delayMs:t.delayMs,easing:t.easing}));
2611
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.
2612
2284
  const{id}=await relayCreateJob({transitionId:active.id,label:active.label,selector,
2613
- phase:active.phase||null,group:active.groupLabel||null,timings,mode,refineType});
2285
+ phase:active.phase||null,group:active.groupLabel||null,timings,mode,refineType:"both"});
2614
2286
  setRefineJobId(id);
2615
2287
  }catch(e){
2616
2288
  setRefinePhase("error");
@@ -2618,7 +2290,9 @@
2618
2290
  }
2619
2291
  },[active,refineMode,refineType,refreshHealth]);
2620
2292
  const changeRefineMode=useCallback((mode)=>{setRefineMode(mode);setRefinePhase("idle");refreshHealth();},[refreshHealth]);
2621
- 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);},[]);
2622
2296
  // poll the relay while a job is running
2623
2297
  useEffect(()=>{
2624
2298
  if(!refineJobId||refinePhase!=="scanning")return;
@@ -2769,47 +2443,81 @@
2769
2443
  },[active]);
2770
2444
  // reset Accept feedback when switching transitions
2771
2445
  useEffect(()=>{setAcceptState("idle");setAcceptError(null);},[active&&active.id]);
2772
- // Auto-run the grouped scan once, after the flat DOM scan has populated.
2773
- // The agent reads the source and returns Open/Close phases; if no agent is
2774
- // live (or the relay is down) we silently keep the flat DOM scan.
2775
- // Lifecycle is tied to `registry` only (a stable ref), never to
2776
- // `entries.length`. On dynamic pages the DOM scanner makes entries.length
2777
- // fluctuate; keying the effect on it tore down the in-flight poll before
2778
- // the agent answered and the once-guard blocked a restart → permanent
2779
- // "Grouping…". Here a single run waits for flat entries to appear, fires
2780
- // one scan job, then polls to completion regardless of DOM churn; only an
2781
- // actual unmount cancels it.
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).
2782
2515
  useEffect(()=>{
2783
2516
  if(didGroupScanRef.current)return;
2784
- let stopped=false;
2785
- const run=async()=>{
2786
- while(!stopped){
2787
- const flat0=registry.getAll().filter(e=>e.kind!=="phase");
2788
- if(flat0.length)break;
2789
- await new Promise(r=>setTimeout(r,300));
2790
- }
2791
- if(stopped||didGroupScanRef.current)return;
2792
- didGroupScanRef.current=true;
2793
- setGroupScanState("scanning");
2794
- const flat=registry.getAll().filter(e=>e.kind!=="phase");
2795
- const raw=flat.map(e=>({label:e.label,selector:e.bindings&&e.bindings.selector,
2796
- properties:e.properties,
2797
- timings:(e.baseLanes||[]).map(l=>({property:l.property,durationMs:l.durationMs,delayMs:l.delayMs,easing:l.easing}))}));
2798
- try{
2799
- const{id}=await relayCreateJob({kind:"scan",url:location.href,raw});
2800
- for(let i=0;i<520&&!stopped;i++){
2801
- await new Promise(r=>setTimeout(r,500));
2802
- const job=await relayGetJob(id);
2803
- if(stopped)return;
2804
- if(job.status==="done"){const groups=(job.result&&job.result.groups)||[];if(groups.length)registry.setGroups(groups);setGroupScanState("done");return;}
2805
- if(job.status==="error"){setGroupScanState("error");return;}
2806
- }
2807
- if(!stopped)setGroupScanState("error");
2808
- }catch(e){ if(!stopped)setGroupScanState("error"); /* relay down → stay flat */ }
2809
- };
2810
- run();
2811
- return ()=>{stopped=true;};
2812
- },[registry]);
2517
+ didGroupScanRef.current=true;
2518
+ runGroupScan();
2519
+ return ()=>{scanTokenRef.current++;};
2520
+ },[runGroupScan]);
2813
2521
 
2814
2522
  // whole-component open/close uses the transitions.dev panel reveal:
2815
2523
  // keep the panel mounted while it animates, flip data-open on the next
@@ -2860,7 +2568,7 @@
2860
2568
  h(Header,{entries,active,onSelect:setActiveId,onReset:resetOverrides,onCopy:copyValues,copied,
2861
2569
  snap,setSnap,onMinimize:()=>setMinimized(true),onRefine:openRefine,refineActive:refineOpen,
2862
2570
  onAccept,acceptState,acceptDisabled:computeChanges(active).length===0,acceptError,
2863
- scanning:groupScanState==="scanning"}),
2571
+ scanning:groupScanState==="scanning",onRescan:rescanTransitions}),
2864
2572
  active
2865
2573
  ?h(Body,{entry:active,onPropChange:(prop,o)=>setPropOverride(prop,o),snap})
2866
2574
  :h("div",{className:"tl-empty"},"Select a transition to inspect and edit it")),
@@ -2877,10 +2585,36 @@
2877
2585
  }
2878
2586
 
2879
2587
  // ── demo boxes ──
2880
- 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)}));}
2881
- 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)}));}
2882
- 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)}));}
2883
- 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
+ }
2884
2618
 
2885
2619
  // ── live controls for the whole-panel reveal transition (demo harness) ──
2886
2620
  // transitions.dev motion tokens (values lifted verbatim from the skill's _root.css)
@@ -2991,8 +2725,8 @@
2991
2725
  const showControls=(()=>{try{return new URLSearchParams(location.search).has("controls");}catch(e){return false;}})();
2992
2726
  return h(TimelineCtx.Provider,{value:ctx},
2993
2727
  showControls&&h(PanelControls),
2994
- 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 interact with the component itself to preview it.")),
2995
- 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))),
2996
2730
  h(TimelinePanel));
2997
2731
  }
2998
2732