transitions-refine 0.3.9 → 0.3.11
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/.agents/skills/refine-live/SKILL.md +118 -65
- package/README.md +40 -13
- package/bin/cli.mjs +167 -58
- package/demo.html +1252 -195
- package/package.json +2 -1
- package/server/motion-tokens.mjs +116 -2
- package/server/relay.mjs +195 -15
package/demo.html
CHANGED
|
@@ -39,6 +39,9 @@
|
|
|
39
39
|
--drop-btn: 0 1px 3px 0 rgba(4,41,117,0.08);
|
|
40
40
|
/* secondary/raised white button (Reset, Settings, Copy, Accept) — exact spec */
|
|
41
41
|
--shadow-btn: 0 1px 3px 0 rgba(0,0,0,0.04), inset 0 0 0 1px rgba(0,0,0,0.06), inset 0 -1px 0 0 rgba(0,0,0,0.10), inset 0 0 0 1px rgba(196,196,196,0.10);
|
|
42
|
+
/* inset-only portion of --shadow-btn — for overlay rings that must reproduce
|
|
43
|
+
the Reset button's visible border on top of a separate fill layer */
|
|
44
|
+
--shadow-btn-inset: inset 0 0 0 1px rgba(0,0,0,0.06), inset 0 -1px 0 0 rgba(0,0,0,0.10), inset 0 0 0 1px rgba(196,196,196,0.10);
|
|
42
45
|
--drop-line: 0 1px 3px 0 rgba(0,0,0,0.08);
|
|
43
46
|
--shadow-border: 0 0 0 1px rgba(0,0,0,0.06), 0 1px 2px -1px rgba(0,0,0,0.06), 0 2px 4px 0 rgba(0,0,0,0.04);
|
|
44
47
|
--shadow-border-hover: 0 0 0 1px rgba(0,0,0,0.08), 0 1px 2px -1px rgba(0,0,0,0.08), 0 2px 4px 0 rgba(0,0,0,0.06);
|
|
@@ -48,7 +51,7 @@
|
|
|
48
51
|
|
|
49
52
|
/* transitions.dev — menu dropdown */
|
|
50
53
|
--dropdown-open-dur: 250ms; --dropdown-close-dur: 150ms;
|
|
51
|
-
--dropdown-pre-scale: 0.
|
|
54
|
+
--dropdown-pre-scale: 0.8; --dropdown-closing-scale: 0.99;
|
|
52
55
|
--dropdown-ease: cubic-bezier(0.22, 1, 0.36, 1);
|
|
53
56
|
/* transitions.dev — icon swap */
|
|
54
57
|
--icon-swap-dur: 200ms; --icon-swap-blur: 2px; --icon-swap-start-scale: 0.25; --icon-swap-ease: ease-in-out;
|
|
@@ -88,15 +91,15 @@
|
|
|
88
91
|
/* @inject-skip-start (demo-only page + sample-box styles — excluded from the injected build) */
|
|
89
92
|
body {
|
|
90
93
|
font-family: "Inter", system-ui, -apple-system, sans-serif;
|
|
91
|
-
background: #
|
|
94
|
+
background: #EEEEEE;
|
|
92
95
|
color: var(--c-text);
|
|
93
96
|
min-height: 100vh;
|
|
94
97
|
-webkit-font-smoothing: antialiased;
|
|
95
98
|
-moz-osx-font-smoothing: grayscale;
|
|
96
99
|
}
|
|
97
100
|
|
|
98
|
-
.demo-root { max-width: 900px; margin: 0 auto; padding:
|
|
99
|
-
.demo-header { margin-bottom:
|
|
101
|
+
.demo-root { max-width: 900px; margin: 0 auto; padding: 24px 24px 120px; }
|
|
102
|
+
.demo-header { margin-bottom: 18px; }
|
|
100
103
|
.demo-header h1 { font-size: 26px; font-weight: 700; letter-spacing: -0.5px; color: #171717; }
|
|
101
104
|
.demo-header p { color: var(--c-ruler); font-size: 14px; margin-top: 6px; }
|
|
102
105
|
.demo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 20px; }
|
|
@@ -191,6 +194,130 @@
|
|
|
191
194
|
@media (prefers-reduced-motion: reduce) {
|
|
192
195
|
.t-morph, .t-morph-plus, .t-morph-menu { transition: none !important; }
|
|
193
196
|
}
|
|
197
|
+
|
|
198
|
+
/* transitions.dev motion tokens for the extra prototypes (verbatim from
|
|
199
|
+
05-menu-dropdown.md / 06-modal.md). Demo-only — excluded from the build. */
|
|
200
|
+
:root {
|
|
201
|
+
--dropdown-open-dur: 250ms; --dropdown-close-dur: 150ms;
|
|
202
|
+
--dropdown-pre-scale: 0.8; --dropdown-closing-scale: 0.99;
|
|
203
|
+
--dropdown-ease: cubic-bezier(0.22, 1, 0.36, 1);
|
|
204
|
+
--modal-open-dur: 250ms; --modal-close-dur: 150ms;
|
|
205
|
+
--modal-scale: 0.96; --modal-scale-close: 0.96;
|
|
206
|
+
--modal-ease: cubic-bezier(0.22, 1, 0.36, 1);
|
|
207
|
+
--modal-overlay-bg: rgba(15, 17, 21, 0.45);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/* ── prototype switcher tabs (sliding pill — 16-tabs-sliding.md) ── */
|
|
211
|
+
.proto-tabs {
|
|
212
|
+
position: relative; display: inline-flex; align-items: center; gap: 3px;
|
|
213
|
+
margin-top: 18px; padding: 3px; border-radius: 48px; background: #f1f1f1;
|
|
214
|
+
}
|
|
215
|
+
.proto-tab {
|
|
216
|
+
position: relative; appearance: none; border: 0; background: transparent;
|
|
217
|
+
height: 30px; padding: 4px 14px; border-radius: 48px; cursor: pointer;
|
|
218
|
+
font: 500 13px/1 "Inter", system-ui, sans-serif; color: rgba(15,15,15,0.6);
|
|
219
|
+
z-index: 1; transition: color var(--dropdown-open-dur) var(--dropdown-ease);
|
|
220
|
+
}
|
|
221
|
+
.proto-tab:not(.is-active):hover { color: rgba(15,15,15,0.85); }
|
|
222
|
+
.proto-tab.is-active { color: #0f0f0f; }
|
|
223
|
+
.proto-tabs-pill {
|
|
224
|
+
position: absolute; top: 3px; left: 0; height: 30px; width: 0;
|
|
225
|
+
background: #fff; border-radius: 48px; transform: translateX(0);
|
|
226
|
+
box-shadow: 0 1px 3px rgba(0,0,0,0.08), 0 0 0 1px rgba(0,0,0,0.04);
|
|
227
|
+
transition:
|
|
228
|
+
transform var(--dropdown-open-dur) var(--dropdown-ease),
|
|
229
|
+
width var(--dropdown-open-dur) var(--dropdown-ease);
|
|
230
|
+
will-change: transform, width; z-index: 0; pointer-events: none;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/* ── prototype: Menu dropdown (05-menu-dropdown.md) ── */
|
|
234
|
+
.dd-stage { position: relative; min-height: 224px; display: flex; justify-content: center; padding-top: 4px; }
|
|
235
|
+
.dd { position: relative; display: inline-block; }
|
|
236
|
+
.dd-trigger {
|
|
237
|
+
display: inline-flex; align-items: center; gap: 8px; padding: 9px 14px;
|
|
238
|
+
font: 500 14px/1 "Inter", system-ui, sans-serif; color: #1b1b1b;
|
|
239
|
+
background: #fff; border: 0; border-radius: 10px; cursor: pointer;
|
|
240
|
+
box-shadow: 0 1px 3px rgba(0,0,0,0.04), inset 0 0 0 1px rgba(0,0,0,0.06), inset 0 -1px 0 rgba(0,0,0,0.10);
|
|
241
|
+
}
|
|
242
|
+
.dd-trigger:hover { background: #f9f9f9; }
|
|
243
|
+
.dd-caret { transition: transform var(--dropdown-open-dur) var(--dropdown-ease); }
|
|
244
|
+
.dd.is-open .dd-caret { transform: rotate(180deg); }
|
|
245
|
+
/* recipe surface */
|
|
246
|
+
.t-dropdown {
|
|
247
|
+
transform-origin: top left; transform: scale(var(--dropdown-pre-scale));
|
|
248
|
+
opacity: 0; pointer-events: none;
|
|
249
|
+
transition:
|
|
250
|
+
transform var(--dropdown-open-dur) var(--dropdown-ease),
|
|
251
|
+
opacity var(--dropdown-open-dur) var(--dropdown-ease);
|
|
252
|
+
will-change: transform, opacity;
|
|
253
|
+
}
|
|
254
|
+
.t-dropdown.is-open { transform: scale(1); opacity: 1; pointer-events: auto; }
|
|
255
|
+
.t-dropdown.is-closing {
|
|
256
|
+
transform: scale(var(--dropdown-closing-scale)); opacity: 0; pointer-events: none;
|
|
257
|
+
transition:
|
|
258
|
+
transform var(--dropdown-close-dur) var(--dropdown-ease),
|
|
259
|
+
opacity var(--dropdown-close-dur) var(--dropdown-ease);
|
|
260
|
+
}
|
|
261
|
+
.dd-menu {
|
|
262
|
+
position: absolute; top: calc(100% + 8px); left: 0; min-width: 216px; padding: 6px;
|
|
263
|
+
background: #fff; border-radius: 12px;
|
|
264
|
+
box-shadow: 0 0 0 1px rgba(0,0,0,0.06), 0 2px 6px rgba(0,0,0,0.05), 0 4px 42px rgba(0,0,0,0.06);
|
|
265
|
+
z-index: 5;
|
|
266
|
+
}
|
|
267
|
+
.dd-title { padding: 6px 10px 4px; font-size: 11px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: #9b9b9b; }
|
|
268
|
+
.dd-item {
|
|
269
|
+
display: flex; align-items: center; gap: 10px; width: 100%; padding: 8px 10px;
|
|
270
|
+
font: 500 13px/1 "Inter", system-ui, sans-serif; color: #2f2f2f;
|
|
271
|
+
background: transparent; border: 0; border-radius: 8px; cursor: pointer; text-align: left;
|
|
272
|
+
}
|
|
273
|
+
.dd-item:hover { background: #f4f4f5; }
|
|
274
|
+
|
|
275
|
+
/* ── prototype: Modal (06-modal.md) ── */
|
|
276
|
+
.modal-stage {
|
|
277
|
+
position: relative; height: 240px; border-radius: 12px; overflow: hidden;
|
|
278
|
+
background: #f6f7f9; box-shadow: inset 0 0 0 1px rgba(0,0,0,0.05);
|
|
279
|
+
display: grid; place-items: center;
|
|
280
|
+
}
|
|
281
|
+
.modal-open-btn {
|
|
282
|
+
position: relative; z-index: 0; padding: 9px 16px;
|
|
283
|
+
font: 500 14px/1 "Inter", system-ui, sans-serif; color: #fff;
|
|
284
|
+
background: #171717; border: 0; border-radius: 10px; cursor: pointer;
|
|
285
|
+
}
|
|
286
|
+
.modal-backdrop {
|
|
287
|
+
position: absolute; inset: 0; z-index: 1; background: var(--modal-overlay-bg);
|
|
288
|
+
opacity: 0; pointer-events: none; cursor: pointer;
|
|
289
|
+
transition: opacity var(--modal-open-dur) var(--modal-ease);
|
|
290
|
+
}
|
|
291
|
+
.modal-backdrop.is-open { opacity: 1; pointer-events: auto; }
|
|
292
|
+
.modal-backdrop.is-closing { opacity: 0; transition: opacity var(--modal-close-dur) var(--modal-ease); }
|
|
293
|
+
.t-modal {
|
|
294
|
+
position: relative; z-index: 2; width: 264px; padding: 20px; background: #fff;
|
|
295
|
+
border-radius: 14px; box-shadow: 0 10px 40px rgba(0,0,0,0.18);
|
|
296
|
+
transform-origin: center; transform: scale(var(--modal-scale)); opacity: 0; pointer-events: none;
|
|
297
|
+
transition:
|
|
298
|
+
transform var(--modal-open-dur) var(--modal-ease),
|
|
299
|
+
opacity var(--modal-open-dur) var(--modal-ease);
|
|
300
|
+
will-change: transform, opacity;
|
|
301
|
+
}
|
|
302
|
+
.t-modal.is-open { transform: scale(1); opacity: 1; pointer-events: auto; }
|
|
303
|
+
.t-modal.is-closing {
|
|
304
|
+
transform: scale(var(--modal-scale-close)); opacity: 0; pointer-events: none;
|
|
305
|
+
transition:
|
|
306
|
+
transform var(--modal-close-dur) var(--modal-ease),
|
|
307
|
+
opacity var(--modal-close-dur) var(--modal-ease);
|
|
308
|
+
}
|
|
309
|
+
.modal-card-title { font-size: 15px; font-weight: 600; color: #171717; }
|
|
310
|
+
.modal-card-body { margin-top: 6px; font-size: 13px; color: var(--c-ruler); line-height: 1.5; }
|
|
311
|
+
.modal-actions { margin-top: 18px; display: flex; justify-content: flex-end; gap: 8px; }
|
|
312
|
+
.modal-btn { padding: 8px 13px; font: 500 13px/1 "Inter", system-ui, sans-serif; border: 0; border-radius: 8px; cursor: pointer; }
|
|
313
|
+
.modal-btn.secondary { background: #f1f1f2; color: #2f2f2f; }
|
|
314
|
+
.modal-btn.secondary:hover { background: #e9e9eb; }
|
|
315
|
+
.modal-btn.primary { background: #e5484d; color: #fff; }
|
|
316
|
+
.modal-btn.primary:hover { background: #d93b40; }
|
|
317
|
+
|
|
318
|
+
@media (prefers-reduced-motion: reduce) {
|
|
319
|
+
.proto-tabs-pill, .proto-tab, .dd-caret, .t-dropdown, .modal-backdrop, .t-modal { transition: none !important; }
|
|
320
|
+
}
|
|
194
321
|
/* @inject-skip-end */
|
|
195
322
|
|
|
196
323
|
/* ───────────────────── timeline panel ───────────────────── */
|
|
@@ -259,10 +386,32 @@
|
|
|
259
386
|
.tl-pill-count.is-single { width: 20px; padding: 0; }
|
|
260
387
|
|
|
261
388
|
/* ── header row ── */
|
|
389
|
+
/* container-type lets the children below respond to the header's own width
|
|
390
|
+
(the panel is full-viewport-width, so this tracks the viewport too). */
|
|
262
391
|
.tl-header { flex: none; position: relative; z-index: 40; display: flex; align-items: center; gap: 8px; padding: 7.5px 14px;
|
|
263
|
-
border-bottom: 1px solid var(--c-line); }
|
|
264
|
-
.tl-header-label { font-size: 13px; font-weight: 500; line-height: 18px; color: var(--c-text-mut2); white-space: nowrap; }
|
|
265
|
-
|
|
392
|
+
border-bottom: 1px solid var(--c-line); container-type: inline-size; }
|
|
393
|
+
.tl-header-label { font-size: 13px; font-weight: 500; line-height: 18px; color: var(--c-text-mut2); white-space: nowrap; flex: none; }
|
|
394
|
+
/* count is the FIRST thing to give up space (high shrink) and clips to … */
|
|
395
|
+
.tl-header-count { margin-left: auto; display: inline-flex; align-items: center; font-size: 13px; line-height: 18px; color: var(--c-count); white-space: nowrap;
|
|
396
|
+
min-width: 0; flex: 0 99 auto; overflow: hidden; }
|
|
397
|
+
.tl-count-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
398
|
+
/* the transition picker truncates its name SECOND (lower shrink than count) */
|
|
399
|
+
.tl-header .tl-ghost-btn { min-width: 0; flex: 0 1 auto; }
|
|
400
|
+
.tl-ghost-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
401
|
+
.tl-header .tl-ghost-btn .tl-dim, .tl-header .tl-ghost-chev { flex: none; }
|
|
402
|
+
/* the right-hand cluster never shrinks or gets pushed off — minimize stays put */
|
|
403
|
+
.tl-header .tl-icon-btn, .tl-header .tl-sec-btn,
|
|
404
|
+
.tl-header .tl-accept-btn, .tl-header .tl-refine-btn,
|
|
405
|
+
.tl-header > .t-tt-wrap { flex: none; }
|
|
406
|
+
/* still too tight → drop the "Selected" label, then collapse Accept + Refine
|
|
407
|
+
to circular icon-only buttons (radius is already 60px → perfect circle). */
|
|
408
|
+
@container (max-width: 540px) {
|
|
409
|
+
.tl-header-label { display: none; }
|
|
410
|
+
}
|
|
411
|
+
@container (max-width: 470px) {
|
|
412
|
+
.tl-accept-btn .tl-btn-label, .tl-refine-btn .tl-btn-label { display: none; }
|
|
413
|
+
.tl-accept-btn, .tl-refine-btn { width: 36px; padding: 0; justify-content: center; gap: 0; }
|
|
414
|
+
}
|
|
266
415
|
|
|
267
416
|
/* ── buttons ── */
|
|
268
417
|
/* topbar buttons share a fully rounded 60px corner radius */
|
|
@@ -327,7 +476,7 @@
|
|
|
327
476
|
border: none; border-radius: 8px; background: transparent; color: var(--c-blue);
|
|
328
477
|
box-shadow: var(--drop-btn);
|
|
329
478
|
font: inherit; font-size: 13px; font-weight: 500; line-height: 14px;
|
|
330
|
-
cursor: pointer; white-space: nowrap; transition: color 0.12s ease; }
|
|
479
|
+
cursor: pointer; white-space: nowrap; transition: color 0.12s ease, scale 0.12s ease; }
|
|
331
480
|
.tl-refine-btn::before { content: ""; position: absolute; inset: 0; border-radius: inherit;
|
|
332
481
|
background: var(--c-blue-bg);
|
|
333
482
|
background: color(display-p3 0 0.451 0.898 / 0.06);
|
|
@@ -339,7 +488,7 @@
|
|
|
339
488
|
/* Figma: wand icon stroke is #0073E5, distinct from the #0071e2 label */
|
|
340
489
|
.tl-refine-btn > svg { color: var(--c-blue-pressed); }
|
|
341
490
|
.tl-refine-btn:hover:not(:disabled)::before { background: var(--c-blue-bg-h); background: color(display-p3 0 0.451 0.898 / 0.10); }
|
|
342
|
-
.tl-refine-btn:active:not(:disabled) { color: var(--c-blue-pressed); }
|
|
491
|
+
.tl-refine-btn:active:not(:disabled) { color: var(--c-blue-pressed); scale: 0.96; }
|
|
343
492
|
.tl-refine-btn:active:not(:disabled)::before { background: var(--c-blue-bg-h); background: color(display-p3 0 0.451 0.898 / 0.10); }
|
|
344
493
|
.tl-refine-btn:disabled { cursor: default; color: var(--c-blue); }
|
|
345
494
|
.tl-refine-btn:disabled::before { background: var(--c-blue-bg); background: color(display-p3 0 0.451 0.898 / 0.06); }
|
|
@@ -412,7 +561,15 @@
|
|
|
412
561
|
.tl-bar { position: absolute; top: 50%; height: 24px; transform: translateY(-50%); background: var(--c-track);
|
|
413
562
|
border-radius: 8px; min-width: 24px; cursor: grab;
|
|
414
563
|
box-shadow: 0 1px 3px 0 rgba(0,0,0,0.08), inset 0 0 0 1px rgba(0,0,0,0.06), inset 0 -1px 0 0 rgba(0,0,0,0.1), inset 0 0 0 1px rgba(196,196,196,0.1);
|
|
415
|
-
|
|
564
|
+
/* card resize (transitions.dev #01): tween left/width when timing changes
|
|
565
|
+
programmatically (Apply, snap, value-field edit). Suppressed mid-drag
|
|
566
|
+
(.is-dragging) so the bar tracks the cursor 1:1. */
|
|
567
|
+
transition: background 0.12s ease,
|
|
568
|
+
left 220ms cubic-bezier(0.22, 1, 0.36, 1),
|
|
569
|
+
width 220ms cubic-bezier(0.22, 1, 0.36, 1); will-change: left, width; }
|
|
570
|
+
.tl-bar.is-dragging { transition: background 0.12s ease; }
|
|
571
|
+
@media (prefers-reduced-motion: reduce) {
|
|
572
|
+
.tl-bar { transition: background 0.12s ease !important; } }
|
|
416
573
|
.tl-prop-row:hover .tl-bar, .tl-prop-row.selected .tl-bar { background: var(--c-track-h); }
|
|
417
574
|
.tl-bar:active { cursor: grabbing; }
|
|
418
575
|
/* resize handles — Figma node 13044:2625: 2×14px, #767676 @60%, centered, ~5px edge inset */
|
|
@@ -450,18 +607,23 @@
|
|
|
450
607
|
.tl-field-fill { position: absolute; left: 0; top: 0; bottom: 0; min-width: 32px; border-radius: 8px;
|
|
451
608
|
background: var(--c-fill);
|
|
452
609
|
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.02), inset 0 -1px 0 0 rgba(0,0,0,0.08), inset 0 0 0 1px rgba(191,191,191,0.10), 0 1px 3px 0 rgba(0,0,0,0.06);
|
|
453
|
-
|
|
454
|
-
|
|
610
|
+
/* card resize (#01): tween width on programmatic changes + release snap;
|
|
611
|
+
suppressed mid-drag so it tracks the cursor 1:1. */
|
|
612
|
+
pointer-events: none; transition: background 0.12s ease, opacity 0.12s ease,
|
|
613
|
+
width 220ms cubic-bezier(0.22, 1, 0.36, 1); z-index: 1; }
|
|
614
|
+
.tl-field.is-dragging .tl-field-fill { background: var(--c-fill-a); transition: background 0.12s ease, opacity 0.12s ease; }
|
|
615
|
+
@media (prefers-reduced-motion: reduce) {
|
|
616
|
+
.tl-field-fill { transition: background 0.12s ease, opacity 0.12s ease !important; } }
|
|
455
617
|
.tl-field.is-dragging .tl-field-label { opacity: 0.7; }
|
|
456
618
|
.tl-field.is-editing .tl-field-fill { opacity: 0; }
|
|
457
619
|
.tl-field-track { position: absolute; inset: 0; cursor: ew-resize; z-index: 2; }
|
|
458
620
|
.tl-field-label { position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
|
|
459
621
|
font-size: 13px; font-weight: 500; color: var(--c-text-mut); pointer-events: none; z-index: 3; }
|
|
460
622
|
.tl-field-value { position: absolute; right: 12px; top: 50%; transform: translateY(-50%);
|
|
461
|
-
font-size: 13px; color: var(--c-text); font-weight:
|
|
623
|
+
font-size: 13px; color: var(--c-text); font-weight: 400; cursor: text; z-index: 3;
|
|
462
624
|
font-variant-numeric: tabular-nums; }
|
|
463
625
|
.tl-field-input { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); width: 64px;
|
|
464
|
-
text-align: right; border: none; background: transparent; font: inherit; font-size: 13px; font-weight:
|
|
626
|
+
text-align: right; border: none; background: transparent; font: inherit; font-size: 13px; font-weight: 400;
|
|
465
627
|
color: var(--c-text); outline: none; z-index: 4; font-variant-numeric: tabular-nums; }
|
|
466
628
|
/* thumb at the right edge of the fill (Figma #767676 2×20px, inset 8px, hover 60% / drag 100%) */
|
|
467
629
|
.tl-field-thumb { position: absolute; right: 8px; top: 7px; height: 18px; width: 2px;
|
|
@@ -520,7 +682,7 @@
|
|
|
520
682
|
/* select trigger — Logram design system (node 13064:2418): flat #f7f7f7 fill */
|
|
521
683
|
.tl-select { display: flex; align-items: center; justify-content: space-between; width: 100%; height: 32px;
|
|
522
684
|
border: none; border-radius: 8px; background: #f7f7f7; padding: 6px 10px 6px 12px;
|
|
523
|
-
font: inherit; font-size: 13px; font-weight:
|
|
685
|
+
font: inherit; font-size: 13px; font-weight: 400; line-height: 16px; color: #1b1b1b; cursor: pointer; text-align: left;
|
|
524
686
|
transition: background 0.12s ease; }
|
|
525
687
|
.tl-select:hover { background: var(--c-sec-h); }
|
|
526
688
|
.tl-select.is-open { background: var(--c-sec-h); }
|
|
@@ -546,7 +708,7 @@
|
|
|
546
708
|
.tl-cubic-cell:focus-within { box-shadow: inset 0 0 0 1px rgba(0,0,0,0.14); }
|
|
547
709
|
.tl-cubic-row input { flex: 1; min-width: 0; width: 100%; height: 100%; border: none; background: transparent;
|
|
548
710
|
box-sizing: border-box; padding: 0; text-align: left;
|
|
549
|
-
font: inherit; font-size: 12px; font-weight:
|
|
711
|
+
font: inherit; font-size: 12px; font-weight: 400; line-height: 16px; color: #1b1b1b; outline: none;
|
|
550
712
|
-moz-appearance: textfield; appearance: textfield; }
|
|
551
713
|
.tl-cubic-row input::-webkit-outer-spin-button, .tl-cubic-row input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; width: 0; }
|
|
552
714
|
.tl-cubic-row input::placeholder { color: #979797; }
|
|
@@ -709,6 +871,12 @@
|
|
|
709
871
|
/* trailing chevron on link rows (Figma 581:2828 — #696969 right chevron) */
|
|
710
872
|
.tl-menu-chevr { display: flex; flex: none; color: #696969; }
|
|
711
873
|
|
|
874
|
+
/* trailing slot on a menu row — current value + chevron (Figma: Theme row
|
|
875
|
+
shows "System" then a right chevron and opens the theme picker sub-page). */
|
|
876
|
+
.tl-menu-trail { display: flex; align-items: center; gap: 4px; }
|
|
877
|
+
.tl-menu-value { font-size: 11px; line-height: 14px; color: #8b8b8b;
|
|
878
|
+
font-variant-numeric: tabular-nums; }
|
|
879
|
+
|
|
712
880
|
/* ── settings dropdown sub-page slider (transitions.dev · 08-page-side-by-side) ──
|
|
713
881
|
One dropdown surface that pages internally: main settings ↔ keyboard
|
|
714
882
|
shortcuts. Both pages stay mounted so they can cross-slide; the surface
|
|
@@ -716,6 +884,10 @@
|
|
|
716
884
|
smoothly instead of jumping. Page 1 exits left, page 2 exits right. */
|
|
717
885
|
.tl-set-slide.t-page-slide {
|
|
718
886
|
position: relative;
|
|
887
|
+
/* bleed past the .tl-menu 6px padding so the clipped (overflow:hidden)
|
|
888
|
+
viewport reaches the menu edges — lets dividers go truly full-width.
|
|
889
|
+
The 6px row inset is restored on .tl-set-page below. */
|
|
890
|
+
margin-left: -6px; margin-right: -6px;
|
|
719
891
|
--page-slide-dur: 250ms; /* Fast — page slide */
|
|
720
892
|
--page-fade-dur: 250ms;
|
|
721
893
|
--page-slide-distance: 8px; /* Base distance */
|
|
@@ -728,9 +900,11 @@
|
|
|
728
900
|
overflow: hidden;
|
|
729
901
|
}
|
|
730
902
|
.tl-set-slide .t-page[data-page-id="1"] { --t-page-from-x: calc(var(--page-slide-distance) * -1); }
|
|
731
|
-
.tl-set-slide .t-page[data-page-id="2"]
|
|
903
|
+
.tl-set-slide .t-page[data-page-id="2"],
|
|
904
|
+
.tl-set-slide .t-page[data-page-id="3"] { --t-page-from-x: var(--page-slide-distance); }
|
|
732
905
|
.tl-set-slide .t-page {
|
|
733
906
|
position: absolute; top: 0; left: 0; right: 0;
|
|
907
|
+
box-sizing: border-box; padding-left: 6px; padding-right: 6px;
|
|
734
908
|
opacity: 0; pointer-events: none;
|
|
735
909
|
transform: translateX(calc(var(--t-page-from-x, 0px) * var(--page-exit-enabled)));
|
|
736
910
|
filter: blur(calc(var(--page-blur) * var(--page-exit-enabled)));
|
|
@@ -741,7 +915,8 @@
|
|
|
741
915
|
will-change: opacity, transform, filter;
|
|
742
916
|
}
|
|
743
917
|
.tl-set-slide[data-page="1"] .t-page[data-page-id="1"],
|
|
744
|
-
.tl-set-slide[data-page="2"] .t-page[data-page-id="2"]
|
|
918
|
+
.tl-set-slide[data-page="2"] .t-page[data-page-id="2"],
|
|
919
|
+
.tl-set-slide[data-page="3"] .t-page[data-page-id="3"] {
|
|
745
920
|
opacity: 1; pointer-events: auto; transform: translateX(0); filter: blur(0);
|
|
746
921
|
transition-delay: var(--page-stagger);
|
|
747
922
|
}
|
|
@@ -887,6 +1062,12 @@
|
|
|
887
1062
|
transform: translate(0, 0) scale(var(--tt-scale)); }
|
|
888
1063
|
.t-tt-wrap:hover .t-tt.tl-tt-msg,
|
|
889
1064
|
.t-tt-trigger:focus-visible + .t-tt.tl-tt-msg { transform: translate(0, 0) scale(1); }
|
|
1065
|
+
/* right-anchored variant for the panel's rightmost trigger (Minimize) so the
|
|
1066
|
+
bubble grows leftward from the right edge and never clips off the panel. */
|
|
1067
|
+
.t-tt.tl-tt-right { left: auto; right: 0; transform-origin: 100% 0;
|
|
1068
|
+
transform: translate(0, 0) scale(var(--tt-scale)); }
|
|
1069
|
+
.t-tt-wrap:hover .t-tt.tl-tt-right,
|
|
1070
|
+
.t-tt-trigger:focus-visible + .t-tt.tl-tt-right { transform: translate(0, 0) scale(1); }
|
|
890
1071
|
|
|
891
1072
|
/* ═════ transitions.dev — panel reveal (per-phase distance/ease/scale) ═════ */
|
|
892
1073
|
/* base = closed / close-end state: resolved travel + scale come from the
|
|
@@ -1018,7 +1199,7 @@
|
|
|
1018
1199
|
/* header: title block (left) + Agent/Deterministic mode dropdown (right) */
|
|
1019
1200
|
/* match the timeline top bar exactly: 7.5px/14px padding, centered, 52px tall (36px row + 15 + 1px border) */
|
|
1020
1201
|
.tl-refine-head { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between;
|
|
1021
|
-
gap: 8px; box-sizing: border-box; padding: 7.5px 14px;
|
|
1202
|
+
gap: 8px; box-sizing: border-box; padding: 7.5px 7.5px 7.5px 14px;
|
|
1022
1203
|
border-bottom: 1px solid #f0f0f0; }
|
|
1023
1204
|
.tl-refine-titles { min-width: 0; }
|
|
1024
1205
|
.tl-refine-titles h3 { font-size: 13px; font-weight: 500; line-height: 14px; color: #17181c; }
|
|
@@ -1047,6 +1228,49 @@
|
|
|
1047
1228
|
.tl-refine-tab[aria-selected="true"] { background: rgba(170,170,170,0.10); color: #17181c; }
|
|
1048
1229
|
.tl-refine-body { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column;
|
|
1049
1230
|
padding: 0 16px; }
|
|
1231
|
+
/* ── refine-type tab content slider (transitions.dev · 08-page-side-by-side) ──
|
|
1232
|
+
Switching the type tabs (Small refinements ↔ Replace transition) slides the
|
|
1233
|
+
body content side-by-side: the outgoing page exits with blur+fade while the
|
|
1234
|
+
incoming page slides in from the opposite edge. Both pages stay mounted so
|
|
1235
|
+
they cross-slide. Page 1 (Small, tab index 0) exits left; page 2 (Replace,
|
|
1236
|
+
tab index 1) exits right — going to Replace brings new content in from the
|
|
1237
|
+
right, going back brings it from the left. Mirrors the settings dropdown's
|
|
1238
|
+
.tl-set-slide tokens exactly. Pages are absolutely stacked and fill the
|
|
1239
|
+
fixed body region, scrolling internally, so no height tween is needed. */
|
|
1240
|
+
.tl-refine-body.tl-refine-pages.t-page-slide {
|
|
1241
|
+
position: relative; overflow: hidden; padding: 0;
|
|
1242
|
+
--page-slide-dur: 250ms; /* Fast — page slide */
|
|
1243
|
+
--page-fade-dur: 250ms;
|
|
1244
|
+
--page-slide-distance: 8px; /* Base distance */
|
|
1245
|
+
--page-blur: 3px; /* Medium blur */
|
|
1246
|
+
--page-stagger: 0ms;
|
|
1247
|
+
--page-exit-enabled: 1;
|
|
1248
|
+
--page-slide-ease: cubic-bezier(0.22, 1, 0.36, 1); /* Smooth ease out */
|
|
1249
|
+
--page-fade-ease: cubic-bezier(0.22, 1, 0.36, 1);
|
|
1250
|
+
}
|
|
1251
|
+
.tl-refine-pages .t-page[data-page-id="1"] { --t-page-from-x: calc(var(--page-slide-distance) * -1); }
|
|
1252
|
+
.tl-refine-pages .t-page[data-page-id="2"] { --t-page-from-x: var(--page-slide-distance); }
|
|
1253
|
+
.tl-refine-pages .t-page {
|
|
1254
|
+
position: absolute; inset: 0; overflow-y: auto;
|
|
1255
|
+
display: flex; flex-direction: column; box-sizing: border-box; padding: 0 16px;
|
|
1256
|
+
opacity: 0; pointer-events: none;
|
|
1257
|
+
transform: translateX(calc(var(--t-page-from-x, 0px) * var(--page-exit-enabled)));
|
|
1258
|
+
filter: blur(calc(var(--page-blur) * var(--page-exit-enabled)));
|
|
1259
|
+
transition:
|
|
1260
|
+
opacity var(--page-fade-dur) var(--page-fade-ease),
|
|
1261
|
+
transform var(--page-slide-dur) var(--page-slide-ease),
|
|
1262
|
+
filter var(--page-slide-dur) var(--page-slide-ease);
|
|
1263
|
+
will-change: opacity, transform, filter;
|
|
1264
|
+
}
|
|
1265
|
+
.tl-refine-pages[data-page="1"] .t-page[data-page-id="1"],
|
|
1266
|
+
.tl-refine-pages[data-page="2"] .t-page[data-page-id="2"] {
|
|
1267
|
+
opacity: 1; pointer-events: auto; transform: translateX(0); filter: blur(0);
|
|
1268
|
+
transition-delay: var(--page-stagger);
|
|
1269
|
+
}
|
|
1270
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1271
|
+
.tl-refine-pages.t-page-slide,
|
|
1272
|
+
.tl-refine-pages .t-page { transition: none !important; }
|
|
1273
|
+
}
|
|
1050
1274
|
/* idle + unavailable share a vertically-centred column */
|
|
1051
1275
|
.tl-refine-center { flex: 1; display: flex; flex-direction: column; align-items: center;
|
|
1052
1276
|
justify-content: center; gap: 12px; text-align: center; padding: 16px 0; }
|
|
@@ -1092,7 +1316,8 @@
|
|
|
1092
1316
|
height var(--resize-dur) var(--resize-ease),
|
|
1093
1317
|
border-radius var(--resize-dur) var(--resize-ease),
|
|
1094
1318
|
color var(--resize-dur) var(--resize-ease),
|
|
1095
|
-
box-shadow var(--resize-dur) var(--resize-ease)
|
|
1319
|
+
box-shadow var(--resize-dur) var(--resize-ease),
|
|
1320
|
+
scale 0.12s ease; }
|
|
1096
1321
|
.tl-scan-morph::before { content: ""; position: absolute; inset: 0; border-radius: inherit;
|
|
1097
1322
|
background: var(--c-blue-bg);
|
|
1098
1323
|
background: color(display-p3 0 0.451 0.898 / 0.06);
|
|
@@ -1104,7 +1329,7 @@
|
|
|
1104
1329
|
.tl-refine-foot .tl-scan-morph { margin-bottom: 20px; }
|
|
1105
1330
|
.tl-scan-morph:hover:not(:disabled):not(.is-scanning)::before {
|
|
1106
1331
|
background: var(--c-blue-bg-h); background: color(display-p3 0 0.451 0.898 / 0.10); }
|
|
1107
|
-
.tl-scan-morph:active:not(.is-scanning):not(:disabled) { color: var(--c-blue-pressed); }
|
|
1332
|
+
.tl-scan-morph:active:not(.is-scanning):not(:disabled) { color: var(--c-blue-pressed); scale: 0.96; }
|
|
1108
1333
|
.tl-scan-morph:active:not(.is-scanning):not(:disabled)::before {
|
|
1109
1334
|
background: var(--c-blue-bg-h); background: color(display-p3 0 0.451 0.898 / 0.10); }
|
|
1110
1335
|
.tl-scan-morph:disabled:not(.is-scanning) { cursor: default; color: var(--c-blue); }
|
|
@@ -1114,12 +1339,12 @@
|
|
|
1114
1339
|
color: var(--c-blue-pressed); opacity: 0.5; }
|
|
1115
1340
|
.tl-scan-morph.is-scanning { width: 100%; height: 49px; border-radius: 60px;
|
|
1116
1341
|
padding: 8px 16px; cursor: default; color: #676767; background: transparent;
|
|
1117
|
-
box-shadow:
|
|
1342
|
+
box-shadow: var(--shadow-btn); }
|
|
1118
1343
|
.tl-scan-morph.is-scanning::before {
|
|
1119
1344
|
background: #fff;
|
|
1120
1345
|
transition: background var(--resize-dur) var(--resize-ease); }
|
|
1121
1346
|
.tl-scan-morph.is-scanning::after {
|
|
1122
|
-
box-shadow: inset
|
|
1347
|
+
box-shadow: var(--shadow-btn-inset);
|
|
1123
1348
|
transition: box-shadow var(--resize-dur) var(--resize-ease); }
|
|
1124
1349
|
/* Two faces share one grid cell and cross-blur (icon swap, 09) between the
|
|
1125
1350
|
label and the icon+status; whichever is hidden fades out with blur+scale.
|
|
@@ -1210,6 +1435,41 @@
|
|
|
1210
1435
|
.tl-scan-note { cursor: help; color: var(--c-count);
|
|
1211
1436
|
display: inline-flex; align-items: center; gap: 6px; }
|
|
1212
1437
|
.tl-scan-note .tl-dotm { width: 14px; height: 14px; }
|
|
1438
|
+
.tl-scan-error { color: var(--c-warn, #c2410c); }
|
|
1439
|
+
.tl-scan-error svg { flex: none; }
|
|
1440
|
+
/* /refine live chip — warns the chat-loop mode bills credits while idle */
|
|
1441
|
+
.tl-live-wrap { display: inline-flex; }
|
|
1442
|
+
.tl-live-chip { display: inline-flex; align-items: center; gap: 6px;
|
|
1443
|
+
height: 22px; padding: 0 4px 0 8px; border-radius: 999px;
|
|
1444
|
+
font-size: 11px; font-weight: 600; line-height: 1; cursor: help;
|
|
1445
|
+
color: #b45309; background: rgba(217,119,6,0.12);
|
|
1446
|
+
border: 1px solid rgba(217,119,6,0.28);
|
|
1447
|
+
/* mount: token dropdown-open feel — 250ms Fast, scale 0.97, smooth ease out */
|
|
1448
|
+
animation: tl-live-in 250ms cubic-bezier(0.22,1,0.36,1) both; }
|
|
1449
|
+
html[data-theme="dark"] .tl-live-chip { color: #fbbf24;
|
|
1450
|
+
background: rgba(251,191,36,0.13); border-color: rgba(251,191,36,0.3); }
|
|
1451
|
+
.tl-live-chip.is-stopped { color: var(--c-count); background: rgba(127,127,127,0.1);
|
|
1452
|
+
border-color: rgba(127,127,127,0.26); padding: 0 8px; }
|
|
1453
|
+
.tl-live-chip.is-stopped svg { flex: none; }
|
|
1454
|
+
.tl-live-dot { width: 7px; height: 7px; border-radius: 50%;
|
|
1455
|
+
background: currentColor; flex: none;
|
|
1456
|
+
animation: tl-live-pulse 1600ms linear infinite; }
|
|
1457
|
+
.tl-live-stop { all: unset; cursor: pointer; font: inherit; font-size: 10px;
|
|
1458
|
+
font-weight: 700; letter-spacing: 0.02em; text-transform: uppercase;
|
|
1459
|
+
color: inherit; padding: 3px 7px; border-radius: 999px;
|
|
1460
|
+
background: rgba(217,119,6,0.16);
|
|
1461
|
+
transition: background 150ms cubic-bezier(0.22,1,0.36,1); }
|
|
1462
|
+
html[data-theme="dark"] .tl-live-stop { background: rgba(251,191,36,0.18); }
|
|
1463
|
+
.tl-live-stop:hover { background: rgba(217,119,6,0.32); }
|
|
1464
|
+
html[data-theme="dark"] .tl-live-stop:hover { background: rgba(251,191,36,0.34); }
|
|
1465
|
+
.tl-live-stop:focus-visible { outline: 2px solid currentColor; outline-offset: 1px; }
|
|
1466
|
+
@keyframes tl-live-in { from { opacity: 0; transform: scale(0.97); } to { opacity: 1; transform: scale(1); } }
|
|
1467
|
+
@keyframes tl-live-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }
|
|
1468
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1469
|
+
.tl-live-chip { animation: none; }
|
|
1470
|
+
.tl-live-dot { animation: none; }
|
|
1471
|
+
.tl-live-stop { transition: none; }
|
|
1472
|
+
}
|
|
1213
1473
|
.tl-scan-shimmer { --shimmer-dur: 2000ms; --shimmer-base: var(--c-count); --shimmer-highlight: #17181c;
|
|
1214
1474
|
--shimmer-band: 400%; --shimmer-ease: linear; }
|
|
1215
1475
|
/* loading status text — transitions.dev shimmer text (15) + text states swap (04) */
|
|
@@ -1242,7 +1502,7 @@
|
|
|
1242
1502
|
font: inherit; transition: background 0.12s ease; }
|
|
1243
1503
|
.tl-mode-row:hover { background: rgba(170,170,170,0.08); }
|
|
1244
1504
|
.tl-mode-row-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
|
|
1245
|
-
.tl-mode-row-title { font-size:
|
|
1505
|
+
.tl-mode-row-title { font-size: 13px; font-weight: 500; line-height: 16px; color: #1b1b1b; }
|
|
1246
1506
|
.tl-mode-row-desc { font-size: 12px; font-weight: 400; line-height: 18px; color: #676767; text-wrap: balance; }
|
|
1247
1507
|
.tl-mode-row-check { flex: 0 0 auto; width: 16px; height: 16px; display: flex; color: #17181c; }
|
|
1248
1508
|
@keyframes tl-fade-up { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
|
|
@@ -1266,6 +1526,7 @@
|
|
|
1266
1526
|
.tl-sug-cat.is-easing { color: #a830fd; background: rgba(173,68,247,0.06); box-shadow: inset 0 0 0 1px rgba(173,68,247,0.18); }
|
|
1267
1527
|
.tl-sug-cat.is-duration { color: #3097fd; background: rgba(111,183,255,0.08); box-shadow: inset 0 0 0 1px rgba(48,151,253,0.18); }
|
|
1268
1528
|
.tl-sug-cat.is-scale { color: #26953b; background: rgba(38,149,59,0.06); box-shadow: inset 0 0 0 1px rgba(38,149,59,0.18); }
|
|
1529
|
+
.tl-sug-cat.is-blur { color: #0e9aa7; background: rgba(14,154,167,0.07); box-shadow: inset 0 0 0 1px rgba(14,154,167,0.20); }
|
|
1269
1530
|
.tl-sug-cat.is-distance, .tl-sug-cat.is-delay { color: #c35c00; background: rgba(247,152,68,0.06); box-shadow: inset 0 0 0 1px rgba(205,115,36,0.18); }
|
|
1270
1531
|
.tl-sug-cat.is-replace { color: #3097fd; background: rgba(111,183,255,0.08); box-shadow: inset 0 0 0 1px rgba(48,151,253,0.18); }
|
|
1271
1532
|
/* content stack */
|
|
@@ -1279,6 +1540,12 @@
|
|
|
1279
1540
|
.tl-sug-from { color: #7b7b7b; text-decoration: line-through; }
|
|
1280
1541
|
.tl-sug-to { color: #1b1b1b; }
|
|
1281
1542
|
.tl-sug-reason { font-size: 12px; font-weight: 400; line-height: 18px; color: #6d6d6d; }
|
|
1543
|
+
/* "Updates Open + Close" — a recipe swap writes every related phase together */
|
|
1544
|
+
.tl-sug-phases { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 6px;
|
|
1545
|
+
font-size: 11px; font-weight: 500; line-height: 16px; color: #6d6d6d; }
|
|
1546
|
+
.tl-sug-phase { display: inline-flex; align-items: baseline; gap: 4px;
|
|
1547
|
+
padding: 1px 7px; border-radius: 20px; background: rgba(15,15,15,0.05); color: #4a4a4a; }
|
|
1548
|
+
.tl-sug-phase-dur { font-family: "Roboto Mono", ui-monospace, SFMono-Regular, Menlo, monospace; color: #1b1b1b; }
|
|
1282
1549
|
/* shared white pill button (Figma 580:757) — card Apply + bottom-bar actions */
|
|
1283
1550
|
.tl-pill-btn { align-self: flex-start; box-sizing: border-box; display: inline-flex; align-items: center;
|
|
1284
1551
|
justify-content: center; gap: 8px; height: 32px; padding: 6px 12px; border: none; cursor: pointer;
|
|
@@ -1289,10 +1556,15 @@
|
|
|
1289
1556
|
transition: background 0.12s ease, scale 0.12s ease, opacity 0.12s ease; }
|
|
1290
1557
|
.tl-pill-btn .tl-dim { color: #696969; }
|
|
1291
1558
|
.tl-pill-btn:hover:not(:disabled) { background: #fafafa; }
|
|
1292
|
-
.tl-pill-btn:active:not(:disabled) { scale: 0.
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
.tl-pill-btn
|
|
1559
|
+
.tl-pill-btn:active:not(:disabled) { scale: 0.96; }
|
|
1560
|
+
/* disabled — flat grey fill matching the active segmented tab, no raised
|
|
1561
|
+
shadow, muted text (instead of a faded white button). */
|
|
1562
|
+
.tl-pill-btn:disabled { cursor: not-allowed; opacity: 1; color: var(--c-disabled);
|
|
1563
|
+
background: rgba(170,170,170,0.1); box-shadow: none; }
|
|
1564
|
+
/* applied state — GRAY, never green (matches Accept icon --c-text #17181c).
|
|
1565
|
+
Flat grey fill matching the active segmented tab, no raised shadow. */
|
|
1566
|
+
.tl-pill-btn.is-applied { color: var(--c-text); cursor: not-allowed; opacity: 1;
|
|
1567
|
+
background: rgba(170,170,170,0.1); box-shadow: none; }
|
|
1296
1568
|
.tl-pill-btn.is-applied svg path { stroke: var(--c-text); }
|
|
1297
1569
|
/* Apply ⟷ Applied: a LEADING check is revealed first and pushes the text to
|
|
1298
1570
|
the right, then the differing SUFFIX cross-blurs. The shared stem "Appl"
|
|
@@ -1570,7 +1842,10 @@
|
|
|
1570
1842
|
return out;
|
|
1571
1843
|
}
|
|
1572
1844
|
_applyOverrides(baseLanes,po){
|
|
1573
|
-
return baseLanes.map(t=>{const o=po[t.laneId];return {...t,durationMs:o
|
|
1845
|
+
return baseLanes.map(t=>{const o=po[t.laneId]||{};return {...t,durationMs:o.durationMs??t.durationMs,delayMs:o.delayMs??t.delayMs,easing:o.easing??t.easing,spring:o.spring??t.spring,
|
|
1846
|
+
// value overrides (scale/blur) come only from applied refine suggestions;
|
|
1847
|
+
// they carry their own from-value + backing var so Accept + live preview work.
|
|
1848
|
+
scale:o.scale??t.scale,blur:o.blur??t.blur,varName:o.varName??t.varName,scaleFrom:o.scaleFrom??t.scaleFrom,blurFrom:o.blurFrom??t.blurFrom};});
|
|
1574
1849
|
}
|
|
1575
1850
|
_notify(){
|
|
1576
1851
|
this.effectiveCache.clear();
|
|
@@ -1703,6 +1978,56 @@
|
|
|
1703
1978
|
}catch{return [];}
|
|
1704
1979
|
}
|
|
1705
1980
|
|
|
1981
|
+
// Read the non-resting ("pre") VALUE a lane animates from the live stylesheets:
|
|
1982
|
+
// transform-scale (≠1) and filter-blur (≠0), plus the CSS custom property
|
|
1983
|
+
// backing it (var-backed values are the ones we can live-preview). Returns
|
|
1984
|
+
// {scale?|blur?, varName?}. Only meaningful for transform/filter lanes.
|
|
1985
|
+
function readElValueHint(el, property){
|
|
1986
|
+
const out={};
|
|
1987
|
+
try{
|
|
1988
|
+
if(!el||(property!=="transform"&&property!=="filter"))return out;
|
|
1989
|
+
const own=(typeof el.className==="string"?el.className.trim().split(/\s+/):Array.from(el.classList||[])).filter(Boolean);
|
|
1990
|
+
const cs=getComputedStyle(el);
|
|
1991
|
+
const esc=s=>s.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");
|
|
1992
|
+
const tokRes=own.map(c=>new RegExp("\\."+esc(c)+"(?![\\w-])"));
|
|
1993
|
+
const matchTok=sel=>!own.length||tokRes.some(re=>re.test(sel));
|
|
1994
|
+
let bestScale=null,bestBlur=null;
|
|
1995
|
+
const consider=txt=>{
|
|
1996
|
+
if(property==="transform"){
|
|
1997
|
+
const tm=txt.match(/transform\s*:\s*([^;}]+)/i);
|
|
1998
|
+
if(tm){const sc=tm[1].match(/scale\(\s*([^),]+)/i);
|
|
1999
|
+
if(sc){const inner=sc[1].trim();const vm=inner.match(/var\(\s*(--[\w-]+)/);
|
|
2000
|
+
const num=vm?parseFloat(cs.getPropertyValue(vm[1])):parseFloat(inner);
|
|
2001
|
+
// pick the value furthest from the resting 1 (the real pre-scale).
|
|
2002
|
+
if(Number.isFinite(num)&&Math.abs(num-1)>1e-4&&(!bestScale||Math.abs(num-1)>Math.abs(bestScale.scale-1)))
|
|
2003
|
+
bestScale={scale:num,...(vm?{varName:vm[1]}:{})};}}
|
|
2004
|
+
}else{
|
|
2005
|
+
const fm=txt.match(/filter\s*:\s*([^;}]+)/i);
|
|
2006
|
+
if(fm){const bl=fm[1].match(/blur\(\s*([^)]+)\)/i);
|
|
2007
|
+
if(bl){const inner=bl[1].trim();const vm=inner.match(/var\(\s*(--[\w-]+)/);
|
|
2008
|
+
const num=vm?parseFloat(cs.getPropertyValue(vm[1])):parseFloat(inner);
|
|
2009
|
+
if(Number.isFinite(num)&&num>1e-4&&(!bestBlur||num>bestBlur.blur))
|
|
2010
|
+
bestBlur={blur:num,...(vm?{varName:vm[1]}:{})};}}
|
|
2011
|
+
}
|
|
2012
|
+
};
|
|
2013
|
+
const collect=list=>{for(const rule of Array.from(list||[])){
|
|
2014
|
+
if(rule.cssRules&&rule.selectorText===undefined){collect(rule.cssRules);continue;}
|
|
2015
|
+
const sel=rule.selectorText;if(!sel||!matchTok(sel))continue;
|
|
2016
|
+
if(rule.cssText)consider(rule.cssText);}};
|
|
2017
|
+
for(const sheet of Array.from(document.styleSheets||[])){let r;try{r=sheet.cssRules;}catch{continue;}collect(r);}
|
|
2018
|
+
if(bestScale)Object.assign(out,bestScale);
|
|
2019
|
+
if(bestBlur)Object.assign(out,bestBlur);
|
|
2020
|
+
}catch{}
|
|
2021
|
+
return out;
|
|
2022
|
+
}
|
|
2023
|
+
// Same, resolving the element from a CSS selector (used when only a lane's
|
|
2024
|
+
// selector is known, e.g. a grouped phase member).
|
|
2025
|
+
function laneValueHint(selector, property){
|
|
2026
|
+
if(!selector||(property!=="transform"&&property!=="filter"))return {};
|
|
2027
|
+
let el=null;try{el=document.querySelector(selector);}catch{return {};}
|
|
2028
|
+
return el?readElValueHint(el,property):{};
|
|
2029
|
+
}
|
|
2030
|
+
|
|
1706
2031
|
// ── hooks ──
|
|
1707
2032
|
const TimelineCtx = createContext(null);
|
|
1708
2033
|
function useReg(){const{registry}=useContext(TimelineCtx);return useSyncExternalStore(useCallback(cb=>registry.subscribe(cb),[registry]),useCallback(()=>registry.getAll(),[registry]),useCallback(()=>registry.getAll(),[registry]));}
|
|
@@ -1758,6 +2083,8 @@
|
|
|
1758
2083
|
// accept checkmark (Figma node 580:9057) — slimmer 1.5px check centered in a
|
|
1759
2084
|
// square 16-frame (the glyph spans ~50% width, like the source icon frame).
|
|
1760
2085
|
ICONS.accept = {vb:"0 0 16 16", svg:`<path d="M4 8.4268L6.46155 11.19223L12 4.97001" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`};
|
|
2086
|
+
// external-link (Figma "link-external-01") — box with an arrow leaving the top-right
|
|
2087
|
+
ICONS.linkout = {vb:"0 0 16 16", svg:`<path d="M9.5 3.5H12.5V6.5M12.5 3.5L7.5 8.5M11.5 9.25V11.5C11.5 12.0523 11.0523 12.5 10.5 12.5H4.5C3.94772 12.5 3.5 12.0523 3.5 11.5V5.5C3.5 4.94772 3.94772 4.5 4.5 4.5H6.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`};
|
|
1761
2088
|
function Ic({name, size=16}){
|
|
1762
2089
|
const ic = ICONS[name];
|
|
1763
2090
|
if(!ic) return null;
|
|
@@ -1860,44 +2187,218 @@
|
|
|
1860
2187
|
// page so the one dropdown surface resizes smoothly between pages. Lives
|
|
1861
2188
|
// inside the Dropdown portal, so its layout effect fires when the surface
|
|
1862
2189
|
// mounts (the parent Header doesn't re-render on the portal's own mount).
|
|
1863
|
-
function SettingsSlide({view,mainPage,shortcutsPage,onBack,onClose}){
|
|
1864
|
-
const slideRef=useRef(null), p1=useRef(null), p2=useRef(null);
|
|
2190
|
+
function SettingsSlide({view,mainPage,shortcutsPage,themePage,onBack,onClose}){
|
|
2191
|
+
const slideRef=useRef(null), p1=useRef(null), p2=useRef(null), p3=useRef(null);
|
|
2192
|
+
const pageNum=view==="shortcuts"?"2":view==="theme"?"3":"1";
|
|
1865
2193
|
useLayoutEffect(()=>{
|
|
1866
2194
|
const slide=slideRef.current; if(!slide) return;
|
|
1867
|
-
const active=view==="shortcuts"?p2.current:p1.current;
|
|
2195
|
+
const active=view==="shortcuts"?p2.current:view==="theme"?p3.current:p1.current;
|
|
1868
2196
|
if(active) slide.style.height=active.offsetHeight+"px";
|
|
1869
2197
|
});
|
|
1870
|
-
// Escape steps back from
|
|
2198
|
+
// Escape steps back from a sub-page first, then closes the dropdown —
|
|
1871
2199
|
// mirrors the picker's nested-dismiss behaviour.
|
|
1872
2200
|
useEffect(()=>{
|
|
1873
2201
|
const onKey=e=>{ if(e.key!=="Escape")return;
|
|
1874
|
-
if(view
|
|
2202
|
+
if(view!=="main"){onBack&&onBack();} else {onClose&&onClose();} };
|
|
1875
2203
|
document.addEventListener("keydown",onKey);
|
|
1876
2204
|
return()=>document.removeEventListener("keydown",onKey);
|
|
1877
2205
|
},[view,onBack,onClose]);
|
|
1878
|
-
return h("div",{ref:slideRef,className:"tl-set-slide t-page-slide","data-page":
|
|
1879
|
-
h("div",{ref:p1,className:"tl-set-page t-page","data-page-id":"1","aria-hidden":view
|
|
1880
|
-
h("div",{ref:p2,className:"tl-set-page t-page","data-page-id":"2","aria-hidden":view!=="shortcuts"},shortcutsPage)
|
|
2206
|
+
return h("div",{ref:slideRef,className:"tl-set-slide t-page-slide","data-page":pageNum},
|
|
2207
|
+
h("div",{ref:p1,className:"tl-set-page t-page","data-page-id":"1","aria-hidden":view!=="main"},mainPage),
|
|
2208
|
+
h("div",{ref:p2,className:"tl-set-page t-page","data-page-id":"2","aria-hidden":view!=="shortcuts"},shortcutsPage),
|
|
2209
|
+
h("div",{ref:p3,className:"tl-set-page t-page","data-page-id":"3","aria-hidden":view!=="theme"},themePage));
|
|
1881
2210
|
}
|
|
1882
2211
|
|
|
1883
2212
|
// ── refine relay client ──
|
|
1884
2213
|
// Talks to the local relay (server/relay.mjs). The browser posts a job and
|
|
1885
2214
|
// polls; the relay answers each job with one agent run (one-shot).
|
|
1886
2215
|
const RELAY_URL = (window.REFINE_RELAY_URL) || "http://localhost:7331";
|
|
2216
|
+
|
|
2217
|
+
// ── demo/mock agent (guarded — additive, OFF by default) ──
|
|
2218
|
+
// When `window.__TX_REFINE_MOCK` is set (the standalone website-demo.html),
|
|
2219
|
+
// the three relay fns below short-circuit to a built-in mock so the panel is
|
|
2220
|
+
// fully interactive with NO relay/LLM/CLI. When the flag is UNSET this whole
|
|
2221
|
+
// block is inert and every fn falls through to the real fetch path unchanged.
|
|
2222
|
+
const TX_MOCK = typeof window!=="undefined" && window.__TX_REFINE_MOCK;
|
|
2223
|
+
const _txMockJobs = new Map(); // id → { request, createdAt }
|
|
2224
|
+
const _txMockId = ()=>"mock-"+Date.now()+"-"+Math.random().toString(36).slice(2,8);
|
|
2225
|
+
const _txMs = (n)=>(n==null?"":Math.round(n)+"ms");
|
|
2226
|
+
const _TX_EASE = "cubic-bezier(0.22, 1, 0.36, 1)"; // transitions.dev standard ease-out
|
|
2227
|
+
// Build a "done" result shaped by the stored request kind (mirrors relay.mjs
|
|
2228
|
+
// parseScanOutput/parseApplyOutput/parseAgentOutput shapes).
|
|
2229
|
+
function _txMockResult(request){
|
|
2230
|
+
const kind = request && request.kind;
|
|
2231
|
+
if(kind==="scan") return { groups: _txMockGroups(request), summary: "Grouped 1 component — Menu dropdown (open + close)." };
|
|
2232
|
+
if(kind==="apply") return { applied: true, summary: "Demo mode — simulated writing the change to your source. (No files touched.)", files: [] };
|
|
2233
|
+
return _txMockSuggestions(request);
|
|
2234
|
+
}
|
|
2235
|
+
// scan → one "Menu dropdown" group with Open + Close phases. Members are
|
|
2236
|
+
// derived from the raw DOM-detected transitions so the timeline shows real
|
|
2237
|
+
// lanes; selectors come straight from the live scan so they resolve in the
|
|
2238
|
+
// DOM. Shape mirrors relay.mjs buildScanPrompt's groups[] (id/label/phases →
|
|
2239
|
+
// phase{id,phase,label,stateTarget,fromState,toState,members[{id,label,
|
|
2240
|
+
// selector,propertyTimings}]}) and what TransitionRegistry.setGroups expects.
|
|
2241
|
+
function _txMockGroups(request){
|
|
2242
|
+
const raw = (request && request.raw) || [];
|
|
2243
|
+
const base = raw.length ? raw : [{label:"Menu", selector:".wd-dd .wd-dd-menu", properties:["opacity","transform"]}];
|
|
2244
|
+
// OPEN phase matches the current DOM → copy the scanned timings verbatim
|
|
2245
|
+
// (so the timeline shows the element's real, possibly off-token values).
|
|
2246
|
+
const open = base.map((e,i)=>({
|
|
2247
|
+
id:"m"+i,
|
|
2248
|
+
label:e.label||("Element "+(i+1)),
|
|
2249
|
+
selector:e.selector||".wd-dd .wd-dd-menu",
|
|
2250
|
+
propertyTimings:((e.timings&&e.timings.length)?e.timings:(e.properties||["opacity","transform"]).map(p=>({property:p,durationMs:280,delayMs:0,easing:"ease"})))
|
|
2251
|
+
.map(t=>({property:t.property, durationMs:t.durationMs!=null?t.durationMs:280, delayMs:t.delayMs||0, easing:t.easing||"ease"})),
|
|
2252
|
+
}));
|
|
2253
|
+
// CLOSE phase lives on a state variant the DOM scan can't see; the recipe's
|
|
2254
|
+
// faster close (the authored 180ms) is recovered here, mirroring how the
|
|
2255
|
+
// real agent reads the opposite-phase timings from cssRules.
|
|
2256
|
+
const close = open.map(m=>({ ...m, propertyTimings:m.propertyTimings.map(t=>({property:t.property, durationMs:180, delayMs:0, easing:"ease"})) }));
|
|
2257
|
+
const phases=[
|
|
2258
|
+
{id:"menu-dropdown:open", phase:"open", label:"Open", stateTarget:".wd-dd", fromState:null, toState:".is-open", members:open},
|
|
2259
|
+
{id:"menu-dropdown:close",phase:"close", label:"Close", stateTarget:".wd-dd", fromState:".is-open", toState:null, members:close},
|
|
2260
|
+
];
|
|
2261
|
+
return [{ id:"menu-dropdown", label:"Menu dropdown", component:null, phases }];
|
|
2262
|
+
}
|
|
2263
|
+
// ── deterministic motion-token engine (mirrors server/motion-tokens.mjs) ──
|
|
2264
|
+
// The website demo answers Refine locally, so it must reach the SAME verdict
|
|
2265
|
+
// the shipped agent would: flag ONLY values that are genuinely off the
|
|
2266
|
+
// transitions.dev tokens (by usage where the skill says so). For the demo
|
|
2267
|
+
// dropdown that means the lone real issue — the 0.8 pre-scale — is the
|
|
2268
|
+
// suggestion, instead of a pile of tweaks for values that are already on-grid.
|
|
2269
|
+
const _TX_DUR_TOKENS = [{v:150,name:"Fast",usage:"quick state changes"},{v:250,name:"Medium",usage:"standard UI motion"},{v:400,name:"Slow",usage:"larger surfaces"}];
|
|
2270
|
+
const _TX_SCALE_TOKENS = [{v:0.96,name:"Large",usage:"modal open / close"},{v:0.97,name:"Medium",usage:"dropdown open"},{v:0.98,name:"Small",usage:"tooltip open"},{v:0.99,name:"Tiny",usage:"dropdown close"}];
|
|
2271
|
+
const _TX_BLUR_TOKENS = [{v:2,name:"Small",usage:"panel reveal, icon swap, text swap"},{v:3,name:"Medium",usage:"page slide, text reveal"},{v:8,name:"Large",usage:"success check open"}];
|
|
2272
|
+
const _TX_TOKEN_EASE = new Set(["cubic-bezier(0.22,1,0.36,1)","ease-in-out","ease-out","linear","cubic-bezier(0.34,1.36,0.64,1)","cubic-bezier(0.34,3.85,0.64,1)"]);
|
|
2273
|
+
const _txLc = s=>String(s||"").toLowerCase();
|
|
2274
|
+
const _txNormEase = s=>String(s||"").replace(/\s+/g,"").toLowerCase();
|
|
2275
|
+
const _txNearest = (arr,val)=>{let best=arr[0],bd=Infinity;for(const t of arr){const d=Math.abs(t.v-val);if(d<bd){bd=d;best=t;}}return{token:best,delta:bd};};
|
|
2276
|
+
const _txScaleUsage = (hint)=>{const h=_txLc(hint);if(!h)return null;const close=/clos/.test(h);
|
|
2277
|
+
if(/\bmodal\b|\bdialog\b|\blightbox\b/.test(h))return _TX_SCALE_TOKENS.find(t=>t.v===0.96);
|
|
2278
|
+
if(/\btooltip\b|\btip\b|\bpopover\b/.test(h))return _TX_SCALE_TOKENS.find(t=>t.v===0.98);
|
|
2279
|
+
if(/\bdropdown\b|drop-down|\bmenu\b|\bselect\b|\bcombobox\b|\bcaret\b/.test(h))return _TX_SCALE_TOKENS.find(t=>t.v===(close?0.99:0.97));
|
|
2280
|
+
return null;};
|
|
2281
|
+
const _txBlurUsage = (hint)=>{const h=_txLc(hint);if(!h)return null;
|
|
2282
|
+
if(/success|check/.test(h))return _TX_BLUR_TOKENS.find(t=>t.v===8);
|
|
2283
|
+
if(/\bpage\b|\bslide\b/.test(h))return _TX_BLUR_TOKENS.find(t=>t.v===3);
|
|
2284
|
+
if(/text/.test(h)&&/reveal/.test(h))return _TX_BLUR_TOKENS.find(t=>t.v===3);
|
|
2285
|
+
if(/panel|icon|swap|skeleton|number|digit|reveal/.test(h))return _TX_BLUR_TOKENS.find(t=>t.v===2);
|
|
2286
|
+
return null;};
|
|
2287
|
+
const _txRefineEasing = (e)=>{const n=_txNormEase(e);if(!n)return false;if(_TX_TOKEN_EASE.has(n))return false;return n==="ease"||n==="ease-in"||n.startsWith("cubic-bezier")||n.startsWith("linear(");};
|
|
2288
|
+
// Token-faithful tweaks for one lane list (mirrors refineTimings()).
|
|
2289
|
+
function _txTokenTweaks(timings, ctx){
|
|
2290
|
+
const out=[]; const c=ctx||{};
|
|
2291
|
+
for(const t of (timings||[])){
|
|
2292
|
+
const prop=t.property||"all";
|
|
2293
|
+
const member=t.member||null;
|
|
2294
|
+
const hint=[c.label,c.selector,c.phase,t.property,t.member].filter(Boolean).join(" ");
|
|
2295
|
+
const mp=(rest)=> member ? {member, ...rest} : rest;
|
|
2296
|
+
if(Number.isFinite(t.durationMs)){const {token,delta}=_txNearest(_TX_DUR_TOKENS,t.durationMs);
|
|
2297
|
+
if(delta>10)out.push({id:"mock-"+prop+"-duration",kind:"duration",property:prop,member,
|
|
2298
|
+
title:"Duration → "+token.name,from:t.durationMs+"ms",to:token.v+"ms",
|
|
2299
|
+
patch:mp({property:prop,durationMs:token.v}),
|
|
2300
|
+
reason:token.name+" ("+token.v+"ms) is the closest motion token — used for "+token.usage+". "+t.durationMs+"ms is off-grid."});}
|
|
2301
|
+
if(_txRefineEasing(t.easing))out.push({id:"mock-"+prop+"-easing",kind:"easing",property:prop,member,
|
|
2302
|
+
title:"Easing → Smooth ease out",from:t.easing,to:_TX_EASE,
|
|
2303
|
+
patch:mp({property:prop,easing:_TX_EASE}),
|
|
2304
|
+
reason:'"'+t.easing+'" is a generic curve. The transitions.dev standard ease-out reads more intentional on opens, closes, slides, and resizes.'});
|
|
2305
|
+
if(Number.isFinite(t.scale)&&Math.abs(t.scale-1)>1e-4){const usage=_txScaleUsage(hint);const {token:near,delta}=_txNearest(_TX_SCALE_TOKENS,t.scale);const token=usage||near;
|
|
2306
|
+
if(token&&(usage||delta>5e-3)&&Math.abs(token.v-t.scale)>1e-4)out.push({id:"mock-"+prop+"-scale",kind:"scale",property:prop,member,
|
|
2307
|
+
title:"Scale → "+token.name,from:String(t.scale),to:String(token.v),
|
|
2308
|
+
patch:mp({property:prop,scale:token.v,...(t.varName?{varName:t.varName}:{})}),
|
|
2309
|
+
reason:token.name+" ("+token.v+") is the transitions.dev scale token for "+token.usage+". "+t.scale+" is "+(usage?"off the usage token":"off-grid")+"."});}
|
|
2310
|
+
if(Number.isFinite(t.blur)&&t.blur>1e-4){const usage=_txBlurUsage(hint);const {token:near,delta}=_txNearest(_TX_BLUR_TOKENS,t.blur);const token=usage||near;
|
|
2311
|
+
if(token&&(usage||delta>0.5)&&Math.abs(token.v-t.blur)>1e-4)out.push({id:"mock-"+prop+"-blur",kind:"blur",property:prop,member,
|
|
2312
|
+
title:"Blur → "+token.name,from:t.blur+"px",to:token.v+"px",
|
|
2313
|
+
patch:mp({property:prop,blur:token.v,...(t.varName?{varName:t.varName}:{})}),
|
|
2314
|
+
reason:token.name+" ("+token.v+"px) is the transitions.dev blur token for "+token.usage+". "+t.blur+"px is "+(usage?"off the usage token":"off-grid")+"."});}
|
|
2315
|
+
}
|
|
2316
|
+
return out;
|
|
2317
|
+
}
|
|
2318
|
+
// refine → token-faithful suggestions (same verdict the shipped agent reaches)
|
|
2319
|
+
// plus the best-fit recipe for the Replace tab. patches derive property/member
|
|
2320
|
+
// from request.timings so Apply changes a real lane. Shape mirrors relay.mjs's
|
|
2321
|
+
// suggestion contract (id/kind/property/title/from/to/patch/reason + reference).
|
|
2322
|
+
function _txMockSuggestions(request){
|
|
2323
|
+
const t = (request && request.timings) || [];
|
|
2324
|
+
const ctx = { label:(request&&request.label)||null, selector:(request&&request.selector)||null, phase:(request&&request.phase)||null };
|
|
2325
|
+
const p0 = (t[0]&&t[0].property) || "opacity";
|
|
2326
|
+
const m0 = (t[0]&&t[0].member) || null;
|
|
2327
|
+
const mp = (member, rest)=> member ? {member, ...rest} : rest;
|
|
2328
|
+
// motion-token tweaks: ONLY off-token values, exactly like the real engine.
|
|
2329
|
+
const tweaks = _txTokenTweaks(t, ctx);
|
|
2330
|
+
// related phases (open + close) → a recipe swap updates them together: one
|
|
2331
|
+
// `patches` entry per phase, open slower than close (250ms / 150ms tokens).
|
|
2332
|
+
const reqPhases = (request && Array.isArray(request.phases)) ? request.phases.filter(p=>p&&p.phase) : [];
|
|
2333
|
+
const replacePatches = reqPhases.length>1
|
|
2334
|
+
? reqPhases.map(p=>({phase:p.phase, property:"all", durationMs:/close/i.test(p.phase)?150:250, easing:_TX_EASE, scale:/close/i.test(p.phase)?0.99:0.97}))
|
|
2335
|
+
: null;
|
|
2336
|
+
const replace = { id:"mock-replace-menu", kind:"replace", property:p0, member:m0,
|
|
2337
|
+
title:"Menu dropdown", reference:"05-menu-dropdown.md",
|
|
2338
|
+
patch:mp(m0,{property:"all", durationMs:250, easing:_TX_EASE, scale:0.97}),
|
|
2339
|
+
...(replacePatches?{patches:replacePatches}:{}),
|
|
2340
|
+
reason:"Best-fit transitions.dev recipe (#05 Menu dropdown): scale 0.97→1 + fade, 250ms open / 150ms close on cubic-bezier(0.22, 1, 0.36, 1)." };
|
|
2341
|
+
const suggestions=[...tweaks, replace];
|
|
2342
|
+
// Scope to the requested refineType so per-tab caches merge cleanly.
|
|
2343
|
+
const rt=(request&&request.refineType)||"both";
|
|
2344
|
+
const scoped=rt==="replace"?suggestions.filter(s=>s.kind==="replace")
|
|
2345
|
+
:rt==="small"?suggestions.filter(s=>s.kind!=="replace")
|
|
2346
|
+
:suggestions;
|
|
2347
|
+
const nTweak=tweaks.length;
|
|
2348
|
+
const tweakWord=nTweak+" motion-token tweak"+(nTweak===1?"":"s");
|
|
2349
|
+
const summary=rt==="replace"?"1 transitions.dev recipe match for the Menu dropdown."
|
|
2350
|
+
:rt==="small"?(nTweak?tweakWord+" for the Menu dropdown.":"The Menu dropdown is already on-grid — no motion-token tweaks needed.")
|
|
2351
|
+
:tweakWord+" and 1 transitions.dev recipe match for the Menu dropdown.";
|
|
2352
|
+
return { suggestions:scoped, summary };
|
|
2353
|
+
}
|
|
2354
|
+
// statusLog so the panel's shimmer status copy has something to show.
|
|
2355
|
+
function _txMockLog(request, createdAt){
|
|
2356
|
+
const kind = request && request.kind;
|
|
2357
|
+
const head = kind==="scan" ? "Grouping transitions from your source…"
|
|
2358
|
+
: kind==="apply" ? "Writing “"+((request&&request.label)||"transition")+"” to your code…"
|
|
2359
|
+
: "Scanning “"+((request&&request.label)||"transition")+"”…";
|
|
2360
|
+
return [{message:head, at:createdAt}, {message:"Reading the Transitions.dev skill…", at:createdAt+350}];
|
|
2361
|
+
}
|
|
2362
|
+
|
|
1887
2363
|
async function relayCreateJob(request){
|
|
2364
|
+
if(TX_MOCK){
|
|
2365
|
+
const id=_txMockId();
|
|
2366
|
+
// Website demo: random 4–6s “agent work” so the scanning loader feels real.
|
|
2367
|
+
const delayMs=4000+Math.floor(Math.random()*2001);
|
|
2368
|
+
_txMockJobs.set(id,{request,createdAt:Date.now(),delayMs});
|
|
2369
|
+
return { id, status:"pending" };
|
|
2370
|
+
}
|
|
1888
2371
|
const r = await fetch(RELAY_URL+"/jobs",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({request})});
|
|
1889
2372
|
if(!r.ok) throw new Error("relay POST /jobs failed ("+r.status+")");
|
|
1890
2373
|
return r.json(); // { id, status }
|
|
1891
2374
|
}
|
|
1892
2375
|
async function relayGetJob(id){
|
|
2376
|
+
if(TX_MOCK){
|
|
2377
|
+
const rec=_txMockJobs.get(id);
|
|
2378
|
+
if(!rec) return { id, status:"error", statusLog:[], result:null, error:"mock: unknown job" };
|
|
2379
|
+
const elapsed=Date.now()-rec.createdAt;
|
|
2380
|
+
const log=_txMockLog(rec.request, rec.createdAt);
|
|
2381
|
+
const wait=rec.delayMs!=null?rec.delayMs:800;
|
|
2382
|
+
if(elapsed<wait) return { id, status:"running", statusLog:[log[0]], result:null, error:null };
|
|
2383
|
+
return { id, status:"done", statusLog:log, result:_txMockResult(rec.request), error:null };
|
|
2384
|
+
}
|
|
1893
2385
|
const r = await fetch(RELAY_URL+"/jobs/"+id);
|
|
1894
2386
|
if(!r.ok) throw new Error("relay GET /jobs/:id failed ("+r.status+")");
|
|
1895
2387
|
return r.json();
|
|
1896
2388
|
}
|
|
1897
2389
|
async function relayHealth(){
|
|
2390
|
+
if(TX_MOCK) return { ok:true, auto:true, llmAvailable:true, cliInstalled:true, agentCmd:true, pollerActive:false, jobs:0 };
|
|
1898
2391
|
const r = await fetch(RELAY_URL+"/health");
|
|
1899
2392
|
if(!r.ok) throw new Error("relay /health failed ("+r.status+")");
|
|
1900
|
-
return r.json(); // { ok, auto, llmAvailable, jobs }
|
|
2393
|
+
return r.json(); // { ok, auto, llmAvailable, pollerActive, agentCmd, cliInstalled, jobs }
|
|
2394
|
+
}
|
|
2395
|
+
// Ask the relay to stop the in-chat `/refine live` loop (the credit-burning
|
|
2396
|
+
// poller). No-op for a wired REFINE_AGENT_CMD (it never polls /jobs/next).
|
|
2397
|
+
async function relayStopLive(){
|
|
2398
|
+
if(TX_MOCK) return { ok:true };
|
|
2399
|
+
const r = await fetch(RELAY_URL+"/poller/stop",{method:"POST"});
|
|
2400
|
+
if(!r.ok) throw new Error("relay POST /poller/stop failed ("+r.status+")");
|
|
2401
|
+
return r.json();
|
|
1901
2402
|
}
|
|
1902
2403
|
|
|
1903
2404
|
const REFINE_MODES=[
|
|
@@ -1944,7 +2445,20 @@
|
|
|
1944
2445
|
const matches=lanes.filter(t=>t.property===s.property&&t.member);
|
|
1945
2446
|
if(!matches.length) return null;
|
|
1946
2447
|
const names=[...new Set(matches.map(t=>t.member))];
|
|
1947
|
-
|
|
2448
|
+
if(names.length===1) return names[0];
|
|
2449
|
+
// Ambiguous: several lanes share this property (e.g. a caret that rotates
|
|
2450
|
+
// AND a panel that scales both animate `transform`). For a value-bearing
|
|
2451
|
+
// kind, attribute to the lane that actually carries that value rather than
|
|
2452
|
+
// blindly taking the first — read the live non-resting value per lane.
|
|
2453
|
+
if(s.kind==="scale"||s.kind==="blur"){
|
|
2454
|
+
const key=s.kind;
|
|
2455
|
+
const owner=matches.find(t=>{
|
|
2456
|
+
const v=laneValueHint(t.selector,t.property);
|
|
2457
|
+
return key==="scale"?(v.scale!=null&&Math.abs(v.scale-1)>1e-4):(v.blur!=null&&v.blur>1e-4);
|
|
2458
|
+
});
|
|
2459
|
+
if(owner) return owner.member;
|
|
2460
|
+
}
|
|
2461
|
+
return matches[0].member;
|
|
1948
2462
|
}
|
|
1949
2463
|
// Apply button — "Apply" ⟷ "✓ Applied". On apply, a LEADING gray check is
|
|
1950
2464
|
// revealed first and pushes the text to the right: .tl-apply-check tweens its
|
|
@@ -2012,18 +2526,25 @@
|
|
|
2012
2526
|
h("span",{className:"tl-apply-seg tl-apply-y"},"y"),
|
|
2013
2527
|
h("span",{className:"tl-apply-seg tl-apply-ied"},"ied"))));
|
|
2014
2528
|
}
|
|
2015
|
-
function RefineResults({suggestions,appliedIds,onApply,lanes}){
|
|
2529
|
+
function RefineResults({suggestions,appliedIds,onApply,lanes,revealedRef,active=true}){
|
|
2016
2530
|
const ref=useRef(null);
|
|
2017
2531
|
useEffect(()=>{
|
|
2018
2532
|
const el=ref.current;if(!el)return;
|
|
2533
|
+
// Both type tabs keep their results mounted so they can cross-slide
|
|
2534
|
+
// (page-side-by-side). Only the visible page plays the staggered reveal,
|
|
2535
|
+
// and only once per scan; the hidden page just shows its final state so
|
|
2536
|
+
// switching tabs slides finished content in without re-staggering.
|
|
2537
|
+
if(!active){ el.classList.add("is-shown"); return; }
|
|
2538
|
+
if(revealedRef&&revealedRef.current){ el.classList.add("is-shown"); return; }
|
|
2019
2539
|
el.classList.remove("is-shown");
|
|
2020
2540
|
void el.offsetHeight; // reflow from the start state before playing
|
|
2021
2541
|
el.classList.add("is-shown");
|
|
2022
|
-
|
|
2542
|
+
if(revealedRef) revealedRef.current=true;
|
|
2543
|
+
},[active]);
|
|
2023
2544
|
const delay=(i)=>({transitionDelay:"calc(var(--stagger-stagger) * "+i+")"});
|
|
2024
2545
|
// category chip label + color modifier, keyed by the suggestion kind
|
|
2025
2546
|
const CAT={easing:["Easing","is-easing"],duration:["Duration","is-duration"],
|
|
2026
|
-
delay:["Delay","is-delay"],scale:["Scale","is-scale"],distance:["Distance","is-distance"],
|
|
2547
|
+
delay:["Delay","is-delay"],scale:["Scale","is-scale"],blur:["Blur","is-blur"],distance:["Distance","is-distance"],
|
|
2027
2548
|
replace:["Transition replacement","is-replace"]};
|
|
2028
2549
|
const cap=(p)=>p&&p!=="all"?p.charAt(0).toUpperCase()+p.slice(1):"";
|
|
2029
2550
|
let i=0;
|
|
@@ -2048,8 +2569,14 @@
|
|
|
2048
2569
|
h("div",{className:"tl-sug-body"},
|
|
2049
2570
|
h("div",{className:"tl-sug-text"},
|
|
2050
2571
|
isReplace
|
|
2051
|
-
? h(
|
|
2052
|
-
|
|
2572
|
+
? h(React.Fragment,null,
|
|
2573
|
+
h("div",{className:"tl-sug-title"},
|
|
2574
|
+
"Current transition",h("br"),"\u2192 "+(s.title||"transition")+" from transitions.dev"),
|
|
2575
|
+
Array.isArray(s.patches)&&s.patches.length>1&&h("div",{className:"tl-sug-phases"},
|
|
2576
|
+
"Updates ",
|
|
2577
|
+
s.patches.map((pp,pi)=>h("span",{className:"tl-sug-phase",key:pi},
|
|
2578
|
+
(cap(pp.phase)||pp.phase||"Phase"),
|
|
2579
|
+
pp.durationMs!=null&&h("span",{className:"tl-sug-phase-dur"},pp.durationMs+"ms")))))
|
|
2053
2580
|
: h(React.Fragment,null,
|
|
2054
2581
|
s.title&&h("div",{className:"tl-sug-title"},s.title),
|
|
2055
2582
|
(s.from||s.to)&&h("div",{className:"tl-sug-delta"},
|
|
@@ -2059,6 +2586,20 @@
|
|
|
2059
2586
|
applyBtn));
|
|
2060
2587
|
}));
|
|
2061
2588
|
}
|
|
2589
|
+
// Shared "Connect with your agent" copy — used by the full-panel gate and by
|
|
2590
|
+
// the Refine area when no live agent is connected, so both read identically.
|
|
2591
|
+
// `cliMissing` swaps the body to an install-first hint.
|
|
2592
|
+
function AgentConnectBody({cliMissing}){
|
|
2593
|
+
return cliMissing
|
|
2594
|
+
? h("p",{className:"tl-gate-text"},"Install an agent CLI, then run ",
|
|
2595
|
+
h("code",{className:"tl-code"},"npx transitions-refine live"),
|
|
2596
|
+
" (recommended) or ",h("code",{className:"tl-code"},"/refine live")," in your editor.")
|
|
2597
|
+
: h("p",{className:"tl-gate-text"},
|
|
2598
|
+
"Run ",h("code",{className:"tl-code"},"npx transitions-refine live"),
|
|
2599
|
+
" in your project to start the relay with a persistent agent — works for hours, no chat loop. Or run ",
|
|
2600
|
+
h("code",{className:"tl-code"},"/refine live"),
|
|
2601
|
+
" in your editor if the relay is already up without an agent wired.");
|
|
2602
|
+
}
|
|
2062
2603
|
function RefinePanel({open,onClose,phase,label,refineType,onType,suggestions,summary,error,appliedIds,onApply,onApplyAll,mode,onMode,llmAvailable,cliInstalled,onStart,lanes}){
|
|
2063
2604
|
// mount-on-open; keep mounted through the panel-reveal slide-out, then unmount
|
|
2064
2605
|
const[render,setRender]=useState(open);
|
|
@@ -2067,6 +2608,11 @@
|
|
|
2067
2608
|
const[modeOpen,setModeOpen]=useState(false);
|
|
2068
2609
|
const[statusIx,setStatusIx]=useState(0);
|
|
2069
2610
|
const modeRef=useRef(null);
|
|
2611
|
+
// The results reveal should play once per scan, not on every tab switch.
|
|
2612
|
+
// This persists across RefineResults remounts; reset it when a new scan
|
|
2613
|
+
// starts (or the panel returns to idle) so fresh results animate again.
|
|
2614
|
+
const revealedRef=useRef(false);
|
|
2615
|
+
useEffect(()=>{ if(phase==="scanning"||phase==="idle") revealedRef.current=false; },[phase]);
|
|
2070
2616
|
useEffect(()=>{
|
|
2071
2617
|
let raf, to;
|
|
2072
2618
|
if(open){
|
|
@@ -2107,14 +2653,11 @@
|
|
|
2107
2653
|
// null (unknown / still probing) is treated as ready so the panel doesn't
|
|
2108
2654
|
// flash the "unavailable" copy before /health resolves.
|
|
2109
2655
|
const agentReady = !agentMode || llmAvailable!==false;
|
|
2110
|
-
const typeDesc = (REFINE_TYPES.find(t=>t.key===refineType)||REFINE_TYPES[0]).desc;
|
|
2111
2656
|
const modeLabel = (REFINE_MODES.find(m=>m.key===mode)||REFINE_MODES[0]).label;
|
|
2112
|
-
// Idle empty-state copy is mode-aware
|
|
2113
|
-
// agent-oriented desc; Deterministic has no
|
|
2114
|
-
// math-snap behavior (and that "replace" needs
|
|
2115
|
-
|
|
2116
|
-
? "Deterministic mode can't pick a recipe to replace the transition — switch to Agent mode for replacements."
|
|
2117
|
-
: "Deterministic mode snaps your transition's timing to the nearest Transitions.dev motion tokens — mathematically, with no agent or credits.");
|
|
2657
|
+
// Idle empty-state copy is mode-aware (computed per type in bodyFor):
|
|
2658
|
+
// Agent (llm) keeps the type's agent-oriented desc; Deterministic has no
|
|
2659
|
+
// agent, so it describes the math-snap behavior (and that "replace" needs
|
|
2660
|
+
// Agent mode).
|
|
2118
2661
|
// One persistent control for the whole foot: in idle/done/error it's the
|
|
2119
2662
|
// pill button; while scanning it carries `.is-scanning` and the same DOM
|
|
2120
2663
|
// node morphs (card resize) into the loading rectangle with the border-beam.
|
|
@@ -2135,25 +2678,58 @@
|
|
|
2135
2678
|
h(ShimmerSwapText,{text:REFINE_STATUS[statusIx]}))));
|
|
2136
2679
|
};
|
|
2137
2680
|
|
|
2138
|
-
|
|
2681
|
+
// Body content for a given refine type. Both type tabs render their own
|
|
2682
|
+
// body so they can cross-slide (page-side-by-side). Phase is global, so
|
|
2683
|
+
// only the type-specific bits (filtered suggestions, empty/idle copy)
|
|
2684
|
+
// change per page. `isActive` tells the results list whether to play its
|
|
2685
|
+
// reveal (only the visible page does).
|
|
2686
|
+
const bodyFor=(rt,isActive)=>{
|
|
2687
|
+
const vis = suggestions.filter(s=> rt==="replace" ? s.kind==="replace" : s.kind!=="replace");
|
|
2688
|
+
if(phase==="scanning"){
|
|
2689
|
+
return h("div",{className:"tl-refine-center"});
|
|
2690
|
+
}
|
|
2691
|
+
if(phase==="error"){
|
|
2692
|
+
return h("div",{className:"tl-refine-center"},
|
|
2693
|
+
h("div",{className:"tl-refine-unavail-title"},"Something went wrong."),
|
|
2694
|
+
h("div",{className:"tl-refine-unavail-text"},error||"The agent reported an error."));
|
|
2695
|
+
}
|
|
2696
|
+
if(phase==="done"){
|
|
2697
|
+
if(vis.length===0){
|
|
2698
|
+
const emptyMsg = rt==="replace"
|
|
2699
|
+
? "I didn't find any transition that would be a good fit as a replacement."
|
|
2700
|
+
: "Already aligned to the transitions.dev motion tokens. Nothing to refine.";
|
|
2701
|
+
return h("div",{className:"tl-refine-center"},
|
|
2702
|
+
h("p",{className:"tl-refine-idle-text"},emptyMsg));
|
|
2703
|
+
}
|
|
2704
|
+
return h(RefineResults,{suggestions:vis,appliedIds,onApply,lanes:lanes||[],revealedRef,active:isActive});
|
|
2705
|
+
}
|
|
2706
|
+
// idle
|
|
2707
|
+
if(!agentReady){
|
|
2708
|
+
// Same "Connect with your agent" copy as the full-panel gate, so opening
|
|
2709
|
+
// Refine after a post-scan auto-stop reads identically. Re-running
|
|
2710
|
+
// /refine live makes the agent available and Start scanning works again.
|
|
2711
|
+
return h("div",{className:"tl-refine-center"},
|
|
2712
|
+
h("div",{className:"tl-refine-unavail-title"},"Connect with your agent"),
|
|
2713
|
+
h(AgentConnectBody,{cliMissing:cliInstalled===false}));
|
|
2714
|
+
}
|
|
2715
|
+
const idleDescR = agentMode ? (REFINE_TYPES.find(t=>t.key===rt)||REFINE_TYPES[0]).desc : (rt==="replace"
|
|
2716
|
+
? "Deterministic mode can't pick a recipe to replace the transition — switch to Agent mode for replacements."
|
|
2717
|
+
: "Deterministic mode snaps your transition's timing to the nearest Transitions.dev motion tokens — mathematically, with no agent or credits.");
|
|
2718
|
+
return h("div",{className:"tl-refine-center"},
|
|
2719
|
+
h("p",{className:"tl-refine-idle-text"},idleDescR));
|
|
2720
|
+
};
|
|
2721
|
+
|
|
2722
|
+
// Foot tracks the active type (it lives below the sliding body, so it
|
|
2723
|
+
// just updates in place on a tab switch).
|
|
2724
|
+
let foot, footBar=false;
|
|
2139
2725
|
if(phase==="scanning"){
|
|
2140
|
-
body = h("div",{className:"tl-refine-center"});
|
|
2141
2726
|
foot = startBtn();
|
|
2142
2727
|
} else if(phase==="error"){
|
|
2143
|
-
body = h("div",{className:"tl-refine-center"},
|
|
2144
|
-
h("div",{className:"tl-refine-unavail-title"},"Something went wrong."),
|
|
2145
|
-
h("div",{className:"tl-refine-unavail-text"},error||"The agent reported an error."));
|
|
2146
2728
|
foot = startBtn({label:"Try again"});
|
|
2147
2729
|
} else if(phase==="done"){
|
|
2148
2730
|
if(visible.length===0){
|
|
2149
|
-
const emptyMsg = refineType==="replace"
|
|
2150
|
-
? "I didn't find any transition that would be a good fit as a replacement."
|
|
2151
|
-
: "Already aligned to the transitions.dev motion tokens. Nothing to refine.";
|
|
2152
|
-
body = h("div",{className:"tl-refine-center"},
|
|
2153
|
-
h("p",{className:"tl-refine-idle-text"},emptyMsg));
|
|
2154
2731
|
foot = startBtn({label:"Scan again"});
|
|
2155
2732
|
} else {
|
|
2156
|
-
body = h(RefineResults,{suggestions:visible,appliedIds,onApply,lanes:lanes||[]});
|
|
2157
2733
|
footBar = true;
|
|
2158
2734
|
foot = h(React.Fragment,null,
|
|
2159
2735
|
h("button",{className:"tl-pill-btn",disabled:pending.length===0,
|
|
@@ -2162,19 +2738,7 @@
|
|
|
2162
2738
|
h("button",{className:"tl-pill-btn",onClick:onStart},"Scan again"));
|
|
2163
2739
|
}
|
|
2164
2740
|
} else { // idle
|
|
2165
|
-
|
|
2166
|
-
const cliMissing = cliInstalled===false;
|
|
2167
|
-
body = h("div",{className:"tl-refine-center"},
|
|
2168
|
-
h("div",{className:"tl-refine-unavail-title"},"Agent unavailable."),
|
|
2169
|
-
cliMissing
|
|
2170
|
-
? h("p",{className:"tl-refine-unavail-text"},"Install Cursor CLI by ",h("code",{className:"tl-code"},"/refine live")," command in your agent to enable live functionality.")
|
|
2171
|
-
: h("p",{className:"tl-refine-unavail-text"},"Run the ",h("code",{className:"tl-code"},"/refine live")," command in your agent to enable live functionality."));
|
|
2172
|
-
foot = startBtn({disabled:true});
|
|
2173
|
-
} else {
|
|
2174
|
-
body = h("div",{className:"tl-refine-center"},
|
|
2175
|
-
h("p",{className:"tl-refine-idle-text"},idleDesc));
|
|
2176
|
-
foot = startBtn({});
|
|
2177
|
-
}
|
|
2741
|
+
foot = startBtn(agentReady?{}:{disabled:true});
|
|
2178
2742
|
}
|
|
2179
2743
|
|
|
2180
2744
|
return h("div",{className:"tl-refine-panel","data-open":panelOpen?"true":"false","data-phase":slidePhase,
|
|
@@ -2202,7 +2766,11 @@
|
|
|
2202
2766
|
REFINE_TYPES.map(t=>h("button",{key:t.key,className:"tl-refine-tab",role:"tab",
|
|
2203
2767
|
"aria-selected":refineType===t.key?"true":"false",
|
|
2204
2768
|
onClick:()=>{if(t.key!==refineType)onType(t.key);}},t.label))),
|
|
2205
|
-
h("div",{className:"tl-refine-body"},
|
|
2769
|
+
h("div",{className:"tl-refine-body tl-refine-pages t-page-slide","data-page":refineType==="replace"?"2":"1"},
|
|
2770
|
+
h("div",{className:"tl-refine-page t-page","data-page-id":"1","aria-hidden":refineType!=="small"},
|
|
2771
|
+
bodyFor("small",refineType==="small")),
|
|
2772
|
+
h("div",{className:"tl-refine-page t-page","data-page-id":"2","aria-hidden":refineType!=="replace"},
|
|
2773
|
+
bodyFor("replace",refineType==="replace"))),
|
|
2206
2774
|
h("div",{className:cx("tl-refine-foot",footBar&&"is-bar")},foot)));
|
|
2207
2775
|
}
|
|
2208
2776
|
|
|
@@ -2210,26 +2778,50 @@
|
|
|
2210
2778
|
// originally-scanned values — the set of changes Accept writes to source.
|
|
2211
2779
|
// Each change carries member/selector/phase context so the agent can target
|
|
2212
2780
|
// the right state rule (e.g. .is-open vs .is-closing) for a grouped phase.
|
|
2213
|
-
function computeChanges(active){
|
|
2781
|
+
function computeChanges(active,allEntries){
|
|
2214
2782
|
if(!active)return[];
|
|
2215
|
-
|
|
2216
|
-
|
|
2783
|
+
// Write the active phase plus — for a grouped phase — its sibling phases
|
|
2784
|
+
// (open/close) so a recipe swap applied across both is saved to both. Only
|
|
2785
|
+
// phases that actually have edits contribute changes, so a single-phase tweak
|
|
2786
|
+
// still writes just that phase. Each change carries its own `phase`.
|
|
2787
|
+
let targets=[active];
|
|
2788
|
+
if(active.kind==="phase"&&active.groupId&&Array.isArray(allEntries)){
|
|
2789
|
+
const sibs=allEntries.filter(e=>e.kind==="phase"&&e.groupId===active.groupId&&e.id!==active.id);
|
|
2790
|
+
targets=targets.concat(sibs);
|
|
2791
|
+
}
|
|
2217
2792
|
const out=[];
|
|
2218
|
-
for(const
|
|
2219
|
-
const
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2793
|
+
for(const tgt of targets){
|
|
2794
|
+
const base=tgt.baseLanes||[];
|
|
2795
|
+
const bmap=new Map(base.map(t=>[t.laneId,t]));
|
|
2796
|
+
for(const t of (tgt.effectiveTimings||[])){
|
|
2797
|
+
const b=bmap.get(t.laneId);if(!b)continue;
|
|
2798
|
+
if(t.durationMs!==b.durationMs||t.delayMs!==b.delayMs||(t.easing||"")!==(b.easing||"")){
|
|
2799
|
+
out.push({property:t.property,member:t.member||null,selector:t.selector||null,phase:tgt.phase||null,
|
|
2800
|
+
from:{durationMs:b.durationMs,delayMs:b.delayMs,easing:b.easing},
|
|
2801
|
+
to:{durationMs:t.durationMs,delayMs:t.delayMs,easing:t.easing}});
|
|
2802
|
+
}
|
|
2803
|
+
// value edits (scale/blur) live as overrides on the effective lane and
|
|
2804
|
+
// carry their own from-value + backing var; emit one change each.
|
|
2805
|
+
if(t.scale!=null){
|
|
2806
|
+
out.push({property:t.property,member:t.member||null,selector:t.selector||null,phase:tgt.phase||null,varName:t.varName||null,
|
|
2807
|
+
from:{scale:t.scaleFrom},to:{scale:t.scale}});
|
|
2808
|
+
}
|
|
2809
|
+
if(t.blur!=null){
|
|
2810
|
+
out.push({property:t.property,member:t.member||null,selector:t.selector||null,phase:tgt.phase||null,varName:t.varName||null,
|
|
2811
|
+
from:{blur:t.blurFrom},to:{blur:t.blur}});
|
|
2812
|
+
}
|
|
2224
2813
|
}
|
|
2225
2814
|
}
|
|
2226
2815
|
return out;
|
|
2227
2816
|
}
|
|
2228
2817
|
|
|
2229
|
-
function Header({entries,active,onSelect,onReset,onCopy,copied,snap,setSnap,onMinimize,onRefine,refineActive,onAccept,acceptState,acceptDisabled,acceptError,scanning,onRescan,pick,setPick}){
|
|
2818
|
+
function Header({entries,active,onSelect,onReset,onCopy,copied,snap,setSnap,onMinimize,onRefine,refineActive,onAccept,acceptState,acceptDisabled,acceptError,scanning,scanError,onRescan,pick,setPick,chatLoop,liveStopped,onStopLive}){
|
|
2230
2819
|
const[setg,setSetg]=useState(false);
|
|
2231
2820
|
// settings dropdown sub-page (main menu ↔ keyboard-shortcuts cheat sheet)
|
|
2232
2821
|
const[settingsView,setSettingsView]=useState("main"); // "main" | "shortcuts"
|
|
2822
|
+
// theme preference — UI only for now (dark theme not wired up yet)
|
|
2823
|
+
const[themePref,setThemePref]=useState("system"); // "light" | "dark" | "system"
|
|
2824
|
+
const THEME_OPTS=[{key:"light",label:"Light"},{key:"dark",label:"Dark"},{key:"system",label:"System"}];
|
|
2233
2825
|
const isMac=typeof navigator!=="undefined"&&/mac/i.test((navigator.userAgentData&&navigator.userAgentData.platform)||navigator.platform||navigator.userAgent||"");
|
|
2234
2826
|
const modKey=isMac?"Cmd":"Ctrl";
|
|
2235
2827
|
// small gray shortcut hint appended inside a tooltip (e.g. "⌘A")
|
|
@@ -2276,7 +2868,7 @@
|
|
|
2276
2868
|
return h("div",{className:"tl-header"},
|
|
2277
2869
|
h("span",{className:"tl-header-label"},"Selected"),
|
|
2278
2870
|
h("button",{ref:pickRef,className:cx("tl-ghost-btn",pick&&"is-active"),disabled:!active,onClick:()=>setPick(v=>!v)},
|
|
2279
|
-
active?h(React.Fragment,null,h("span",
|
|
2871
|
+
active?h(React.Fragment,null,h("span",{className:"tl-ghost-label"},active.label),h("span",{className:"tl-dim"}," "+active.durationMs+"ms"))
|
|
2280
2872
|
:h("span",{className:"tl-dim"},"None"),
|
|
2281
2873
|
h("span",{className:"tl-ghost-chev"},h(Ic,{name:"chevron"}))),
|
|
2282
2874
|
h(Dropdown,{open:pick,onClose:()=>setPick(false),triggerRef:pickRef,width:Math.max(240,(pickRef.current&&pickRef.current.offsetWidth)||240),align:"left"},
|
|
@@ -2292,7 +2884,6 @@
|
|
|
2292
2884
|
...fGroups.map(g=>h(React.Fragment,{key:g.id},
|
|
2293
2885
|
h("div",{className:"tl-menu-section"},g.label),
|
|
2294
2886
|
...g.phases.map(phaseItem))),
|
|
2295
|
-
fFlat.length>0&&fGroups.length>0&&h("div",{className:"tl-menu-section"},"Ungrouped"),
|
|
2296
2887
|
...fFlat.map(flatItem))),
|
|
2297
2888
|
h("span",{className:"tl-header-count"},
|
|
2298
2889
|
scanning
|
|
@@ -2302,29 +2893,70 @@
|
|
|
2302
2893
|
h("span",{className:"t-shimmer tl-scan-shimmer","data-text":"Agent scanning transitions…"},"Agent scanning transitions…")),
|
|
2303
2894
|
h("span",{className:"t-tt tl-tt-below tl-tt-msg",role:"tooltip"},
|
|
2304
2895
|
"Agent is working on understanding your transitions and creating grouping and naming. This usually takes a few seconds to a minute."))
|
|
2305
|
-
:
|
|
2896
|
+
: scanError
|
|
2897
|
+
? h("span",{className:"t-tt-wrap"},
|
|
2898
|
+
h("span",{className:"tl-scan-note tl-scan-error",tabIndex:0},
|
|
2899
|
+
h(Ic,{name:"help",size:13}),
|
|
2900
|
+
h("span",null,"Couldn’t group — agent offline")),
|
|
2901
|
+
h("span",{className:"t-tt tl-tt-below tl-tt-msg",role:"tooltip"},
|
|
2902
|
+
/refine live|REFINE_AGENT_CMD/i.test(scanError)
|
|
2903
|
+
? scanError
|
|
2904
|
+
: scanError+" — grouping needs the agent. Run `npx transitions-refine live` (or `/refine live` in your editor)."))
|
|
2905
|
+
: h("span",{className:"tl-count-text"},entries.length+" transition"+(entries.length===1?"":"s")+" found")),
|
|
2906
|
+
chatLoop
|
|
2907
|
+
? h("span",{className:"t-tt-wrap tl-live-wrap"},
|
|
2908
|
+
h("span",{className:"tl-live-chip",tabIndex:0},
|
|
2909
|
+
h("span",{className:"tl-live-dot"}),
|
|
2910
|
+
h("span",null,"Live"),
|
|
2911
|
+
h("button",{className:"tl-live-stop",title:"Stop /refine live",
|
|
2912
|
+
onClick:(e)=>{e.stopPropagation();onStopLive&&onStopLive();}},"Stop")),
|
|
2913
|
+
h("span",{className:"t-tt tl-tt-below tl-tt-msg",role:"tooltip"},
|
|
2914
|
+
"`/refine live` is polling in your editor chat — it uses agent credits while idle. Stop it when you’re done, or it auto-stops after 10 min idle."))
|
|
2915
|
+
: liveStopped
|
|
2916
|
+
? h("span",{className:"t-tt-wrap tl-live-wrap"},
|
|
2917
|
+
h("span",{className:"tl-live-chip is-stopped",tabIndex:0},
|
|
2918
|
+
h(Ic,{name:"check",size:12}),
|
|
2919
|
+
h("span",null,"Live stopped")),
|
|
2920
|
+
h("span",{className:"t-tt tl-tt-below tl-tt-msg",role:"tooltip"},
|
|
2921
|
+
"The `/refine live` loop was asked to stop and will exit on its next poll. Run `/refine live` again to resume."))
|
|
2922
|
+
: null,
|
|
2306
2923
|
h("button",{ref:gearRef,className:cx("tl-icon-btn",setg&&"is-active"),title:"Settings",onClick:()=>setSetg(v=>!v)},h(Ic,{name:"dotsv"})),
|
|
2307
2924
|
h(Dropdown,{open:setg,onClose:()=>{setSetg(false);setSettingsView("main");},triggerRef:gearRef,width:240,align:"right"},
|
|
2308
2925
|
h(SettingsSlide,{view:settingsView,
|
|
2309
2926
|
onBack:()=>setSettingsView("main"),
|
|
2310
2927
|
onClose:()=>{setSetg(false);setSettingsView("main");},
|
|
2311
|
-
// ── main settings page ──
|
|
2928
|
+
// ── main settings page (Figma 580:4723) ──
|
|
2312
2929
|
mainPage:h(React.Fragment,null,
|
|
2313
|
-
h("div",{className:"tl-menu-head"},
|
|
2314
|
-
h("span",null,"Transitions.dev ",h("span",{className:"tl-menu-head-name"},"Refine")),
|
|
2315
|
-
h("span",{className:"tl-menu-head-ver"},REFINE_VERSION)),
|
|
2316
|
-
h("div",{className:"tl-menu-divider"}),
|
|
2317
2930
|
h(MenuItem,{disabled:!active,onClick:()=>{onCopy&&onCopy();setSetg(false);},
|
|
2318
2931
|
left:h("span",{className:"tl-menu-icon"},h(Ic,{name:"copy"})),
|
|
2319
2932
|
right:copied&&h("span",{className:"tl-menu-check"},h(Ic,{name:"accept"}))},copied?"Copied":"Copy values"),
|
|
2320
2933
|
h(MenuItem,{disabled:scanning,onClick:()=>{setSetg(false);onRescan&&onRescan();},
|
|
2321
2934
|
left:h("span",{className:"tl-menu-icon"},h(Ic,{name:"restart"}))},
|
|
2322
2935
|
scanning?"Rescanning…":"Rescan transitions"),
|
|
2936
|
+
h("div",{className:"tl-menu-divider"}),
|
|
2937
|
+
// theme picker row — shows the current value + chevron; opens the sub-page
|
|
2938
|
+
h(MenuItem,{onClick:()=>setSettingsView("theme"),
|
|
2939
|
+
right:h("span",{className:"tl-menu-trail"},
|
|
2940
|
+
h("span",{className:"tl-menu-value"},(THEME_OPTS.find(o=>o.key===themePref)||{}).label),
|
|
2941
|
+
h("span",{className:"tl-menu-chevr"},h(Ic,{name:"chevronr",size:16})))},"Theme"),
|
|
2323
2942
|
h(MenuItem,{onClick:()=>setSettingsView("shortcuts"),
|
|
2324
2943
|
right:h("span",{className:"tl-menu-chevr"},h(Ic,{name:"chevronr",size:16}))},"Keyboard shortcuts"),
|
|
2325
|
-
h("div",{className:"tl-menu-divider"}),
|
|
2326
2944
|
h(MenuItem,{onClick:()=>{setSetg(false);window.open("https://transitions.dev","_blank","noopener");},
|
|
2327
|
-
right:h("span",{className:"tl-menu-chevr"},h(Ic,{name:"
|
|
2945
|
+
right:h("span",{className:"tl-menu-chevr"},h(Ic,{name:"linkout",size:16}))},"Send feedback"),
|
|
2946
|
+
h(MenuItem,{onClick:()=>{setSetg(false);window.open("https://transitions.dev","_blank","noopener");},
|
|
2947
|
+
right:h("span",{className:"tl-menu-chevr"},h(Ic,{name:"linkout",size:16}))},"Learn more"),
|
|
2948
|
+
h("div",{className:"tl-menu-divider"}),
|
|
2949
|
+
h("div",{className:"tl-menu-head"},
|
|
2950
|
+
h("span",null,"Transitions.dev ",h("span",{className:"tl-menu-head-name"},"Refine")),
|
|
2951
|
+
h("span",{className:"tl-menu-head-ver"},REFINE_VERSION))),
|
|
2952
|
+
// ── theme sub-page (Light / Dark / System — System default) ──
|
|
2953
|
+
themePage:h(React.Fragment,null,
|
|
2954
|
+
h("button",{type:"button",className:"tl-set-back",onClick:()=>setSettingsView("main")},
|
|
2955
|
+
h("span",{className:"tl-set-back-ic"},h(Ic,{name:"chevronr",size:16})),
|
|
2956
|
+
h("span",{className:"tl-set-back-title"},"Theme")),
|
|
2957
|
+
h("div",{className:"tl-menu-divider"}),
|
|
2958
|
+
...THEME_OPTS.map(o=>h(MenuItem,{key:o.key,onClick:()=>{setThemePref(o.key);setSettingsView("main");},
|
|
2959
|
+
right:themePref===o.key&&h("span",{className:"tl-menu-check"},h(Ic,{name:"accept"}))},o.label))),
|
|
2328
2960
|
// ── keyboard-shortcuts sub-page ──
|
|
2329
2961
|
shortcutsPage:h(React.Fragment,null,
|
|
2330
2962
|
h("button",{type:"button",className:"tl-set-back",onClick:()=>setSettingsView("main")},
|
|
@@ -2347,14 +2979,14 @@
|
|
|
2347
2979
|
acceptState==="saving"
|
|
2348
2980
|
? h("span",{className:"tl-accept-spin","aria-hidden":"true"})
|
|
2349
2981
|
: h(Ic,{name:"accept"}),
|
|
2350
|
-
h("span",
|
|
2982
|
+
h("span",{className:"tl-btn-label"},acceptState==="done"?"Done":"Accept")),
|
|
2351
2983
|
h("span",{className:cx("t-tt","tl-tt-below",acceptState==="error"&&acceptError&&"tl-tt-msg"),role:"tooltip"},
|
|
2352
2984
|
acceptState==="error"&&acceptError?acceptError
|
|
2353
2985
|
:acceptState==="done"?"Saved to your code"
|
|
2354
2986
|
:acceptDisabled?"No changes to save"
|
|
2355
2987
|
:h(React.Fragment,null,"Save changes to your codebase",ttHint("A")))),
|
|
2356
2988
|
h("span",{className:"t-tt-wrap"},
|
|
2357
|
-
h("button",{className:cx("tl-refine-btn t-tt-trigger",refineActive&&"is-active"),disabled:!active,onClick:onRefine},
|
|
2989
|
+
h("button",{className:cx("tl-refine-btn t-tt-trigger",refineActive&&"is-active"),disabled:!active,onClick:onRefine,"aria-label":"Refine"},
|
|
2358
2990
|
h(Ic,{name:"wand"}),
|
|
2359
2991
|
h("span",{className:"tl-refine-sparks","aria-hidden":"true"},
|
|
2360
2992
|
h("i",{style:{"--ox":"-1px","--oy":"-3px","--sx":"0px","--sy":"-11px","--sd":"900ms","--sdelay":"0ms"}}),
|
|
@@ -2367,39 +2999,58 @@
|
|
|
2367
2999
|
h("i",{style:{"--ox":"2px","--oy":"5px","--sx":"9px","--sy":"9px","--sd":"1000ms","--sdelay":"760ms"}}),
|
|
2368
3000
|
h("i",{style:{"--ox":"-5px","--oy":"-2px","--sx":"-9px","--sy":"-9px","--sd":"930ms","--sdelay":"180ms"}}),
|
|
2369
3001
|
h("i",{style:{"--ox":"3px","--oy":"1px","--sx":"12px","--sy":"1px","--sd":"980ms","--sdelay":"300ms"}})),
|
|
2370
|
-
h("span",
|
|
3002
|
+
h("span",{className:"tl-btn-label"},"Refine")),
|
|
2371
3003
|
h("span",{className:"t-tt tl-tt-below",role:"tooltip"},"Refine",ttHint("R"))),
|
|
2372
3004
|
h("span",{className:"t-tt-wrap"},
|
|
2373
3005
|
h("button",{className:"tl-icon-btn ghost t-tt-trigger","aria-label":"Minimize",onClick:onMinimize},h(Ic,{name:"minimize"})),
|
|
2374
|
-
h("span",{className:"t-tt tl-tt-below",role:"tooltip"},"Minimize",ttHint("."))),
|
|
3006
|
+
h("span",{className:"t-tt tl-tt-below tl-tt-right",role:"tooltip"},"Minimize",ttHint("."))),
|
|
2375
3007
|
);
|
|
2376
3008
|
}
|
|
2377
3009
|
|
|
2378
3010
|
function PropTrack({property, member, delayMs, durationMs, selected, onSelect, onDelayChange, onDurationChange, snap, scaleMs, lockDuration, labelW}){
|
|
2379
3011
|
const trackRef=useRef(null);
|
|
2380
|
-
const
|
|
2381
|
-
|
|
3012
|
+
const [dragging,setDragging]=useState(false);
|
|
3013
|
+
// 1ms resolution while dragging so the bar moves smoothly; the snap grid
|
|
3014
|
+
// (25ms) is applied only on release when `snap` is on.
|
|
3015
|
+
const SNAP_GRID = 25;
|
|
3016
|
+
const pxToMs=useCallback((px,g)=>{if(!trackRef.current)return 0;const w=trackRef.current.getBoundingClientRect().width;const gg=g||1;return Math.round((px/w)*scaleMs/gg)*gg;},[scaleMs]);
|
|
2382
3017
|
|
|
2383
3018
|
const startDrag=useCallback((mode,e)=>{
|
|
2384
3019
|
e.preventDefault(); e.stopPropagation();
|
|
2385
3020
|
onSelect();
|
|
2386
3021
|
// a spring controls its own duration, so its bar can be moved (delay) but not resized
|
|
2387
3022
|
if(lockDuration && mode!=="move") return;
|
|
3023
|
+
setDragging(true);
|
|
2388
3024
|
const startX=e.clientX; const sd=delayMs; const sdur=durationMs;
|
|
3025
|
+
let lastDel=sd, lastDur=sdur;
|
|
2389
3026
|
const onMove=e2=>{
|
|
2390
|
-
const dx=e2.clientX-startX; const dMs=pxToMs(dx);
|
|
3027
|
+
const dx=e2.clientX-startX; const dMs=pxToMs(dx,1); // smooth: 1ms steps
|
|
2391
3028
|
if(mode==="move"){
|
|
2392
|
-
|
|
3029
|
+
lastDel=Math.max(0,Math.min(sd+dMs,scaleMs-sdur));
|
|
3030
|
+
onDelayChange(lastDel);
|
|
2393
3031
|
} else if(mode==="left"){
|
|
2394
|
-
const rawDel=sd+dMs; const clampedDel=Math.max(0,Math.min(rawDel,sd+sdur-
|
|
2395
|
-
|
|
3032
|
+
const rawDel=sd+dMs; const clampedDel=Math.max(0,Math.min(rawDel,sd+sdur-1));
|
|
3033
|
+
lastDel=clampedDel; lastDur=sdur-(clampedDel-sd);
|
|
3034
|
+
onDelayChange(lastDel); onDurationChange(lastDur);
|
|
2396
3035
|
} else {
|
|
2397
|
-
|
|
3036
|
+
lastDur=Math.max(1,Math.min(sdur+dMs,scaleMs-sd));
|
|
3037
|
+
onDurationChange(lastDur);
|
|
3038
|
+
}
|
|
3039
|
+
};
|
|
3040
|
+
const onUp=()=>{
|
|
3041
|
+
setDragging(false);
|
|
3042
|
+
// snap to the grid once, on release (bar tweens there since the
|
|
3043
|
+
// is-dragging transition lock is now removed).
|
|
3044
|
+
if(snap){
|
|
3045
|
+
const g=SNAP_GRID, sn=v=>Math.round(v/g)*g;
|
|
3046
|
+
if(mode==="move"){ onDelayChange(Math.max(0,Math.min(sn(lastDel),scaleMs-sdur))); }
|
|
3047
|
+
else if(mode==="left"){ onDelayChange(Math.max(0,sn(lastDel))); onDurationChange(Math.max(g,sn(lastDur))); }
|
|
3048
|
+
else { onDurationChange(Math.max(g,Math.min(sn(lastDur),scaleMs-sd))); }
|
|
2398
3049
|
}
|
|
3050
|
+
window.removeEventListener("mousemove",onMove);window.removeEventListener("mouseup",onUp);
|
|
2399
3051
|
};
|
|
2400
|
-
const onUp=()=>{window.removeEventListener("mousemove",onMove);window.removeEventListener("mouseup",onUp);};
|
|
2401
3052
|
window.addEventListener("mousemove",onMove);window.addEventListener("mouseup",onUp);
|
|
2402
|
-
},[delayMs,durationMs,pxToMs,
|
|
3053
|
+
},[delayMs,durationMs,pxToMs,snap,scaleMs,onSelect,onDelayChange,onDurationChange,lockDuration]);
|
|
2403
3054
|
|
|
2404
3055
|
const delPct=(delayMs/scaleMs)*100; const durPct=(durationMs/scaleMs)*100;
|
|
2405
3056
|
return h("div",{className:cx("tl-prop-row",selected&&"selected",lockDuration&&"is-spring"),onClick:onSelect},
|
|
@@ -2408,7 +3059,7 @@
|
|
|
2408
3059
|
member&&h("span",{className:"tl-prop-member"},member),
|
|
2409
3060
|
h("span",{className:"tl-prop-prop"},property))),
|
|
2410
3061
|
h("div",{className:"tl-prop-track",ref:trackRef},
|
|
2411
|
-
h("div",{className:"tl-bar",style:{left:delPct+"%",width:durPct+"%"},onMouseDown:e=>startDrag("move",e),
|
|
3062
|
+
h("div",{className:cx("tl-bar",dragging&&"is-dragging"),style:{left:delPct+"%",width:durPct+"%"},onMouseDown:e=>startDrag("move",e),
|
|
2412
3063
|
title:lockDuration?"Spring duration is derived \u2014 drag to move, resize is locked":undefined},
|
|
2413
3064
|
h("span",{className:"tl-bar-handle left"}),
|
|
2414
3065
|
h("span",{className:"tl-bar-handle right"}),
|
|
@@ -2428,7 +3079,8 @@
|
|
|
2428
3079
|
const[dragging,setDragging]=useState(false);
|
|
2429
3080
|
const[menu,setMenu]=useState(false);
|
|
2430
3081
|
const pct = Math.min(Math.max((value - min) / (max - min), 0), 1) * 100;
|
|
2431
|
-
|
|
3082
|
+
// fine grid while dragging (smooth); the snap `step` is applied on release.
|
|
3083
|
+
const dragGrid = Math.max(1, Math.round(step/5));
|
|
2432
3084
|
const selectAll = useCallback(e=>{
|
|
2433
3085
|
const el = e && e.target ? e.target : inputRef.current;
|
|
2434
3086
|
if(!el) return;
|
|
@@ -2436,25 +3088,27 @@
|
|
|
2436
3088
|
requestAnimationFrame(()=>el.select());
|
|
2437
3089
|
},[]);
|
|
2438
3090
|
|
|
2439
|
-
const setFromX = useCallback(clientX => {
|
|
3091
|
+
const setFromX = useCallback((clientX, g) => {
|
|
2440
3092
|
if(!trackRef.current) return;
|
|
2441
3093
|
const rect = trackRef.current.getBoundingClientRect();
|
|
2442
3094
|
const ratio = Math.max(0, Math.min((clientX - rect.left) / rect.width, 1));
|
|
2443
3095
|
const raw = min + ratio * (max - min);
|
|
2444
|
-
const
|
|
3096
|
+
const gg = g || 1;
|
|
3097
|
+
const snapped = Math.round(raw / gg) * gg;
|
|
2445
3098
|
onChange(Math.max(min, Math.min(max, snapped)));
|
|
2446
|
-
},[min,max,
|
|
3099
|
+
},[min,max,onChange]);
|
|
2447
3100
|
|
|
2448
3101
|
const startDrag = useCallback(e=>{
|
|
2449
3102
|
if(editing || readOnly) return;
|
|
2450
3103
|
e.preventDefault();
|
|
2451
3104
|
setDragging(true);
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
const
|
|
3105
|
+
let lastX = e.clientX;
|
|
3106
|
+
setFromX(e.clientX, dragGrid); // smooth fine steps during drag
|
|
3107
|
+
const onMove = e2 => { lastX = e2.clientX; setFromX(e2.clientX, dragGrid); };
|
|
3108
|
+
const onUp = () => { setDragging(false); if(snap) setFromX(lastX, step); window.removeEventListener("mousemove",onMove); window.removeEventListener("mouseup",onUp); };
|
|
2455
3109
|
window.addEventListener("mousemove",onMove);
|
|
2456
3110
|
window.addEventListener("mouseup",onUp);
|
|
2457
|
-
},[setFromX,editing]);
|
|
3111
|
+
},[setFromX,editing,readOnly,snap,step,dragGrid]);
|
|
2458
3112
|
|
|
2459
3113
|
const beginEdit = useCallback(e=>{ e.stopPropagation(); if(readOnly) return; setDraft(String(value)); setEditing(true); },[value,readOnly]);
|
|
2460
3114
|
const commit = useCallback(()=>{ const n=parseFloat(draft); if(!isNaN(n)) onChange(Math.max(min,Math.min(max,Math.round(n)))); setEditing(false); },[draft,min,max,onChange]);
|
|
@@ -3016,18 +3670,85 @@
|
|
|
3016
3670
|
const[acceptError,setAcceptError]=useState(null);
|
|
3017
3671
|
// ── grouped scan (agent reads source → Open/Close phases) ──
|
|
3018
3672
|
const[groupScanState,setGroupScanState]=useState("idle"); // idle | scanning | done | error
|
|
3673
|
+
// Whether the one-time grouping scan has produced groups. Once true the main
|
|
3674
|
+
// panel stays usable even after a chat-loop agent disconnects (so the panel
|
|
3675
|
+
// doesn't snap back to the gate). Seeded synchronously from a cached grouping
|
|
3676
|
+
// or a host-baked seed so a returning user never flashes the gate. (Key is
|
|
3677
|
+
// inlined to match GROUP_STORE_KEY, which is defined further down.)
|
|
3678
|
+
const[scanned,setScanned]=useState(()=>{
|
|
3679
|
+
try{
|
|
3680
|
+
if(typeof window!=="undefined"&&Array.isArray(window.__TX_SEED_GROUPS)&&window.__TX_SEED_GROUPS.length)return true;
|
|
3681
|
+
const c=JSON.parse(localStorage.getItem("tlGroups:"+location.pathname)||"null");
|
|
3682
|
+
return !!(c&&Array.isArray(c.groups)&&c.groups.length);
|
|
3683
|
+
}catch{return false;}
|
|
3684
|
+
});
|
|
3685
|
+
// last scan failure message (relay surfaces "needs the agent. Run /refine
|
|
3686
|
+
// live …") so a failed Rescan can explain WHY nothing re-grouped instead of
|
|
3687
|
+
// silently showing the same flat count.
|
|
3688
|
+
const[groupScanErr,setGroupScanErr]=useState(null);
|
|
3019
3689
|
const didGroupScanRef=useRef(false);
|
|
3690
|
+
// One failed agent spawn (e.g. cursor-agent's cli-config race on cold start)
|
|
3691
|
+
// shouldn't leave the panel stuck flat until a manual rescan. Allow a couple
|
|
3692
|
+
// of automatic re-tries with backoff before giving up; reset on success and
|
|
3693
|
+
// on manual rescan so each fresh scan gets its own retry budget.
|
|
3694
|
+
const groupScanRetryRef=useRef(0);
|
|
3695
|
+
const GROUP_SCAN_MAX_RETRIES=2;
|
|
3020
3696
|
const[refineLabel,setRefineLabel]=useState(null);
|
|
3021
3697
|
const[appliedIds,setAppliedIds]=useState({});
|
|
3022
3698
|
const[refineMode,setRefineMode]=useState("llm"); // llm (Agent) | deterministic
|
|
3023
3699
|
const[refineType,setRefineType]=useState("small"); // small | replace
|
|
3024
3700
|
const[llmAvailable,setLlmAvailable]=useState(null); // null=unknown, true/false from /health
|
|
3025
3701
|
const[cliInstalled,setCliInstalled]=useState(null); // null=unknown, true/false from /health
|
|
3702
|
+
// chatLoop = LLM served by the in-chat `/refine live` poll loop (pollerActive
|
|
3703
|
+
// and no wired REFINE_AGENT_CMD). This is the mode that bills agent turns
|
|
3704
|
+
// while idle, so the header shows a credit warning + Stop for it.
|
|
3705
|
+
const[chatLoop,setChatLoop]=useState(false);
|
|
3706
|
+
const[liveStopped,setLiveStopped]=useState(false); // user hit Stop → show paused chip
|
|
3707
|
+
// After a Stop, the loop stays pollerActive for its ~120s TTL while it
|
|
3708
|
+
// winds down, so we can't just re-read pollerActive. stopPendingRef tracks
|
|
3709
|
+
// "a stop was requested"; sawPollerGoneRef flips once the poller actually
|
|
3710
|
+
// disappears, so only a poller that returns *after that* counts as a fresh
|
|
3711
|
+
// `/refine live` and flips the chip back to Live. (Refs because
|
|
3712
|
+
// refreshHealth is a []-deps callback and would otherwise read stale state.)
|
|
3713
|
+
const stopPendingRef=useRef(false);
|
|
3714
|
+
const sawPollerGoneRef=useRef(false);
|
|
3715
|
+
// A quiet stop (the after-scan auto-stop) drains the loop without showing the
|
|
3716
|
+
// "Live stopped" chip — it's a normal connect-for-scan, not a user Stop.
|
|
3717
|
+
const quietStopRef=useRef(false);
|
|
3718
|
+
// Latest chatLoop value for callbacks (runGroupScan, autoStopAfterScan) that
|
|
3719
|
+
// are []-deps and would otherwise read a stale value.
|
|
3720
|
+
const chatLoopRef=useRef(false);
|
|
3026
3721
|
// probe the relay so the idle panel can show the right availability copy
|
|
3027
3722
|
const refreshHealth=useCallback(async()=>{
|
|
3028
3723
|
try{const j=await relayHealth();setLlmAvailable(!!j.llmAvailable);
|
|
3029
|
-
setCliInstalled(j.cliInstalled==null?null:!!j.cliInstalled);
|
|
3030
|
-
|
|
3724
|
+
setCliInstalled(j.cliInstalled==null?null:!!j.cliInstalled);
|
|
3725
|
+
const loop=!!j.pollerActive&&!j.agentCmd;
|
|
3726
|
+
if(!stopPendingRef.current){chatLoopRef.current=loop;setChatLoop(loop);setLiveStopped(false);return j;}
|
|
3727
|
+
if(!j.pollerActive)sawPollerGoneRef.current=true;
|
|
3728
|
+
if(loop&&sawPollerGoneRef.current){ // a fresh loop started after the stop
|
|
3729
|
+
stopPendingRef.current=false;quietStopRef.current=false;
|
|
3730
|
+
chatLoopRef.current=true;setChatLoop(true);setLiveStopped(false);
|
|
3731
|
+
}else{chatLoopRef.current=false;setChatLoop(false);setLiveStopped(!quietStopRef.current);} // draining
|
|
3732
|
+
return j;}
|
|
3733
|
+
catch{chatLoopRef.current=false;setLlmAvailable(false);setCliInstalled(false);setChatLoop(false);return null;}
|
|
3734
|
+
},[]);
|
|
3735
|
+
const stopLive=useCallback(async()=>{
|
|
3736
|
+
quietStopRef.current=false;
|
|
3737
|
+
stopPendingRef.current=true;sawPollerGoneRef.current=false;
|
|
3738
|
+
chatLoopRef.current=false;setLiveStopped(true);setChatLoop(false);
|
|
3739
|
+
try{await relayStopLive();}catch{}
|
|
3740
|
+
refreshHealth();
|
|
3741
|
+
},[refreshHealth]);
|
|
3742
|
+
// Quiet auto-stop after the one-time grouping scan: in chat-loop mode the
|
|
3743
|
+
// loop has done its only needed job (the scan), so stop it to avoid idle
|
|
3744
|
+
// credit burn. Drains like a normal stop but shows no chip; the Refine area
|
|
3745
|
+
// then shows "Connect with your agent" until the user re-runs /refine live.
|
|
3746
|
+
const autoStopAfterScan=useCallback(async()=>{
|
|
3747
|
+
if(!chatLoopRef.current)return; // only the in-chat loop burns idle credits
|
|
3748
|
+
quietStopRef.current=true;
|
|
3749
|
+
stopPendingRef.current=true;sawPollerGoneRef.current=false;
|
|
3750
|
+
chatLoopRef.current=false;setChatLoop(false);setLiveStopped(false);
|
|
3751
|
+
try{await relayStopLive();}catch{}
|
|
3031
3752
|
},[]);
|
|
3032
3753
|
// a live agent = the relay reports an agent it can drive (REFINE_AGENT_CMD
|
|
3033
3754
|
// wired OR a `/refine live` loop polling). Until then the panel is gated.
|
|
@@ -3051,35 +3772,91 @@
|
|
|
3051
3772
|
refreshHealth();
|
|
3052
3773
|
},[active,refineOpen,refreshHealth]);
|
|
3053
3774
|
// "Start scanning" resolves availability first, then posts the job.
|
|
3054
|
-
|
|
3775
|
+
// per-(transition,type) result cache so switching tabs reuses a prior scan
|
|
3776
|
+
// instead of re-asking the agent. In llm mode each scan is scoped to the
|
|
3777
|
+
// active tab (Small = token tweaks, Replace = recipe) so the common Small
|
|
3778
|
+
// view skips the recipe selection + reference-file read. Deterministic stays
|
|
3779
|
+
// a single instant "both" pass that feeds both tabs.
|
|
3780
|
+
const refineCacheRef=useRef(new Map()); // `${id}::${type}` -> {suggestions,summary}
|
|
3781
|
+
const refineScanTypeRef=useRef("small"); // which type the in-flight job targets
|
|
3782
|
+
// results are not comparable across transitions — drop the cache when the
|
|
3783
|
+
// target transition changes.
|
|
3784
|
+
useEffect(()=>{refineCacheRef.current.clear();},[active&&active.id]);
|
|
3785
|
+
// recompute the displayed suggestions from whatever types are cached for a
|
|
3786
|
+
// transition (the panel filters by kind per tab, so a merged array is fine).
|
|
3787
|
+
const applyMerged=useCallback((id)=>{
|
|
3788
|
+
const c=refineCacheRef.current;
|
|
3789
|
+
const both=c.get(id+"::both");
|
|
3790
|
+
if(both){setRefineSuggestions(both.suggestions||[]);setRefineSummary(both.summary||null);return;}
|
|
3791
|
+
const small=c.get(id+"::small"), repl=c.get(id+"::replace");
|
|
3792
|
+
setRefineSuggestions([...(small?small.suggestions:[]),...(repl?repl.suggestions:[])]);
|
|
3793
|
+
setRefineSummary((small&&small.summary)||(repl&&repl.summary)||null);
|
|
3794
|
+
},[]);
|
|
3795
|
+
const startScan=useCallback(async(typeArg,opts)=>{
|
|
3055
3796
|
if(!active)return;
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
const avail=
|
|
3797
|
+
opts=opts||{};
|
|
3798
|
+
// Resolve availability from the continuously-polled /health (2s cadence)
|
|
3799
|
+
// rather than a serial probe on the click path. Unknown (null) counts as
|
|
3800
|
+
// available so the first click isn't blocked; a dead relay surfaces via
|
|
3801
|
+
// the POST below (→ error state).
|
|
3802
|
+
const avail=llmAvailable!==false;
|
|
3062
3803
|
const mode=(refineMode==="llm"&&!avail)?"deterministic":refineMode;
|
|
3063
|
-
scanModeRef.current=mode;
|
|
3064
3804
|
if(mode!==refineMode)setRefineMode(mode);
|
|
3805
|
+
scanModeRef.current=mode;
|
|
3806
|
+
// deterministic → one "both" pass (instant, free). llm → scope to the
|
|
3807
|
+
// active tab so Small skips recipe work; Replace asks for it on demand.
|
|
3808
|
+
// NOTE: this is also wired as a button onClick, so typeArg may be a click
|
|
3809
|
+
// event — only honour it when it's an explicit "small"/"replace" string.
|
|
3810
|
+
const reqType=(typeArg==="small"||typeArg==="replace")?typeArg:refineType;
|
|
3811
|
+
const type=mode==="deterministic"?"both":reqType;
|
|
3812
|
+
refineScanTypeRef.current=type;
|
|
3813
|
+
scanStartedAtRef.current=Date.now();
|
|
3814
|
+
setRefinePhase("scanning");setRefineLog([]);setRefineError(null);
|
|
3815
|
+
setRefineLabel(active.label);setRefineJobId(null);
|
|
3816
|
+
if(!opts.keepApplied)setAppliedIds({});
|
|
3817
|
+
// re-scan only this type; keep the other tab's cached result for the merge.
|
|
3818
|
+
refineCacheRef.current.delete(active.id+"::"+type);
|
|
3065
3819
|
try{
|
|
3066
3820
|
const et=active.effectiveTimings||[];
|
|
3067
|
-
|
|
3821
|
+
// attach the lane's non-resting scale/blur value (+ backing var) so the
|
|
3822
|
+
// agent + deterministic engine can refine transform/filter VALUES too.
|
|
3823
|
+
const withVal=(t)=>{const v=laneValueHint(t.selector,t.property);return {property:t.property,member:t.member||null,durationMs:t.durationMs,delayMs:t.delayMs,easing:t.easing,
|
|
3824
|
+
...(v.scale!=null?{scale:v.scale}:{}),...(v.blur!=null?{blur:v.blur}:{}),...(v.varName?{varName:v.varName}:{})};};
|
|
3825
|
+
const timings=et.map(withVal);
|
|
3068
3826
|
const selector=(active.bindings&&active.bindings.selector)||(et[0]&&et[0].selector)||null;
|
|
3069
|
-
//
|
|
3070
|
-
//
|
|
3827
|
+
// A recipe swap (replace/both) on a grouped phase should update its related
|
|
3828
|
+
// phases (open + close) together — they're one motion. Send the whole
|
|
3829
|
+
// group's phases so the agent can return a per-phase `patches` array.
|
|
3830
|
+
let phases;
|
|
3831
|
+
if((type==="replace"||type==="both")&&active.kind==="phase"&&active.groupId){
|
|
3832
|
+
const gp=entries.filter(e=>e.kind==="phase"&&e.groupId===active.groupId);
|
|
3833
|
+
if(gp.length>1)phases=gp.map(e=>({phase:e.phase||e.phaseLabel||"phase",label:e.phaseLabel||e.phase||"Phase",
|
|
3834
|
+
timings:(e.effectiveTimings||[]).map(withVal)}));
|
|
3835
|
+
}
|
|
3071
3836
|
const{id}=await relayCreateJob({transitionId:active.id,label:active.label,selector,
|
|
3072
|
-
phase:active.phase||null,group:active.groupLabel||null,timings,mode,refineType:
|
|
3837
|
+
phase:active.phase||null,group:active.groupLabel||null,timings,mode,refineType:type,phases});
|
|
3073
3838
|
setRefineJobId(id);
|
|
3074
3839
|
}catch(e){
|
|
3075
3840
|
setRefinePhase("error");
|
|
3076
3841
|
setRefineError(h(React.Fragment,null,"Couldn't reach the refine relay. Start it with ",h("code",{className:"tl-code"},"npx transitions-refine live"),"."));
|
|
3077
3842
|
}
|
|
3078
|
-
},[active,refineMode,refineType,
|
|
3079
|
-
const changeRefineMode=useCallback((mode)=>{setRefineMode(mode);setRefinePhase("idle");refreshHealth();},[refreshHealth]);
|
|
3080
|
-
//
|
|
3081
|
-
//
|
|
3082
|
-
|
|
3843
|
+
},[active,refineMode,refineType,llmAvailable,entries]);
|
|
3844
|
+
const changeRefineMode=useCallback((mode)=>{setRefineMode(mode);setRefinePhase("idle");refineCacheRef.current.clear();refreshHealth();},[refreshHealth]);
|
|
3845
|
+
// Switching tabs reuses a cached scan when present; otherwise (llm only) the
|
|
3846
|
+
// tab drops back to its idle state and waits for an explicit Start-scanning
|
|
3847
|
+
// click — switching never auto-runs a scan. Deterministic feeds both tabs
|
|
3848
|
+
// from one "both" pass, so its tab switch is a pure filter. Before any scan
|
|
3849
|
+
// (idle/error), tabs just preview copy and never auto-scan.
|
|
3850
|
+
const changeRefineType=useCallback((t)=>{
|
|
3851
|
+
setRefineType(t);
|
|
3852
|
+
if(!active)return;
|
|
3853
|
+
if(scanModeRef.current==="deterministic")return;
|
|
3854
|
+
if(refinePhase!=="done"&&refinePhase!=="scanning")return;
|
|
3855
|
+
if(refineCacheRef.current.has(active.id+"::"+t)){applyMerged(active.id);setRefinePhase("done");return;}
|
|
3856
|
+
// No cached result for this tab → show its idle scan button and wait for
|
|
3857
|
+
// the user. (A scan in progress is left running so it can finish.)
|
|
3858
|
+
if(refinePhase==="done")setRefinePhase("idle");
|
|
3859
|
+
},[active,refinePhase,applyMerged]);
|
|
3083
3860
|
// poll the relay while a job is running
|
|
3084
3861
|
useEffect(()=>{
|
|
3085
3862
|
if(!refineJobId||refinePhase!=="scanning")return;
|
|
@@ -3090,7 +3867,15 @@
|
|
|
3090
3867
|
if(!live)return;
|
|
3091
3868
|
if(Array.isArray(job.statusLog))setRefineLog(job.statusLog);
|
|
3092
3869
|
if(job.status==="done"){
|
|
3093
|
-
|
|
3870
|
+
try{console.log("[refine] "+refineScanTypeRef.current+" job done in "+(Date.now()-scanStartedAtRef.current)+"ms");}catch{}
|
|
3871
|
+
const finish=()=>{if(!live)return;
|
|
3872
|
+
// cache this scan's result under its type, then show the merged set
|
|
3873
|
+
// (so the other tab's cached suggestions stay visible).
|
|
3874
|
+
const type=refineScanTypeRef.current;
|
|
3875
|
+
const id=active?active.id:"";
|
|
3876
|
+
refineCacheRef.current.set(id+"::"+type,{suggestions:(job.result&&job.result.suggestions)||[],summary:(job.result&&job.result.summary)||null});
|
|
3877
|
+
if(active)applyMerged(active.id);else{setRefineSuggestions((job.result&&job.result.suggestions)||[]);setRefineSummary(job.result&&job.result.summary);}
|
|
3878
|
+
setRefinePhase("done");};
|
|
3094
3879
|
// deterministic mode floors the scanning phase at 2s so it doesn't flash by.
|
|
3095
3880
|
const minMs=scanModeRef.current==="deterministic"?2000:0;
|
|
3096
3881
|
const wait=Math.max(0,minMs-(Date.now()-scanStartedAtRef.current));
|
|
@@ -3098,27 +3883,50 @@
|
|
|
3098
3883
|
return;
|
|
3099
3884
|
}
|
|
3100
3885
|
if(job.status==="error"){setRefineError(job.error||"The agent reported an error.");setRefinePhase("error");return;}
|
|
3101
|
-
to=setTimeout(tick,
|
|
3886
|
+
to=setTimeout(tick,200);
|
|
3102
3887
|
}catch(e){if(live){setRefineError("Lost connection to the relay.");setRefinePhase("error");}}
|
|
3103
3888
|
};
|
|
3104
|
-
to=setTimeout(tick,
|
|
3889
|
+
to=setTimeout(tick,200);
|
|
3105
3890
|
return()=>{live=false;if(to)clearTimeout(to);};
|
|
3106
3891
|
},[refineJobId,refinePhase]);
|
|
3107
3892
|
const applySuggestion=useCallback((s)=>{
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
const
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3893
|
+
// A replace suggestion may carry `patches` (one per related phase, e.g.
|
|
3894
|
+
// open + close) — a recipe swap is one motion, so applying it writes BOTH.
|
|
3895
|
+
// Otherwise fall back to the single `patch` on the active phase.
|
|
3896
|
+
const patches=Array.isArray(s.patches)&&s.patches.length
|
|
3897
|
+
? s.patches
|
|
3898
|
+
: (s.patch&&s.patch.property?[{...s.patch,phase:(active&&active.phase)||null}]:[]);
|
|
3899
|
+
if(!patches.length)return;
|
|
3900
|
+
const groupId=active&&active.groupId;
|
|
3901
|
+
for(const p of patches){
|
|
3902
|
+
if(!p.property)continue;
|
|
3903
|
+
const o={};
|
|
3904
|
+
if(p.durationMs!=null)o.durationMs=p.durationMs;
|
|
3905
|
+
if(p.delayMs!=null)o.delayMs=p.delayMs;
|
|
3906
|
+
if(p.easing!=null)o.easing=p.easing;
|
|
3907
|
+
// value edits: keep the new value, the backing var, and the from-value
|
|
3908
|
+
// (parsed off the suggestion) so computeChanges can write source on Accept.
|
|
3909
|
+
if(p.scale!=null){o.scale=p.scale;o.scaleFrom=(s.from!=null?parseFloat(s.from):undefined);}
|
|
3910
|
+
if(p.blur!=null){o.blur=p.blur;o.blurFrom=(s.from!=null?parseFloat(s.from):undefined);}
|
|
3911
|
+
if(p.varName!=null)o.varName=p.varName;
|
|
3912
|
+
// resolve the phase entry this patch targets (its sibling open/close
|
|
3913
|
+
// lives in the same group); default to the active entry.
|
|
3914
|
+
let target=active;
|
|
3915
|
+
if(p.phase&&groupId){
|
|
3916
|
+
const found=entries.find(e=>e.kind==="phase"&&e.groupId===groupId&&e.phase===p.phase);
|
|
3917
|
+
if(found)target=found;
|
|
3918
|
+
}
|
|
3919
|
+
if(!target)continue;
|
|
3920
|
+
// map the property patch onto the matching lane(s) — a property can span
|
|
3921
|
+
// several members in a grouped phase, so apply to each.
|
|
3922
|
+
const et=target.effectiveTimings||[];
|
|
3923
|
+
let lanes=p.property==="all"?et:et.filter(t=>t.property===p.property);
|
|
3924
|
+
if(p.member)lanes=lanes.filter(t=>(t.member===p.member||t.memberId===p.member));
|
|
3925
|
+
const ids=lanes.length?lanes.map(t=>t.laneId):[p.property];
|
|
3926
|
+
for(const id of ids)registry.setPropOverride(target.id,id,o);
|
|
3927
|
+
}
|
|
3120
3928
|
setAppliedIds(prev=>({...prev,[s.id]:true}));
|
|
3121
|
-
},[
|
|
3929
|
+
},[registry,active,entries]);
|
|
3122
3930
|
const applyAllSuggestions=useCallback(()=>{
|
|
3123
3931
|
for(const s of refineSuggestions){if(!appliedIds[s.id])applySuggestion(s);}
|
|
3124
3932
|
},[refineSuggestions,appliedIds,applySuggestion]);
|
|
@@ -3134,8 +3942,13 @@
|
|
|
3134
3942
|
useEffect(()=>{
|
|
3135
3943
|
if(typeof document==="undefined")return;
|
|
3136
3944
|
const applied=liveAppliedRef.current;
|
|
3137
|
-
const desired=new Map(); // el → css
|
|
3138
|
-
|
|
3945
|
+
const desired=new Map(); // el → {css, vars:{name:value}}
|
|
3946
|
+
// collect the var-backed scale/blur overrides on a set of lanes into a
|
|
3947
|
+
// {varName: cssValue} map (scale → unitless, blur → px) for live preview.
|
|
3948
|
+
const lanesToVars=(lanes)=>{const v={};for(const l of (lanes||[])){if(!l.varName)continue;
|
|
3949
|
+
if(l.scale!=null)v[l.varName]=String(l.scale);
|
|
3950
|
+
if(l.blur!=null)v[l.varName]=l.blur+"px";}return v;};
|
|
3951
|
+
const add=(els,css,vars)=>{ for(const el of els){ if(!el||(el.closest&&el.closest("[data-timeline-panel],[data-tl-ui]")))continue; desired.set(el,{css,vars:vars||{}}); } };
|
|
3139
3952
|
for(const item of entries){
|
|
3140
3953
|
const ov=registry.getPropOverrides(item.id);
|
|
3141
3954
|
if(!ov||!Object.keys(ov).length)continue; // only edited transitions go live
|
|
@@ -3143,31 +3956,40 @@
|
|
|
3143
3956
|
for(const m of (item.members||[])){
|
|
3144
3957
|
if(!m.selector||!m.lanes||!m.lanes.length)continue;
|
|
3145
3958
|
let els=[];try{els=Array.from(document.querySelectorAll(m.selector));}catch{}
|
|
3146
|
-
add(els,transitionLanesToCss(m.lanes));
|
|
3959
|
+
add(els,transitionLanesToCss(m.lanes),lanesToVars(m.lanes));
|
|
3147
3960
|
}
|
|
3148
3961
|
}else{
|
|
3149
3962
|
const lanes=item.effectiveTimings||[];
|
|
3150
3963
|
if(!lanes.length)continue;
|
|
3151
3964
|
const els=((item.bindings&&item.bindings.elements)||[]).map(w=>w.deref&&w.deref()).filter(Boolean);
|
|
3152
|
-
add(els,transitionLanesToCss(lanes));
|
|
3965
|
+
add(els,transitionLanesToCss(lanes),lanesToVars(lanes));
|
|
3153
3966
|
}
|
|
3154
3967
|
}
|
|
3968
|
+
const varsKey=v=>Object.keys(v).sort().map(k=>k+":"+v[k]).join(";");
|
|
3155
3969
|
// apply new / changed
|
|
3156
|
-
for(const [el,
|
|
3970
|
+
for(const [el,d] of desired){
|
|
3971
|
+
const css=d.css,vars=d.vars||{},vk=varsKey(vars);
|
|
3157
3972
|
const cur=applied.get(el);
|
|
3158
|
-
if(cur&&cur.css===css)continue;
|
|
3973
|
+
if(cur&&cur.css===css&&cur.vk===vk)continue;
|
|
3159
3974
|
if(!cur){
|
|
3160
3975
|
const s=getComputedStyle(el);
|
|
3161
3976
|
const z=zipTransitionLists((s.transitionProperty||"").split(","),(s.transitionDuration||"0s").split(","),(s.transitionDelay||"0s").split(","),splitCssValues(s.transitionTimingFunction||"ease"));
|
|
3162
3977
|
_TX_LIVE_BASE.set(el,{z});
|
|
3163
|
-
applied.set(el,{prevInline:el.style.transition||"",css});
|
|
3164
|
-
}else
|
|
3978
|
+
applied.set(el,{prevInline:el.style.transition||"",css,vars,vk,prevVars:Object.fromEntries(Object.keys(vars).map(n=>[n,el.style.getPropertyValue(n)]))});
|
|
3979
|
+
}else{
|
|
3980
|
+
// revert vars the new desired set no longer drives before re-applying
|
|
3981
|
+
for(const n of Object.keys(cur.vars||{})){if(!(n in vars)){try{const pv=(cur.prevVars||{})[n];if(pv)el.style.setProperty(n,pv);else el.style.removeProperty(n);}catch{}}}
|
|
3982
|
+
const prevVars={...(cur.prevVars||{})};for(const n of Object.keys(vars)){if(!(n in prevVars))prevVars[n]=el.style.getPropertyValue(n);}
|
|
3983
|
+
applied.set(el,{prevInline:cur.prevInline,css,vars,vk,prevVars});
|
|
3984
|
+
}
|
|
3165
3985
|
try{el.style.transition=css;}catch{}
|
|
3986
|
+
for(const n of Object.keys(vars)){try{el.style.setProperty(n,vars[n]);}catch{}}
|
|
3166
3987
|
}
|
|
3167
3988
|
// revert elements that no longer have edits
|
|
3168
3989
|
for(const [el,rec] of Array.from(applied)){
|
|
3169
3990
|
if(desired.has(el))continue;
|
|
3170
3991
|
try{el.style.transition=rec.prevInline;}catch{}
|
|
3992
|
+
for(const n of Object.keys(rec.vars||{})){try{const pv=(rec.prevVars||{})[n];if(pv)el.style.setProperty(n,pv);else el.style.removeProperty(n);}catch{}}
|
|
3171
3993
|
_TX_LIVE_BASE.delete(el);
|
|
3172
3994
|
applied.delete(el);
|
|
3173
3995
|
}
|
|
@@ -3175,7 +3997,7 @@
|
|
|
3175
3997
|
// restore everything on unmount
|
|
3176
3998
|
useEffect(()=>()=>{
|
|
3177
3999
|
const applied=liveAppliedRef.current;
|
|
3178
|
-
for(const [el,rec] of applied){ try{el.style.transition=rec.prevInline;}catch{} _TX_LIVE_BASE.delete(el); }
|
|
4000
|
+
for(const [el,rec] of applied){ try{el.style.transition=rec.prevInline;}catch{} for(const n of Object.keys(rec.vars||{})){try{const pv=(rec.prevVars||{})[n];if(pv)el.style.setProperty(n,pv);else el.style.removeProperty(n);}catch{}} _TX_LIVE_BASE.delete(el); }
|
|
3179
4001
|
applied.clear();
|
|
3180
4002
|
},[]);
|
|
3181
4003
|
const startResize=useCallback(e=>{
|
|
@@ -3227,11 +4049,20 @@
|
|
|
3227
4049
|
}else legacyCopy();
|
|
3228
4050
|
}catch(e){legacyCopy();}
|
|
3229
4051
|
},[active,showToast]);
|
|
3230
|
-
|
|
4052
|
+
// Reset the selected transition. For a grouped phase, clear ALL of its
|
|
4053
|
+
// related phases (open/close) too — a recipe swap edits them as one motion,
|
|
4054
|
+
// so Reset should revert them as one.
|
|
4055
|
+
const resetOverrides=useCallback(()=>{
|
|
4056
|
+
if(!active)return;
|
|
4057
|
+
registry.clearOverride(active.id);
|
|
4058
|
+
if(active.kind==="phase"&&active.groupId){
|
|
4059
|
+
for(const e of entries)if(e.kind==="phase"&&e.groupId===active.groupId&&e.id!==active.id)registry.clearOverride(e.id);
|
|
4060
|
+
}
|
|
4061
|
+
},[registry,active,entries]);
|
|
3231
4062
|
// Accept → send an "apply" job so the agent writes the edited timings into
|
|
3232
4063
|
// the user's source, then reflect saving / done / error on the button.
|
|
3233
4064
|
const onAccept=useCallback(async()=>{
|
|
3234
|
-
const changes=computeChanges(active);
|
|
4065
|
+
const changes=computeChanges(active,entries);
|
|
3235
4066
|
if(!active||!changes.length)return;
|
|
3236
4067
|
setAcceptState("saving");setAcceptError(null);
|
|
3237
4068
|
try{
|
|
@@ -3256,7 +4087,7 @@
|
|
|
3256
4087
|
setAcceptState("error");
|
|
3257
4088
|
setAcceptError("Couldn't reach the relay. Run: npx transitions-refine live");
|
|
3258
4089
|
}
|
|
3259
|
-
},[active]);
|
|
4090
|
+
},[active,entries]);
|
|
3260
4091
|
// reset Accept feedback when switching transitions
|
|
3261
4092
|
useEffect(()=>{setAcceptState("idle");setAcceptError(null);},[active&&active.id]);
|
|
3262
4093
|
// while the agent is applying changes, surface the sticky loader toast
|
|
@@ -3286,8 +4117,15 @@
|
|
|
3286
4117
|
},[GROUP_STORE_KEY]);
|
|
3287
4118
|
// monotonic token so an unmount or a newer rescan supersedes an in-flight poll
|
|
3288
4119
|
const scanTokenRef=useRef(0);
|
|
3289
|
-
const runGroupScan=useCallback(async()=>{
|
|
4120
|
+
const runGroupScan=useCallback(async(opts)=>{
|
|
4121
|
+
opts=opts||{};
|
|
3290
4122
|
const token=++scanTokenRef.current;
|
|
4123
|
+
// Hardcoded grouping (window.__TX_SEED_GROUPS): apply INSTANTLY on first load
|
|
4124
|
+
// — no relay, no agent, no settle loop. Manual "Rescan transitions" passes
|
|
4125
|
+
// skipSeed so the user gets a real agent scan instead of re-applying the bake-in.
|
|
4126
|
+
const seedNow=!opts.skipSeed&&typeof window!=="undefined"&&Array.isArray(window.__TX_SEED_GROUPS)&&window.__TX_SEED_GROUPS.length?window.__TX_SEED_GROUPS:null;
|
|
4127
|
+
if(seedNow){registry.setGroups(seedNow);groupScanRetryRef.current=0;setScanned(true);setGroupScanState("done");if(!opts.skipSeed)autoStopAfterScan();return;}
|
|
4128
|
+
setGroupScanErr(null);
|
|
3291
4129
|
setGroupScanState("scanning");
|
|
3292
4130
|
// wait for the flat DOM scan to settle (count stable twice) so the agent
|
|
3293
4131
|
// sees every transition, not a partial set discovered progressively.
|
|
@@ -3310,7 +4148,7 @@
|
|
|
3310
4148
|
// fresh scan and is overwritten below.
|
|
3311
4149
|
const sig=flatSig(flat);
|
|
3312
4150
|
const cached=readGroupCache();
|
|
3313
|
-
if(cached&&cached.sig===sig){registry.setGroups(cached.groups);setGroupScanState("done");return;}
|
|
4151
|
+
if(cached&&cached.sig===sig){registry.setGroups(cached.groups);groupScanRetryRef.current=0;setScanned(true);setGroupScanState("done");if(!opts.skipSeed)autoStopAfterScan();return;}
|
|
3314
4152
|
setGroupScanState("scanning");
|
|
3315
4153
|
const raw=flat.map(e=>({label:e.label,selector:e.bindings&&e.bindings.selector,
|
|
3316
4154
|
properties:e.properties,
|
|
@@ -3328,14 +4166,30 @@
|
|
|
3328
4166
|
const job=await relayGetJob(id);
|
|
3329
4167
|
if(job.status==="done"){
|
|
3330
4168
|
const groups=(job.result&&job.result.groups)||[];
|
|
3331
|
-
if(groups.length){registry.setGroups(groups);
|
|
4169
|
+
if(groups.length){registry.setGroups(groups);setScanned(true);
|
|
3332
4170
|
try{localStorage.setItem(GROUP_STORE_KEY,JSON.stringify({groups,sig,ts:Date.now()}));}catch{}}
|
|
3333
|
-
setGroupScanState("done");return;}
|
|
3334
|
-
if(job.status==="error"){
|
|
4171
|
+
groupScanRetryRef.current=0;setGroupScanState("done");if(!opts.skipSeed)autoStopAfterScan();return;}
|
|
4172
|
+
if(job.status==="error"){throw new Error(job.error||"scan job errored");}
|
|
4173
|
+
}
|
|
4174
|
+
throw new Error("scan timed out");
|
|
4175
|
+
}catch(e){
|
|
4176
|
+
// Transient failure (a cold-start agent spawn racing on its CLI config,
|
|
4177
|
+
// a relay blip): auto-retry a couple of times with backoff before
|
|
4178
|
+
// surfacing the error, instead of leaving the panel stuck flat.
|
|
4179
|
+
if(scanTokenRef.current!==token)return;
|
|
4180
|
+
if(groupScanRetryRef.current<GROUP_SCAN_MAX_RETRIES){
|
|
4181
|
+
groupScanRetryRef.current++;
|
|
4182
|
+
const backoff=700*groupScanRetryRef.current;
|
|
4183
|
+
setGroupScanState("scanning");
|
|
4184
|
+
setTimeout(()=>{if(scanTokenRef.current===token)runGroupScan(opts);},backoff);
|
|
4185
|
+
return;
|
|
3335
4186
|
}
|
|
4187
|
+
// gave up → stay flat, retry next open/rescan. Keep the relay's reason
|
|
4188
|
+
// (agent unavailable / run /refine live) so the header can surface it.
|
|
4189
|
+
setGroupScanErr(String((e&&e.message)||e||"Scan failed"));
|
|
3336
4190
|
setGroupScanState("error");
|
|
3337
|
-
}
|
|
3338
|
-
},[registry,GROUP_STORE_KEY,flatSig,readGroupCache]);
|
|
4191
|
+
}
|
|
4192
|
+
},[registry,GROUP_STORE_KEY,flatSig,readGroupCache,autoStopAfterScan]);
|
|
3339
4193
|
// manual "Rescan transitions" — drop the cache AND clear the in-memory
|
|
3340
4194
|
// groups first. Once groups claim every DOM transition the snapshot has no
|
|
3341
4195
|
// "flat" entries left, so the settle loop above would otherwise spin forever
|
|
@@ -3343,20 +4197,28 @@
|
|
|
3343
4197
|
// the flat entries the agent needs to re-group.
|
|
3344
4198
|
const rescanTransitions=useCallback(()=>{
|
|
3345
4199
|
try{localStorage.removeItem(GROUP_STORE_KEY);}catch{}
|
|
4200
|
+
groupScanRetryRef.current=0; // fresh manual scan → fresh retry budget
|
|
3346
4201
|
registry.clearGroups();
|
|
3347
|
-
runGroupScan();
|
|
4202
|
+
runGroupScan({skipSeed:true});
|
|
3348
4203
|
},[runGroupScan,GROUP_STORE_KEY,registry]);
|
|
3349
4204
|
// Gate the auto group-scan behind a live agent: the panel stays on the
|
|
3350
|
-
// "
|
|
3351
|
-
// makes the relay report an agent. The first time we see a
|
|
3352
|
-
// the resolver once — it settles the flat scan, then
|
|
3353
|
-
// (signature match, no agent) or kicks off a fresh
|
|
3354
|
-
// manual after that.
|
|
4205
|
+
// "Connect with your agent" screen until `/refine live` (or a wired
|
|
4206
|
+
// REFINE_AGENT_CMD) makes the relay report an agent. The first time we see a
|
|
4207
|
+
// live agent we run the resolver once — it settles the flat scan, then
|
|
4208
|
+
// re-applies cached groups (signature match, no agent) or kicks off a fresh
|
|
4209
|
+
// agent scan. Rescan is manual after that.
|
|
3355
4210
|
useEffect(()=>{
|
|
3356
|
-
|
|
4211
|
+
// A host/demo page can hardcode a grouping via window.__TX_SEED_GROUPS so
|
|
4212
|
+
// the panel shows it WITHOUT a live agent (demo.html bakes in the agent's
|
|
4213
|
+
// initial scan this way). A prior cached grouping likewise lets a returning
|
|
4214
|
+
// user skip the gate — apply it without a live agent. Otherwise the agent
|
|
4215
|
+
// group-scan stays gated behind a live agent as usual.
|
|
4216
|
+
const seeded=typeof window!=="undefined"&&Array.isArray(window.__TX_SEED_GROUPS)&&window.__TX_SEED_GROUPS.length;
|
|
4217
|
+
const cachedExists=!!readGroupCache();
|
|
4218
|
+
if((!live&&!seeded&&!cachedExists)||didGroupScanRef.current)return;
|
|
3357
4219
|
didGroupScanRef.current=true;
|
|
3358
4220
|
runGroupScan();
|
|
3359
|
-
},[live,runGroupScan]);
|
|
4221
|
+
},[live,runGroupScan,readGroupCache]);
|
|
3360
4222
|
useEffect(()=>()=>{scanTokenRef.current++;},[]);
|
|
3361
4223
|
|
|
3362
4224
|
// whole-component open/close uses the transitions.dev panel reveal:
|
|
@@ -3419,7 +4281,7 @@
|
|
|
3419
4281
|
panelHotRef.current=hoveredRef.current||!!(el&&document.activeElement&&el.contains(document.activeElement));
|
|
3420
4282
|
},[]);
|
|
3421
4283
|
const kbdRef=useRef({});
|
|
3422
|
-
kbdRef.current={entries,active,acceptState,acceptDisabled:computeChanges(active).length===0,minimized,
|
|
4284
|
+
kbdRef.current={entries,active,acceptState,acceptDisabled:computeChanges(active,entries).length===0,minimized,
|
|
3423
4285
|
openRefine,resetOverrides,copyValues,onAccept,setActiveId,setMinimized,setPickOpen};
|
|
3424
4286
|
useEffect(()=>{
|
|
3425
4287
|
const onKey=e=>{
|
|
@@ -3473,9 +4335,12 @@
|
|
|
3473
4335
|
window.addEventListener("keydown",onKey);
|
|
3474
4336
|
return()=>window.removeEventListener("keydown",onKey);
|
|
3475
4337
|
},[recomputeHot]);
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
4338
|
+
// Ready once a live agent is connected OR the one-time grouping scan has
|
|
4339
|
+
// produced groups (so the panel stays usable after a chat-loop agent
|
|
4340
|
+
// auto-stops post-scan). Only an un-scanned, agent-less panel shows the gate.
|
|
4341
|
+
const gate = (live || scanned)
|
|
4342
|
+
? "ready"
|
|
4343
|
+
: (llmAvailable===null ? "loading" : "blocked");
|
|
3479
4344
|
return h(React.Fragment,null,
|
|
3480
4345
|
render&&h("div",{className:"t-panel-slide","data-timeline-panel":true,ref:panelRootRef,
|
|
3481
4346
|
onMouseEnter:()=>{hoveredRef.current=true;},onMouseLeave:()=>{hoveredRef.current=false;},
|
|
@@ -3487,8 +4352,10 @@
|
|
|
3487
4352
|
h("div",{className:"tl-panel-main"},
|
|
3488
4353
|
h(Header,{entries,active,onSelect:setActiveId,onReset:resetOverrides,onCopy:copyValues,copied,
|
|
3489
4354
|
snap,setSnap,onMinimize:()=>setMinimized(true),onRefine:openRefine,refineActive:refineOpen,
|
|
3490
|
-
onAccept,acceptState,acceptDisabled:computeChanges(active).length===0,acceptError,
|
|
3491
|
-
scanning:groupScanState==="scanning",
|
|
4355
|
+
onAccept,acceptState,acceptDisabled:computeChanges(active,entries).length===0,acceptError,
|
|
4356
|
+
scanning:groupScanState==="scanning",scanError:groupScanState==="error"?groupScanErr:null,
|
|
4357
|
+
onRescan:rescanTransitions,pick:pickOpen,setPick:setPickOpen,
|
|
4358
|
+
chatLoop,liveStopped,onStopLive:stopLive}),
|
|
3492
4359
|
active
|
|
3493
4360
|
?h(Body,{entry:active,onPropChange:(prop,o)=>setPropOverride(prop,o),snap})
|
|
3494
4361
|
:h("div",{className:"tl-empty"},"Select a transition to inspect and edit it")),
|
|
@@ -3500,10 +4367,8 @@
|
|
|
3500
4367
|
: h("div",{className:"tl-panel-main"},
|
|
3501
4368
|
gate==="blocked" && h("div",{className:"tl-gate"},
|
|
3502
4369
|
h("div",{className:"tl-gate-col"},
|
|
3503
|
-
h("div",{className:"tl-gate-title"},"
|
|
3504
|
-
h(
|
|
3505
|
-
"Please run the ",h("code",{className:"tl-code"},"/refine live"),
|
|
3506
|
-
" command in your agent to enable live features, such as scanning and refining transitions.")))),
|
|
4370
|
+
h("div",{className:"tl-gate-title"},"Connect with your agent"),
|
|
4371
|
+
h(AgentConnectBody,{cliMissing:cliInstalled===false})))),
|
|
3507
4372
|
toast&&createPortal(
|
|
3508
4373
|
h("div",{className:"tl-toast-wrap","data-tl-ui":"","aria-live":"polite"},
|
|
3509
4374
|
h("div",{className:cx("tl-toast",toast.closing&&"is-closing")},
|
|
@@ -3522,6 +4387,72 @@
|
|
|
3522
4387
|
}
|
|
3523
4388
|
|
|
3524
4389
|
// ── demo boxes ──
|
|
4390
|
+
// Hardcoded groupings — the transitions.dev agent's initial scan of each
|
|
4391
|
+
// prototype, baked in so the panel groups instantly with NO relay/agent
|
|
4392
|
+
// round-trip. The active prototype's seed is written to window.__TX_SEED_GROUPS
|
|
4393
|
+
// by App (PROTO_SEEDS map below). Member labels are cleaned up from the agent's
|
|
4394
|
+
// output. Edit here if a prototype's transitions change, or clear the seed to
|
|
4395
|
+
// fall back to a live agent scan. Shape mirrors relay.mjs buildScanPrompt's
|
|
4396
|
+
// groups[] (the same structure TransitionRegistry.setGroups consumes). This is
|
|
4397
|
+
// below the inject CUT_MARKER, so it never ships in the injected/website build.
|
|
4398
|
+
const _mEO="cubic-bezier(0.22, 1, 0.36, 1)"; // smooth ease-out (open)
|
|
4399
|
+
const _mEM="cubic-bezier(0.34, 1.25, 0.64, 1)"; // morph ease (close settle)
|
|
4400
|
+
const _mpt=(property,durationMs,easing)=>({property,durationMs,delayMs:0,easing});
|
|
4401
|
+
const MORPH_SEED_GROUPS=[
|
|
4402
|
+
{id:"morph",label:"Plus \u2192 menu morph",component:null,phases:[
|
|
4403
|
+
{id:"morph:open",phase:"open",label:"Open",stateTarget:".t-morph",fromState:null,toState:'[data-open="true"]',members:[
|
|
4404
|
+
{id:"morph-container",label:"Container",selector:".t-morph",propertyTimings:[
|
|
4405
|
+
_mpt("width",250,_mEO),_mpt("height",250,_mEO),_mpt("border-radius",250,_mEO)]},
|
|
4406
|
+
{id:"morph-plus",label:"Plus button",selector:".t-morph .t-morph-plus",propertyTimings:[
|
|
4407
|
+
_mpt("opacity",200,_mEO),_mpt("transform",350,_mEO),_mpt("filter",200,_mEO)]},
|
|
4408
|
+
{id:"morph-menu",label:"Menu panel",selector:".t-morph .t-morph-menu",propertyTimings:[
|
|
4409
|
+
_mpt("opacity",200,_mEO),_mpt("transform",350,_mEO),_mpt("filter",200,_mEO)]}]},
|
|
4410
|
+
{id:"morph:close",phase:"close",label:"Close",stateTarget:".t-morph",fromState:'[data-open="true"]',toState:null,members:[
|
|
4411
|
+
{id:"morph-container",label:"Container",selector:".t-morph",propertyTimings:[
|
|
4412
|
+
_mpt("width",400,_mEM),_mpt("height",350,_mEM),_mpt("border-radius",350,_mEM)]},
|
|
4413
|
+
{id:"morph-plus",label:"Plus button",selector:".t-morph .t-morph-plus",propertyTimings:[
|
|
4414
|
+
_mpt("opacity",200,_mEO),_mpt("transform",350,_mEO),_mpt("filter",200,_mEO)]},
|
|
4415
|
+
{id:"morph-menu",label:"Menu panel",selector:".t-morph .t-morph-menu",propertyTimings:[
|
|
4416
|
+
_mpt("opacity",200,_mEO),_mpt("transform",350,_mEO),_mpt("filter",200,_mEO)]}]}]},
|
|
4417
|
+
{id:"morph-item",label:"Menu item hover",component:null,phases:[
|
|
4418
|
+
{id:"morph-item:hover",phase:"hover",label:"Hover",stateTarget:".t-morph-menu button.morph-item",fromState:null,toState:":hover",members:[
|
|
4419
|
+
{id:"morph-item-bg",label:"Menu item",selector:".t-morph-menu button.morph-item",propertyTimings:[
|
|
4420
|
+
_mpt("background",120,"ease")]}]}]},
|
|
4421
|
+
];
|
|
4422
|
+
// Menu dropdown (05-menu-dropdown.md) — the surface scales+fades from its
|
|
4423
|
+
// trigger; the caret flips. Close runs shorter and dips to a smaller scale.
|
|
4424
|
+
const DROPDOWN_SEED_GROUPS=[
|
|
4425
|
+
{id:"dropdown",label:"Menu dropdown",component:null,phases:[
|
|
4426
|
+
{id:"dropdown:open",phase:"open",label:"Open",stateTarget:".t-dropdown",fromState:null,toState:".is-open",members:[
|
|
4427
|
+
{id:"dd-caret",label:"Caret",selector:".dd-caret",propertyTimings:[
|
|
4428
|
+
_mpt("transform",250,_mEO)]},
|
|
4429
|
+
{id:"dd-menu",label:"Menu",selector:".t-dropdown",propertyTimings:[
|
|
4430
|
+
_mpt("transform",250,_mEO),_mpt("opacity",250,_mEO)]}]},
|
|
4431
|
+
{id:"dropdown:close",phase:"close",label:"Close",stateTarget:".t-dropdown",fromState:".is-open",toState:null,members:[
|
|
4432
|
+
{id:"dd-caret",label:"Caret",selector:".dd-caret",propertyTimings:[
|
|
4433
|
+
_mpt("transform",250,_mEO)]},
|
|
4434
|
+
{id:"dd-menu",label:"Menu",selector:".t-dropdown",propertyTimings:[
|
|
4435
|
+
_mpt("transform",150,_mEO),_mpt("opacity",150,_mEO)]}]}]},
|
|
4436
|
+
];
|
|
4437
|
+
// Modal (06-modal.md) — the dialog scales up from center over a fading
|
|
4438
|
+
// backdrop; close dips back down. Both phases share the standard ease-out.
|
|
4439
|
+
const MODAL_SEED_GROUPS=[
|
|
4440
|
+
{id:"modal",label:"Modal",component:null,phases:[
|
|
4441
|
+
{id:"modal:open",phase:"open",label:"Open",stateTarget:".t-modal",fromState:null,toState:".is-open",members:[
|
|
4442
|
+
{id:"modal-backdrop",label:"Backdrop",selector:".modal-backdrop",propertyTimings:[
|
|
4443
|
+
_mpt("opacity",250,_mEO)]},
|
|
4444
|
+
{id:"modal-dialog",label:"Dialog",selector:".t-modal",propertyTimings:[
|
|
4445
|
+
_mpt("transform",250,_mEO),_mpt("opacity",250,_mEO)]}]},
|
|
4446
|
+
{id:"modal:close",phase:"close",label:"Close",stateTarget:".t-modal",fromState:".is-open",toState:null,members:[
|
|
4447
|
+
{id:"modal-backdrop",label:"Backdrop",selector:".modal-backdrop",propertyTimings:[
|
|
4448
|
+
_mpt("opacity",150,_mEO)]},
|
|
4449
|
+
{id:"modal-dialog",label:"Dialog",selector:".t-modal",propertyTimings:[
|
|
4450
|
+
_mpt("transform",150,_mEO),_mpt("opacity",150,_mEO)]}]}]},
|
|
4451
|
+
];
|
|
4452
|
+
// Active prototype → seed. App writes window.__TX_SEED_GROUPS[proto] before the
|
|
4453
|
+
// (remounted) panel runs its group scan, so each tab groups agent-free.
|
|
4454
|
+
const PROTO_SEEDS={morph:MORPH_SEED_GROUPS,dropdown:DROPDOWN_SEED_GROUPS,modal:MODAL_SEED_GROUPS};
|
|
4455
|
+
|
|
3525
4456
|
// Plus → menu morph — transitions.dev (20-plus-menu-morph.md). The trigger
|
|
3526
4457
|
// button morphs into the panel it opens; CSS owns the morph, JS only flips
|
|
3527
4458
|
// data-open and mirrors it to aria-expanded, closing on outside-click / Escape.
|
|
@@ -3553,6 +4484,100 @@
|
|
|
3553
4484
|
h("path",{d:"M10 4.5v11M4.5 10h11",stroke:"currentColor","strokeWidth":1.8,"strokeLinecap":"round"}))))));
|
|
3554
4485
|
}
|
|
3555
4486
|
|
|
4487
|
+
// Menu dropdown — transitions.dev (05-menu-dropdown.md). The .t-dropdown surface
|
|
4488
|
+
// owns the scale+fade; close swaps .is-open for .is-closing, then drops the class
|
|
4489
|
+
// after --dropdown-close-dur so the closing scale animates before it resets.
|
|
4490
|
+
function BoxDropdown(){
|
|
4491
|
+
const[open,setOpen]=useState(false);
|
|
4492
|
+
const[closing,setClosing]=useState(false);
|
|
4493
|
+
const ref=useRef(null), toRef=useRef(null);
|
|
4494
|
+
const close=useCallback(()=>{
|
|
4495
|
+
setOpen(false);setClosing(true);
|
|
4496
|
+
if(toRef.current)clearTimeout(toRef.current);
|
|
4497
|
+
toRef.current=setTimeout(()=>setClosing(false),150);
|
|
4498
|
+
},[]);
|
|
4499
|
+
const toggle=useCallback(()=>{setOpen(o=>{if(o){close();return false;}setClosing(false);return true;});},[close]);
|
|
4500
|
+
useEffect(()=>{
|
|
4501
|
+
const onDoc=e=>{if(ref.current&&!ref.current.contains(e.target))close();};
|
|
4502
|
+
const onKey=e=>{if(e.key==="Escape")close();};
|
|
4503
|
+
document.addEventListener("click",onDoc);
|
|
4504
|
+
document.addEventListener("keydown",onKey);
|
|
4505
|
+
return()=>{document.removeEventListener("click",onDoc);document.removeEventListener("keydown",onKey);if(toRef.current)clearTimeout(toRef.current);};
|
|
4506
|
+
},[close]);
|
|
4507
|
+
const item=(label,icon)=>h("button",{className:"dd-item",type:"button",onClick:close},
|
|
4508
|
+
h(Ic,{name:icon,size:16}),label);
|
|
4509
|
+
return h("div",{className:"card"},
|
|
4510
|
+
h("h2",null,"Menu dropdown"),
|
|
4511
|
+
h("p",null,"Click Menu. The panel grows from its trigger with a scale + fade and the caret flips. Closing runs shorter and dips to a smaller scale."),
|
|
4512
|
+
h("div",{className:"dd-stage"},
|
|
4513
|
+
h("div",{className:cx("dd",open&&"is-open"),ref},
|
|
4514
|
+
h("button",{className:"dd-trigger",type:"button","aria-haspopup":"menu","aria-expanded":String(open),
|
|
4515
|
+
onClick:e=>{e.stopPropagation();toggle();}},
|
|
4516
|
+
"Menu",
|
|
4517
|
+
h("svg",{className:"dd-caret",width:14,height:14,viewBox:"0 0 16 16",fill:"none","aria-hidden":"true"},
|
|
4518
|
+
h("path",{d:"M4 6l4 4 4-4",stroke:"currentColor","strokeWidth":1.6,"strokeLinecap":"round","strokeLinejoin":"round"}))),
|
|
4519
|
+
h("div",{className:cx("t-dropdown","dd-menu",open&&"is-open",closing&&"is-closing"),"data-origin":"top-left",role:"menu","aria-hidden":String(!open)},
|
|
4520
|
+
h("div",{className:"dd-title"},"Actions"),
|
|
4521
|
+
item("New file","copy"),
|
|
4522
|
+
item("Refresh","restart"),
|
|
4523
|
+
item("Settings","gear")))));
|
|
4524
|
+
}
|
|
4525
|
+
|
|
4526
|
+
// Modal — transitions.dev (06-modal.md). The .t-modal dialog scales up from
|
|
4527
|
+
// center over a fading .modal-backdrop; same close-then-cleanup pattern as the
|
|
4528
|
+
// dropdown. Scoped to the stage (not full-viewport) so it stays in the scan root.
|
|
4529
|
+
function BoxModal(){
|
|
4530
|
+
const[open,setOpen]=useState(false);
|
|
4531
|
+
const[closing,setClosing]=useState(false);
|
|
4532
|
+
const toRef=useRef(null);
|
|
4533
|
+
const close=useCallback(()=>{
|
|
4534
|
+
setOpen(false);setClosing(true);
|
|
4535
|
+
if(toRef.current)clearTimeout(toRef.current);
|
|
4536
|
+
toRef.current=setTimeout(()=>setClosing(false),150);
|
|
4537
|
+
},[]);
|
|
4538
|
+
const openIt=useCallback(()=>{setClosing(false);setOpen(true);},[]);
|
|
4539
|
+
useEffect(()=>{
|
|
4540
|
+
const onKey=e=>{if(e.key==="Escape")close();};
|
|
4541
|
+
document.addEventListener("keydown",onKey);
|
|
4542
|
+
return()=>{document.removeEventListener("keydown",onKey);if(toRef.current)clearTimeout(toRef.current);};
|
|
4543
|
+
},[close]);
|
|
4544
|
+
const st=(base)=>cx(base,open&&"is-open",closing&&"is-closing");
|
|
4545
|
+
return h("div",{className:"card"},
|
|
4546
|
+
h("h2",null,"Modal"),
|
|
4547
|
+
h("p",null,"Open the dialog. It scales up from 0.96 over a fading backdrop; closing dips back down. Click the backdrop or press Escape to dismiss."),
|
|
4548
|
+
h("div",{className:"modal-stage"},
|
|
4549
|
+
h("button",{className:"modal-open-btn",type:"button",onClick:openIt},"Open dialog"),
|
|
4550
|
+
h("div",{className:st("modal-backdrop"),onClick:close,"aria-hidden":"true"}),
|
|
4551
|
+
h("div",{className:st("t-modal"),role:"dialog","aria-modal":"true","aria-hidden":String(!open)},
|
|
4552
|
+
h("div",{className:"modal-card-title"},"Delete project?"),
|
|
4553
|
+
h("div",{className:"modal-card-body"},"This permanently removes the project and all of its files. This action can\u2019t be undone."),
|
|
4554
|
+
h("div",{className:"modal-actions"},
|
|
4555
|
+
h("button",{className:"modal-btn secondary",type:"button",onClick:close},"Cancel"),
|
|
4556
|
+
h("button",{className:"modal-btn primary",type:"button",onClick:close},"Delete")))));
|
|
4557
|
+
}
|
|
4558
|
+
|
|
4559
|
+
// Prototype switcher — sliding-pill tabs (16-tabs-sliding.md). JS measures the
|
|
4560
|
+
// active tab's offsetLeft/Width onto the pill; CSS tweens. First paint + resize
|
|
4561
|
+
// snap without a transition; only a user switch animates.
|
|
4562
|
+
function ProtoTabs({value,onChange,items}){
|
|
4563
|
+
const pillRef=useRef(null), tabRefs=useRef({}), firstRef=useRef(true);
|
|
4564
|
+
const place=useCallback((animate)=>{
|
|
4565
|
+
const t=tabRefs.current[value], pill=pillRef.current;
|
|
4566
|
+
if(!t||!pill)return;
|
|
4567
|
+
if(!animate){const prev=pill.style.transition;pill.style.transition="none";
|
|
4568
|
+
pill.style.transform="translateX("+t.offsetLeft+"px)";pill.style.width=t.offsetWidth+"px";
|
|
4569
|
+
void pill.offsetWidth;pill.style.transition=prev;}
|
|
4570
|
+
else{pill.style.transform="translateX("+t.offsetLeft+"px)";pill.style.width=t.offsetWidth+"px";}
|
|
4571
|
+
},[value]);
|
|
4572
|
+
useLayoutEffect(()=>{place(firstRef.current?false:true);firstRef.current=false;},[value,place]);
|
|
4573
|
+
useEffect(()=>{const onR=()=>place(false);window.addEventListener("resize",onR);return()=>window.removeEventListener("resize",onR);},[place]);
|
|
4574
|
+
return h("div",{className:"proto-tabs",role:"tablist"},
|
|
4575
|
+
h("span",{className:"proto-tabs-pill","aria-hidden":"true",ref:pillRef}),
|
|
4576
|
+
items.map(it=>h("button",{key:it.key,role:"tab",type:"button","aria-selected":String(value===it.key),
|
|
4577
|
+
className:cx("proto-tab",value===it.key&&"is-active"),ref:el=>{tabRefs.current[it.key]=el;},
|
|
4578
|
+
onClick:()=>onChange(it.key)},it.label)));
|
|
4579
|
+
}
|
|
4580
|
+
|
|
3556
4581
|
// ── live controls for the whole-panel reveal transition (demo harness) ──
|
|
3557
4582
|
// transitions.dev motion tokens (values lifted verbatim from the skill's _root.css)
|
|
3558
4583
|
// transitions.dev easing motion tokens. `token` is the actual skill CSS var
|
|
@@ -3652,19 +4677,51 @@
|
|
|
3652
4677
|
);
|
|
3653
4678
|
}
|
|
3654
4679
|
|
|
4680
|
+
// Prototypes the demo can switch between. Each owns a component + a description
|
|
4681
|
+
// + a baked-in seed grouping (PROTO_SEEDS) so the panel groups it agent-free.
|
|
4682
|
+
const PROTOS=[
|
|
4683
|
+
{key:"morph", label:"Morph menu", comp:BoxMorph, desc:"the plus \u2192 menu morph"},
|
|
4684
|
+
{key:"dropdown",label:"Dropdown", comp:BoxDropdown, desc:"an anchored menu dropdown"},
|
|
4685
|
+
{key:"modal", label:"Modal", comp:BoxModal, desc:"a centered modal dialog"},
|
|
4686
|
+
];
|
|
3655
4687
|
function App(){
|
|
3656
|
-
const
|
|
3657
|
-
|
|
4688
|
+
const[proto,setProto]=useState("dropdown");
|
|
4689
|
+
// Fresh registry per prototype so switching tabs starts the scan clean. The
|
|
4690
|
+
// active prototype's baked grouping is published before the (remounted) panel
|
|
4691
|
+
// runs its seeded group scan — see runGroupScan's window.__TX_SEED_GROUPS path.
|
|
4692
|
+
if(typeof window!=="undefined")window.__TX_SEED_GROUPS=PROTO_SEEDS[proto];
|
|
4693
|
+
const rootRef=useRef(null);const scanRef=useRef(null);
|
|
4694
|
+
const registry=useMemo(()=>new TransitionRegistry(),[proto]);
|
|
4695
|
+
const[activeId,setActiveId]=useState(null);
|
|
4696
|
+
// Scan only the prototype stage (not the header), so the switcher tabs' own
|
|
4697
|
+
// pill transition is never picked up as a demo transition.
|
|
4698
|
+
useEffect(()=>{const root=scanRef.current??document.body;const scanner=new DomScanner(root,registry);scanner.start();return()=>{scanner.stop();};},[registry]);
|
|
3658
4699
|
const ctx=useMemo(()=>({registry,activeId,setActiveId}),[registry,activeId]);
|
|
3659
4700
|
// Demo-only tweak controls: hidden by default. Append ?controls to the URL
|
|
3660
4701
|
// to show them for testing. (This whole block is below the inject CUT_MARKER,
|
|
3661
4702
|
// so it never ships in the injected build.)
|
|
3662
4703
|
const showControls=(()=>{try{return new URLSearchParams(location.search).has("controls");}catch(e){return false;}})();
|
|
4704
|
+
// TESTING ONLY (not part of the tool): top-right toggle to hide the demo
|
|
4705
|
+
// page content so the panel can be inspected against a blank page.
|
|
4706
|
+
const[demoHidden,setDemoHidden]=useState(false);
|
|
4707
|
+
const active=PROTOS.find(p=>p.key===proto)||PROTOS[0];
|
|
3663
4708
|
return h(TimelineCtx.Provider,{value:ctx},
|
|
3664
4709
|
showControls&&h(PanelControls),
|
|
3665
|
-
h("
|
|
3666
|
-
|
|
3667
|
-
|
|
4710
|
+
h("button",{type:"button",onClick:()=>setDemoHidden(v=>!v),
|
|
4711
|
+
title:"Testing only: hide/show demo page content",
|
|
4712
|
+
style:{position:"fixed",top:"12px",right:"12px",zIndex:2147483000,
|
|
4713
|
+
height:"28px",padding:"0 10px",borderRadius:"8px",cursor:"pointer",
|
|
4714
|
+
border:"1px solid rgba(0,0,0,0.12)",background:"#fff",color:"#444",
|
|
4715
|
+
font:"500 12px/1 ui-sans-serif,system-ui,sans-serif",
|
|
4716
|
+
boxShadow:"0 1px 3px rgba(0,0,0,0.12)"}},
|
|
4717
|
+
demoHidden?"Show demo":"Hide demo"),
|
|
4718
|
+
h("div",{ref:rootRef,className:"demo-root",style:{display:demoHidden?"none":undefined}},
|
|
4719
|
+
h("div",{className:"demo-header"},
|
|
4720
|
+
h("h1",null,"Timeline Inspector \u2014 Demo"),
|
|
4721
|
+
h("p",null,"A live transitions.dev component: ",active.desc,". 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."),
|
|
4722
|
+
h(ProtoTabs,{value:proto,onChange:setProto,items:PROTOS})),
|
|
4723
|
+
h("div",{ref:scanRef,className:"demo-grid"},h(active.comp,{key:proto}))),
|
|
4724
|
+
h(TimelinePanel,{key:proto}));
|
|
3668
4725
|
}
|
|
3669
4726
|
|
|
3670
4727
|
createRoot(document.getElementById("root")).render(h(App));
|