transitions-refine 0.1.2 → 0.2.0
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 +137 -1
- package/README.md +17 -3
- package/demo.html +468 -93
- package/package.json +1 -1
- package/server/relay.mjs +123 -12
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: refine-live
|
|
3
|
-
description: Become the live "Refine" agent for the Timeline Inspector. Use when the user runs `/refine live`, asks to "refine live", "go live", "answer refine jobs", or wants the timeline panel's Refine button (LLM mode) to be backed by a real agent. Long-polls the local refine relay, reasons about each CSS transition with the transitions-dev skill,
|
|
3
|
+
description: Become the live "Refine" agent for the Timeline Inspector. Use when the user runs `/refine live`, asks to "refine live", "go live", "answer refine jobs", or wants the timeline panel's Refine button (LLM mode), Accept button, or grouped scan to be backed by a real agent. Long-polls the local refine relay, reasons about each CSS transition with the transitions-dev skill, posts suggestions back to the browser panel, for "scan" jobs groups the page's transitions into components with open/close phases by reading the source, and for "apply" jobs writes the accepted timing changes into the user's source code.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Refine Live
|
|
@@ -54,6 +54,15 @@ never has to re-run `/refine live`.
|
|
|
54
54
|
}
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
+
- **If `request.kind === "scan"`** this is not a suggestion job — the panel is
|
|
58
|
+
asking you to group the page's transitions by reading the source. Jump to
|
|
59
|
+
[`## Scan jobs`](#scan-jobs-group-from-source) and return `groups` instead of
|
|
60
|
+
suggestions.
|
|
61
|
+
- **If `request.kind === "apply"`** this is not a suggestion job — the user
|
|
62
|
+
pressed **Accept** to write changes to their code. Jump to
|
|
63
|
+
[`## Apply jobs`](#apply-jobs-write-to-source) and edit the source instead of
|
|
64
|
+
posting suggestions. Everything below (refineType, steps 3–4) is for the
|
|
65
|
+
normal Refine flow.
|
|
57
66
|
- `refineType` chooses what kinds of suggestions to make (it mirrors the
|
|
58
67
|
panel's two tabs):
|
|
59
68
|
- `"small"` (or missing) → **Small refinements**: nudge the existing
|
|
@@ -171,6 +180,133 @@ never has to re-run `/refine live`.
|
|
|
171
180
|
stop, tell them the LLM tab will go unavailable and how to restart
|
|
172
181
|
(`/refine live`).
|
|
173
182
|
|
|
183
|
+
## Scan jobs (group from source)
|
|
184
|
+
|
|
185
|
+
When a claimed job has `request.kind === "scan"`, the panel wants you to turn a
|
|
186
|
+
flat list of DOM-detected transitions into **components with phases**. A naive
|
|
187
|
+
DOM scan only sees each element's *current* computed transition — it can't tell
|
|
188
|
+
open from close, and lists related elements (panel, backdrop, staggered items)
|
|
189
|
+
separately. You fix that by reading the source. The request looks like:
|
|
190
|
+
|
|
191
|
+
```json
|
|
192
|
+
{
|
|
193
|
+
"id": "uuid",
|
|
194
|
+
"request": {
|
|
195
|
+
"kind": "scan",
|
|
196
|
+
"url": "http://localhost:5173/",
|
|
197
|
+
"raw": [
|
|
198
|
+
{ "label": "div.dropdown-panel", "selector": ".dropdown-panel",
|
|
199
|
+
"properties": ["opacity","transform"],
|
|
200
|
+
"timings": [{ "property": "opacity", "durationMs": 200, "delayMs": 0, "easing": "ease-out" }] }
|
|
201
|
+
]
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Do this:
|
|
207
|
+
|
|
208
|
+
1. **Identify each animated component** the raw entries belong to (dropdown,
|
|
209
|
+
modal, tooltip, accordion, drawer, toast…). Use the selectors/labels as hints,
|
|
210
|
+
then read the source — plain CSS / CSS Modules, styled-components/emotion,
|
|
211
|
+
Tailwind, inline styles, or Motion/Framer variants.
|
|
212
|
+
2. **Split each component into phases** — usually `open` and `close` (a hover-only
|
|
213
|
+
component can be a single phase). Open and close often live on different
|
|
214
|
+
selectors (`.is-open` vs `.is-closing`) with different timings; report **both**
|
|
215
|
+
even though only one is in the DOM right now.
|
|
216
|
+
3. **List each phase's members** — the elements that animate in that phase. Give
|
|
217
|
+
each a stable `id`, a human `label`, a live-resolvable CSS `selector`, an
|
|
218
|
+
optional `toState` hint (the class/attribute that drives the phase, e.g.
|
|
219
|
+
`.is-open`), and its real `propertyTimings`. **Quote the real timings from the
|
|
220
|
+
source — never invent.**
|
|
221
|
+
4. **Post the groups** (this completes the job):
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
curl -s -X POST http://localhost:7331/jobs/<id>/result \
|
|
225
|
+
-H 'Content-Type: application/json' \
|
|
226
|
+
-d '{
|
|
227
|
+
"summary": "Grouped Dropdown into Open/Close.",
|
|
228
|
+
"groups": [
|
|
229
|
+
{ "id": "dropdown", "label": "Dropdown", "component": "src/Dropdown.tsx",
|
|
230
|
+
"phases": [
|
|
231
|
+
{ "id": "dropdown:open", "phase": "open", "label": "Open", "members": [
|
|
232
|
+
{ "id": "panel", "label": "Panel", "selector": ".dropdown-panel", "toState": ".is-open",
|
|
233
|
+
"propertyTimings": [
|
|
234
|
+
{ "property": "opacity", "durationMs": 200, "delayMs": 0, "easing": "ease-out" },
|
|
235
|
+
{ "property": "transform", "durationMs": 200, "delayMs": 0, "easing": "cubic-bezier(0.22, 1, 0.36, 1)" }
|
|
236
|
+
] }
|
|
237
|
+
] },
|
|
238
|
+
{ "id": "dropdown:close", "phase": "close", "label": "Close", "members": [
|
|
239
|
+
{ "id": "panel", "label": "Panel", "selector": ".dropdown-panel", "toState": ".is-closing",
|
|
240
|
+
"propertyTimings": [
|
|
241
|
+
{ "property": "opacity", "durationMs": 150, "delayMs": 0, "easing": "ease-in" }
|
|
242
|
+
] }
|
|
243
|
+
] }
|
|
244
|
+
] }
|
|
245
|
+
]
|
|
246
|
+
}'
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
If you can't confidently group anything, post `{"groups":[],"summary":"…"}` —
|
|
250
|
+
the panel keeps its flat DOM scan. Reserve `/jobs/<id>/error` for unexpected
|
|
251
|
+
failures.
|
|
252
|
+
|
|
253
|
+
Then go back to step 1 of the loop.
|
|
254
|
+
|
|
255
|
+
## Apply jobs (write to source)
|
|
256
|
+
|
|
257
|
+
When a claimed job has `request.kind === "apply"`, the user accepted their current
|
|
258
|
+
timeline values and wants them written to the codebase. The request looks like:
|
|
259
|
+
|
|
260
|
+
```json
|
|
261
|
+
{
|
|
262
|
+
"id": "uuid",
|
|
263
|
+
"request": {
|
|
264
|
+
"kind": "apply",
|
|
265
|
+
"label": "Dropdown · Close",
|
|
266
|
+
"selector": ".dropdown-panel",
|
|
267
|
+
"component": "src/Dropdown.tsx",
|
|
268
|
+
"group": "Dropdown",
|
|
269
|
+
"phase": "close",
|
|
270
|
+
"changes": [
|
|
271
|
+
{ "property": "opacity", "member": "Panel", "selector": ".dropdown-panel",
|
|
272
|
+
"from": { "durationMs": 300, "delayMs": 0, "easing": "ease" },
|
|
273
|
+
"to": { "durationMs": 150, "delayMs": 0, "easing": "cubic-bezier(0.4, 0, 1, 1)" } }
|
|
274
|
+
]
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
Do this:
|
|
280
|
+
|
|
281
|
+
1. **Locate the real declaration in the source.** The `selector` is a DOM-path
|
|
282
|
+
*hint*, not necessarily the source selector. Use the `component` hint and search
|
|
283
|
+
by the label/class names; handle whatever the project uses: plain CSS / CSS
|
|
284
|
+
Modules, styled-components or emotion template literals, Tailwind utilities
|
|
285
|
+
(`duration-300`, arbitrary `[transition-duration:300ms]`, or the
|
|
286
|
+
`tailwind.config` theme), inline `style={{ transition: … }}` objects, and
|
|
287
|
+
Motion/Framer variants. Match by the `from` values to disambiguate.
|
|
288
|
+
- **If `phase` is set** (e.g. `"open"`/`"close"`), edit only that state's rule
|
|
289
|
+
(the `.is-open` rule for open, the `.is-closing`/base rule for close) — not
|
|
290
|
+
the other phase. Each change's `member` + `selector` says which element.
|
|
291
|
+
2. **Edit each change's property** to its `to` values (`durationMs` ms, `easing`,
|
|
292
|
+
`delayMs` ms) on the right member + phase. Keep the file's existing unit/format
|
|
293
|
+
(`0.25s` vs `250ms`) and touch only that property's timing. If a CSS variable /
|
|
294
|
+
design token backs the value, update it at the single most sensible place.
|
|
295
|
+
3. **Minimal edit** — no reformatting or unrelated changes.
|
|
296
|
+
4. **Post the outcome** (this completes the job):
|
|
297
|
+
|
|
298
|
+
```bash
|
|
299
|
+
curl -s -X POST http://localhost:7331/jobs/<id>/result \
|
|
300
|
+
-H 'Content-Type: application/json' \
|
|
301
|
+
-d '{"applied":true,"summary":"Set .t-modal transition to 150ms ease-in","files":["src/Modal.css:42"]}'
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
If you cannot confidently find the declaration, post
|
|
305
|
+
`{"applied":false,"summary":"<what you searched and why not found>"}` (still a
|
|
306
|
+
`result`, not an `error`). Reserve `/jobs/<id>/error` for unexpected failures.
|
|
307
|
+
|
|
308
|
+
Then go back to step 1 of the loop.
|
|
309
|
+
|
|
174
310
|
## Suggestion shape (must match the panel)
|
|
175
311
|
|
|
176
312
|
Each suggestion object:
|
package/README.md
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
A live, agent-driven **Refine** panel for CSS and [Motion](https://motion.dev) transitions. One command injects a docked timeline + Refine panel onto your running app — no `npm install`, no source edits of your own — and every "Refine" click asks a coding agent to review the selected transition against the [transitions.dev](https://transitions.dev) motion tokens and suggest token-aligned values (or a whole-transition replacement from the library).
|
|
4
4
|
|
|
5
|
-
The feedback shows up **in a panel that slides in from the right** — not in your chat — and you pick which suggestions to apply. Applied suggestions are **live overrides** (instant preview, reversible) — the same path as dragging the timeline bars.
|
|
5
|
+
The feedback shows up **in a panel that slides in from the right** — not in your chat — and you pick which suggestions to apply. Applied suggestions are **live overrides** (instant preview, reversible) — the same path as dragging the timeline bars. When you're happy, **Accept** writes those values back into your source via the agent.
|
|
6
|
+
|
|
7
|
+
Real components rarely live in one CSS rule. A dropdown has an **Open** and a **Close** phase, each animating several elements (panel, backdrop, staggered items) with different timings — and the close phase usually isn't even in the DOM while the panel is open. So when the panel opens it also asks the agent to **read your source and group** the page's transitions into components → phases → member elements. You then pick a whole phase (e.g. *Dropdown · Open*) and see every sub-transition as a labeled lane on one shared timeline; Play arms all of them together. If no agent is live, the panel falls back to the flat DOM scan with no regression.
|
|
6
8
|
|
|
7
9
|
Inspired by the [impeccable.style](https://impeccable.style/live-mode/) "live" pattern: the browser drops a job in a tiny local relay, and the relay answers it with **one agent run per click**. No standing loop, nothing to start per click — you just keep the relay running.
|
|
8
10
|
|
|
@@ -54,11 +56,23 @@ REFINE_AGENT_CMD='cursor-agent -p' npm run relay # or: codex exec - | claude
|
|
|
54
56
|
|
|
55
57
|
The CLI must have the `transitions-dev` skill available (the prompt tells it to read the skill).
|
|
56
58
|
|
|
59
|
+
## Grouped scan — Open / Close phases
|
|
60
|
+
|
|
61
|
+
When the panel opens it posts a **scan job** to the relay; the agent reads your source and returns the page's animated components, each split into phases (`open`, `close`, …) with their **member elements** and the *real* per-state timings — including the close transition the DOM can't show you. The picker then groups by component, you select a phase, and the timeline renders one lane per member-property (each lane labeled with its member) on a single time axis so stagger and delays line up. **Play** arms every member of the phase at once (driving each element via its `toState` class/attribute), and **Accept** writes back to the correct state rule (`.is-open` vs `.is-closing`) per member.
|
|
62
|
+
|
|
63
|
+
Grouping needs the agent (`/refine live`, `--llm`, or `REFINE_AGENT_CMD`); with no agent the panel just shows the flat DOM scan as before.
|
|
64
|
+
|
|
57
65
|
## Refine modes
|
|
58
66
|
|
|
59
67
|
- **Small refinements** — keeps the transition, suggests motion-token tweaks (duration/easing), and may add a whole-transition replacement when one clearly fits better.
|
|
60
68
|
- **Replace transition** — only whole-transition replacements from the transitions.dev library (no token tweaks). This path needs the agent; the deterministic answerer will tell you to switch to the LLM.
|
|
61
69
|
|
|
70
|
+
## Accept — write changes to your code
|
|
71
|
+
|
|
72
|
+
The **Accept** button (next to Refine) is enabled whenever the selected transition has unsaved changes — whether you edited the bars/easing by hand or applied a Refine suggestion. Pressing it sends an **apply job** to the relay: the agent finds where that transition is declared in your source (plain CSS, CSS Modules, styled-components/emotion, Tailwind, or inline styles), edits only the changed timings, and reports back. The button shows a spinner while saving and flips to **Done** on success.
|
|
73
|
+
|
|
74
|
+
Like Replace, Accept needs the agent — run `/refine live` (or `--llm` / `REFINE_AGENT_CMD`). The deterministic answerer can't edit files. Play preview also no longer needs you to trigger the transition first: it recovers the end-state from your stylesheets (hover/focus pseudo-states and toggled classes like `.modal.open`), so opening the panel and pressing Play just works.
|
|
75
|
+
|
|
62
76
|
## Pieces
|
|
63
77
|
|
|
64
78
|
| Piece | File | Role |
|
|
@@ -80,9 +94,9 @@ The CLI must have the `transitions-dev` skill available (the prompt tells it to
|
|
|
80
94
|
| `REFINE_AUTO=0` | — | disable auto-answer and wait for an external poller |
|
|
81
95
|
| `window.REFINE_RELAY_URL` | injected origin | browser override for the relay URL |
|
|
82
96
|
|
|
83
|
-
Endpoints: `POST /jobs`, `GET /jobs/:id` (browser). In `REFINE_AUTO=0` mode an external poller also uses `GET /jobs/next` and `POST /jobs/:id/{status,result,error}
|
|
97
|
+
Endpoints: `POST /jobs` (refine, `kind: "apply"`, or `kind: "scan"`), `GET /jobs/:id` (browser). In `REFINE_AUTO=0` mode an external poller also uses `GET /jobs/next` and `POST /jobs/:id/{status,result,error}` (the result body accepts `suggestions`, `groups`, or `applied`).
|
|
84
98
|
|
|
85
|
-
|
|
99
|
+
Refine suggestions stay as live overrides until you press **Accept**, which is the explicit step that writes them into your source.
|
|
86
100
|
|
|
87
101
|
## License
|
|
88
102
|
|
package/demo.html
CHANGED
|
@@ -164,7 +164,21 @@
|
|
|
164
164
|
.tl-header .tl-ghost-btn,
|
|
165
165
|
.tl-header .tl-icon-btn,
|
|
166
166
|
.tl-header .tl-sec-btn,
|
|
167
|
+
.tl-header .tl-accept-btn,
|
|
167
168
|
.tl-header .tl-refine-btn { border-radius: 60px; }
|
|
169
|
+
.tl-accept-btn { display: inline-flex; align-items: center; gap: 8px; height: 36px; padding: 0 14px;
|
|
170
|
+
border: none; cursor: pointer; font-weight: 500; font-size: 13px; line-height: 14px; color: #17181C;
|
|
171
|
+
background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.04),
|
|
172
|
+
inset 0 0 0 1px rgba(0,0,0,0.06), inset 0 -1px 0 0 rgba(0,0,0,0.06), inset 0 0 0 1px rgba(196,196,196,0.10);
|
|
173
|
+
transition: background 140ms ease, scale 140ms ease, opacity 140ms ease; }
|
|
174
|
+
.tl-accept-btn > svg { width: 16px; height: 16px; color: #17181C; flex: none; }
|
|
175
|
+
.tl-accept-btn:hover:not(:disabled) { background: #f9f9f9; }
|
|
176
|
+
.tl-accept-btn:active:not(:disabled) { background: #f9f9f9; scale: 0.96; }
|
|
177
|
+
.tl-accept-btn:disabled { opacity: 0.5; cursor: default; }
|
|
178
|
+
.tl-accept-spin { width: 16px; height: 16px; flex: none; border-radius: 50%;
|
|
179
|
+
border: 2px solid rgba(0,0,0,0.15); border-top-color: #17181C; animation: tl-accept-rot 0.7s linear infinite; }
|
|
180
|
+
@keyframes tl-accept-rot { to { transform: rotate(360deg); } }
|
|
181
|
+
@media (prefers-reduced-motion: reduce) { .tl-accept-spin { animation-duration: 1.4s; } }
|
|
168
182
|
|
|
169
183
|
/* ghost button: transparent, grey on hover/pressed (Figma #f7f7f7) */
|
|
170
184
|
.tl-ghost-btn { position: relative; display: inline-flex; align-items: center; gap: 6px; height: 36px;
|
|
@@ -309,8 +323,15 @@
|
|
|
309
323
|
position: relative; z-index: 5; cursor: grabbing; }
|
|
310
324
|
.tl-prop-row.reordering .tl-prop-grip { color: var(--c-ruler); cursor: grabbing; }
|
|
311
325
|
.tl-prop-label { font-size: 13px; font-weight: 500; line-height: 18px; color: var(--c-text-mut2); white-space: nowrap; overflow: hidden;
|
|
312
|
-
text-overflow: ellipsis; text-transform: capitalize; }
|
|
326
|
+
text-overflow: ellipsis; text-transform: capitalize; display: flex; align-items: center; gap: 6px; min-width: 0; }
|
|
313
327
|
.tl-prop-row.selected .tl-prop-label { color: #171717; }
|
|
328
|
+
.tl-prop-member { flex: none; font-size: 11px; font-weight: 600; line-height: 14px; padding: 1px 6px; border-radius: 60px;
|
|
329
|
+
background: var(--c-sec, rgba(0,0,0,0.05)); color: var(--c-text-faint); text-transform: none; max-width: 96px;
|
|
330
|
+
overflow: hidden; text-overflow: ellipsis; }
|
|
331
|
+
.tl-prop-row.selected .tl-prop-member { color: var(--c-text-mut2); }
|
|
332
|
+
.tl-prop-prop { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
|
333
|
+
.tl-insp-member { display: inline-block; font-size: 11px; font-weight: 600; line-height: 14px; padding: 1px 6px; border-radius: 60px;
|
|
334
|
+
background: var(--c-sec, rgba(0,0,0,0.05)); color: var(--c-text-faint); text-transform: none; margin-right: 6px; vertical-align: middle; }
|
|
314
335
|
/* 16px left inset of the plot area (kept in sync with ruler/playhead/scrub) */
|
|
315
336
|
.tl-prop-track { position: relative; flex: 1; user-select: none; margin-left: 16px; }
|
|
316
337
|
/* timeline line — capsule (fully rounded), Figma #eeeeef default / #e9e9e9 hover */
|
|
@@ -534,6 +555,9 @@
|
|
|
534
555
|
.tl-menu-help svg { display: block; }
|
|
535
556
|
.tl-menu-check { display: flex; color: var(--c-text-strong); flex: none; }
|
|
536
557
|
.tl-menu-empty { padding: 10px; color: var(--c-disabled); font-size: 13px; }
|
|
558
|
+
.tl-menu-section { padding: 8px 10px 4px; font-size: 11px; font-weight: 600; line-height: 14px;
|
|
559
|
+
letter-spacing: 0.02em; text-transform: uppercase; color: var(--c-text-faint); }
|
|
560
|
+
.tl-menu-section:not(:first-child) { margin-top: 2px; border-top: 1px solid var(--c-border, rgba(0,0,0,0.06)); padding-top: 8px; }
|
|
537
561
|
|
|
538
562
|
/* ═════ transitions.dev — menu dropdown (verbatim) ═════ */
|
|
539
563
|
.t-dropdown {
|
|
@@ -1101,36 +1125,103 @@
|
|
|
1101
1125
|
return out;
|
|
1102
1126
|
}
|
|
1103
1127
|
|
|
1104
|
-
// ── registry (per-
|
|
1128
|
+
// ── registry (per-lane overrides) ──
|
|
1129
|
+
// A "lane" is one animated property of one element. For flat DOM transitions
|
|
1130
|
+
// a lane's id is just the property; for agent-grouped phases it is
|
|
1131
|
+
// `memberId::property`, so two members can each animate e.g. `opacity`.
|
|
1132
|
+
// Selectable items are either flat DOM entries (kind "flat") or one phase of
|
|
1133
|
+
// an agent group (kind "phase"); a phase fans out into per-member lanes.
|
|
1105
1134
|
class TransitionRegistry {
|
|
1106
|
-
entries=new Map();
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1135
|
+
entries=new Map(); // flat DOM entries by id
|
|
1136
|
+
groups=[]; // agent groups: [{id,label,component,phases:[{id,phase,label,members:[...]}]}]
|
|
1137
|
+
claimedEntryIds=new Set();// flat entries covered by a group member (hidden from "Ungrouped")
|
|
1138
|
+
listeners=new Set(); laneOverrides=new Map(); snapshot=[]; effectiveCache=new Map();
|
|
1139
|
+
register(e){ this.entries.set(e.id,e); this._recomputeClaimed(); this._notify(); }
|
|
1140
|
+
unregister(id){ this.entries.delete(id); this.laneOverrides.delete(id); this._recomputeClaimed(); this._notify(); }
|
|
1141
|
+
replaceAll(list){ this.entries.clear(); for(const e of list) this.entries.set(e.id,e); this._recomputeClaimed(); this._notify(); }
|
|
1142
|
+
setGroups(groups){ this.groups = Array.isArray(groups)?groups:[]; this._recomputeClaimed(); this._notify(); }
|
|
1143
|
+
clearGroups(){ this.groups=[]; this.claimedEntryIds.clear(); this._notify(); }
|
|
1110
1144
|
get(id){ return this.entries.get(id); }
|
|
1111
1145
|
getAll(){ return this.snapshot; }
|
|
1112
1146
|
getEffective(id){ return this.effectiveCache.get(id); }
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
m
|
|
1116
|
-
|
|
1147
|
+
// overrides are keyed by selectable-item id, then by laneId
|
|
1148
|
+
setPropOverride(id, laneId, o){
|
|
1149
|
+
const m = this.laneOverrides.get(id) || {};
|
|
1150
|
+
m[laneId] = { ...m[laneId], ...o };
|
|
1151
|
+
this.laneOverrides.set(id, m);
|
|
1117
1152
|
this._notify();
|
|
1118
1153
|
}
|
|
1119
|
-
clearOverride(id){ this.
|
|
1120
|
-
getPropOverrides(id){ return this.
|
|
1154
|
+
clearOverride(id){ this.laneOverrides.delete(id); this._notify(); }
|
|
1155
|
+
getPropOverrides(id){ return this.laneOverrides.get(id); }
|
|
1121
1156
|
subscribe(fn){ this.listeners.add(fn); return ()=>this.listeners.delete(fn); }
|
|
1157
|
+
// A flat entry is "claimed" when every live element it is bound to is also
|
|
1158
|
+
// matched by some group member's selector, so we don't list it twice.
|
|
1159
|
+
_recomputeClaimed(){
|
|
1160
|
+
this.claimedEntryIds.clear();
|
|
1161
|
+
if(!this.groups.length||typeof document==="undefined")return;
|
|
1162
|
+
const memberEls=new Set();
|
|
1163
|
+
for(const g of this.groups)for(const ph of (g.phases||[]))for(const m of (ph.members||[])){
|
|
1164
|
+
if(!m.selector)continue;
|
|
1165
|
+
let els=[];try{els=Array.from(document.querySelectorAll(m.selector));}catch{}
|
|
1166
|
+
for(const el of els)memberEls.add(el);
|
|
1167
|
+
}
|
|
1168
|
+
if(!memberEls.size)return;
|
|
1169
|
+
for(const[id,entry]of this.entries){
|
|
1170
|
+
const els=((entry.bindings&&entry.bindings.elements)||[]).map(w=>w.deref&&w.deref()).filter(Boolean);
|
|
1171
|
+
if(els.length&&els.every(el=>memberEls.has(el)))this.claimedEntryIds.add(id);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
_baseLanesFlat(entry){
|
|
1175
|
+
const timings=(entry.propertyTimings||entry.properties.map(p=>({property:p,durationMs:entry.durationMs,delayMs:entry.delayMs,easing:entry.easing})));
|
|
1176
|
+
return timings.map(t=>({laneId:t.property,property:t.property,memberId:null,member:null,selector:entry.bindings&&entry.bindings.selector,durationMs:t.durationMs,delayMs:t.delayMs,easing:t.easing,spring:t.spring}));
|
|
1177
|
+
}
|
|
1178
|
+
_baseLanesPhase(phase){
|
|
1179
|
+
const out=[];
|
|
1180
|
+
for(const m of (phase.members||[])){
|
|
1181
|
+
for(const t of (m.propertyTimings||[])){
|
|
1182
|
+
out.push({laneId:m.id+"::"+t.property,property:t.property,memberId:m.id,member:m.label||m.id,selector:m.selector,toState:m.toState,durationMs:t.durationMs,delayMs:t.delayMs,easing:t.easing,spring:t.spring});
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
return out;
|
|
1186
|
+
}
|
|
1187
|
+
_applyOverrides(baseLanes,po){
|
|
1188
|
+
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};});
|
|
1189
|
+
}
|
|
1122
1190
|
_notify(){
|
|
1123
|
-
this.snapshot=Array.from(this.entries.values());
|
|
1124
1191
|
this.effectiveCache.clear();
|
|
1192
|
+
const items=[];
|
|
1193
|
+
// 1) agent groups → one selectable item per phase
|
|
1194
|
+
for(const g of this.groups){
|
|
1195
|
+
for(const ph of (g.phases||[])){
|
|
1196
|
+
const id=ph.id||(g.id+":"+(ph.phase||ph.label||"phase"));
|
|
1197
|
+
const po=this.laneOverrides.get(id)||{};
|
|
1198
|
+
const baseLanes=this._baseLanesPhase(ph);
|
|
1199
|
+
const effTimings=this._applyOverrides(baseLanes,po);
|
|
1200
|
+
const repDur=effTimings.reduce((mx,t)=>Math.max(mx,(t.durationMs||0)+(t.delayMs||0)),0);
|
|
1201
|
+
const members=(ph.members||[]).map(m=>{
|
|
1202
|
+
const mb=baseLanes.filter(l=>l.memberId===m.id);
|
|
1203
|
+
return {memberId:m.id,label:m.label||m.id,selector:m.selector,toState:m.toState,
|
|
1204
|
+
lanes:this._applyOverrides(mb,po),baseLanes:mb};
|
|
1205
|
+
});
|
|
1206
|
+
const item={id,kind:"phase",groupId:g.id,groupLabel:g.label||g.id,component:g.component||null,
|
|
1207
|
+
phase:ph.phase||null,phaseLabel:ph.label||ph.phase||"Phase",
|
|
1208
|
+
label:(g.label||g.id)+" · "+(ph.label||ph.phase||"Phase"),durationMs:repDur,
|
|
1209
|
+
members,effectiveTimings:effTimings,baseLanes};
|
|
1210
|
+
this.effectiveCache.set(id,item);
|
|
1211
|
+
items.push(item);
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
// 2) flat DOM entries not claimed by a group → "Ungrouped"
|
|
1125
1215
|
for(const[id,entry]of this.entries){
|
|
1126
|
-
|
|
1127
|
-
const
|
|
1128
|
-
const
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1216
|
+
if(this.claimedEntryIds.has(id))continue;
|
|
1217
|
+
const po=this.laneOverrides.get(id)||{};
|
|
1218
|
+
const baseLanes=this._baseLanesFlat(entry);
|
|
1219
|
+
const effTimings=this._applyOverrides(baseLanes,po);
|
|
1220
|
+
const item={...entry,kind:"flat",groupId:null,groupLabel:null,effectiveTimings:effTimings,baseLanes};
|
|
1221
|
+
this.effectiveCache.set(id,item);
|
|
1222
|
+
items.push(item);
|
|
1133
1223
|
}
|
|
1224
|
+
this.snapshot=items;
|
|
1134
1225
|
for(const fn of this.listeners) fn(this.snapshot);
|
|
1135
1226
|
}
|
|
1136
1227
|
}
|
|
@@ -1169,6 +1260,109 @@
|
|
|
1169
1260
|
// capture phase + the bubbling transitionrun event reaches document
|
|
1170
1261
|
document.addEventListener("transitionrun",_txOnRun,true);
|
|
1171
1262
|
}
|
|
1263
|
+
// from/to recovered from a previously observed real run
|
|
1264
|
+
function _txCaptured(el,et){
|
|
1265
|
+
const rec=_txCapture.get(el);if(!rec)return null;
|
|
1266
|
+
const from={},to={};let n=0;
|
|
1267
|
+
for(const t of et){const c=rec.get(t.property);if(c){from[t.property]=c.from;to[t.property]=c.to;n++;}}
|
|
1268
|
+
return n?{from,to}:null;
|
|
1269
|
+
}
|
|
1270
|
+
// Discover the transition's end-state WITHOUT interaction by reading the
|
|
1271
|
+
// stylesheets: find rules that set a transitioning prop and represent a
|
|
1272
|
+
// *state* of the element (a :hover/:focus pseudo, or a toggled class/attr
|
|
1273
|
+
// like `.modal.open`). from = current computed value, to = the rule's value.
|
|
1274
|
+
const _TX_STATE_PSEUDO=/:{1,2}(hover|focus|focus-visible|focus-within|active|checked|enabled|disabled|target|visited|link|valid|invalid|placeholder-shown|default)\b(\([^)]*\))?/g;
|
|
1275
|
+
function _txReduceLast(el,sel){
|
|
1276
|
+
const parts=sel.split(/(\s*[>+~]\s*|\s+)/);
|
|
1277
|
+
let i=parts.length-1;while(i>=0&&/^\s*[>+~]?\s*$/.test(parts[i]))i--;
|
|
1278
|
+
if(i<0)return null;
|
|
1279
|
+
const toks=parts[i].match(/[.#]?[\w-]+|\[[^\]]*\]|::?[\w-]+(?:\([^)]*\))?|\*/g)||[];
|
|
1280
|
+
let dropped=false,hasId=false;
|
|
1281
|
+
const kept=toks.filter(tk=>{
|
|
1282
|
+
if(tk[0]==="."){const ok=el.classList.contains(tk.slice(1));if(ok)hasId=true;else dropped=true;return ok;}
|
|
1283
|
+
if(tk[0]==="["){let ok;try{ok=el.matches(tk);}catch{ok=false;}if(ok)hasId=true;else dropped=true;return ok;}
|
|
1284
|
+
if(tk[0]==="#"){const ok=el.id&&("#"+el.id)===tk;if(ok)hasId=true;else dropped=true;return ok;}
|
|
1285
|
+
if(tk[0]===":"){dropped=true;return false;} // pseudo = the state delta
|
|
1286
|
+
return true; // tag / *
|
|
1287
|
+
});
|
|
1288
|
+
// Only a real *variant of this element*: we must have removed a state token
|
|
1289
|
+
// AND kept a class/id/attr that ties the rule to el. Dropping every class to
|
|
1290
|
+
// land on a bare tag/`*` would falsely match unrelated rules.
|
|
1291
|
+
if(!dropped||!hasId)return null;
|
|
1292
|
+
parts[i]=kept.join("");
|
|
1293
|
+
return parts.join("");
|
|
1294
|
+
}
|
|
1295
|
+
function _txIsStateOf(el,sel){
|
|
1296
|
+
try{if(el.matches(sel))return false;}catch{return false;}
|
|
1297
|
+
const noP=sel.replace(_TX_STATE_PSEUDO,"").trim();
|
|
1298
|
+
if(noP&&noP!==sel){try{if(el.matches(noP))return true;}catch{}}
|
|
1299
|
+
const red=_txReduceLast(el,sel);
|
|
1300
|
+
if(red&&red!==sel){try{if(el.matches(red))return true;}catch{}}
|
|
1301
|
+
return false;
|
|
1302
|
+
}
|
|
1303
|
+
function _txScanRules(rules,el,props,to){
|
|
1304
|
+
for(const rule of rules){
|
|
1305
|
+
// Read style rules directly. NOTE: in browsers with CSS Nesting a plain
|
|
1306
|
+
// CSSStyleRule also has a (usually empty) .cssRules, so test selectorText
|
|
1307
|
+
// first — don't treat every style rule as a grouping rule.
|
|
1308
|
+
const decl=rule.style;
|
|
1309
|
+
if(decl&&rule.selectorText){
|
|
1310
|
+
let sets=false;for(const p of props){if(decl.getPropertyValue(p)){sets=true;break;}}
|
|
1311
|
+
if(sets){
|
|
1312
|
+
for(const sub of rule.selectorText.split(",")){
|
|
1313
|
+
const s=sub.trim();if(!s)continue;
|
|
1314
|
+
if(_txIsStateOf(el,s)){for(const p of props){const v=decl.getPropertyValue(p);if(v&&!(p in to))to[p]=v.trim();}}
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
// Recurse into @media / @supports / @layer and any nested rules.
|
|
1319
|
+
if(rule.cssRules&&rule.cssRules.length){
|
|
1320
|
+
try{if(rule.media&&!window.matchMedia(rule.media.mediaText).matches)continue;}catch{}
|
|
1321
|
+
_txScanRules(rule.cssRules,el,props,to);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
function _txDiscover(el,et){
|
|
1326
|
+
if(typeof document==="undefined"||!el.matches)return null;
|
|
1327
|
+
const props=et.map(t=>t.property).filter(p=>p&&p!=="all");
|
|
1328
|
+
if(!props.length)return null;
|
|
1329
|
+
const to={};
|
|
1330
|
+
for(const sheet of Array.from(document.styleSheets||[])){
|
|
1331
|
+
let rules;try{rules=sheet.cssRules;}catch{continue;} // cross-origin sheet
|
|
1332
|
+
if(rules)_txScanRules(rules,el,props,to);
|
|
1333
|
+
}
|
|
1334
|
+
const keys=Object.keys(to);if(!keys.length)return null;
|
|
1335
|
+
const cs=getComputedStyle(el);const from={};
|
|
1336
|
+
for(const p of keys)from[p]=cs.getPropertyValue(p)||cs[_txCamel(p)]||"";
|
|
1337
|
+
return {from,to};
|
|
1338
|
+
}
|
|
1339
|
+
// Toggle a phase's state class/attribute (e.g. ".is-open" or "[data-open]")
|
|
1340
|
+
// and read the resulting computed values, so a phase plays its real end-state
|
|
1341
|
+
// even when the source uses a toggled class the DOM isn't currently in.
|
|
1342
|
+
function _txToState(el,et,toState){
|
|
1343
|
+
if(typeof document==="undefined"||!el.matches||!toState)return null;
|
|
1344
|
+
const props=et.map(t=>t.property).filter(p=>p&&p!=="all");
|
|
1345
|
+
if(!props.length)return null;
|
|
1346
|
+
const s=String(toState).trim();
|
|
1347
|
+
let applied=null; // {undo}
|
|
1348
|
+
try{
|
|
1349
|
+
if(s[0]==="."){const c=s.slice(1);if(c&&!el.classList.contains(c)){el.classList.add(c);applied=()=>el.classList.remove(c);}}
|
|
1350
|
+
else if(s[0]==="["){
|
|
1351
|
+
const m=s.match(/^\[\s*([\w-]+)\s*(?:([~|^$*]?=)\s*"?([^"\]]*)"?\s*)?\]$/);
|
|
1352
|
+
if(m){const name=m[1],val=m[3]!=null?m[3]:"";if(el.getAttribute(name)!==val){el.setAttribute(name,val);applied=()=>el.removeAttribute(name);}}
|
|
1353
|
+
}
|
|
1354
|
+
}catch{}
|
|
1355
|
+
if(!applied)return null; // already in that state, or unparseable
|
|
1356
|
+
const cs=getComputedStyle(el);const to={};
|
|
1357
|
+
for(const p of props)to[p]=cs.getPropertyValue(p)||cs[_txCamel(p)]||"";
|
|
1358
|
+
try{applied();}catch{}
|
|
1359
|
+
const cs0=getComputedStyle(el);const from={};
|
|
1360
|
+
for(const p of props)from[p]=cs0.getPropertyValue(p)||cs0[_txCamel(p)]||"";
|
|
1361
|
+
const keys=Object.keys(to).filter(p=>to[p]!=null&&to[p]!==""&&to[p]!==from[p]);
|
|
1362
|
+
if(!keys.length)return null;
|
|
1363
|
+
const f={},t={};for(const p of keys){f[p]=from[p];t[p]=to[p];}
|
|
1364
|
+
return {from:f,to:t};
|
|
1365
|
+
}
|
|
1172
1366
|
|
|
1173
1367
|
class PreviewController {
|
|
1174
1368
|
state="idle"; listeners=new Set(); cleanups=[]; animations=[]; progressListeners=new Set(); _rafId=null; scanner=null; _gen=0;
|
|
@@ -1179,7 +1373,25 @@
|
|
|
1179
1373
|
subscribe(fn){this.listeners.add(fn);return()=>this.listeners.delete(fn);}
|
|
1180
1374
|
onProgress(fn){this.progressListeners.add(fn);return()=>this.progressListeners.delete(fn);}
|
|
1181
1375
|
play(entry){this.stop();this._gen++;this._current=entry;
|
|
1182
|
-
if(this.scanner)this.scanner.pause();
|
|
1376
|
+
if(this.scanner)this.scanner.pause();this._playCss(entry,this._gen);this._setState("playing");}
|
|
1377
|
+
// Resolve a selectable item to a flat list of {el, et, toState} arm targets.
|
|
1378
|
+
// Phase items fan out to their members (selector may match several elements,
|
|
1379
|
+
// e.g. staggered items); flat items use their bound DOM elements.
|
|
1380
|
+
_targets(entry){
|
|
1381
|
+
const out=[];
|
|
1382
|
+
if(entry.kind==="phase"){
|
|
1383
|
+
for(const m of (entry.members||[])){
|
|
1384
|
+
if(!m.selector||!m.lanes||!m.lanes.length)continue;
|
|
1385
|
+
let els=[];try{els=Array.from(document.querySelectorAll(m.selector));}catch{}
|
|
1386
|
+
for(const el of els)out.push({el,et:m.lanes,toState:m.toState});
|
|
1387
|
+
}
|
|
1388
|
+
return out;
|
|
1389
|
+
}
|
|
1390
|
+
if(!entry.bindings||entry.bindings.type!=="css")return out;
|
|
1391
|
+
const et=entry.effectiveTimings||entry.properties.map(p=>({property:p,durationMs:entry.durationMs,delayMs:entry.delayMs,easing:entry.easing}));
|
|
1392
|
+
for(const wr of entry.bindings.elements){const el=wr.deref&&wr.deref();if(el)out.push({el,et,toState:null});}
|
|
1393
|
+
return out;
|
|
1394
|
+
}
|
|
1183
1395
|
pause(){if(this.state!=="playing")return;for(const a of this.animations){try{a.pause();}catch{}}this._stopPL();this._setState("paused");}
|
|
1184
1396
|
resume(){if(this.state!=="paused")return;for(const a of this.animations){try{a.play();}catch{}}this._startPL();this._setState("playing");}
|
|
1185
1397
|
stop(){this._stopPL();for(const a of this.animations){try{a.cancel();}catch{}}this.animations=[];for(const c of this.cleanups)c();this.cleanups=[];this._ep(0);if(this.scanner)this.scanner.unpause();this._setState("idle");}
|
|
@@ -1188,11 +1400,11 @@
|
|
|
1188
1400
|
this.stop();this._gen++;const gen=this._gen;
|
|
1189
1401
|
this._pendingSeek=seekMs;
|
|
1190
1402
|
if(this.scanner)this.scanner.pause();
|
|
1191
|
-
|
|
1403
|
+
const targets=this._targets(entry);
|
|
1404
|
+
if(!targets.length)return;
|
|
1192
1405
|
this.animations=[];
|
|
1193
|
-
const
|
|
1194
|
-
|
|
1195
|
-
const restore=this._arm(el,et);
|
|
1406
|
+
for(const {el,et,toState} of targets){
|
|
1407
|
+
const restore=this._arm(el,et,toState);
|
|
1196
1408
|
requestAnimationFrame(()=>{if(this._gen!==gen)return;const running=el.getAnimations();
|
|
1197
1409
|
for(const a of running){a.pause();a.playbackRate=this.rate;this.animations.push(a);}
|
|
1198
1410
|
const t=this._pendingSeek??seekMs;
|
|
@@ -1210,31 +1422,54 @@
|
|
|
1210
1422
|
this._pendingSeek=timeMs;
|
|
1211
1423
|
}
|
|
1212
1424
|
_finish(){this._stopPL();if(this.animations.length>0){let end=0;for(const a of this.animations){const t=a.effect?.getTiming();const e=(t?.delay??0)+(Number(t?.duration)||0);if(e>end)end=e;}this._ep(end);}this.animations=[];for(const c of this.cleanups)c();this.cleanups=[];if(this.scanner)this.scanner.unpause();this._setState("idle");}
|
|
1213
|
-
// Arm an element so its transition runs now
|
|
1214
|
-
//
|
|
1215
|
-
//
|
|
1216
|
-
|
|
1425
|
+
// Arm an element so its transition runs now, without needing the user to
|
|
1426
|
+
// trigger it first. Priority:
|
|
1427
|
+
// 1. a previously observed real run (exact),
|
|
1428
|
+
// 2. end-state discovered from the stylesheets (hover/focus/toggled class),
|
|
1429
|
+
// 3. a synthetic opacity/transform pulse to preview the timing,
|
|
1430
|
+
// 4. click the element (last resort, may have side effects).
|
|
1431
|
+
// Returns a restore fn.
|
|
1432
|
+
_arm(el,et,toState){
|
|
1217
1433
|
const saved=el.style.cssText;
|
|
1218
1434
|
const tv=et.map(t=>`${t.property} ${formatCssTime(t.durationMs)} ${t.easing} ${formatCssTime(t.delayMs)}`).join(", ");
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1435
|
+
// toState (a phase's driving class/attr) is the most reliable source of
|
|
1436
|
+
// the real end-state; fall back to a captured run, then CSSOM discovery.
|
|
1437
|
+
const states=(toState&&_txToState(el,et,toState))||_txCaptured(el,et)||_txDiscover(el,et);
|
|
1438
|
+
if(states){
|
|
1439
|
+
const props=et.map(t=>t.property).filter(p=>states.from[p]!=null&&states.to[p]!=null);
|
|
1440
|
+
if(props.length){
|
|
1441
|
+
el.style.transition="none";
|
|
1442
|
+
for(const p of props)el.style.setProperty(p,states.from[p]);
|
|
1443
|
+
void el.offsetWidth; // commit the from-state before transitioning
|
|
1444
|
+
el.style.transition=tv;
|
|
1445
|
+
for(const p of props)el.style.setProperty(p,states.to[p]);
|
|
1446
|
+
return ()=>{try{el.style.cssText=saved;}catch{}};
|
|
1447
|
+
}
|
|
1229
1448
|
}
|
|
1449
|
+
const synth=et.filter(t=>t.property==="opacity"||t.property==="transform"||t.property==="all");
|
|
1450
|
+
if(synth.length){
|
|
1451
|
+
const dur=Math.max(...et.map(t=>(t.durationMs||0)+(t.delayMs||0)))||et[0].durationMs||300;
|
|
1452
|
+
const ease=et[0].easing||"ease";
|
|
1453
|
+
const hasOp=synth.some(t=>t.property!=="transform"),hasTr=synth.some(t=>t.property!=="opacity");
|
|
1454
|
+
const k0={},k1={},k2={};
|
|
1455
|
+
if(hasOp){k0.opacity=1;k1.opacity=0.35;k2.opacity=1;}
|
|
1456
|
+
if(hasTr){k0.transform="none";k1.transform="translateY(8px)";k2.transform="none";}
|
|
1457
|
+
try{el.animate([k0,k1,k2],{duration:dur,easing:ease,fill:"none"});return ()=>{};}catch{}
|
|
1458
|
+
}
|
|
1459
|
+
el.style.transition=tv;el.click();
|
|
1230
1460
|
return ()=>{try{el.style.cssText=saved;}catch{}};
|
|
1231
1461
|
}
|
|
1232
|
-
_playCss(entry,gen){
|
|
1233
|
-
const
|
|
1234
|
-
|
|
1235
|
-
|
|
1462
|
+
_playCss(entry,gen){
|
|
1463
|
+
const targets=this._targets(entry);
|
|
1464
|
+
this.animations=[];
|
|
1465
|
+
if(!targets.length){this._finish();return;}
|
|
1466
|
+
let pending=targets.length;
|
|
1467
|
+
for(const {el,et,toState} of targets){
|
|
1468
|
+
const restore=this._arm(el,et,toState);
|
|
1236
1469
|
requestAnimationFrame(()=>{if(this._gen!==gen)return;const running=el.getAnimations();for(const a of running){a.playbackRate=this.rate;this.animations.push(a);}this._startPL();
|
|
1237
|
-
|
|
1470
|
+
// resolve the whole group when every target's animations have settled
|
|
1471
|
+
const onDone=()=>{if(this._gen!==gen||this.state!=="playing")return;pending--;if(pending>0)return;if(this.loop&&this._current){this.play(this._current);}else{this._finish();}};
|
|
1472
|
+
if(running.length>0){Promise.allSettled(running.map(a=>a.finished)).then(onDone);}else{onDone();}});
|
|
1238
1473
|
this.cleanups.push(restore);}}
|
|
1239
1474
|
_startPL(){this._stopPL();const tick=()=>{if(this.animations.length>0){let cur=0;for(const a of this.animations){const c=Number(a.currentTime)||0;if(c>cur)cur=c;}this._ep(cur);}this._rafId=requestAnimationFrame(tick);};this._rafId=requestAnimationFrame(tick);}
|
|
1240
1475
|
_stopPL(){if(this._rafId!==null){cancelAnimationFrame(this._rafId);this._rafId=null;}}
|
|
@@ -1642,10 +1877,46 @@
|
|
|
1642
1877
|
h("div",{className:"tl-refine-foot"},foot)));
|
|
1643
1878
|
}
|
|
1644
1879
|
|
|
1645
|
-
|
|
1880
|
+
// Diff the active item's effective (edited/refined) lanes against their
|
|
1881
|
+
// originally-scanned values — the set of changes Accept writes to source.
|
|
1882
|
+
// Each change carries member/selector/phase context so the agent can target
|
|
1883
|
+
// the right state rule (e.g. .is-open vs .is-closing) for a grouped phase.
|
|
1884
|
+
function computeChanges(active){
|
|
1885
|
+
if(!active)return[];
|
|
1886
|
+
const base=active.baseLanes||[];
|
|
1887
|
+
const bmap=new Map(base.map(t=>[t.laneId,t]));
|
|
1888
|
+
const out=[];
|
|
1889
|
+
for(const t of (active.effectiveTimings||[])){
|
|
1890
|
+
const b=bmap.get(t.laneId);if(!b)continue;
|
|
1891
|
+
if(t.durationMs!==b.durationMs||t.delayMs!==b.delayMs||(t.easing||"")!==(b.easing||"")){
|
|
1892
|
+
out.push({property:t.property,member:t.member||null,selector:t.selector||null,
|
|
1893
|
+
from:{durationMs:b.durationMs,delayMs:b.delayMs,easing:b.easing},
|
|
1894
|
+
to:{durationMs:t.durationMs,delayMs:t.delayMs,easing:t.easing}});
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
return out;
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
function Header({entries,active,onSelect,onReset,onCopy,copied,loop,setLoop,snap,setSnap,onMinimize,onRefine,refineActive,onAccept,acceptState,acceptDisabled,acceptError,scanning}){
|
|
1646
1901
|
const[pick,setPick]=useState(false);
|
|
1647
1902
|
const[setg,setSetg]=useState(false);
|
|
1648
1903
|
const pickRef=useRef(null), gearRef=useRef(null);
|
|
1904
|
+
// Split selectable items into agent groups (component → phases) and the
|
|
1905
|
+
// flat, ungrouped DOM transitions, preserving order.
|
|
1906
|
+
const groupList=[]; const groupMap=new Map(); const flatItems=[];
|
|
1907
|
+
for(const e of entries){
|
|
1908
|
+
if(e.kind==="phase"){
|
|
1909
|
+
let g=groupMap.get(e.groupId);
|
|
1910
|
+
if(!g){g={id:e.groupId,label:e.groupLabel,phases:[]};groupMap.set(e.groupId,g);groupList.push(g);}
|
|
1911
|
+
g.phases.push(e);
|
|
1912
|
+
}else flatItems.push(e);
|
|
1913
|
+
}
|
|
1914
|
+
const phaseItem=(e)=>h(MenuItem,{key:e.id,active:active&&e.id===active.id,onClick:()=>{onSelect(e.id);setPick(false);},
|
|
1915
|
+
right:active&&e.id===active.id&&h("span",{className:"tl-menu-check"},h(Ic,{name:"check"}))},
|
|
1916
|
+
h("span",null,e.phaseLabel,h("span",{className:"tl-menu-dim"}," "+e.durationMs+"ms")));
|
|
1917
|
+
const flatItem=(e)=>h(MenuItem,{key:e.id,active:active&&e.id===active.id,onClick:()=>{onSelect(e.id);setPick(false);},
|
|
1918
|
+
right:active&&e.id===active.id&&h("span",{className:"tl-menu-check"},h(Ic,{name:"check"}))},
|
|
1919
|
+
h("span",null,e.label,h("span",{className:"tl-menu-dim"}," "+e.durationMs+"ms")));
|
|
1649
1920
|
return h("div",{className:"tl-header"},
|
|
1650
1921
|
h("span",{className:"tl-header-label"},"Selected"),
|
|
1651
1922
|
h("button",{ref:pickRef,className:cx("tl-ghost-btn",pick&&"is-active"),disabled:!active,onClick:()=>setPick(v=>!v)},
|
|
@@ -1655,10 +1926,14 @@
|
|
|
1655
1926
|
h(Dropdown,{open:pick,onClose:()=>setPick(false),triggerRef:pickRef,width:Math.max(240,(pickRef.current&&pickRef.current.offsetWidth)||240),align:"left"},
|
|
1656
1927
|
entries.length===0
|
|
1657
1928
|
? h("div",{className:"tl-menu-empty"},"No transitions found")
|
|
1658
|
-
:
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1929
|
+
: h(React.Fragment,null,
|
|
1930
|
+
...groupList.map(g=>h(React.Fragment,{key:g.id},
|
|
1931
|
+
h("div",{className:"tl-menu-section"},g.label),
|
|
1932
|
+
...g.phases.map(phaseItem))),
|
|
1933
|
+
flatItems.length>0&&groupList.length>0&&h("div",{className:"tl-menu-section"},"Ungrouped"),
|
|
1934
|
+
...flatItems.map(flatItem))),
|
|
1935
|
+
h("span",{className:"tl-header-count"},
|
|
1936
|
+
scanning?"Grouping…":entries.length+" transition"+(entries.length===1?"":"s")+" found"),
|
|
1662
1937
|
h("button",{ref:gearRef,className:cx("tl-icon-btn",setg&&"is-active"),title:"Settings",onClick:()=>setSetg(v=>!v)},h(Ic,{name:"gear"})),
|
|
1663
1938
|
h(Dropdown,{open:setg,onClose:()=>setSetg(false),triggerRef:gearRef,width:210,align:"right"},
|
|
1664
1939
|
h(MenuItem,{onClick:()=>setLoop(v=>!v),right:loop&&h("span",{className:"tl-menu-check"},h(Ic,{name:"check"}))},"Loop playback"),
|
|
@@ -1672,6 +1947,18 @@
|
|
|
1672
1947
|
h("span",{className:"t-icon","data-icon":"a"},h(Ic,{name:"copy"})),
|
|
1673
1948
|
h("span",{className:"t-icon","data-icon":"b"},h(Ic,{name:"check"})))),
|
|
1674
1949
|
h("span",{className:"t-tt tl-tt-below",role:"tooltip"},copied?"Copied":"Copy values")),
|
|
1950
|
+
h("span",{className:"t-tt-wrap"},
|
|
1951
|
+
h("button",{className:cx("tl-accept-btn",acceptState==="saving"&&"is-saving",acceptState==="done"&&"is-done"),
|
|
1952
|
+
disabled:!active||acceptDisabled||acceptState==="saving"||acceptState==="done",onClick:onAccept,"aria-label":"Accept changes to your code"},
|
|
1953
|
+
acceptState==="saving"
|
|
1954
|
+
? h("span",{className:"tl-accept-spin","aria-hidden":"true"})
|
|
1955
|
+
: h(Ic,{name:"check"}),
|
|
1956
|
+
h("span",null,acceptState==="done"?"Done":"Accept")),
|
|
1957
|
+
h("span",{className:"t-tt tl-tt-below",role:"tooltip"},
|
|
1958
|
+
acceptState==="error"&&acceptError?acceptError
|
|
1959
|
+
:acceptState==="done"?"Saved to your code"
|
|
1960
|
+
:acceptDisabled?"No changes to save"
|
|
1961
|
+
:"Save changes to your codebase")),
|
|
1675
1962
|
h("button",{className:cx("tl-refine-btn",refineActive&&"is-active"),disabled:!active,onClick:onRefine},
|
|
1676
1963
|
h(Ic,{name:"wand"}),
|
|
1677
1964
|
h("span",{className:"tl-refine-sparks","aria-hidden":"true"},
|
|
@@ -1690,7 +1977,7 @@
|
|
|
1690
1977
|
);
|
|
1691
1978
|
}
|
|
1692
1979
|
|
|
1693
|
-
function PropTrack({property, delayMs, durationMs, selected, dragging, onSelect, onReorder, onDelayChange, onDurationChange, snap, scaleMs, lockDuration}){
|
|
1980
|
+
function PropTrack({property, member, delayMs, durationMs, selected, dragging, onSelect, onReorder, onDelayChange, onDurationChange, snap, scaleMs, lockDuration}){
|
|
1694
1981
|
const trackRef=useRef(null);
|
|
1695
1982
|
const grid = snap ? 25 : 1;
|
|
1696
1983
|
const pxToMs=useCallback(px=>{if(!trackRef.current)return 0;const w=trackRef.current.getBoundingClientRect().width;return Math.round((px/w)*scaleMs/grid)*grid;},[grid,scaleMs]);
|
|
@@ -1720,7 +2007,9 @@
|
|
|
1720
2007
|
return h("div",{className:cx("tl-prop-row",selected&&"selected",dragging&&"reordering",lockDuration&&"is-spring"),onClick:onSelect},
|
|
1721
2008
|
h("div",{className:"tl-prop-head"},
|
|
1722
2009
|
h("span",{className:"tl-prop-grip",title:"Drag to reorder",onMouseDown:onReorder},h(Ic,{name:"dots"})),
|
|
1723
|
-
h("span",{className:"tl-prop-label"},
|
|
2010
|
+
h("span",{className:"tl-prop-label"},
|
|
2011
|
+
member&&h("span",{className:"tl-prop-member"},member),
|
|
2012
|
+
h("span",{className:"tl-prop-prop"},property))),
|
|
1724
2013
|
h("div",{className:"tl-prop-track",ref:trackRef},
|
|
1725
2014
|
h("div",{className:"tl-bar",style:{left:delPct+"%",width:durPct+"%"},onMouseDown:e=>startDrag("move",e),
|
|
1726
2015
|
title:lockDuration?"Spring duration is derived \u2014 drag to move, resize is locked":undefined},
|
|
@@ -2136,44 +2425,44 @@
|
|
|
2136
2425
|
return h("div",{className:"tl-scrub-zone",ref:areaRef,onMouseDown:startScrub});
|
|
2137
2426
|
}
|
|
2138
2427
|
|
|
2139
|
-
function Tracks({et,
|
|
2428
|
+
function Tracks({et, selLane, setSelLane, onPropChange, snap, scaleMs}){
|
|
2140
2429
|
const t = usePreviewTime();
|
|
2141
2430
|
const ratio = Math.min(t / scaleMs, 1);
|
|
2142
2431
|
const ROW_H = 48;
|
|
2143
2432
|
const rowsRef = useRef(null);
|
|
2144
2433
|
const [order, setOrder] = useState([]);
|
|
2145
|
-
const [
|
|
2146
|
-
const
|
|
2434
|
+
const [dragLane, setDragLane] = useState(null);
|
|
2435
|
+
const laneKey = et.map(x=>x.laneId).join("|");
|
|
2147
2436
|
useEffect(()=>{
|
|
2148
|
-
const
|
|
2437
|
+
const ids = et.map(x=>x.laneId);
|
|
2149
2438
|
setOrder(prev=>{
|
|
2150
|
-
const kept = prev.filter(p=>
|
|
2151
|
-
const added =
|
|
2439
|
+
const kept = prev.filter(p=>ids.includes(p));
|
|
2440
|
+
const added = ids.filter(p=>!kept.includes(p));
|
|
2152
2441
|
return [...kept, ...added];
|
|
2153
2442
|
});
|
|
2154
|
-
},[
|
|
2155
|
-
const orderedRows = order.map(p=>et.find(x=>x.
|
|
2156
|
-
const startReorder = useCallback((
|
|
2443
|
+
},[laneKey]);
|
|
2444
|
+
const orderedRows = order.map(p=>et.find(x=>x.laneId===p)).filter(Boolean);
|
|
2445
|
+
const startReorder = useCallback((laneId,e)=>{
|
|
2157
2446
|
e.preventDefault(); e.stopPropagation();
|
|
2158
|
-
|
|
2159
|
-
|
|
2447
|
+
setSelLane(laneId);
|
|
2448
|
+
setDragLane(laneId);
|
|
2160
2449
|
const top = rowsRef.current ? rowsRef.current.getBoundingClientRect().top : 0;
|
|
2161
2450
|
const onMove = e2=>{
|
|
2162
2451
|
const idx = Math.floor((e2.clientY - top) / ROW_H);
|
|
2163
2452
|
setOrder(prev=>{
|
|
2164
|
-
const cur = prev.indexOf(
|
|
2453
|
+
const cur = prev.indexOf(laneId);
|
|
2165
2454
|
if(cur<0) return prev;
|
|
2166
2455
|
const target = Math.max(0, Math.min(prev.length-1, idx));
|
|
2167
2456
|
if(target===cur) return prev;
|
|
2168
2457
|
const next = prev.slice();
|
|
2169
2458
|
next.splice(cur,1);
|
|
2170
|
-
next.splice(target,0,
|
|
2459
|
+
next.splice(target,0,laneId);
|
|
2171
2460
|
return next;
|
|
2172
2461
|
});
|
|
2173
2462
|
};
|
|
2174
|
-
const onUp = ()=>{
|
|
2463
|
+
const onUp = ()=>{ setDragLane(null); window.removeEventListener("mousemove",onMove); window.removeEventListener("mouseup",onUp); };
|
|
2175
2464
|
window.addEventListener("mousemove",onMove); window.addEventListener("mouseup",onUp);
|
|
2176
|
-
},[
|
|
2465
|
+
},[setSelLane]);
|
|
2177
2466
|
const majorStep = scaleMs <= 10000 ? 1000 : scaleMs <= 25000 ? 5000 : 10000;
|
|
2178
2467
|
const minorStep = majorStep / 4;
|
|
2179
2468
|
const ruler=[];
|
|
@@ -2191,13 +2480,13 @@
|
|
|
2191
2480
|
h("div",{className:"tl-ruler"},...ruler)),
|
|
2192
2481
|
h("div",{className:"tl-rows",ref:rowsRef},
|
|
2193
2482
|
...orderedRows.map(row=>h(PropTrack,{
|
|
2194
|
-
key:row.
|
|
2483
|
+
key:row.laneId, property:row.property, member:row.member,
|
|
2195
2484
|
delayMs:row.delayMs, durationMs:row.durationMs, lockDuration:!!row.spring,
|
|
2196
|
-
selected:row.
|
|
2197
|
-
onSelect:()=>
|
|
2198
|
-
onReorder:e=>startReorder(row.
|
|
2199
|
-
onDelayChange:ms=>onPropChange(row.
|
|
2200
|
-
onDurationChange:ms=>onPropChange(row.
|
|
2485
|
+
selected:row.laneId===selLane, dragging:row.laneId===dragLane, snap, scaleMs,
|
|
2486
|
+
onSelect:()=>setSelLane(row.laneId),
|
|
2487
|
+
onReorder:e=>startReorder(row.laneId,e),
|
|
2488
|
+
onDelayChange:ms=>onPropChange(row.laneId,{delayMs:ms}),
|
|
2489
|
+
onDurationChange:ms=>onPropChange(row.laneId,{durationMs:ms}),
|
|
2201
2490
|
}))),
|
|
2202
2491
|
h(ScrubZone,{scaleMs}),
|
|
2203
2492
|
h("div",{className:"tl-playhead-layer"},
|
|
@@ -2208,40 +2497,42 @@
|
|
|
2208
2497
|
);
|
|
2209
2498
|
}
|
|
2210
2499
|
|
|
2211
|
-
function Inspector({entry,
|
|
2500
|
+
function Inspector({entry, selLane, onPropChange, snap}){
|
|
2212
2501
|
const et = entry.effectiveTimings || [];
|
|
2213
|
-
const sel = et.find(t=>t.
|
|
2502
|
+
const sel = et.find(t=>t.laneId===selLane) || et[0];
|
|
2214
2503
|
if(!sel) return h("div",{className:"tl-inspector"});
|
|
2215
2504
|
const cubic = easingToCubic(sel.easing);
|
|
2216
2505
|
const isSpring = !!sel.spring;
|
|
2217
2506
|
return h("div",{className:"tl-inspector"},
|
|
2218
|
-
h("div",{className:"tl-insp-title"},
|
|
2507
|
+
h("div",{className:"tl-insp-title"},
|
|
2508
|
+
sel.member&&h("span",{className:"tl-insp-member"},sel.member),
|
|
2509
|
+
h("span",null,sel.property)),
|
|
2219
2510
|
h(ValueField,{label:"Duration",value:sel.durationMs,min:0,max:5000,step:25,tokens:DURATION_TOKENS,snap,
|
|
2220
2511
|
readOnly:isSpring,
|
|
2221
2512
|
readOnlyHint:"Duration is set by the spring. A spring's settle time is derived from its stiffness, damping and mass \u2014 so it can't be edited directly. Switch to the Easing tab to set a fixed duration.",
|
|
2222
|
-
onChange:v=>onPropChange(sel.
|
|
2513
|
+
onChange:v=>onPropChange(sel.laneId,{durationMs:v})}),
|
|
2223
2514
|
h(ValueField,{label:"Delay",value:sel.delayMs,min:0,max:5000,step:25,tokens:DELAY_TOKENS,snap,
|
|
2224
|
-
onChange:v=>onPropChange(sel.
|
|
2225
|
-
h(EasingEditor,{easing:sel.easing, cubic, spring:sel.spring, durationMs:sel.durationMs, propKey:sel.
|
|
2226
|
-
apply:partial=>onPropChange(sel.
|
|
2515
|
+
onChange:v=>onPropChange(sel.laneId,{delayMs:v})}),
|
|
2516
|
+
h(EasingEditor,{easing:sel.easing, cubic, spring:sel.spring, durationMs:sel.durationMs, propKey:sel.laneId,
|
|
2517
|
+
apply:partial=>onPropChange(sel.laneId,partial)}),
|
|
2227
2518
|
);
|
|
2228
2519
|
}
|
|
2229
2520
|
|
|
2230
2521
|
function Body({entry, onPropChange, state, play, pause, resume, restart, stop, speed, setSpeed, snap}){
|
|
2231
2522
|
const et = entry.effectiveTimings || [];
|
|
2232
|
-
const [
|
|
2523
|
+
const [selLane, setSelLane] = useState(et[0]?.laneId ?? null);
|
|
2233
2524
|
const [zoom, setZoom] = useState(ZOOM_DEFAULT);
|
|
2234
2525
|
const scaleMs = scaleFromZoom(zoom);
|
|
2235
2526
|
useEffect(()=>{
|
|
2236
|
-
if(et.length && !et.find(t=>t.
|
|
2237
|
-
},[et,
|
|
2527
|
+
if(et.length && !et.find(t=>t.laneId===selLane)) setSelLane(et[0]?.laneId);
|
|
2528
|
+
},[et,selLane]);
|
|
2238
2529
|
|
|
2239
2530
|
return h("div",{className:"tl-body"},
|
|
2240
2531
|
h("div",{className:"tl-main"},
|
|
2241
2532
|
h(Transport,{state,disabled:!entry,onPlay:play,onPause:pause,onResume:resume,onRestart:restart,onStop:stop,speed,setSpeed,zoom,setZoom}),
|
|
2242
|
-
h(Tracks,{et,
|
|
2533
|
+
h(Tracks,{et,selLane,setSelLane,onPropChange,snap,scaleMs}),
|
|
2243
2534
|
),
|
|
2244
|
-
h(Inspector,{entry,
|
|
2535
|
+
h(Inspector,{entry,selLane,onPropChange,snap}),
|
|
2245
2536
|
);
|
|
2246
2537
|
}
|
|
2247
2538
|
|
|
@@ -2295,6 +2586,12 @@
|
|
|
2295
2586
|
const[refineSuggestions,setRefineSuggestions]=useState([]);
|
|
2296
2587
|
const[refineSummary,setRefineSummary]=useState(null);
|
|
2297
2588
|
const[refineError,setRefineError]=useState(null);
|
|
2589
|
+
// ── accept (write to source) ──
|
|
2590
|
+
const[acceptState,setAcceptState]=useState("idle"); // idle | saving | done | error
|
|
2591
|
+
const[acceptError,setAcceptError]=useState(null);
|
|
2592
|
+
// ── grouped scan (agent reads source → Open/Close phases) ──
|
|
2593
|
+
const[groupScanState,setGroupScanState]=useState("idle"); // idle | scanning | done | error
|
|
2594
|
+
const didGroupScanRef=useRef(false);
|
|
2298
2595
|
const[refineLabel,setRefineLabel]=useState(null);
|
|
2299
2596
|
const[appliedIds,setAppliedIds]=useState({});
|
|
2300
2597
|
const[refineMode,setRefineMode]=useState("llm"); // llm (Agent) | deterministic
|
|
@@ -2328,8 +2625,11 @@
|
|
|
2328
2625
|
const mode=(refineMode==="llm"&&!avail)?"deterministic":refineMode;
|
|
2329
2626
|
if(mode!==refineMode)setRefineMode(mode);
|
|
2330
2627
|
try{
|
|
2331
|
-
const
|
|
2332
|
-
const
|
|
2628
|
+
const et=active.effectiveTimings||[];
|
|
2629
|
+
const timings=et.map(t=>({property:t.property,durationMs:t.durationMs,delayMs:t.delayMs,easing:t.easing}));
|
|
2630
|
+
const selector=(active.bindings&&active.bindings.selector)||(et[0]&&et[0].selector)||null;
|
|
2631
|
+
const{id}=await relayCreateJob({transitionId:active.id,label:active.label,selector,
|
|
2632
|
+
phase:active.phase||null,group:active.groupLabel||null,timings,mode,refineType});
|
|
2333
2633
|
setRefineJobId(id);
|
|
2334
2634
|
}catch(e){
|
|
2335
2635
|
setRefinePhase("error");
|
|
@@ -2361,9 +2661,15 @@
|
|
|
2361
2661
|
if(p.durationMs!=null)o.durationMs=p.durationMs;
|
|
2362
2662
|
if(p.delayMs!=null)o.delayMs=p.delayMs;
|
|
2363
2663
|
if(p.easing!=null)o.easing=p.easing;
|
|
2364
|
-
|
|
2664
|
+
// map a property patch onto the matching lane(s) — for a grouped phase the
|
|
2665
|
+
// same property can appear on several members, so apply to each.
|
|
2666
|
+
const et=(active&&active.effectiveTimings)||[];
|
|
2667
|
+
let lanes=p.property==="all"?et:et.filter(t=>t.property===p.property);
|
|
2668
|
+
if(p.member)lanes=lanes.filter(t=>(t.member===p.member||t.memberId===p.member));
|
|
2669
|
+
const ids=lanes.length?lanes.map(t=>t.laneId):[p.property];
|
|
2670
|
+
for(const id of ids)setPropOverride(id,o);
|
|
2365
2671
|
setAppliedIds(prev=>({...prev,[s.id]:true}));
|
|
2366
|
-
},[setPropOverride]);
|
|
2672
|
+
},[setPropOverride,active]);
|
|
2367
2673
|
const applyAllSuggestions=useCallback(()=>{
|
|
2368
2674
|
for(const s of refineSuggestions){if(!appliedIds[s.id])applySuggestion(s);}
|
|
2369
2675
|
},[refineSuggestions,appliedIds,applySuggestion]);
|
|
@@ -2385,12 +2691,79 @@
|
|
|
2385
2691
|
const copyValues=useCallback(()=>{
|
|
2386
2692
|
if(!active)return;
|
|
2387
2693
|
const et=active.effectiveTimings||[];
|
|
2388
|
-
const
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2694
|
+
const decl=t=>`${t.property} ${formatCssTime(t.durationMs)} ${t.easing} ${formatCssTime(t.delayMs)}`;
|
|
2695
|
+
let text;
|
|
2696
|
+
if(et.some(t=>t.member)){
|
|
2697
|
+
// grouped phase: one transition block per member, labelled
|
|
2698
|
+
const byMember=new Map();
|
|
2699
|
+
for(const t of et){const k=t.memberId||t.member||"";if(!byMember.has(k))byMember.set(k,{member:t.member,selector:t.selector,lanes:[]});byMember.get(k).lanes.push(t);}
|
|
2700
|
+
text=[...byMember.values()].map(m=>
|
|
2701
|
+
`/* ${m.member||"element"}${m.selector?" — "+m.selector:""} */\ntransition: ${m.lanes.map(decl).join(",\n ")};`
|
|
2702
|
+
).join("\n\n");
|
|
2703
|
+
}else{
|
|
2704
|
+
text="transition: "+et.map(decl).join(",\n ")+";";
|
|
2705
|
+
}
|
|
2706
|
+
navigator.clipboard.writeText(text).then(()=>{setCopied(true);setTimeout(()=>setCopied(false),1500);});
|
|
2392
2707
|
},[active]);
|
|
2393
2708
|
const resetOverrides=useCallback(()=>{if(active)registry.clearOverride(active.id);},[registry,active]);
|
|
2709
|
+
// Accept → send an "apply" job so the agent writes the edited timings into
|
|
2710
|
+
// the user's source, then reflect saving / done / error on the button.
|
|
2711
|
+
const onAccept=useCallback(async()=>{
|
|
2712
|
+
const changes=computeChanges(active);
|
|
2713
|
+
if(!active||!changes.length)return;
|
|
2714
|
+
setAcceptState("saving");setAcceptError(null);
|
|
2715
|
+
try{
|
|
2716
|
+
const{id}=await relayCreateJob({kind:"apply",transitionId:active.id,label:active.label,
|
|
2717
|
+
selector:active.bindings&&active.bindings.selector,
|
|
2718
|
+
group:active.groupLabel||null,phase:active.phase||null,component:active.component||null,
|
|
2719
|
+
changes});
|
|
2720
|
+
let settled=false;
|
|
2721
|
+
for(let i=0;i<240&&!settled;i++){
|
|
2722
|
+
await new Promise(r=>setTimeout(r,500));
|
|
2723
|
+
const job=await relayGetJob(id);
|
|
2724
|
+
if(job.status==="done"){settled=true;
|
|
2725
|
+
if(job.result&&job.result.applied===false){
|
|
2726
|
+
setAcceptState("error");setAcceptError((job.result&&job.result.summary)||"The agent couldn't find this transition in your source.");
|
|
2727
|
+
}else{
|
|
2728
|
+
setAcceptState("done");setTimeout(()=>setAcceptState("idle"),2500);
|
|
2729
|
+
}
|
|
2730
|
+
}else if(job.status==="error"){settled=true;setAcceptState("error");setAcceptError(job.error||"The agent reported an error.");}
|
|
2731
|
+
}
|
|
2732
|
+
if(!settled){setAcceptState("error");setAcceptError("Timed out waiting for the agent.");}
|
|
2733
|
+
}catch(e){
|
|
2734
|
+
setAcceptState("error");
|
|
2735
|
+
setAcceptError("Couldn't reach the relay. Run: npx transitions-refine live");
|
|
2736
|
+
}
|
|
2737
|
+
},[active]);
|
|
2738
|
+
// reset Accept feedback when switching transitions
|
|
2739
|
+
useEffect(()=>{setAcceptState("idle");setAcceptError(null);},[active&&active.id]);
|
|
2740
|
+
// Auto-run the grouped scan once, after the flat DOM scan has populated.
|
|
2741
|
+
// The agent reads the source and returns Open/Close phases; if no agent is
|
|
2742
|
+
// live (or the relay is down) we silently keep the flat DOM scan.
|
|
2743
|
+
useEffect(()=>{
|
|
2744
|
+
if(didGroupScanRef.current||!entries.length)return;
|
|
2745
|
+
didGroupScanRef.current=true;
|
|
2746
|
+
let cancelled=false;
|
|
2747
|
+
(async()=>{
|
|
2748
|
+
setGroupScanState("scanning");
|
|
2749
|
+
const flat=registry.getAll().filter(e=>e.kind!=="phase");
|
|
2750
|
+
const raw=flat.map(e=>({label:e.label,selector:e.bindings&&e.bindings.selector,
|
|
2751
|
+
properties:e.properties,
|
|
2752
|
+
timings:(e.baseLanes||[]).map(l=>({property:l.property,durationMs:l.durationMs,delayMs:l.delayMs,easing:l.easing}))}));
|
|
2753
|
+
try{
|
|
2754
|
+
const{id}=await relayCreateJob({kind:"scan",url:location.href,raw});
|
|
2755
|
+
for(let i=0;i<240&&!cancelled;i++){
|
|
2756
|
+
await new Promise(r=>setTimeout(r,500));
|
|
2757
|
+
const job=await relayGetJob(id);
|
|
2758
|
+
if(cancelled)return;
|
|
2759
|
+
if(job.status==="done"){const groups=(job.result&&job.result.groups)||[];if(groups.length)registry.setGroups(groups);setGroupScanState("done");return;}
|
|
2760
|
+
if(job.status==="error"){setGroupScanState("error");return;}
|
|
2761
|
+
}
|
|
2762
|
+
if(!cancelled)setGroupScanState("error");
|
|
2763
|
+
}catch(e){ if(!cancelled)setGroupScanState("error"); /* relay down → stay flat */ }
|
|
2764
|
+
})();
|
|
2765
|
+
return ()=>{cancelled=true;};
|
|
2766
|
+
},[entries.length,registry]);
|
|
2394
2767
|
|
|
2395
2768
|
// whole-component open/close uses the transitions.dev panel reveal:
|
|
2396
2769
|
// keep the panel mounted while it animates, flip data-open on the next
|
|
@@ -2439,7 +2812,9 @@
|
|
|
2439
2812
|
h("div",{className:"tl-panel-body"},
|
|
2440
2813
|
h("div",{className:"tl-panel-main"},
|
|
2441
2814
|
h(Header,{entries,active,onSelect:setActiveId,onReset:resetOverrides,onCopy:copyValues,copied,
|
|
2442
|
-
loop,setLoop,snap,setSnap,onMinimize:()=>setMinimized(true),onRefine:openRefine,refineActive:refineOpen
|
|
2815
|
+
loop,setLoop,snap,setSnap,onMinimize:()=>setMinimized(true),onRefine:openRefine,refineActive:refineOpen,
|
|
2816
|
+
onAccept,acceptState,acceptDisabled:computeChanges(active).length===0,acceptError,
|
|
2817
|
+
scanning:groupScanState==="scanning"}),
|
|
2443
2818
|
active
|
|
2444
2819
|
?h(Body,{entry:active,onPropChange:(prop,o)=>setPropOverride(prop,o),
|
|
2445
2820
|
state,play,pause,resume,restart,stop,speed,setSpeed,snap})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "transitions-refine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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
|
@@ -140,24 +140,43 @@ function buildPrompt(job) {
|
|
|
140
140
|
return lines.join("\n");
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
-
function
|
|
143
|
+
function parseJsonish(stdout) {
|
|
144
144
|
let s = (stdout || "").trim();
|
|
145
145
|
const fence = s.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
146
146
|
if (fence) s = fence[1].trim();
|
|
147
|
-
let obj;
|
|
148
147
|
try {
|
|
149
|
-
|
|
148
|
+
return JSON.parse(s);
|
|
150
149
|
} catch {
|
|
151
150
|
const a = s.indexOf("{");
|
|
152
151
|
const b = s.lastIndexOf("}");
|
|
153
|
-
if (a >= 0 && b > a)
|
|
154
|
-
|
|
152
|
+
if (a >= 0 && b > a) return JSON.parse(s.slice(a, b + 1));
|
|
153
|
+
throw new Error("agent output was not JSON");
|
|
155
154
|
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function parseAgentOutput(stdout) {
|
|
158
|
+
const obj = parseJsonish(stdout);
|
|
156
159
|
if (!obj || !Array.isArray(obj.suggestions)) throw new Error("agent output missing suggestions[]");
|
|
157
160
|
return { suggestions: obj.suggestions, summary: obj.summary ?? null };
|
|
158
161
|
}
|
|
159
162
|
|
|
160
|
-
|
|
163
|
+
// Apply jobs ask the agent to edit the user's source, so the result is an
|
|
164
|
+
// outcome, not suggestions.
|
|
165
|
+
function parseApplyOutput(stdout) {
|
|
166
|
+
const obj = parseJsonish(stdout);
|
|
167
|
+
if (!obj || typeof obj.applied === "undefined") throw new Error("agent output missing `applied`");
|
|
168
|
+
return { applied: Boolean(obj.applied), summary: obj.summary ?? null, files: Array.isArray(obj.files) ? obj.files : null };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Scan jobs ask the agent to read the source and group related transitions into
|
|
172
|
+
// components with open/close phases and member elements.
|
|
173
|
+
function parseScanOutput(stdout) {
|
|
174
|
+
const obj = parseJsonish(stdout);
|
|
175
|
+
if (!obj || !Array.isArray(obj.groups)) throw new Error("agent output missing groups[]");
|
|
176
|
+
return { groups: obj.groups, summary: obj.summary ?? null };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function runAgentCmd(cmd, prompt, parse = parseAgentOutput) {
|
|
161
180
|
return new Promise((resolve, reject) => {
|
|
162
181
|
const child = spawn("sh", ["-c", cmd], { stdio: ["pipe", "pipe", "pipe"] });
|
|
163
182
|
let out = "";
|
|
@@ -176,7 +195,7 @@ function runAgentCmd(cmd, prompt) {
|
|
|
176
195
|
clearTimeout(timer);
|
|
177
196
|
if (code !== 0) return reject(new Error(`agent exited ${code}: ${err.slice(0, 300)}`));
|
|
178
197
|
try {
|
|
179
|
-
resolve(
|
|
198
|
+
resolve(parse(out));
|
|
180
199
|
} catch (e) {
|
|
181
200
|
reject(new Error(`${e.message} — got: ${out.slice(0, 200)}`));
|
|
182
201
|
}
|
|
@@ -186,6 +205,50 @@ function runAgentCmd(cmd, prompt) {
|
|
|
186
205
|
});
|
|
187
206
|
}
|
|
188
207
|
|
|
208
|
+
// Prompt for an "apply" job: the agent edits the user's source so the selected
|
|
209
|
+
// transition uses the approved timings.
|
|
210
|
+
function buildApplyPrompt(job) {
|
|
211
|
+
const r = job.request || {};
|
|
212
|
+
return [
|
|
213
|
+
"You are APPLYING an approved transition change to the user's SOURCE CODE. Edit files; do not just suggest.",
|
|
214
|
+
"",
|
|
215
|
+
"Change context (JSON):",
|
|
216
|
+
JSON.stringify({ label: r.label, selector: r.selector, component: r.component, group: r.group, phase: r.phase, changes: r.changes }, null, 2),
|
|
217
|
+
"",
|
|
218
|
+
"If `phase` is set (e.g. \"open\"/\"close\"), the change targets ONE state of a component — edit the rule for THAT state (e.g. the `.is-open` rule for open, the `.is-closing`/base rule for close), not the other phase. Each change may carry its own `member` + `selector` identifying which element it belongs to.",
|
|
219
|
+
"",
|
|
220
|
+
"Steps:",
|
|
221
|
+
"1. Find where this transition is defined in the source. Search by the per-change `selector`/`member`, the `component` hint, and class names. Handle plain CSS, CSS Modules, styled-components/emotion template literals, Tailwind utilities/config, inline style objects, and Motion/Framer variants — the browser selector is a hint, the real declaration may live in any of these.",
|
|
222
|
+
"2. For each change, edit the source so that property's transition uses the `to` values: durationMs (ms), easing, delayMs (ms). Keep the file's existing unit/format conventions (e.g. `0.25s` vs `250ms`) and only touch the timing of the named property on the right member + phase. If a CSS variable / design token backs the value, update it at the most sensible single place.",
|
|
223
|
+
"3. Make the minimal edit. Do not reformat or change unrelated code.",
|
|
224
|
+
"",
|
|
225
|
+
'Output ONLY a JSON object — no prose, no markdown fences — shaped exactly like:',
|
|
226
|
+
'{"applied":true,"summary":"Set .modal transition to 250ms ease-out","files":["src/Modal.css:42"]}',
|
|
227
|
+
'If you cannot confidently locate the declaration, output {"applied":false,"summary":"<what you looked for and why it was not found>"}.',
|
|
228
|
+
].join("\n");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Prompt for a "scan" job: the agent reads the source and groups the raw,
|
|
232
|
+
// DOM-detected transitions into components with open/close phases and members.
|
|
233
|
+
function buildScanPrompt(job) {
|
|
234
|
+
const r = job.request || {};
|
|
235
|
+
return [
|
|
236
|
+
"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.",
|
|
237
|
+
"",
|
|
238
|
+
"Raw DOM-detected transitions (JSON) — use as hints to locate the components, not as the final answer:",
|
|
239
|
+
JSON.stringify({ url: r.url, raw: r.raw }, null, 2),
|
|
240
|
+
"",
|
|
241
|
+
"Steps:",
|
|
242
|
+
"1. Identify each animated UI component (dropdown, modal, tooltip, accordion, drawer, toast…). Read its source (CSS/CSS Modules, styled-components/emotion, Tailwind, inline styles, Motion/Framer variants).",
|
|
243
|
+
"2. For each component, split into PHASES — typically `open` and `close` (a hover-only component may have a single phase). Open and close often live on different selectors (e.g. `.is-open` vs `.is-closing`) with different timings; report BOTH even though only one is in the DOM right now.",
|
|
244
|
+
"3. For each phase, list its MEMBER elements (panel, backdrop, the staggered items…). Give each member a stable `id`, a human `label`, a CSS `selector` that resolves in the live DOM, an optional `toState` hint (the class/attribute that drives that phase, e.g. `.is-open`), and its real `propertyTimings` (durationMs, delayMs, easing per animated property). Quote the real timings from source — never invent.",
|
|
245
|
+
"",
|
|
246
|
+
"Output ONLY a JSON object — no prose, no markdown fences — shaped exactly like:",
|
|
247
|
+
'{"summary":"Grouped 3 components.","groups":[{"id":"dropdown","label":"Dropdown","component":"src/Dropdown.tsx","phases":[{"id":"dropdown:open","phase":"open","label":"Open","members":[{"id":"panel","label":"Panel","selector":".dropdown-panel","toState":".is-open","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","members":[{"id":"panel","label":"Panel","selector":".dropdown-panel","toState":".is-closing","propertyTimings":[{"property":"opacity","durationMs":150,"delayMs":0,"easing":"ease-in"}]}]}]}]}',
|
|
248
|
+
"If you cannot confidently group anything, return an empty groups array with a short summary; the panel keeps its flat DOM scan.",
|
|
249
|
+
].join("\n");
|
|
250
|
+
}
|
|
251
|
+
|
|
189
252
|
function refineDeterministic(job) {
|
|
190
253
|
// Whole-transition replacement needs usage inference + recipe selection, which
|
|
191
254
|
// only the agent (LLM) path can do. Deterministic can only snap to tokens.
|
|
@@ -207,13 +270,50 @@ function refineDeterministic(job) {
|
|
|
207
270
|
async function answerJob(job) {
|
|
208
271
|
job.status = "working";
|
|
209
272
|
job.updatedAt = now();
|
|
273
|
+
const isApply = job.request?.kind === "apply";
|
|
274
|
+
const isScan = job.request?.kind === "scan";
|
|
210
275
|
const label = job.request?.label || job.request?.selector || "transition";
|
|
211
276
|
// The browser picks the mode per job via the LLM / Deterministic tabs.
|
|
212
277
|
// Default: LLM when a command is configured, otherwise deterministic.
|
|
213
278
|
const mode = job.request?.mode || (AGENT_CMD ? "llm" : "deterministic");
|
|
214
|
-
job.statusLog.push({
|
|
279
|
+
job.statusLog.push({
|
|
280
|
+
message: isApply ? `Writing "${label}" to your code…` : isScan ? "Grouping transitions from your source…" : `Scanning "${label}"…`,
|
|
281
|
+
at: now(),
|
|
282
|
+
});
|
|
215
283
|
try {
|
|
216
284
|
let result;
|
|
285
|
+
if (isApply) {
|
|
286
|
+
// Editing source can only be done by the agent.
|
|
287
|
+
if (!AGENT_CMD) {
|
|
288
|
+
throw new Error(
|
|
289
|
+
"Saving to your code needs the agent. Run `/refine live` in your editor, " +
|
|
290
|
+
"or start the relay with REFINE_AGENT_CMD set."
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
job.statusLog.push({ message: "Editing source files…", at: now() });
|
|
294
|
+
result = await runAgentCmd(AGENT_CMD, buildApplyPrompt(job), parseApplyOutput);
|
|
295
|
+
job.result = { applied: result.applied, summary: result.summary, files: result.files };
|
|
296
|
+
job.status = "done";
|
|
297
|
+
job.updatedAt = now();
|
|
298
|
+
console.log(` ✓ apply ${job.id.slice(0, 8)} — applied=${result.applied}`);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (isScan) {
|
|
302
|
+
// Reading source to group transitions can only be done by the agent.
|
|
303
|
+
if (!AGENT_CMD) {
|
|
304
|
+
throw new Error(
|
|
305
|
+
"Grouping transitions needs the agent. Run `/refine live` in your editor, " +
|
|
306
|
+
"or start the relay with REFINE_AGENT_CMD set."
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
job.statusLog.push({ message: "Reading components from source…", at: now() });
|
|
310
|
+
result = await runAgentCmd(AGENT_CMD, buildScanPrompt(job), parseScanOutput);
|
|
311
|
+
job.result = { groups: result.groups, summary: result.summary };
|
|
312
|
+
job.status = "done";
|
|
313
|
+
job.updatedAt = now();
|
|
314
|
+
console.log(` ✓ scan ${job.id.slice(0, 8)} — ${result.groups.length} group(s)`);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
217
317
|
if (mode === "llm") {
|
|
218
318
|
if (!AGENT_CMD) {
|
|
219
319
|
throw new Error(
|
|
@@ -313,7 +413,10 @@ const server = createServer(async (req, res) => {
|
|
|
313
413
|
return send(res, 400, { error: "Body must be { request: {...} }" });
|
|
314
414
|
}
|
|
315
415
|
const job = createJob(body.request);
|
|
316
|
-
|
|
416
|
+
// Apply and scan jobs read/edit source — agent only, never deterministic.
|
|
417
|
+
const mode = (job.request.kind === "apply" || job.request.kind === "scan")
|
|
418
|
+
? "llm"
|
|
419
|
+
: (job.request.mode || (llmAvailable() ? "llm" : "deterministic"));
|
|
317
420
|
job.request.mode = mode;
|
|
318
421
|
|
|
319
422
|
if (!AUTO) {
|
|
@@ -386,9 +489,17 @@ const server = createServer(async (req, res) => {
|
|
|
386
489
|
|
|
387
490
|
if (method === "POST" && sub === "result") {
|
|
388
491
|
const body = await readJson(req);
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
492
|
+
if (body && Array.isArray(body.suggestions)) {
|
|
493
|
+
job.result = { suggestions: body.suggestions, summary: body.summary ?? null };
|
|
494
|
+
} else if (body && Array.isArray(body.groups)) {
|
|
495
|
+
// scan-job result from a `/refine live` agent
|
|
496
|
+
job.result = { groups: body.groups, summary: body.summary ?? null };
|
|
497
|
+
} else if (body && typeof body.applied !== "undefined") {
|
|
498
|
+
// apply-job result from a `/refine live` agent
|
|
499
|
+
job.result = { applied: Boolean(body.applied), summary: body.summary ?? null, files: Array.isArray(body.files) ? body.files : null };
|
|
500
|
+
} else {
|
|
501
|
+
return send(res, 400, { error: "Body must be { suggestions: [...] }, { groups: [...] }, or { applied, summary }" });
|
|
502
|
+
}
|
|
392
503
|
job.status = "done";
|
|
393
504
|
job.updatedAt = now();
|
|
394
505
|
return send(res, 200, { ok: true });
|