transitions-refine 0.3.7 → 0.3.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/refine-live/SKILL.md +26 -9
- package/demo.html +540 -177
- package/package.json +1 -1
- package/server/relay.mjs +4 -4
|
@@ -197,18 +197,33 @@ separately. You fix that by reading the source. The request looks like:
|
|
|
197
197
|
"raw": [
|
|
198
198
|
{ "label": "div.dropdown-panel", "selector": ".dropdown-panel",
|
|
199
199
|
"properties": ["opacity","transform"],
|
|
200
|
-
"timings": [{ "property": "opacity", "durationMs": 200, "delayMs": 0, "easing": "ease-out" }]
|
|
200
|
+
"timings": [{ "property": "opacity", "durationMs": 200, "delayMs": 0, "easing": "ease-out" }],
|
|
201
|
+
"cssRules": [
|
|
202
|
+
".dropdown .dropdown-panel { opacity: 0; transition: opacity 200ms ease-out 0ms, transform 200ms cubic-bezier(0.22, 1, 0.36, 1) 0ms; }",
|
|
203
|
+
".dropdown.is-open .dropdown-panel { opacity: 1; transform: translateY(0); }",
|
|
204
|
+
".dropdown.is-closing .dropdown-panel { transition: opacity 150ms ease-in 0ms; opacity: 0; }"
|
|
205
|
+
] }
|
|
201
206
|
]
|
|
202
207
|
}
|
|
203
208
|
}
|
|
204
209
|
```
|
|
205
210
|
|
|
206
211
|
**Be fast.** The `raw.timings` are already accurate for each element's *current*
|
|
207
|
-
on-screen state — treat them as ground truth and reuse them verbatim.
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
(
|
|
211
|
-
|
|
212
|
+
on-screen state — treat them as ground truth and reuse them verbatim. Most `raw`
|
|
213
|
+
entries also carry **`cssRules`**: the CSS rules harvested live from the page
|
|
214
|
+
(CSSOM) that drive that element across *all* states (base + open + close), with
|
|
215
|
+
`var()` already resolved to concrete values.
|
|
216
|
+
|
|
217
|
+
**Fast path — prefer `cssRules` over the filesystem.** When an entry has
|
|
218
|
+
`cssRules`, they are authoritative and contain everything you need: the opposite
|
|
219
|
+
phase's timings live on a state-variant selector inside them (e.g.
|
|
220
|
+
`.dd.is-closing .dd-panel`, `.modal[data-closing] .dialog`), and the toggled
|
|
221
|
+
state is visible in those selectors. Derive grouping, phases, toggled state, and
|
|
222
|
+
opposite-phase timings **directly from `cssRules` + `timings`** — do **not**
|
|
223
|
+
glob/grep/read files for any element whose `cssRules` is non-empty; it only
|
|
224
|
+
wastes time. Only fall back to reading source for entries with an empty/missing
|
|
225
|
+
`cssRules` (CORS-locked sheets, styled-components, Tailwind, etc.), and even then
|
|
226
|
+
read the minimum.
|
|
212
227
|
|
|
213
228
|
Do this:
|
|
214
229
|
|
|
@@ -220,14 +235,16 @@ Do this:
|
|
|
220
235
|
2. **Split each component into phases** — usually `open` and `close` (a hover-only
|
|
221
236
|
component can be a single phase). The phase matching the current DOM reuses the
|
|
222
237
|
provided timings; the *opposite* phase often lives on a different selector
|
|
223
|
-
(`.is-open` vs `.is-closing`) with different timings —
|
|
224
|
-
|
|
238
|
+
(`.is-open` vs `.is-closing`) with different timings — take it from the entry's
|
|
239
|
+
`cssRules` (or, only if it has none, read source). Report **both** even though
|
|
240
|
+
only one is in the DOM right now.
|
|
225
241
|
3. **List each phase's members** — the elements that animate in that phase. Give
|
|
226
242
|
each a stable `id`, a human `label`, a live-resolvable CSS `selector`, an
|
|
227
243
|
optional `toState` hint (the class/attribute that drives the phase, e.g.
|
|
228
244
|
`.is-open`), and its `propertyTimings`. For the current-state phase, **copy the
|
|
229
245
|
provided `raw.timings` verbatim**; for the opposite phase, **quote the real
|
|
230
|
-
timings from
|
|
246
|
+
timings from the entry's `cssRules`** (already var()-resolved) — or from source
|
|
247
|
+
if it has none — **never invent.**
|
|
231
248
|
4. **Post the groups** (this completes the job):
|
|
232
249
|
|
|
233
250
|
```bash
|
package/demo.html
CHANGED
|
@@ -244,7 +244,7 @@
|
|
|
244
244
|
padding: 6px 10px 6px 16px;
|
|
245
245
|
font-family: "Inter", system-ui, -apple-system, sans-serif;
|
|
246
246
|
cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 8px;
|
|
247
|
-
box-shadow: var(--
|
|
247
|
+
box-shadow: var(--shadow-btn); transition: background 0.15s, scale 0.12s ease;
|
|
248
248
|
animation: tl-pill-in 220ms cubic-bezier(0.22, 1, 0.36, 1) both; }
|
|
249
249
|
@keyframes tl-pill-in { from { opacity: 0; transform: translateY(6px) scale(0.96); }
|
|
250
250
|
to { opacity: 1; transform: none; } }
|
|
@@ -369,7 +369,7 @@
|
|
|
369
369
|
.tl-insp-title { display: flex; align-items: center; font-size: 13px; font-weight: 500; line-height: 18px; color: #171717; margin-bottom: 16px; text-transform: capitalize; }
|
|
370
370
|
/* full-bleed divider between the Delay field and the Easing/Springs tabs
|
|
371
371
|
(Figma 580:9049 — spans the inspector edge-to-edge past the 16px padding) */
|
|
372
|
-
.tl-inspector-divider { height: 1px; background: var(--c-line); margin:
|
|
372
|
+
.tl-inspector-divider { height: 1px; background: var(--c-line); margin: 4px -16px; }
|
|
373
373
|
.tl-insp-label { font-size: 12px; line-height: 18px; color: #737373; margin: 10px 0 6px; }
|
|
374
374
|
|
|
375
375
|
/* ── tracks / ruler ── */
|
|
@@ -440,7 +440,9 @@
|
|
|
440
440
|
/* ── value field (slider + input) — exact Figma "Value slider and input" ── */
|
|
441
441
|
.tl-field-wrap { display: flex; align-items: center; gap: 6px; margin-bottom: 12px; }
|
|
442
442
|
.tl-field { position: relative; flex: 1; min-width: 0; height: 32px; border-radius: 8px;
|
|
443
|
-
background: var(--c-field-bg); transition: box-shadow 0.12s ease; }
|
|
443
|
+
background: var(--c-field-bg); transition: box-shadow 0.12s ease, background 0.12s ease; }
|
|
444
|
+
/* match the chevron stepper's hover wash so the whole field reads as one control */
|
|
445
|
+
.tl-field:hover:not(.is-editing) { background: var(--c-sec-h); }
|
|
444
446
|
/* focused: dual inset ring (Figma Focused state) */
|
|
445
447
|
.tl-field.is-editing { box-shadow: inset 0 0 0 1px rgba(0,0,0,0.14), inset 0 0 0 0.5px rgba(255,255,255,0.06); }
|
|
446
448
|
/* inner slider fill — Figma button/tiny: soft drop shadow + inset ring give the
|
|
@@ -520,15 +522,17 @@
|
|
|
520
522
|
border: none; border-radius: 8px; background: #f7f7f7; padding: 6px 10px 6px 12px;
|
|
521
523
|
font: inherit; font-size: 13px; font-weight: 500; line-height: 16px; color: #1b1b1b; cursor: pointer; text-align: left;
|
|
522
524
|
transition: background 0.12s ease; }
|
|
523
|
-
.tl-select:hover { background:
|
|
524
|
-
.tl-select.is-open { background:
|
|
525
|
+
.tl-select:hover { background: var(--c-sec-h); }
|
|
526
|
+
.tl-select.is-open { background: var(--c-sec-h); }
|
|
525
527
|
.tl-select:disabled { opacity: 0.5; cursor: default; }
|
|
526
528
|
.tl-select-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
527
529
|
.tl-select-chev { display: flex; color: var(--c-ruler); flex: none; }
|
|
528
530
|
/* easing curve — exact Figma (node 580:11158): #f6f6f7, 8px radius, no grid */
|
|
531
|
+
/* overflow:visible so overshoot handles/lines spill past the box while
|
|
532
|
+
dragging instead of being clipped to the rectangle. */
|
|
529
533
|
.tl-curve { background: #f6f6f7; border-radius: 8px;
|
|
530
|
-
padding: 0; position: relative; overflow:
|
|
531
|
-
.tl-curve svg { display: block; cursor: crosshair; width: 100%; height: auto; }
|
|
534
|
+
padding: 0; position: relative; overflow: visible; }
|
|
535
|
+
.tl-curve svg { display: block; cursor: crosshair; width: 100%; height: auto; overflow: visible; }
|
|
532
536
|
.tl-curve-handle { cursor: grab; }
|
|
533
537
|
.tl-curve-handle:active { cursor: grabbing; }
|
|
534
538
|
.tl-cubic-row { display: flex; gap: 4px; }
|
|
@@ -536,9 +540,9 @@
|
|
|
536
540
|
32px #f7f7f7 pill, hover overlay + inset focus ring. */
|
|
537
541
|
.tl-cubic-cell { flex: 1; min-width: 0; display: flex; flex-direction: row; align-items: center;
|
|
538
542
|
gap: 4px; height: 32px; box-sizing: border-box;
|
|
539
|
-
padding: 0 8px; border-radius: 8px; background:
|
|
543
|
+
padding: 0 8px; border-radius: 8px; background: var(--c-field-bg);
|
|
540
544
|
box-shadow: inset 0 0 0 0 rgba(0,0,0,0.14); transition: box-shadow 0.12s ease, background 0.12s ease; }
|
|
541
|
-
.tl-cubic-cell:hover { background:
|
|
545
|
+
.tl-cubic-cell:hover { background: var(--c-sec-h); }
|
|
542
546
|
.tl-cubic-cell:focus-within { box-shadow: inset 0 0 0 1px rgba(0,0,0,0.14); }
|
|
543
547
|
.tl-cubic-row input { flex: 1; min-width: 0; width: 100%; height: 100%; border: none; background: transparent;
|
|
544
548
|
box-sizing: border-box; padding: 0; text-align: left;
|
|
@@ -590,6 +594,27 @@
|
|
|
590
594
|
transition: color 0.12s ease; }
|
|
591
595
|
.tl-preview-play:hover { color: #171717; }
|
|
592
596
|
.tl-preview-play:active { color: var(--c-blue); }
|
|
597
|
+
/* Play ⟷ Pause: cross-blur text swap — same mechanism as the index
|
|
598
|
+
Copy code → Copied tooltip. Both words stack in one slot; the active
|
|
599
|
+
one is sharp/opaque, the other blurred/transparent, and they cross-blur
|
|
600
|
+
in place while the slot width tweens between the two measured widths. */
|
|
601
|
+
.tl-play-swap { position: relative; display: inline-block; vertical-align: baseline;
|
|
602
|
+
line-height: 18px; width: var(--pw-a, auto);
|
|
603
|
+
transition: width 240ms cubic-bezier(0.22, 1, 0.36, 1); }
|
|
604
|
+
.tl-play-swap[data-state="pause"] { width: var(--pw-b, auto); }
|
|
605
|
+
.tl-play-label { white-space: pre;
|
|
606
|
+
transition: opacity 180ms cubic-bezier(0.22, 1, 0.36, 1),
|
|
607
|
+
filter 180ms cubic-bezier(0.22, 1, 0.36, 1);
|
|
608
|
+
will-change: opacity, filter; }
|
|
609
|
+
.tl-play-a { display: inline-block; }
|
|
610
|
+
.tl-play-b { position: absolute; top: 0; left: 0; }
|
|
611
|
+
.tl-play-swap .tl-play-a { opacity: 1; filter: blur(0); }
|
|
612
|
+
.tl-play-swap .tl-play-b { opacity: 0; filter: blur(2px); }
|
|
613
|
+
.tl-play-swap[data-state="pause"] .tl-play-a { opacity: 0; filter: blur(2px); }
|
|
614
|
+
.tl-play-swap[data-state="pause"] .tl-play-b { opacity: 1; filter: blur(0); }
|
|
615
|
+
@media (prefers-reduced-motion: reduce) {
|
|
616
|
+
.tl-play-swap, .tl-play-label { transition: none !important; }
|
|
617
|
+
}
|
|
593
618
|
/* position-preview track — Figma 580:9117: dot + dashed rail inside a
|
|
594
619
|
#f6f6f7 rounded card (58px tall, 21px horizontal inset for the dot) */
|
|
595
620
|
.tl-preview-track { height: 58px; background: #f6f6f7; border-radius: 8px;
|
|
@@ -659,7 +684,10 @@
|
|
|
659
684
|
.tl-menu-item:focus-visible { outline: none; background: #fff; box-shadow: inset 0 0 0 1px rgba(0,115,229,0.4); }
|
|
660
685
|
.tl-menu-item.disabled { color: var(--c-disabled); pointer-events: none; }
|
|
661
686
|
.tl-menu-item-label { flex: 1; min-width: 0; display: flex; align-items: center; }
|
|
662
|
-
.tl-menu-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
687
|
+
.tl-menu-text { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
688
|
+
.tl-menu-stack { flex: 1; min-width: 0; display: flex; flex-direction: row; align-items: baseline; gap: 6px; }
|
|
689
|
+
.tl-menu-stack .tl-menu-text { flex: 0 1 auto; }
|
|
690
|
+
.tl-menu-sub { flex: 0 0 auto; font-size: 13px; line-height: 18px; }
|
|
663
691
|
.tl-menu-dim { color: #979797; }
|
|
664
692
|
.tl-menu-check { display: flex; color: var(--c-text-strong); flex: none; }
|
|
665
693
|
.tl-menu-empty { padding: 10px 8px; color: var(--c-disabled); font-size: 13px; }
|
|
@@ -681,6 +709,70 @@
|
|
|
681
709
|
/* trailing chevron on link rows (Figma 581:2828 — #696969 right chevron) */
|
|
682
710
|
.tl-menu-chevr { display: flex; flex: none; color: #696969; }
|
|
683
711
|
|
|
712
|
+
/* ── settings dropdown sub-page slider (transitions.dev · 08-page-side-by-side) ──
|
|
713
|
+
One dropdown surface that pages internally: main settings ↔ keyboard
|
|
714
|
+
shortcuts. Both pages stay mounted so they can cross-slide; the surface
|
|
715
|
+
height follows the active page (card-resize-style) so it grows/shrinks
|
|
716
|
+
smoothly instead of jumping. Page 1 exits left, page 2 exits right. */
|
|
717
|
+
.tl-set-slide.t-page-slide {
|
|
718
|
+
position: relative;
|
|
719
|
+
--page-slide-dur: 250ms; /* Fast — page slide */
|
|
720
|
+
--page-fade-dur: 250ms;
|
|
721
|
+
--page-slide-distance: 8px; /* Base distance */
|
|
722
|
+
--page-blur: 3px; /* Medium blur */
|
|
723
|
+
--page-stagger: 0ms;
|
|
724
|
+
--page-exit-enabled: 1;
|
|
725
|
+
--page-slide-ease: cubic-bezier(0.22, 1, 0.36, 1); /* Smooth ease out */
|
|
726
|
+
--page-fade-ease: cubic-bezier(0.22, 1, 0.36, 1);
|
|
727
|
+
transition: height var(--page-slide-dur) var(--page-slide-ease);
|
|
728
|
+
overflow: hidden;
|
|
729
|
+
}
|
|
730
|
+
.tl-set-slide .t-page[data-page-id="1"] { --t-page-from-x: calc(var(--page-slide-distance) * -1); }
|
|
731
|
+
.tl-set-slide .t-page[data-page-id="2"] { --t-page-from-x: var(--page-slide-distance); }
|
|
732
|
+
.tl-set-slide .t-page {
|
|
733
|
+
position: absolute; top: 0; left: 0; right: 0;
|
|
734
|
+
opacity: 0; pointer-events: none;
|
|
735
|
+
transform: translateX(calc(var(--t-page-from-x, 0px) * var(--page-exit-enabled)));
|
|
736
|
+
filter: blur(calc(var(--page-blur) * var(--page-exit-enabled)));
|
|
737
|
+
transition:
|
|
738
|
+
opacity var(--page-fade-dur) var(--page-fade-ease),
|
|
739
|
+
transform var(--page-slide-dur) var(--page-slide-ease),
|
|
740
|
+
filter var(--page-slide-dur) var(--page-slide-ease);
|
|
741
|
+
will-change: opacity, transform, filter;
|
|
742
|
+
}
|
|
743
|
+
.tl-set-slide[data-page="1"] .t-page[data-page-id="1"],
|
|
744
|
+
.tl-set-slide[data-page="2"] .t-page[data-page-id="2"] {
|
|
745
|
+
opacity: 1; pointer-events: auto; transform: translateX(0); filter: blur(0);
|
|
746
|
+
transition-delay: var(--page-stagger);
|
|
747
|
+
}
|
|
748
|
+
@media (prefers-reduced-motion: reduce) {
|
|
749
|
+
.tl-set-slide.t-page-slide,
|
|
750
|
+
.tl-set-slide .t-page { transition: none !important; }
|
|
751
|
+
}
|
|
752
|
+
/* sub-page back header — mirrors the menu-item row metrics (h-32, rounded-8,
|
|
753
|
+
13/16 label) so the surface feels continuous between pages. */
|
|
754
|
+
.tl-set-back { display: flex; align-items: center; gap: 4px; width: 100%; box-sizing: border-box;
|
|
755
|
+
min-height: 32px; padding: 0 8px 0 4px; border: none; background: transparent; border-radius: 8px;
|
|
756
|
+
font: inherit; font-size: 13px; font-weight: 500; line-height: 16px; color: #1b1b1b;
|
|
757
|
+
cursor: pointer; text-align: left; transition: background 0.1s ease, box-shadow 0.1s ease; }
|
|
758
|
+
.tl-set-back:hover { background: #f4f4f5; }
|
|
759
|
+
.tl-set-back:active { background: #ededee; }
|
|
760
|
+
.tl-set-back:focus-visible { outline: none; background: #fff; box-shadow: inset 0 0 0 1px rgba(0,115,229,0.4); }
|
|
761
|
+
/* reuse the right chevron flipped to point back; the menu chevron color */
|
|
762
|
+
.tl-set-back-ic { display: flex; flex: none; color: #696969; transform: scaleX(-1); }
|
|
763
|
+
.tl-set-back-title { flex: 1; min-width: 0; }
|
|
764
|
+
/* shortcut row — label left, keycap cluster right; same h-32 rhythm as rows */
|
|
765
|
+
.tl-kbd-row { display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
|
766
|
+
min-height: 32px; padding: 0 8px; font-size: 13px; line-height: 16px; color: #1b1b1b; }
|
|
767
|
+
.tl-kbd-row-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
768
|
+
.tl-kbd-cluster { display: flex; align-items: center; gap: 4px; flex: none; }
|
|
769
|
+
.tl-kbd-plus { color: #b8b8b8; font-size: 11px; line-height: 1; }
|
|
770
|
+
/* keycap — hairline via inset shadow (shadows over borders), tabular monospace */
|
|
771
|
+
.tl-kbd { display: inline-flex; align-items: center; justify-content: center; min-width: 18px; height: 18px;
|
|
772
|
+
padding: 0 5px; box-sizing: border-box; border-radius: 5px; background: #f4f4f5;
|
|
773
|
+
box-shadow: inset 0 0 0 1px #ececed; font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
|
|
774
|
+
font-size: 11px; line-height: 1; font-weight: 500; color: #4c4c4c; font-variant-numeric: tabular-nums; }
|
|
775
|
+
|
|
684
776
|
/* ═════ transitions.dev — menu dropdown (verbatim) ═════ */
|
|
685
777
|
.t-dropdown {
|
|
686
778
|
transform-origin: top left;
|
|
@@ -783,6 +875,8 @@
|
|
|
783
875
|
}
|
|
784
876
|
/* below-variant for triggers near the panel top edge */
|
|
785
877
|
.t-tt.tl-tt-below { bottom: auto; top: calc(100% + 8px); transform-origin: 50% 0; font-size: 12px; }
|
|
878
|
+
/* small gray shortcut hint inside a tooltip (matches the cheat-sheet keys) */
|
|
879
|
+
.tl-tt-kbd { margin-left: 8px; opacity: 0.4; }
|
|
786
880
|
/* multi-line message variant (e.g. Accept error) — long text wraps to a
|
|
787
881
|
readable measure instead of one overflowing line. Right-anchored so the
|
|
788
882
|
wide bubble grows leftward and never clips off the panel edge. Mirrors the
|
|
@@ -874,7 +968,7 @@
|
|
|
874
968
|
.pc-btn.primary:hover { background: #000; }
|
|
875
969
|
|
|
876
970
|
.tl-refine-btn.is-active { color: var(--c-blue-pressed); }
|
|
877
|
-
.tl-refine-btn.is-active::before { background: color(display-p3 0 0.451 0.898 / 0.12); }
|
|
971
|
+
.tl-refine-btn.is-active::before { background: var(--c-blue-bg-a); background: color(display-p3 0 0.451 0.898 / 0.12); }
|
|
878
972
|
|
|
879
973
|
/* refine panel slides in from the right using transitions.dev panel reveal
|
|
880
974
|
tokens (translate + opacity + cross-blur, per-phase open/close dur/ease). */
|
|
@@ -942,8 +1036,10 @@
|
|
|
942
1036
|
width: 36px; height: 36px; border: none; background: transparent; border-radius: 60px; cursor: pointer;
|
|
943
1037
|
color: #676767; transition: background 0.14s ease, color 0.14s ease; }
|
|
944
1038
|
.tl-refine-close:hover { background: rgba(170,170,170,0.10); color: #17181c; }
|
|
945
|
-
/* refine-type tabs
|
|
946
|
-
|
|
1039
|
+
/* refine-type tabs — toolbar strip (Figma 587:9258): 48px white bar, hairline
|
|
1040
|
+
bottom divider, segments inset 16px and packed gap:0 like a segmented control */
|
|
1041
|
+
.tl-refine-tabs { flex: 0 0 auto; box-sizing: border-box; display: flex; align-items: center; gap: 0;
|
|
1042
|
+
height: 48px; padding: 0 16px; background: #fff; border-bottom: 1px solid #f0f0f0; }
|
|
947
1043
|
.tl-refine-tab { height: 32px; padding: 6px 12px; border: none; background: transparent; cursor: pointer;
|
|
948
1044
|
border-radius: 8px; font: inherit; font-size: 13px; font-weight: 500; line-height: 14px; color: #676767;
|
|
949
1045
|
transition: background 0.14s ease, color 0.14s ease; }
|
|
@@ -1029,7 +1125,7 @@
|
|
|
1029
1125
|
label and the icon+status; whichever is hidden fades out with blur+scale.
|
|
1030
1126
|
overflow:hidden + nowrap keep the status copy on a single line while the
|
|
1031
1127
|
box is still narrow mid-morph. */
|
|
1032
|
-
.tl-scan-content { position: relative; z-index:
|
|
1128
|
+
.tl-scan-content { position: relative; z-index: 4; display: grid; width: 100%; height: 100%;
|
|
1033
1129
|
min-width: 0; overflow: hidden; }
|
|
1034
1130
|
.tl-scan-face { grid-area: 1 / 1; display: flex; align-items: center; gap: 8px;
|
|
1035
1131
|
min-width: 0; white-space: nowrap;
|
|
@@ -1051,8 +1147,14 @@
|
|
|
1051
1147
|
position:relative on its [data-beam] container (and it lands later in the
|
|
1052
1148
|
DOM, so it would win at equal specificity). Raise specificity here so the
|
|
1053
1149
|
beam stays an absolute overlay filling the button instead of collapsing
|
|
1054
|
-
into flow as a flex sibling (which hid the beam and skewed the width).
|
|
1055
|
-
|
|
1150
|
+
into flow as a flex sibling (which hid the beam and skewed the width).
|
|
1151
|
+
z-index:3 lifts the beam ABOVE the box's 1px hairline (the .is-scanning
|
|
1152
|
+
::after `inset 0 0 0 1px` ring at z-index:2). The beam's own stroke is a
|
|
1153
|
+
1px ring at inset 0 with the same radius, so its travelling light now lands
|
|
1154
|
+
pixel-for-pixel ON the hairline instead of being occluded by it — leaving
|
|
1155
|
+
only the diffuse bloom (which read as a thick, offset border). The face
|
|
1156
|
+
content sits at z-index:4 so the glow never washes over the label/status. */
|
|
1157
|
+
.tl-scan-morph .tl-scan-beam { position: absolute; inset: 0; z-index: 3;
|
|
1056
1158
|
border-radius: inherit; pointer-events: none;
|
|
1057
1159
|
animation: tl-fade-in 360ms var(--resize-ease) both; }
|
|
1058
1160
|
/* Beam radius is auto-detected from this fill child; inherit keeps it locked
|
|
@@ -1072,33 +1174,8 @@
|
|
|
1072
1174
|
.tl-gate-text { margin: 4px 0 0; max-width: 250px; font-size: 13px; font-weight: 400; line-height: 21px;
|
|
1073
1175
|
color: #676767; text-wrap: pretty; }
|
|
1074
1176
|
.tl-gate-text .tl-code { color: #4b4b4b; }
|
|
1075
|
-
/* "Agent is scanning your transitions" — pill with dot-matrix loader + a
|
|
1076
|
-
line-style border beam tracing the capsule edge, with a subtext below. */
|
|
1077
|
-
.tl-gate-pill-wrap { position: relative; }
|
|
1078
|
-
.tl-gate-pill { position: relative; z-index: 1; display: inline-flex; align-items: center; gap: 6px;
|
|
1079
|
-
height: 36px; padding: 6px 12px; border-radius: 36px; background: #fff; color: #17181c;
|
|
1080
|
-
font-size: 13px; font-weight: 400; line-height: 14px; white-space: nowrap;
|
|
1081
|
-
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.06), inset 0 0 0 1px rgba(196,196,196,0.10); }
|
|
1082
|
-
.tl-gate-pill .tl-dotm { color: #17181c; }
|
|
1083
|
-
.tl-gate-pill-wrap .tl-gate-beam { position: absolute; inset: 0; z-index: 0; border-radius: 36px;
|
|
1084
|
-
pointer-events: none; animation: tl-fade-in 360ms var(--resize-ease, cubic-bezier(0.22,1,0.36,1)) both; }
|
|
1085
|
-
.tl-gate-beam-fill { display: block; width: 100%; height: 100%; border-radius: 36px; background: #fff; }
|
|
1086
|
-
.tl-gate-sub { margin: 16px 0 0; max-width: 244px; font-size: 12px; font-weight: 400; line-height: 14px;
|
|
1087
|
-
color: #6f6f6f; text-wrap: pretty; }
|
|
1088
|
-
/* recovery actions when a scan errored/timed out — never trap the panel */
|
|
1089
|
-
.tl-gate-actions { display: flex; align-items: center; gap: 8px; margin: 18px 0 0; }
|
|
1090
|
-
.tl-gate-btn { height: 32px; padding: 0 14px; border: 0; border-radius: 60px; cursor: pointer;
|
|
1091
|
-
font: 500 12px/14px inherit; color: #2c2c2c; background: #f3f3f3; white-space: nowrap;
|
|
1092
|
-
box-shadow: 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);
|
|
1093
|
-
transition: background 140ms cubic-bezier(0.4,0,0.2,1); }
|
|
1094
|
-
.tl-gate-btn:hover { background: #ececec; }
|
|
1095
|
-
.tl-gate-btn:active { background: #e6e6e6; }
|
|
1096
|
-
.tl-gate-btn-primary { color: #fff; background: #0a84ff;
|
|
1097
|
-
box-shadow: 0 0 0 1px rgba(0,101,208,0.10) inset, 0 -1px 0 0 rgba(3,66,142,0.15) inset, 0 1px 3px 0 rgba(4,41,117,0.08); }
|
|
1098
|
-
.tl-gate-btn-primary:hover { background: #0a78e6; }
|
|
1099
1177
|
@media (prefers-reduced-motion: reduce) {
|
|
1100
|
-
.tl-gate
|
|
1101
|
-
.tl-gate-btn { transition: none !important; } }
|
|
1178
|
+
.tl-gate { animation: none !important; } }
|
|
1102
1179
|
/* dot-matrix loader — ported from @dotmatrix/dotm-square-14
|
|
1103
1180
|
(github.com/zzzzshawn/matrix, MIT). A 5×5 dot grid cross-fades through four
|
|
1104
1181
|
frame masks in the sequence 0→1→2→3→2→1, dot opacities x=1 / o=0.52 / .=0.08.
|
|
@@ -1125,7 +1202,16 @@
|
|
|
1125
1202
|
@keyframes tl-dotm-e { 0%{opacity:1} 16.667%{opacity:.52} 33.333%{opacity:.08} 50%{opacity:.52} 66.667%{opacity:.08} 83.333%{opacity:.52} 100%{opacity:1} }
|
|
1126
1203
|
@keyframes tl-dotm-f { 0%{opacity:.08} 16.667%{opacity:1} 33.333%{opacity:.52} 50%{opacity:.08} 66.667%{opacity:.52} 83.333%{opacity:1} 100%{opacity:.08} }
|
|
1127
1204
|
@keyframes tl-dotm-g { 0%{opacity:.08} 16.667%{opacity:.52} 33.333%{opacity:.08} 50%{opacity:.08} 66.667%{opacity:.08} 83.333%{opacity:.52} 100%{opacity:.08} }
|
|
1128
|
-
|
|
1205
|
+
/* background-scan note in the header count slot — dot-matrix loader + shimmer
|
|
1206
|
+
text (transitions.dev shimmer text 15). Hover shows the tooltip. */
|
|
1207
|
+
/* the tooltip wrap fills the count slot's height; center its content so the
|
|
1208
|
+
loader + text sit on the slot's vertical midline instead of its bottom. */
|
|
1209
|
+
.tl-header-count .t-tt-wrap { display: inline-flex; align-items: center; align-self: center; }
|
|
1210
|
+
.tl-scan-note { cursor: help; color: var(--c-count);
|
|
1211
|
+
display: inline-flex; align-items: center; gap: 6px; }
|
|
1212
|
+
.tl-scan-note .tl-dotm { width: 14px; height: 14px; }
|
|
1213
|
+
.tl-scan-shimmer { --shimmer-dur: 2000ms; --shimmer-base: var(--c-count); --shimmer-highlight: #17181c;
|
|
1214
|
+
--shimmer-band: 400%; --shimmer-ease: linear; }
|
|
1129
1215
|
/* loading status text — transitions.dev shimmer text (15) + text states swap (04) */
|
|
1130
1216
|
.tl-refine-status-text {
|
|
1131
1217
|
--shimmer-dur: 2000ms; --shimmer-base: #9a9a9a; --shimmer-highlight: #17181c;
|
|
@@ -1208,33 +1294,57 @@
|
|
|
1208
1294
|
/* applied state — GRAY, never green (matches Accept icon --c-text #17181c) */
|
|
1209
1295
|
.tl-pill-btn.is-applied { color: var(--c-text); cursor: default; }
|
|
1210
1296
|
.tl-pill-btn.is-applied svg path { stroke: var(--c-text); }
|
|
1211
|
-
/*
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
.tl-
|
|
1220
|
-
|
|
1297
|
+
/* Apply ⟷ Applied: a LEADING check is revealed first and pushes the text to
|
|
1298
|
+
the right, then the differing SUFFIX cross-blurs. The shared stem "Appl"
|
|
1299
|
+
stays put while "y" ⟷ "ied" cross-blur in a slot whose width tweens between
|
|
1300
|
+
the two measured widths. Both the leading check space AND the suffix slot
|
|
1301
|
+
use the SAME resize token (--apply-resize-*: card resize, smooth ease out),
|
|
1302
|
+
so the whole button grows in one synchronized motion. The check lives in its
|
|
1303
|
+
own overflow:hidden box whose width tweens 0 -> (icon + gap), shoving the
|
|
1304
|
+
text rightward — the check itself stroke-draws IN PLACE (no translate). */
|
|
1305
|
+
.tl-apply-swap { position: relative; display: inline-flex; align-items: center;
|
|
1306
|
+
vertical-align: middle; line-height: 14px;
|
|
1307
|
+
--apply-resize-dur: 400ms; --apply-resize-ease: cubic-bezier(0.22, 1, 0.36, 1); }
|
|
1308
|
+
/* leading check slot: collapsed to width 0 at rest; expands to the measured
|
|
1309
|
+
icon+gap width (--chk-w) when applied, using the same token as the suffix
|
|
1310
|
+
resize. overflow:hidden clips the pinned-left check so it reveals in place
|
|
1311
|
+
(left edge fixed) rather than translating. */
|
|
1312
|
+
.tl-apply-check { display: inline-flex; align-items: center; overflow: hidden;
|
|
1313
|
+
flex: 0 0 auto; width: 0;
|
|
1314
|
+
transition: width var(--apply-resize-dur) var(--apply-resize-ease); }
|
|
1315
|
+
.tl-apply-swap[data-state="applied"] .tl-apply-check { width: var(--chk-w, 19px); }
|
|
1316
|
+
.tl-apply-stem { display: inline-block; }
|
|
1317
|
+
.tl-apply-suffix { position: relative; display: inline-block; width: var(--sfx-y, auto);
|
|
1318
|
+
transition: width var(--apply-resize-dur) var(--apply-resize-ease); }
|
|
1319
|
+
.tl-apply-swap[data-state="applied"] .tl-apply-suffix { width: var(--sfx-ied, auto); }
|
|
1320
|
+
.tl-apply-seg { white-space: pre;
|
|
1321
|
+
transition: opacity 180ms cubic-bezier(0.22, 1, 0.36, 1),
|
|
1322
|
+
filter 180ms cubic-bezier(0.22, 1, 0.36, 1);
|
|
1323
|
+
will-change: opacity, filter; }
|
|
1324
|
+
.tl-apply-y { display: inline-block; }
|
|
1325
|
+
.tl-apply-ied { position: absolute; top: 0; left: 0; display: inline-block; }
|
|
1326
|
+
.tl-apply-swap .tl-apply-y { opacity: 1; filter: blur(0); }
|
|
1327
|
+
.tl-apply-swap .tl-apply-ied { opacity: 0; filter: blur(2px); }
|
|
1328
|
+
.tl-apply-swap[data-state="applied"] .tl-apply-y { opacity: 0; filter: blur(2px); }
|
|
1329
|
+
.tl-apply-swap[data-state="applied"] .tl-apply-ied { opacity: 1; filter: blur(0); }
|
|
1330
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1331
|
+
.tl-apply-suffix, .tl-apply-seg { transition: none !important; }
|
|
1332
|
+
}
|
|
1333
|
+
/* success check (transitions.dev 10-success-check): stroke-draw on the path with
|
|
1334
|
+
a plain opacity fade — IN PLACE, no rotate / Y-bob / scale, so the check never
|
|
1335
|
+
moves while it draws. It is the leading element, unclipped by the .tl-apply-check
|
|
1336
|
+
width tween that pushes the text. GRAY stroke. */
|
|
1337
|
+
.tl-sug-check { display: inline-flex; opacity: 0; will-change: opacity; }
|
|
1221
1338
|
.tl-sug-check svg { display: block; overflow: visible; }
|
|
1222
1339
|
/* stroke-dasharray = path.getTotalLength() of ICONS.accept "M4 8.4268 L6.46155
|
|
1223
1340
|
11.19223 L12 4.97001" (~12.03 user units, rounded up to 13). JS also sets it
|
|
1224
1341
|
inline per-render via getTotalLength() for sub-pixel safety. */
|
|
1225
1342
|
.tl-sug-check svg path { stroke-dasharray: 13; stroke-dashoffset: 13; }
|
|
1226
1343
|
.tl-sug-check[data-state="in"] {
|
|
1227
|
-
animation:
|
|
1228
|
-
tl-check-fade 500ms cubic-bezier(0.22, 1, 0.36, 1) forwards,
|
|
1229
|
-
tl-check-rotate 500ms cubic-bezier(0.22, 1, 0.36, 1) forwards,
|
|
1230
|
-
tl-check-blur 500ms cubic-bezier(0.22, 1, 0.36, 1) forwards,
|
|
1231
|
-
tl-check-bob 500ms cubic-bezier(0.34, 1.35, 0.64, 1) forwards; }
|
|
1344
|
+
animation: tl-check-fade 500ms cubic-bezier(0.22, 1, 0.36, 1) forwards; }
|
|
1232
1345
|
.tl-sug-check[data-state="in"] svg path {
|
|
1233
1346
|
animation: tl-check-draw 500ms cubic-bezier(0.22, 1, 0.36, 1) 80ms forwards; }
|
|
1234
1347
|
@keyframes tl-check-fade { from { opacity: 0; } to { opacity: 1; } }
|
|
1235
|
-
@keyframes tl-check-rotate { from { transform: rotate(80deg); } to { transform: rotate(0deg); } }
|
|
1236
|
-
@keyframes tl-check-blur { from { filter: blur(10px); } to { filter: blur(0); } }
|
|
1237
|
-
@keyframes tl-check-bob { from { translate: 0 40px; } to { translate: 0 0; } }
|
|
1238
1348
|
@keyframes tl-check-draw { to { stroke-dashoffset: 0; } }
|
|
1239
1349
|
/* results action bar (Figma node 581:6285): white sticky bar with top hairline
|
|
1240
1350
|
holding two equal-width pill buttons (Apply all + Scan again). */
|
|
@@ -1259,6 +1369,7 @@
|
|
|
1259
1369
|
.tl-refine-results .t-stagger-line { transition: none !important; }
|
|
1260
1370
|
.t-resize, .tl-scan-morph { transition: none !important; }
|
|
1261
1371
|
.tl-pill-btn.tl-apply-btn { transition: background 0.12s ease, scale 0.12s ease, opacity 0.12s ease !important; }
|
|
1372
|
+
.tl-apply-check { transition: none !important; }
|
|
1262
1373
|
.tl-sug-check { animation: none !important; opacity: 1; }
|
|
1263
1374
|
.tl-sug-check svg path { animation: none !important; stroke-dashoffset: 0 !important; }
|
|
1264
1375
|
.tl-scan-face { transition: none !important; }
|
|
@@ -1511,7 +1622,10 @@
|
|
|
1511
1622
|
_sched(){if(this.rafId!==null)return;this.rafId=requestAnimationFrame(()=>{this.rafId=null;if(this.running&&!this.paused)this.scan();});}
|
|
1512
1623
|
scan(){const seen=new Map();const w=document.createTreeWalker(this.root,NodeFilter.SHOW_ELEMENT);let n=w.currentNode;while(n){if(n instanceof HTMLElement)this._proc(n,seen);n=w.nextNode();}this.registry.replaceAll(Array.from(seen.values()));}
|
|
1513
1624
|
_proc(el,seen){
|
|
1514
|
-
|
|
1625
|
+
// Skip the Refine UI itself: the panel ([data-timeline-panel]) plus any of
|
|
1626
|
+
// our surfaces that portal OUT of it (dropdown menus, toasts) — those carry
|
|
1627
|
+
// [data-tl-ui]. Without this the scanner lists the tool's own transitions.
|
|
1628
|
+
if(el.closest("[data-timeline-panel],[data-tl-ui]"))return;
|
|
1515
1629
|
const s=getComputedStyle(el); const rp=s.transitionProperty;
|
|
1516
1630
|
if(!rp||rp==="none"||rp==="all")return;
|
|
1517
1631
|
const props=rp.split(",").map(p=>p.trim());
|
|
@@ -1536,6 +1650,59 @@
|
|
|
1536
1650
|
_sel(el){if(el.id)return"#"+el.id;const p=[];let c=el,d=0;while(c&&d<3){let s=c.tagName.toLowerCase();if(c.id){p.unshift("#"+c.id);break;}if(c.className&&typeof c.className==="string"){const cls=c.className.trim().split(/\s+/)[0];if(cls)s+="."+cls;}p.unshift(s);c=c.parentElement;d++;}return p.join(" > ");}
|
|
1537
1651
|
}
|
|
1538
1652
|
|
|
1653
|
+
// ── CSSOM hint harvest ──
|
|
1654
|
+
// The scan's slowest part isn't reasoning — it's the agent NOT knowing where
|
|
1655
|
+
// the source lives, so it globs the whole tree, greps, and wanders into wrong
|
|
1656
|
+
// files (this explodes on real/large repos). We sidestep that: for each
|
|
1657
|
+
// detected element we pull the CSS rules that drive it across ALL states
|
|
1658
|
+
// (base + open + close) straight from the live stylesheets via CSSOM, resolve
|
|
1659
|
+
// var() to concrete values, and hand them to the agent inline. With these the
|
|
1660
|
+
// agent groups + recovers the opposite phase from the payload alone and never
|
|
1661
|
+
// touches the filesystem. Falls back to source-reading when empty (CORS-locked
|
|
1662
|
+
// sheets, styled-components/Tailwind, etc.).
|
|
1663
|
+
const HINT_MAX_RULES = 16; // per element — base + a few state variants
|
|
1664
|
+
const HINT_MAX_RULE_LEN = 600; // chars per rule, trimmed
|
|
1665
|
+
function harvestCssHints(entry){
|
|
1666
|
+
try{
|
|
1667
|
+
const refs=(entry.bindings&&entry.bindings.elements)||[];
|
|
1668
|
+
let el=null;
|
|
1669
|
+
for(const r of refs){const e=r&&r.deref&&r.deref();if(e){el=e;break;}}
|
|
1670
|
+
if(!el)return [];
|
|
1671
|
+
// Own classes are the precise anchor: every state variant that targets
|
|
1672
|
+
// this element (`.dd.is-open .dd-panel`, `.dd[data-open] .dd-panel`) still
|
|
1673
|
+
// names the element's own leaf class, so matching on it scopes tightly to
|
|
1674
|
+
// this component without dragging in unrelated utility-class rules.
|
|
1675
|
+
const own=(typeof el.className==="string"?el.className.trim().split(/\s+/):Array.from(el.classList||[])).filter(Boolean);
|
|
1676
|
+
if(!own.length)return [];
|
|
1677
|
+
const esc=s=>s.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");
|
|
1678
|
+
const tokRes=own.map(c=>new RegExp("\\."+esc(c)+"(?![\\w-])"));
|
|
1679
|
+
const matchTok=sel=>tokRes.some(re=>re.test(sel));
|
|
1680
|
+
const cs=getComputedStyle(el);
|
|
1681
|
+
const resolveVars=txt=>txt.replace(/var\(\s*(--[\w-]+)\s*(?:,[^()]*)?\)/g,(m,v)=>{const r=cs.getPropertyValue(v).trim();return r||m;});
|
|
1682
|
+
const out=[];const seen=new Set();
|
|
1683
|
+
const collect=list=>{
|
|
1684
|
+
for(const rule of Array.from(list||[])){
|
|
1685
|
+
if(out.length>=HINT_MAX_RULES)return;
|
|
1686
|
+
// @media / @supports etc. — recurse into nested rules.
|
|
1687
|
+
if(rule.cssRules&&rule.selectorText===undefined){collect(rule.cssRules);continue;}
|
|
1688
|
+
const sel=rule.selectorText;
|
|
1689
|
+
if(!sel||!matchTok(sel))continue;
|
|
1690
|
+
let txt=rule.cssText;if(!txt)continue;
|
|
1691
|
+
txt=resolveVars(txt).replace(/\s+/g," ").trim();
|
|
1692
|
+
if(txt.length>HINT_MAX_RULE_LEN)txt=txt.slice(0,HINT_MAX_RULE_LEN)+" …}";
|
|
1693
|
+
if(seen.has(txt))continue;seen.add(txt);
|
|
1694
|
+
out.push(txt);
|
|
1695
|
+
}
|
|
1696
|
+
};
|
|
1697
|
+
for(const sheet of Array.from(document.styleSheets||[])){
|
|
1698
|
+
if(out.length>=HINT_MAX_RULES)break;
|
|
1699
|
+
let rules;try{rules=sheet.cssRules;}catch{continue;} // cross-origin → skip
|
|
1700
|
+
collect(rules);
|
|
1701
|
+
}
|
|
1702
|
+
return out;
|
|
1703
|
+
}catch{return [];}
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1539
1706
|
// ── hooks ──
|
|
1540
1707
|
const TimelineCtx = createContext(null);
|
|
1541
1708
|
function useReg(){const{registry}=useContext(TimelineCtx);return useSyncExternalStore(useCallback(cb=>registry.subscribe(cb),[registry]),useCallback(()=>registry.getAll(),[registry]),useCallback(()=>registry.getAll(),[registry]));}
|
|
@@ -1676,7 +1843,7 @@
|
|
|
1676
1843
|
const origin=pos?pos.origin:"top-left";
|
|
1677
1844
|
const cls=closing?"is-closing":shown?"is-open":"";
|
|
1678
1845
|
return createPortal(
|
|
1679
|
-
h("div",{ref,className:cx("t-dropdown","tl-menu",cls),"data-origin":origin,style},children),
|
|
1846
|
+
h("div",{ref,className:cx("t-dropdown","tl-menu",cls),"data-origin":origin,"data-tl-ui":"",style},children),
|
|
1680
1847
|
target);
|
|
1681
1848
|
}
|
|
1682
1849
|
|
|
@@ -1687,6 +1854,32 @@
|
|
|
1687
1854
|
right);
|
|
1688
1855
|
}
|
|
1689
1856
|
|
|
1857
|
+
// Settings dropdown body that pages between the main menu and the keyboard-
|
|
1858
|
+
// shortcuts cheat-sheet (transitions.dev · 08-page-side-by-side). Both pages
|
|
1859
|
+
// stay mounted so they cross-slide; the container height follows the active
|
|
1860
|
+
// page so the one dropdown surface resizes smoothly between pages. Lives
|
|
1861
|
+
// inside the Dropdown portal, so its layout effect fires when the surface
|
|
1862
|
+
// mounts (the parent Header doesn't re-render on the portal's own mount).
|
|
1863
|
+
function SettingsSlide({view,mainPage,shortcutsPage,onBack,onClose}){
|
|
1864
|
+
const slideRef=useRef(null), p1=useRef(null), p2=useRef(null);
|
|
1865
|
+
useLayoutEffect(()=>{
|
|
1866
|
+
const slide=slideRef.current; if(!slide) return;
|
|
1867
|
+
const active=view==="shortcuts"?p2.current:p1.current;
|
|
1868
|
+
if(active) slide.style.height=active.offsetHeight+"px";
|
|
1869
|
+
});
|
|
1870
|
+
// Escape steps back from the sub-page first, then closes the dropdown —
|
|
1871
|
+
// mirrors the picker's nested-dismiss behaviour.
|
|
1872
|
+
useEffect(()=>{
|
|
1873
|
+
const onKey=e=>{ if(e.key!=="Escape")return;
|
|
1874
|
+
if(view==="shortcuts"){onBack&&onBack();} else {onClose&&onClose();} };
|
|
1875
|
+
document.addEventListener("keydown",onKey);
|
|
1876
|
+
return()=>document.removeEventListener("keydown",onKey);
|
|
1877
|
+
},[view,onBack,onClose]);
|
|
1878
|
+
return h("div",{ref:slideRef,className:"tl-set-slide t-page-slide","data-page":view==="shortcuts"?"2":"1"},
|
|
1879
|
+
h("div",{ref:p1,className:"tl-set-page t-page","data-page-id":"1","aria-hidden":view==="shortcuts"},mainPage),
|
|
1880
|
+
h("div",{ref:p2,className:"tl-set-page t-page","data-page-id":"2","aria-hidden":view!=="shortcuts"},shortcutsPage));
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1690
1883
|
// ── refine relay client ──
|
|
1691
1884
|
// Talks to the local relay (server/relay.mjs). The browser posts a job and
|
|
1692
1885
|
// polls; the relay answers each job with one agent run (one-shot).
|
|
@@ -1753,51 +1946,71 @@
|
|
|
1753
1946
|
const names=[...new Set(matches.map(t=>t.member))];
|
|
1754
1947
|
return names.length===1?names[0]:matches[0].member;
|
|
1755
1948
|
}
|
|
1756
|
-
// Apply button —
|
|
1757
|
-
//
|
|
1758
|
-
//
|
|
1759
|
-
//
|
|
1949
|
+
// Apply button — "Apply" ⟷ "✓ Applied". On apply, a LEADING gray check is
|
|
1950
|
+
// revealed first and pushes the text to the right: .tl-apply-check tweens its
|
|
1951
|
+
// width 0 -> (icon + 6px gap) using the SAME resize token as the suffix slot
|
|
1952
|
+
// (--apply-resize-*, smooth ease out), so the check expansion + the "y" -> "ied"
|
|
1953
|
+
// suffix resize grow the button in one synchronized motion. The stem "Appl"
|
|
1954
|
+
// stays put and "y" ⟷ "ied" cross-blur (transitions.dev 04). The check itself
|
|
1955
|
+
// stroke-draws IN PLACE (transitions.dev 10, no rotate/bob/scale/translate) —
|
|
1956
|
+
// it is pinned to the slot's left edge and the overflow clip reveals it, so it
|
|
1957
|
+
// never translates. The same <button> node is reused across states.
|
|
1760
1958
|
function ApplyBtn({applied,onApply}){
|
|
1761
1959
|
const ref=useRef(null);
|
|
1762
|
-
const lastW=useRef(0); // width captured while showing "Apply"
|
|
1763
1960
|
const prev=useRef(applied);
|
|
1961
|
+
// measure both suffixes once (after fonts/layout) and pin the two slot widths
|
|
1962
|
+
// so the suffix can tween between "y" and the checked "ied".
|
|
1963
|
+
useLayoutEffect(()=>{
|
|
1964
|
+
const btn=ref.current;if(!btn)return;
|
|
1965
|
+
const sw=btn.querySelector(".tl-apply-swap"); if(!sw) return;
|
|
1966
|
+
const CHK_GAP=6; // space between the leading check and "Applied"
|
|
1967
|
+
const measure=()=>{
|
|
1968
|
+
const y=sw.querySelector(".tl-apply-y"), ied=sw.querySelector(".tl-apply-ied");
|
|
1969
|
+
if(y) sw.style.setProperty("--sfx-y", Math.ceil(y.getBoundingClientRect().width)+"px");
|
|
1970
|
+
if(ied) sw.style.setProperty("--sfx-ied", Math.ceil(ied.getBoundingClientRect().width)+"px");
|
|
1971
|
+
// leading check slot target width = icon natural width + gap; it stays
|
|
1972
|
+
// measurable even while clipped (the inner .tl-sug-check keeps its size).
|
|
1973
|
+
const chk=sw.querySelector(".tl-sug-check");
|
|
1974
|
+
if(chk) sw.style.setProperty("--chk-w", (Math.ceil(chk.getBoundingClientRect().width)+CHK_GAP)+"px");
|
|
1975
|
+
};
|
|
1976
|
+
measure();
|
|
1977
|
+
if(document.fonts&&document.fonts.ready) document.fonts.ready.then(measure).catch(()=>{});
|
|
1978
|
+
},[]);
|
|
1979
|
+
// drive the stroke-draw: set exact dasharray, and play on the flip to applied.
|
|
1980
|
+
// already-applied items mount fully drawn (data-state stays "in", no replay).
|
|
1764
1981
|
useLayoutEffect(()=>{
|
|
1765
1982
|
const btn=ref.current;if(!btn)return;
|
|
1983
|
+
const chk=btn.querySelector(".tl-sug-check");
|
|
1766
1984
|
const justApplied=applied&&!prev.current;
|
|
1767
|
-
if(
|
|
1768
|
-
const chk
|
|
1769
|
-
if(
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
path.style.strokeDasharray=len; path.style.strokeDashoffset=len; }
|
|
1985
|
+
if(chk){
|
|
1986
|
+
const path=chk.querySelector("svg path");
|
|
1987
|
+
if(path){ const len=Math.ceil(path.getTotalLength()); // exact path length
|
|
1988
|
+
path.style.strokeDasharray=len; path.style.strokeDashoffset=len; }
|
|
1989
|
+
if(applied){
|
|
1773
1990
|
if(justApplied){
|
|
1774
|
-
//
|
|
1775
|
-
const to=btn.getBoundingClientRect().width, from=lastW.current;
|
|
1776
|
-
if(from&&to&&Math.abs(from-to)>0.5){
|
|
1777
|
-
btn.style.width=from+"px";
|
|
1778
|
-
void btn.offsetWidth; // reflow so the tween starts from `from`
|
|
1779
|
-
btn.style.width=to+"px";
|
|
1780
|
-
const onEnd=(e)=>{ if(e.target===btn&&e.propertyName==="width"){
|
|
1781
|
-
btn.style.width=""; btn.removeEventListener("transitionend",onEnd); } };
|
|
1782
|
-
btn.addEventListener("transitionend",onEnd);
|
|
1783
|
-
}
|
|
1784
|
-
// success-check replay: reset, force reflow, play from offset 0
|
|
1991
|
+
// replay from offset 0: reset, force reflow, play
|
|
1785
1992
|
chk.setAttribute("data-state","out");
|
|
1786
1993
|
void chk.offsetWidth;
|
|
1787
1994
|
chk.setAttribute("data-state","in");
|
|
1788
1995
|
} else { chk.setAttribute("data-state","in"); }
|
|
1789
|
-
}
|
|
1790
|
-
}
|
|
1996
|
+
} else { chk.setAttribute("data-state","out"); }
|
|
1997
|
+
}
|
|
1791
1998
|
prev.current=applied;
|
|
1792
1999
|
});
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
2000
|
+
return h("button",{ref,
|
|
2001
|
+
className:cx("tl-pill-btn tl-apply-btn",applied&&"is-applied"),
|
|
2002
|
+
disabled:applied,onClick:applied?undefined:onApply},
|
|
2003
|
+
h("span",{className:"tl-apply-swap","data-state":applied?"applied":"apply"},
|
|
2004
|
+
h("span",{className:"tl-apply-check"},
|
|
2005
|
+
h("span",{className:"tl-sug-check","data-state":"out","aria-hidden":"true"},
|
|
2006
|
+
h("svg",{width:13,height:13,viewBox:"0 0 16 16",fill:"none",
|
|
2007
|
+
xmlns:"http://www.w3.org/2000/svg",style:{display:"block"}},
|
|
2008
|
+
h("path",{d:"M4 8.4268L6.46155 11.19223L12 4.97001",stroke:"currentColor",
|
|
2009
|
+
strokeWidth:1.5,strokeLinecap:"round",strokeLinejoin:"round"})))),
|
|
2010
|
+
h("span",{className:"tl-apply-stem"},"Appl"),
|
|
2011
|
+
h("span",{className:"tl-apply-suffix"},
|
|
2012
|
+
h("span",{className:"tl-apply-seg tl-apply-y"},"y"),
|
|
2013
|
+
h("span",{className:"tl-apply-seg tl-apply-ied"},"ied"))));
|
|
1801
2014
|
}
|
|
1802
2015
|
function RefineResults({suggestions,appliedIds,onApply,lanes}){
|
|
1803
2016
|
const ref=useRef(null);
|
|
@@ -1840,7 +2053,7 @@
|
|
|
1840
2053
|
: h(React.Fragment,null,
|
|
1841
2054
|
s.title&&h("div",{className:"tl-sug-title"},s.title),
|
|
1842
2055
|
(s.from||s.to)&&h("div",{className:"tl-sug-delta"},
|
|
1843
|
-
|
|
2056
|
+
s.from&&h("span",{className:"tl-sug-from"},s.from),
|
|
1844
2057
|
s.to&&h("span",{className:"tl-sug-to"},s.to))),
|
|
1845
2058
|
s.reason&&h("div",{className:"tl-sug-reason"},s.reason)),
|
|
1846
2059
|
applyBtn));
|
|
@@ -2013,9 +2226,24 @@
|
|
|
2013
2226
|
return out;
|
|
2014
2227
|
}
|
|
2015
2228
|
|
|
2016
|
-
function Header({entries,active,onSelect,onReset,onCopy,copied,snap,setSnap,onMinimize,onRefine,refineActive,onAccept,acceptState,acceptDisabled,acceptError,scanning,onRescan}){
|
|
2017
|
-
const[pick,setPick]=useState(false);
|
|
2229
|
+
function Header({entries,active,onSelect,onReset,onCopy,copied,snap,setSnap,onMinimize,onRefine,refineActive,onAccept,acceptState,acceptDisabled,acceptError,scanning,onRescan,pick,setPick}){
|
|
2018
2230
|
const[setg,setSetg]=useState(false);
|
|
2231
|
+
// settings dropdown sub-page (main menu ↔ keyboard-shortcuts cheat sheet)
|
|
2232
|
+
const[settingsView,setSettingsView]=useState("main"); // "main" | "shortcuts"
|
|
2233
|
+
const isMac=typeof navigator!=="undefined"&&/mac/i.test((navigator.userAgentData&&navigator.userAgentData.platform)||navigator.platform||navigator.userAgent||"");
|
|
2234
|
+
const modKey=isMac?"Cmd":"Ctrl";
|
|
2235
|
+
// small gray shortcut hint appended inside a tooltip (e.g. "⌘A")
|
|
2236
|
+
const ttHint=(k)=>h("span",{className:"tl-tt-kbd"},(isMac?"\u2318":"Ctrl ")+k);
|
|
2237
|
+
// ⌫ = Backspace, ↑/↓ = arrows; letters/Enter shown verbatim
|
|
2238
|
+
const SHORTCUTS=[
|
|
2239
|
+
{label:"Accept changes",k:"Enter"},
|
|
2240
|
+
{label:"Reset values",k:"\u232B"},
|
|
2241
|
+
{label:"Copy values",k:"C"},
|
|
2242
|
+
{label:"Toggle Refine panel",k:"E"},
|
|
2243
|
+
{label:"Open transition picker",k:"K"},
|
|
2244
|
+
{label:"Previous transition",k:"\u2191"},
|
|
2245
|
+
{label:"Next transition",k:"\u2193"},
|
|
2246
|
+
];
|
|
2019
2247
|
const pickRef=useRef(null), gearRef=useRef(null);
|
|
2020
2248
|
const[q,setQ]=useState("");
|
|
2021
2249
|
useEffect(()=>{if(!pick)setQ("");},[pick]); // reset search when the picker closes
|
|
@@ -2037,10 +2265,14 @@
|
|
|
2037
2265
|
const noMatches=!!ql&&fGroups.length===0&&fFlat.length===0;
|
|
2038
2266
|
const phaseItem=(e)=>h(MenuItem,{key:e.id,active:active&&e.id===active.id,onClick:()=>{onSelect(e.id);setPick(false);},
|
|
2039
2267
|
right:active&&e.id===active.id&&h("span",{className:"tl-menu-check"},h(Ic,{name:"accept"}))},
|
|
2040
|
-
h("span",
|
|
2268
|
+
h("span",{className:"tl-menu-stack"},
|
|
2269
|
+
h("span",{className:"tl-menu-text"},e.phaseLabel),
|
|
2270
|
+
h("span",{className:"tl-menu-dim tl-menu-sub"},e.durationMs+"ms")));
|
|
2041
2271
|
const flatItem=(e)=>h(MenuItem,{key:e.id,active:active&&e.id===active.id,onClick:()=>{onSelect(e.id);setPick(false);},
|
|
2042
2272
|
right:active&&e.id===active.id&&h("span",{className:"tl-menu-check"},h(Ic,{name:"accept"}))},
|
|
2043
|
-
h("span",
|
|
2273
|
+
h("span",{className:"tl-menu-stack"},
|
|
2274
|
+
h("span",{className:"tl-menu-text"},e.label),
|
|
2275
|
+
h("span",{className:"tl-menu-dim tl-menu-sub"},e.durationMs+"ms")));
|
|
2044
2276
|
return h("div",{className:"tl-header"},
|
|
2045
2277
|
h("span",{className:"tl-header-label"},"Selected"),
|
|
2046
2278
|
h("button",{ref:pickRef,className:cx("tl-ghost-btn",pick&&"is-active"),disabled:!active,onClick:()=>setPick(v=>!v)},
|
|
@@ -2064,26 +2296,51 @@
|
|
|
2064
2296
|
...fFlat.map(flatItem))),
|
|
2065
2297
|
h("span",{className:"tl-header-count"},
|
|
2066
2298
|
scanning
|
|
2067
|
-
? h("span",{className:"
|
|
2299
|
+
? h("span",{className:"t-tt-wrap"},
|
|
2300
|
+
h("span",{className:"tl-scan-note",tabIndex:0},
|
|
2301
|
+
h(DotmLoader),
|
|
2302
|
+
h("span",{className:"t-shimmer tl-scan-shimmer","data-text":"Agent scanning transitions…"},"Agent scanning transitions…")),
|
|
2303
|
+
h("span",{className:"t-tt tl-tt-below tl-tt-msg",role:"tooltip"},
|
|
2304
|
+
"Agent is working on understanding your transitions and creating grouping and naming. This usually takes a few seconds to a minute."))
|
|
2068
2305
|
: entries.length+" transition"+(entries.length===1?"":"s")+" found"),
|
|
2069
2306
|
h("button",{ref:gearRef,className:cx("tl-icon-btn",setg&&"is-active"),title:"Settings",onClick:()=>setSetg(v=>!v)},h(Ic,{name:"dotsv"})),
|
|
2070
|
-
h(Dropdown,{open:setg,onClose:()=>setSetg(false),triggerRef:gearRef,width:
|
|
2071
|
-
h(
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2307
|
+
h(Dropdown,{open:setg,onClose:()=>{setSetg(false);setSettingsView("main");},triggerRef:gearRef,width:240,align:"right"},
|
|
2308
|
+
h(SettingsSlide,{view:settingsView,
|
|
2309
|
+
onBack:()=>setSettingsView("main"),
|
|
2310
|
+
onClose:()=>{setSetg(false);setSettingsView("main");},
|
|
2311
|
+
// ── main settings page ──
|
|
2312
|
+
mainPage:h(React.Fragment,null,
|
|
2313
|
+
h("div",{className:"tl-menu-head"},
|
|
2314
|
+
h("span",null,"Transitions.dev ",h("span",{className:"tl-menu-head-name"},"Refine")),
|
|
2315
|
+
h("span",{className:"tl-menu-head-ver"},REFINE_VERSION)),
|
|
2316
|
+
h("div",{className:"tl-menu-divider"}),
|
|
2317
|
+
h(MenuItem,{disabled:!active,onClick:()=>{onCopy&&onCopy();setSetg(false);},
|
|
2318
|
+
left:h("span",{className:"tl-menu-icon"},h(Ic,{name:"copy"})),
|
|
2319
|
+
right:copied&&h("span",{className:"tl-menu-check"},h(Ic,{name:"accept"}))},copied?"Copied":"Copy values"),
|
|
2320
|
+
h(MenuItem,{disabled:scanning,onClick:()=>{setSetg(false);onRescan&&onRescan();},
|
|
2321
|
+
left:h("span",{className:"tl-menu-icon"},h(Ic,{name:"restart"}))},
|
|
2322
|
+
scanning?"Rescanning…":"Rescan transitions"),
|
|
2323
|
+
h(MenuItem,{onClick:()=>setSettingsView("shortcuts"),
|
|
2324
|
+
right:h("span",{className:"tl-menu-chevr"},h(Ic,{name:"chevronr",size:16}))},"Keyboard shortcuts"),
|
|
2325
|
+
h("div",{className:"tl-menu-divider"}),
|
|
2326
|
+
h(MenuItem,{onClick:()=>{setSetg(false);window.open("https://transitions.dev","_blank","noopener");},
|
|
2327
|
+
right:h("span",{className:"tl-menu-chevr"},h(Ic,{name:"chevronr",size:16}))},"Learn more")),
|
|
2328
|
+
// ── keyboard-shortcuts sub-page ──
|
|
2329
|
+
shortcutsPage:h(React.Fragment,null,
|
|
2330
|
+
h("button",{type:"button",className:"tl-set-back",onClick:()=>setSettingsView("main")},
|
|
2331
|
+
h("span",{className:"tl-set-back-ic"},h(Ic,{name:"chevronr",size:16})),
|
|
2332
|
+
h("span",{className:"tl-set-back-title"},"Keyboard shortcuts")),
|
|
2333
|
+
h("div",{className:"tl-menu-divider"}),
|
|
2334
|
+
...SHORTCUTS.map((s,i)=>h("div",{key:i,className:"tl-kbd-row"},
|
|
2335
|
+
h("span",{className:"tl-kbd-row-label"},s.label),
|
|
2336
|
+
h("span",{className:"tl-kbd-cluster"},
|
|
2337
|
+
h("kbd",{className:"tl-kbd"},modKey),
|
|
2338
|
+
h("span",{className:"tl-kbd-plus"},"+"),
|
|
2339
|
+
h("kbd",{className:"tl-kbd"},s.k)))))}),
|
|
2340
|
+
),
|
|
2084
2341
|
h("span",{className:"t-tt-wrap"},
|
|
2085
2342
|
h("button",{className:"tl-sec-btn t-tt-trigger",disabled:!active,onClick:onReset},"Reset"),
|
|
2086
|
-
h("span",{className:"t-tt tl-tt-below",role:"tooltip"},"Reset values")),
|
|
2343
|
+
h("span",{className:"t-tt tl-tt-below",role:"tooltip"},"Reset values",ttHint("\u232B"))),
|
|
2087
2344
|
h("span",{className:"t-tt-wrap"},
|
|
2088
2345
|
h("button",{className:cx("tl-accept-btn",acceptState==="saving"&&"is-saving",acceptState==="done"&&"is-done"),
|
|
2089
2346
|
disabled:!active||acceptDisabled||acceptState==="saving"||acceptState==="done",onClick:onAccept,"aria-label":"Accept changes to your code"},
|
|
@@ -2095,8 +2352,9 @@
|
|
|
2095
2352
|
acceptState==="error"&&acceptError?acceptError
|
|
2096
2353
|
:acceptState==="done"?"Saved to your code"
|
|
2097
2354
|
:acceptDisabled?"No changes to save"
|
|
2098
|
-
:"Save changes to your codebase")),
|
|
2099
|
-
h("
|
|
2355
|
+
:h(React.Fragment,null,"Save changes to your codebase",ttHint("A")))),
|
|
2356
|
+
h("span",{className:"t-tt-wrap"},
|
|
2357
|
+
h("button",{className:cx("tl-refine-btn t-tt-trigger",refineActive&&"is-active"),disabled:!active,onClick:onRefine},
|
|
2100
2358
|
h(Ic,{name:"wand"}),
|
|
2101
2359
|
h("span",{className:"tl-refine-sparks","aria-hidden":"true"},
|
|
2102
2360
|
h("i",{style:{"--ox":"-1px","--oy":"-3px","--sx":"0px","--sy":"-11px","--sd":"900ms","--sdelay":"0ms"}}),
|
|
@@ -2110,7 +2368,10 @@
|
|
|
2110
2368
|
h("i",{style:{"--ox":"-5px","--oy":"-2px","--sx":"-9px","--sy":"-9px","--sd":"930ms","--sdelay":"180ms"}}),
|
|
2111
2369
|
h("i",{style:{"--ox":"3px","--oy":"1px","--sx":"12px","--sy":"1px","--sd":"980ms","--sdelay":"300ms"}})),
|
|
2112
2370
|
h("span",null,"Refine")),
|
|
2113
|
-
|
|
2371
|
+
h("span",{className:"t-tt tl-tt-below",role:"tooltip"},"Refine",ttHint("R"))),
|
|
2372
|
+
h("span",{className:"t-tt-wrap"},
|
|
2373
|
+
h("button",{className:"tl-icon-btn ghost t-tt-trigger","aria-label":"Minimize",onClick:onMinimize},h(Ic,{name:"minimize"})),
|
|
2374
|
+
h("span",{className:"t-tt tl-tt-below",role:"tooltip"},"Minimize",ttHint("."))),
|
|
2114
2375
|
);
|
|
2115
2376
|
}
|
|
2116
2377
|
|
|
@@ -2227,12 +2488,12 @@
|
|
|
2227
2488
|
}
|
|
2228
2489
|
|
|
2229
2490
|
// shared plot geometry for both the easing curve and the spring curve.
|
|
2230
|
-
//
|
|
2231
|
-
//
|
|
2232
|
-
//
|
|
2233
|
-
//
|
|
2234
|
-
//
|
|
2235
|
-
const CURVE = { VBW:
|
|
2491
|
+
// Square plot with equal padding on all sides (standard cubic-bezier graph
|
|
2492
|
+
// ratio, easing.dev): the 0→1 region is centered with identical top/bottom
|
|
2493
|
+
// (and left/right) margins. The box no longer reserves room for overshoot —
|
|
2494
|
+
// overshoot/undershoot handles overflow the box freely (the curve container
|
|
2495
|
+
// is overflow:visible) so dragging is never clamped to the rectangle.
|
|
2496
|
+
const CURVE = { VBW:240, VBH:240, PAD_X:24, PAD_Y:24 };
|
|
2236
2497
|
|
|
2237
2498
|
// Unified "Preview" section (Figma node 580:11158) — collapsible, collapsed by
|
|
2238
2499
|
// default (transitions.dev · 21-accordion). Holds BOTH the timing curve card
|
|
@@ -2243,6 +2504,7 @@
|
|
|
2243
2504
|
const trackRef = useRef(null);
|
|
2244
2505
|
const dotRef = useRef(null);
|
|
2245
2506
|
const svgRef = useRef(null);
|
|
2507
|
+
const playSwapRef = useRef(null);
|
|
2246
2508
|
const [playing, setPlaying] = useState(false);
|
|
2247
2509
|
const [open, setOpen] = useState(false);
|
|
2248
2510
|
const safeEasing = (!easing || easing.trim()==="") ? "linear" : easing;
|
|
@@ -2316,6 +2578,20 @@
|
|
|
2316
2578
|
return ()=>{ cancelled = true; if(anim){try{anim.cancel();}catch{}} if(timer)clearTimeout(timer); };
|
|
2317
2579
|
},[playing, open, safeEasing, dur]);
|
|
2318
2580
|
|
|
2581
|
+
// measure both labels once the panel is open (fonts/layout ready) and
|
|
2582
|
+
// pin the two widths so the slot can tween between them on toggle.
|
|
2583
|
+
useEffect(()=>{
|
|
2584
|
+
if(!open) return;
|
|
2585
|
+
const sw = playSwapRef.current; if(!sw) return;
|
|
2586
|
+
const measure = ()=>{
|
|
2587
|
+
const a = sw.querySelector(".tl-play-a"), b = sw.querySelector(".tl-play-b");
|
|
2588
|
+
if(a) sw.style.setProperty("--pw-a", Math.ceil(a.getBoundingClientRect().width)+"px");
|
|
2589
|
+
if(b) sw.style.setProperty("--pw-b", Math.ceil(b.getBoundingClientRect().width)+"px");
|
|
2590
|
+
};
|
|
2591
|
+
measure();
|
|
2592
|
+
if(document.fonts&&document.fonts.ready) document.fonts.ready.then(measure).catch(()=>{});
|
|
2593
|
+
},[open]);
|
|
2594
|
+
|
|
2319
2595
|
const p1x = fx(cb[0]), p1y = fy(cb[1]), p2x = fx(cb[2]), p2y = fy(cb[3]);
|
|
2320
2596
|
|
|
2321
2597
|
return h("div",{className:"tl-preview","data-open":open?"true":"false"},
|
|
@@ -2346,7 +2622,10 @@
|
|
|
2346
2622
|
),
|
|
2347
2623
|
h("div",{className:"tl-preview-sub"},
|
|
2348
2624
|
h("span",{className:"tl-preview-sub-label"},"Position preview"),
|
|
2349
|
-
h("button",{className:"tl-preview-play",onClick:()=>setPlaying(p=>!p)},
|
|
2625
|
+
h("button",{className:"tl-preview-play",onClick:()=>setPlaying(p=>!p)},
|
|
2626
|
+
h("span",{ref:playSwapRef,className:"tl-play-swap","data-state":playing?"pause":"play"},
|
|
2627
|
+
h("span",{className:"tl-play-label tl-play-a"},"Play"),
|
|
2628
|
+
h("span",{className:"tl-play-label tl-play-b"},"Pause"))),
|
|
2350
2629
|
),
|
|
2351
2630
|
h("div",{className:"tl-preview-track"},
|
|
2352
2631
|
h("div",{className:"tl-preview-rail-wrap",ref:trackRef},
|
|
@@ -2717,6 +2996,9 @@
|
|
|
2717
2996
|
const[panelHeight,setPanelHeight]=useState(440);
|
|
2718
2997
|
const[resizing,setResizing]=useState(false);
|
|
2719
2998
|
const[snap,setSnap]=useState(true);
|
|
2999
|
+
// transition picker open state — lifted out of Header so the panel-focused
|
|
3000
|
+
// Cmd/Ctrl+K shortcut can open it.
|
|
3001
|
+
const[pickOpen,setPickOpen]=useState(false);
|
|
2720
3002
|
// ── refine ──
|
|
2721
3003
|
const[refineOpen,setRefineOpen]=useState(false);
|
|
2722
3004
|
const[refinePhase,setRefinePhase]=useState("idle"); // idle | scanning | done | error
|
|
@@ -2725,14 +3007,15 @@
|
|
|
2725
3007
|
const[refineSuggestions,setRefineSuggestions]=useState([]);
|
|
2726
3008
|
const[refineSummary,setRefineSummary]=useState(null);
|
|
2727
3009
|
const[refineError,setRefineError]=useState(null);
|
|
3010
|
+
// deterministic scans answer almost instantly, so enforce a 2s floor on the
|
|
3011
|
+
// "scanning" phase for that mode only (LLM scans stay immediate).
|
|
3012
|
+
const scanStartedAtRef=useRef(0);
|
|
3013
|
+
const scanModeRef=useRef("llm");
|
|
2728
3014
|
// ── accept (write to source) ──
|
|
2729
3015
|
const[acceptState,setAcceptState]=useState("idle"); // idle | saving | done | error
|
|
2730
3016
|
const[acceptError,setAcceptError]=useState(null);
|
|
2731
3017
|
// ── grouped scan (agent reads source → Open/Close phases) ──
|
|
2732
3018
|
const[groupScanState,setGroupScanState]=useState("idle"); // idle | scanning | done | error
|
|
2733
|
-
// Escape hatch for the gate: if a scan errors/times out the user can choose
|
|
2734
|
-
// to proceed with the flat (ungrouped) list instead of being trapped.
|
|
2735
|
-
const[skipGrouping,setSkipGrouping]=useState(false);
|
|
2736
3019
|
const didGroupScanRef=useRef(false);
|
|
2737
3020
|
const[refineLabel,setRefineLabel]=useState(null);
|
|
2738
3021
|
const[appliedIds,setAppliedIds]=useState({});
|
|
@@ -2770,12 +3053,14 @@
|
|
|
2770
3053
|
// "Start scanning" resolves availability first, then posts the job.
|
|
2771
3054
|
const startScan=useCallback(async()=>{
|
|
2772
3055
|
if(!active)return;
|
|
3056
|
+
scanStartedAtRef.current=Date.now();
|
|
2773
3057
|
setRefinePhase("scanning");setRefineLog([]);
|
|
2774
3058
|
setRefineSuggestions([]);setRefineSummary(null);setRefineError(null);
|
|
2775
3059
|
setAppliedIds({});setRefineLabel(active.label);setRefineJobId(null);
|
|
2776
3060
|
const j=await refreshHealth();
|
|
2777
3061
|
const avail=j?!!j.llmAvailable:false;
|
|
2778
3062
|
const mode=(refineMode==="llm"&&!avail)?"deterministic":refineMode;
|
|
3063
|
+
scanModeRef.current=mode;
|
|
2779
3064
|
if(mode!==refineMode)setRefineMode(mode);
|
|
2780
3065
|
try{
|
|
2781
3066
|
const et=active.effectiveTimings||[];
|
|
@@ -2804,7 +3089,14 @@
|
|
|
2804
3089
|
const job=await relayGetJob(refineJobId);
|
|
2805
3090
|
if(!live)return;
|
|
2806
3091
|
if(Array.isArray(job.statusLog))setRefineLog(job.statusLog);
|
|
2807
|
-
if(job.status==="done"){
|
|
3092
|
+
if(job.status==="done"){
|
|
3093
|
+
const finish=()=>{if(!live)return;setRefineSuggestions((job.result&&job.result.suggestions)||[]);setRefineSummary(job.result&&job.result.summary);setRefinePhase("done");};
|
|
3094
|
+
// deterministic mode floors the scanning phase at 2s so it doesn't flash by.
|
|
3095
|
+
const minMs=scanModeRef.current==="deterministic"?2000:0;
|
|
3096
|
+
const wait=Math.max(0,minMs-(Date.now()-scanStartedAtRef.current));
|
|
3097
|
+
if(wait>0){to=setTimeout(finish,wait);}else{finish();}
|
|
3098
|
+
return;
|
|
3099
|
+
}
|
|
2808
3100
|
if(job.status==="error"){setRefineError(job.error||"The agent reported an error.");setRefinePhase("error");return;}
|
|
2809
3101
|
to=setTimeout(tick,500);
|
|
2810
3102
|
}catch(e){if(live){setRefineError("Lost connection to the relay.");setRefinePhase("error");}}
|
|
@@ -2843,7 +3135,7 @@
|
|
|
2843
3135
|
if(typeof document==="undefined")return;
|
|
2844
3136
|
const applied=liveAppliedRef.current;
|
|
2845
3137
|
const desired=new Map(); // el → css
|
|
2846
|
-
const add=(els,css)=>{ for(const el of els){ if(!el||(el.closest&&el.closest("[data-timeline-panel]")))continue; desired.set(el,css); } };
|
|
3138
|
+
const add=(els,css)=>{ for(const el of els){ if(!el||(el.closest&&el.closest("[data-timeline-panel],[data-tl-ui]")))continue; desired.set(el,css); } };
|
|
2847
3139
|
for(const item of entries){
|
|
2848
3140
|
const ov=registry.getPropOverrides(item.id);
|
|
2849
3141
|
if(!ov||!Object.keys(ov).length)continue; // only edited transitions go live
|
|
@@ -2911,8 +3203,29 @@
|
|
|
2911
3203
|
}else{
|
|
2912
3204
|
text="transition: "+et.map(decl).join(",\n ")+";";
|
|
2913
3205
|
}
|
|
2914
|
-
|
|
2915
|
-
showToast("
|
|
3206
|
+
const onCopied=()=>{setCopied(true);setTimeout(()=>setCopied(false),1500);showToast("Values copied");};
|
|
3207
|
+
const onCopyFail=()=>{showToast("Couldn't copy");};
|
|
3208
|
+
// navigator.clipboard needs a secure context (https/localhost); when the
|
|
3209
|
+
// host page is plain http — or Safari blocks the async API — fall back to
|
|
3210
|
+
// a hidden-textarea execCommand("copy") so copy still works everywhere.
|
|
3211
|
+
const legacyCopy=()=>{
|
|
3212
|
+
try{
|
|
3213
|
+
const ta=document.createElement("textarea");
|
|
3214
|
+
ta.value=text; ta.setAttribute("readonly","");
|
|
3215
|
+
ta.style.position="fixed"; ta.style.top="-9999px"; ta.style.opacity="0";
|
|
3216
|
+
document.body.appendChild(ta);
|
|
3217
|
+
ta.focus(); ta.select();
|
|
3218
|
+
try{ta.setSelectionRange(0,text.length);}catch(e){}
|
|
3219
|
+
const ok=document.execCommand("copy");
|
|
3220
|
+
document.body.removeChild(ta);
|
|
3221
|
+
ok?onCopied():onCopyFail();
|
|
3222
|
+
}catch(e){onCopyFail();}
|
|
3223
|
+
};
|
|
3224
|
+
try{
|
|
3225
|
+
if(navigator.clipboard&&navigator.clipboard.writeText){
|
|
3226
|
+
navigator.clipboard.writeText(text).then(onCopied).catch(legacyCopy);
|
|
3227
|
+
}else legacyCopy();
|
|
3228
|
+
}catch(e){legacyCopy();}
|
|
2916
3229
|
},[active,showToast]);
|
|
2917
3230
|
const resetOverrides=useCallback(()=>{if(active)registry.clearOverride(active.id);},[registry,active]);
|
|
2918
3231
|
// Accept → send an "apply" job so the agent writes the edited timings into
|
|
@@ -3001,7 +3314,11 @@
|
|
|
3001
3314
|
setGroupScanState("scanning");
|
|
3002
3315
|
const raw=flat.map(e=>({label:e.label,selector:e.bindings&&e.bindings.selector,
|
|
3003
3316
|
properties:e.properties,
|
|
3004
|
-
timings:(e.baseLanes||[]).map(l=>({property:l.property,durationMs:l.durationMs,delayMs:l.delayMs,easing:l.easing}))
|
|
3317
|
+
timings:(e.baseLanes||[]).map(l=>({property:l.property,durationMs:l.durationMs,delayMs:l.delayMs,easing:l.easing})),
|
|
3318
|
+
// CSS rules (base + open + close states) for this element, var()s
|
|
3319
|
+
// resolved — lets the agent recover the opposite phase + toggled state
|
|
3320
|
+
// without reading source. Empty → agent falls back to source reads.
|
|
3321
|
+
cssRules:harvestCssHints(e)}));
|
|
3005
3322
|
try{
|
|
3006
3323
|
const{id}=await relayCreateJob({kind:"scan",url:location.href,raw});
|
|
3007
3324
|
for(let i=0;i<500;i++){
|
|
@@ -3027,7 +3344,6 @@
|
|
|
3027
3344
|
const rescanTransitions=useCallback(()=>{
|
|
3028
3345
|
try{localStorage.removeItem(GROUP_STORE_KEY);}catch{}
|
|
3029
3346
|
registry.clearGroups();
|
|
3030
|
-
setSkipGrouping(false); // re-engage the gate so a fresh scan can be shown
|
|
3031
3347
|
runGroupScan();
|
|
3032
3348
|
},[runGroupScan,GROUP_STORE_KEY,registry]);
|
|
3033
3349
|
// Gate the auto group-scan behind a live agent: the panel stays on the
|
|
@@ -3087,35 +3403,98 @@
|
|
|
3087
3403
|
// scanned the page's transitions.
|
|
3088
3404
|
// loading → first /health probe pending (blank, avoids a text flash)
|
|
3089
3405
|
// blocked → no live agent → "Before we start" (run /refine live)
|
|
3090
|
-
//
|
|
3091
|
-
//
|
|
3092
|
-
//
|
|
3093
|
-
//
|
|
3094
|
-
//
|
|
3095
|
-
//
|
|
3406
|
+
// ready → live agent → the real UI, immediately. The group scan runs
|
|
3407
|
+
// in the BACKGROUND (no blocking screen); progress shows as a
|
|
3408
|
+
// small "Agent scanning transitions…" note in the header count
|
|
3409
|
+
// slot. A failed scan just leaves the flat list (Rescan in
|
|
3410
|
+
// Settings to retry) — it never traps the panel.
|
|
3411
|
+
// ── keyboard shortcuts (panel-focused, Cmd/Ctrl based) ──
|
|
3412
|
+
// Fire only when the panel is focused or hovered so we never hijack the
|
|
3413
|
+
// host app. A latest-values ref keeps the single window listener stable.
|
|
3414
|
+
const panelRootRef=useRef(null);
|
|
3415
|
+
const hoveredRef=useRef(false);
|
|
3416
|
+
const panelHotRef=useRef(false);
|
|
3417
|
+
const recomputeHot=useCallback(()=>{
|
|
3418
|
+
const el=panelRootRef.current;
|
|
3419
|
+
panelHotRef.current=hoveredRef.current||!!(el&&document.activeElement&&el.contains(document.activeElement));
|
|
3420
|
+
},[]);
|
|
3421
|
+
const kbdRef=useRef({});
|
|
3422
|
+
kbdRef.current={entries,active,acceptState,acceptDisabled:computeChanges(active).length===0,minimized,
|
|
3423
|
+
openRefine,resetOverrides,copyValues,onAccept,setActiveId,setMinimized,setPickOpen};
|
|
3424
|
+
useEffect(()=>{
|
|
3425
|
+
const onKey=e=>{
|
|
3426
|
+
const mod=e.metaKey||e.ctrlKey;
|
|
3427
|
+
if(!mod)return;
|
|
3428
|
+
const L=kbdRef.current;
|
|
3429
|
+
// Cmd+. always restores a minimized panel (panel root isn't in the DOM
|
|
3430
|
+
// then, so the hot/input gates can't apply). All other combos require
|
|
3431
|
+
// the panel to be focused/hovered and not typing in a field.
|
|
3432
|
+
const isRestore=e.key==="."&&L.minimized;
|
|
3433
|
+
recomputeHot();
|
|
3434
|
+
if(!isRestore){
|
|
3435
|
+
if(!panelHotRef.current)return;
|
|
3436
|
+
const t=e.target;
|
|
3437
|
+
if(t&&(t.tagName==="INPUT"||t.tagName==="TEXTAREA"||t.isContentEditable))return;
|
|
3438
|
+
}
|
|
3439
|
+
const handle=fn=>{e.preventDefault();e.stopPropagation();fn&&fn();};
|
|
3440
|
+
switch(e.key){
|
|
3441
|
+
case"a":case"A": // Accept changes (same enabled condition as the Accept button)
|
|
3442
|
+
handle(()=>{if(L.active&&!L.acceptDisabled&&L.acceptState!=="saving"&&L.acceptState!=="done")L.onAccept&&L.onAccept();});
|
|
3443
|
+
break;
|
|
3444
|
+
case"Backspace": // Reset values
|
|
3445
|
+
handle(()=>{if(L.active)L.resetOverrides&&L.resetOverrides();});
|
|
3446
|
+
break;
|
|
3447
|
+
case"c":case"C":{ // Copy values — only when no text is selected (native copy wins otherwise)
|
|
3448
|
+
const sel=window.getSelection();
|
|
3449
|
+
if(sel&&!sel.isCollapsed)return;
|
|
3450
|
+
handle(()=>{if(L.active)L.copyValues&&L.copyValues();});
|
|
3451
|
+
break;}
|
|
3452
|
+
case"r":case"R": // Toggle Refine panel
|
|
3453
|
+
handle(()=>{if(L.active)L.openRefine&&L.openRefine();});
|
|
3454
|
+
break;
|
|
3455
|
+
case"k":case"K": // Open transition picker
|
|
3456
|
+
handle(()=>{if(L.active)L.setPickOpen&&L.setPickOpen(true);});
|
|
3457
|
+
break;
|
|
3458
|
+
case"ArrowUp":
|
|
3459
|
+
case"ArrowDown":{ // Previous / next transition
|
|
3460
|
+
const list=L.entries||[];
|
|
3461
|
+
if(!list.length)break;
|
|
3462
|
+
const dir=e.key==="ArrowUp"?-1:1;
|
|
3463
|
+
const cur=L.active?list.findIndex(x=>x.id===L.active.id):-1;
|
|
3464
|
+
let ni=cur<0?(dir>0?0:list.length-1):cur+dir;
|
|
3465
|
+
ni=Math.max(0,Math.min(list.length-1,ni));
|
|
3466
|
+
if(list[ni])handle(()=>L.setActiveId&&L.setActiveId(list[ni].id));
|
|
3467
|
+
break;}
|
|
3468
|
+
case".": // Minimize / restore panel
|
|
3469
|
+
handle(()=>L.setMinimized&&L.setMinimized(v=>!v));
|
|
3470
|
+
break;
|
|
3471
|
+
}
|
|
3472
|
+
};
|
|
3473
|
+
window.addEventListener("keydown",onKey);
|
|
3474
|
+
return()=>window.removeEventListener("keydown",onKey);
|
|
3475
|
+
},[recomputeHot]);
|
|
3096
3476
|
const gate = !live
|
|
3097
3477
|
? (llmAvailable===null ? "loading" : "blocked")
|
|
3098
|
-
: groupScanState==="scanning" ? "scanning"
|
|
3099
|
-
: (groupScanState==="error" && !skipGrouping) ? "scan-error"
|
|
3100
3478
|
: "ready";
|
|
3101
3479
|
return h(React.Fragment,null,
|
|
3102
|
-
render&&h("div",{className:"t-panel-slide","data-timeline-panel":true,
|
|
3480
|
+
render&&h("div",{className:"t-panel-slide","data-timeline-panel":true,ref:panelRootRef,
|
|
3481
|
+
onMouseEnter:()=>{hoveredRef.current=true;},onMouseLeave:()=>{hoveredRef.current=false;},
|
|
3103
3482
|
"data-open":panelOpen?"true":"false","data-phase":phase,style:{height:panelHeight+"px"}},
|
|
3104
3483
|
h("div",{className:cx("tl-resize-handle",resizing&&"dragging"),onMouseDown:startResize,title:"Drag to resize"}),
|
|
3105
3484
|
h("div",{className:"tl-panel-body"},
|
|
3106
3485
|
gate==="ready"
|
|
3107
3486
|
? h(React.Fragment,null,
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3487
|
+
h("div",{className:"tl-panel-main"},
|
|
3488
|
+
h(Header,{entries,active,onSelect:setActiveId,onReset:resetOverrides,onCopy:copyValues,copied,
|
|
3489
|
+
snap,setSnap,onMinimize:()=>setMinimized(true),onRefine:openRefine,refineActive:refineOpen,
|
|
3490
|
+
onAccept,acceptState,acceptDisabled:computeChanges(active).length===0,acceptError,
|
|
3491
|
+
scanning:groupScanState==="scanning",onRescan:rescanTransitions,pick:pickOpen,setPick:setPickOpen}),
|
|
3492
|
+
active
|
|
3493
|
+
?h(Body,{entry:active,onPropChange:(prop,o)=>setPropOverride(prop,o),snap})
|
|
3494
|
+
:h("div",{className:"tl-empty"},"Select a transition to inspect and edit it")),
|
|
3495
|
+
h(RefinePanel,{open:refineOpen,onClose:()=>setRefineOpen(false),phase:refinePhase,label:refineLabel,
|
|
3496
|
+
refineType,onType:changeRefineType,suggestions:refineSuggestions,summary:refineSummary,error:refineError,
|
|
3497
|
+
appliedIds,onApply:applySuggestion,onApplyAll:applyAllSuggestions,
|
|
3119
3498
|
mode:refineMode,onMode:changeRefineMode,llmAvailable,cliInstalled,onStart:startScan,
|
|
3120
3499
|
lanes:active?.effectiveTimings||[]}))
|
|
3121
3500
|
: h("div",{className:"tl-panel-main"},
|
|
@@ -3124,35 +3503,19 @@
|
|
|
3124
3503
|
h("div",{className:"tl-gate-title"},"Before we start"),
|
|
3125
3504
|
h("p",{className:"tl-gate-text"},
|
|
3126
3505
|
"Please run the ",h("code",{className:"tl-code"},"/refine live"),
|
|
3127
|
-
" command in your agent to enable live features, such as scanning and refining transitions."))),
|
|
3128
|
-
gate==="scanning" && h("div",{className:"tl-gate"},
|
|
3129
|
-
h("div",{className:"tl-gate-col"},
|
|
3130
|
-
h("div",{className:"tl-gate-pill-wrap"},
|
|
3131
|
-
h(BorderBeam,{size:"sm",colorVariant:"ocean",theme:"light",borderRadius:18,duration:2.6,saturation:2,brightness:1.6,className:"tl-gate-beam"},
|
|
3132
|
-
h("span",{className:"tl-gate-beam-fill"})),
|
|
3133
|
-
h("span",{className:"tl-gate-pill"},
|
|
3134
|
-
h(DotmLoader),
|
|
3135
|
-
h("span",{className:"tl-gate-pill-label"},"Agent is scanning your transitions"))),
|
|
3136
|
-
h("p",{className:"tl-gate-sub"},"Just a moment while we get things ready."))),
|
|
3137
|
-
gate==="scan-error" && h("div",{className:"tl-gate"},
|
|
3138
|
-
h("div",{className:"tl-gate-col"},
|
|
3139
|
-
h("div",{className:"tl-gate-title"},"We couldn’t scan your transitions"),
|
|
3140
|
-
h("p",{className:"tl-gate-text"},
|
|
3141
|
-
"The agent didn’t finish grouping. Make sure ",h("code",{className:"tl-code"},"/refine live"),
|
|
3142
|
-
" is running with the latest skill, then try again — or continue with the ungrouped list."),
|
|
3143
|
-
h("div",{className:"tl-gate-actions"},
|
|
3144
|
-
h("button",{className:"tl-gate-btn tl-gate-btn-primary",onClick:rescanTransitions},"Try again"),
|
|
3145
|
-
h("button",{className:"tl-gate-btn",onClick:()=>setSkipGrouping(true)},"Continue without grouping"))))),
|
|
3506
|
+
" command in your agent to enable live features, such as scanning and refining transitions.")))),
|
|
3146
3507
|
toast&&createPortal(
|
|
3147
|
-
h("div",{className:"tl-toast-wrap","aria-live":"polite"},
|
|
3508
|
+
h("div",{className:"tl-toast-wrap","data-tl-ui":"","aria-live":"polite"},
|
|
3148
3509
|
h("div",{className:cx("tl-toast",toast.closing&&"is-closing")},
|
|
3149
3510
|
h("span",{className:"tl-toast-ic"},
|
|
3150
3511
|
toast.loader?h(DotmLoader):h(Ic,{name:"accept",size:14})),
|
|
3151
|
-
|
|
3512
|
+
toast.loader
|
|
3513
|
+
? h("span",{className:"t-shimmer tl-scan-shimmer","data-text":toast.msg},toast.msg)
|
|
3514
|
+
: h("span",null,toast.msg))),
|
|
3152
3515
|
document.body),
|
|
3153
3516
|
),
|
|
3154
3517
|
),
|
|
3155
|
-
minimized&&h("div",{className:"tl-pill",onClick:()=>setMinimized(false)},
|
|
3518
|
+
minimized&&h("div",{className:"tl-pill","data-tl-ui":"",onClick:()=>setMinimized(false)},
|
|
3156
3519
|
h("span",{className:"tl-pill-label"},"Transitions"),
|
|
3157
3520
|
h("span",{className:cx("tl-pill-count",String(entries.length).length===1&&"is-single")},entries.length)),
|
|
3158
3521
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "transitions-refine",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.9",
|
|
4
4
|
"description": "Live, agent-driven Refine panel for CSS/Motion transitions — injects a timeline + Refine UI and runs transitions.dev suggestions via your coding agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/server/relay.mjs
CHANGED
|
@@ -279,19 +279,19 @@ function buildScanPrompt(job) {
|
|
|
279
279
|
return [
|
|
280
280
|
"You are GROUPING UI transitions by reading the user's SOURCE CODE. A naive DOM scan only sees each element's current computed transition — it cannot tell open from close, and lists related elements separately. Fix that.",
|
|
281
281
|
"",
|
|
282
|
-
"Raw DOM-detected transitions (JSON).
|
|
282
|
+
"Raw DOM-detected transitions (JSON). Each entry's `timings` are ALREADY ACCURATE for the component's CURRENT on-screen state — treat them as ground truth, do NOT re-derive them from source. Most entries also carry `cssRules`: the CSS rules harvested live from the page (CSSOM) that drive that element across ALL states (base + open + close), with var() already resolved to concrete values.",
|
|
283
283
|
JSON.stringify({ url: r.url, raw: r.raw }, null, 2),
|
|
284
284
|
"",
|
|
285
|
-
"
|
|
285
|
+
"FAST PATH — when an entry has `cssRules`, they are AUTHORITATIVE and contain everything you need: the opposite-phase timings live on a state-variant selector inside them (e.g. `.dd.is-closing .dd-panel`, `.modal[data-closing] .dialog`), and the toggled state is visible in those selectors. Derive grouping, phases, toggled state, and opposite-phase timings DIRECTLY from `cssRules` + `timings`. Do NOT glob, grep, or read files for any element whose `cssRules` is non-empty — it only wastes time. ONLY fall back to reading source for entries with an empty/missing `cssRules` (CORS-locked sheets, styled-components, Tailwind, etc.), and even then read the minimum.",
|
|
286
286
|
"",
|
|
287
287
|
"Steps:",
|
|
288
288
|
"1. Identify each animated UI component (dropdown, modal, tooltip, accordion, drawer, toast…). The provided `label`/`selector`/`properties` usually make the grouping obvious; only read source when the grouping is genuinely unclear.",
|
|
289
|
-
"2. For each component, split into PHASES — typically `open` and `close` (a hover-only component may have a single phase). The phase matching the CURRENT DOM state reuses the provided timings verbatim. The OPPOSITE phase often lives on a different selector (e.g. `.is-open` vs `.is-closing`) with different timings —
|
|
289
|
+
"2. For each component, split into PHASES — typically `open` and `close` (a hover-only component may have a single phase). The phase matching the CURRENT DOM state reuses the provided timings verbatim. The OPPOSITE phase often lives on a different selector (e.g. `.is-open` vs `.is-closing`) with different timings — take it from this element's `cssRules` (or, only if it has none, read source). Report BOTH even though only one is in the DOM right now.",
|
|
290
290
|
"3. PHASE STATE — how the phase is driven (REQUIRED for playback to work). For each phase provide:",
|
|
291
291
|
" - `stateTarget`: a CSS selector for the ONE element whose class/attribute is toggled to drive the whole phase (e.g. the dropdown root, the `.modal`, the element with `[data-open]`). It MUST resolve in the live DOM RIGHT NOW, in any state — so it must NOT itself contain the toggled state (write `.t-morph`, never `.t-morph[data-open=\"true\"]`).",
|
|
292
292
|
" - `fromState` and `toState`: the class/attribute on `stateTarget` at the START and END of this phase, as a token: a class `\".is-open\"`, an attribute `\"[data-open=\\\"true\\\"]\"`, or `null`/`\"\"` for the base/no-class state. OPEN usually goes base→open (`fromState:null`, `toState:\".is-open\"`); CLOSE goes open→base (`fromState:\".is-open\"`, `toState:null`). Get the DIRECTION right — open must animate into the open look, close must animate back out.",
|
|
293
293
|
"4. For each phase, list its MEMBER elements (panel, backdrop, the staggered items…). Give each member a stable `id`, a human `label`, a CSS `selector`, and its `propertyTimings`. For the phase that matches the current DOM, COPY each member's `propertyTimings` straight from the provided `raw.timings` (same per-property duration/delay/easing) — don't change numbers you were handed. The member `selector` MUST resolve in the live DOM RIGHT NOW regardless of phase — use the BASE element selector and do NOT bake the phase's toggled class/attribute into it (write `.t-morph .t-morph-plus`, never `.t-morph[data-open=\"true\"] .t-morph-plus`). The toggled state belongs only in the phase's `stateTarget`/`toState`.",
|
|
294
|
-
"5. TIMINGS ARE PER-PROPERTY. For the OPPOSITE phase
|
|
294
|
+
"5. TIMINGS ARE PER-PROPERTY. For the OPPOSITE phase (from `cssRules`, or source if none): list one `propertyTimings` entry per animated property with its own duration/delay/easing; `cssRules` already have var() resolved, but if you ever read a raw `var(...)` resolve it to a concrete number, and convert `s`→ms (`0.25s`→250); never emit a `var(...)` or a guess. Open and close usually have DIFFERENT durations/easings. (The current-state phase just reuses the provided numbers.)",
|
|
295
295
|
"",
|
|
296
296
|
"Output ONLY a JSON object — no prose, no markdown fences — shaped exactly like:",
|
|
297
297
|
'{"summary":"Grouped 3 components.","groups":[{"id":"dropdown","label":"Dropdown","component":"src/Dropdown.tsx","phases":[{"id":"dropdown:open","phase":"open","label":"Open","stateTarget":".dropdown","fromState":null,"toState":".is-open","members":[{"id":"panel","label":"Panel","selector":".dropdown .dropdown-panel","propertyTimings":[{"property":"opacity","durationMs":200,"delayMs":0,"easing":"ease-out"},{"property":"transform","durationMs":200,"delayMs":0,"easing":"cubic-bezier(0.22, 1, 0.36, 1)"}]}]},{"id":"dropdown:close","phase":"close","label":"Close","stateTarget":".dropdown","fromState":".is-open","toState":null,"members":[{"id":"panel","label":"Panel","selector":".dropdown .dropdown-panel","propertyTimings":[{"property":"opacity","durationMs":150,"delayMs":0,"easing":"ease-in"},{"property":"transform","durationMs":150,"delayMs":0,"easing":"ease-in"}]}]}]}]}',
|