transitions-refine 0.1.3 → 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 +96 -12
- package/README.md +9 -1
- package/demo.html +293 -87
- package/package.json +1 -1
- package/server/relay.mjs +61 -7
|
@@ -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)
|
|
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,10 @@ 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.
|
|
57
61
|
- **If `request.kind === "apply"`** this is not a suggestion job — the user
|
|
58
62
|
pressed **Accept** to write changes to their code. Jump to
|
|
59
63
|
[`## Apply jobs`](#apply-jobs-write-to-source) and edit the source instead of
|
|
@@ -176,6 +180,78 @@ never has to re-run `/refine live`.
|
|
|
176
180
|
stop, tell them the LLM tab will go unavailable and how to restart
|
|
177
181
|
(`/refine live`).
|
|
178
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
|
+
|
|
179
255
|
## Apply jobs (write to source)
|
|
180
256
|
|
|
181
257
|
When a claimed job has `request.kind === "apply"`, the user accepted their current
|
|
@@ -186,10 +262,14 @@ timeline values and wants them written to the codebase. The request looks like:
|
|
|
186
262
|
"id": "uuid",
|
|
187
263
|
"request": {
|
|
188
264
|
"kind": "apply",
|
|
189
|
-
"label": "
|
|
190
|
-
"selector": "
|
|
265
|
+
"label": "Dropdown · Close",
|
|
266
|
+
"selector": ".dropdown-panel",
|
|
267
|
+
"component": "src/Dropdown.tsx",
|
|
268
|
+
"group": "Dropdown",
|
|
269
|
+
"phase": "close",
|
|
191
270
|
"changes": [
|
|
192
|
-
{ "property": "opacity", "
|
|
271
|
+
{ "property": "opacity", "member": "Panel", "selector": ".dropdown-panel",
|
|
272
|
+
"from": { "durationMs": 300, "delayMs": 0, "easing": "ease" },
|
|
193
273
|
"to": { "durationMs": 150, "delayMs": 0, "easing": "cubic-bezier(0.4, 0, 1, 1)" } }
|
|
194
274
|
]
|
|
195
275
|
}
|
|
@@ -199,15 +279,19 @@ timeline values and wants them written to the codebase. The request looks like:
|
|
|
199
279
|
Do this:
|
|
200
280
|
|
|
201
281
|
1. **Locate the real declaration in the source.** The `selector` is a DOM-path
|
|
202
|
-
*hint*, not necessarily the source selector.
|
|
203
|
-
handle whatever the project uses: plain CSS / CSS
|
|
204
|
-
emotion template literals, Tailwind utilities
|
|
205
|
-
`[transition-duration:300ms]`, or the
|
|
206
|
-
`style={{ transition: … }}` objects
|
|
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.
|
|
207
291
|
2. **Edit each change's property** to its `to` values (`durationMs` ms, `easing`,
|
|
208
|
-
`delayMs` ms). Keep the file's existing unit/format
|
|
209
|
-
touch only that property's timing. If a CSS variable /
|
|
210
|
-
value, update it at the single most sensible place.
|
|
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.
|
|
211
295
|
3. **Minimal edit** — no reformatting or unrelated changes.
|
|
212
296
|
4. **Post the outcome** (this completes the job):
|
|
213
297
|
|
package/README.md
CHANGED
|
@@ -4,6 +4,8 @@ A live, agent-driven **Refine** panel for CSS and [Motion](https://motion.dev) t
|
|
|
4
4
|
|
|
5
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
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.
|
|
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
|
|
|
9
11
|
```
|
|
@@ -54,6 +56,12 @@ 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.
|
|
@@ -86,7 +94,7 @@ Like Replace, Accept needs the agent — run `/refine live` (or `--llm` / `REFIN
|
|
|
86
94
|
| `REFINE_AUTO=0` | — | disable auto-answer and wait for an external poller |
|
|
87
95
|
| `window.REFINE_RELAY_URL` | injected origin | browser override for the relay URL |
|
|
88
96
|
|
|
89
|
-
Endpoints: `POST /jobs` (refine or `kind: "
|
|
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`).
|
|
90
98
|
|
|
91
99
|
Refine suggestions stay as live overrides until you press **Accept**, which is the explicit step that writes them into your source.
|
|
92
100
|
|
package/demo.html
CHANGED
|
@@ -323,8 +323,15 @@
|
|
|
323
323
|
position: relative; z-index: 5; cursor: grabbing; }
|
|
324
324
|
.tl-prop-row.reordering .tl-prop-grip { color: var(--c-ruler); cursor: grabbing; }
|
|
325
325
|
.tl-prop-label { font-size: 13px; font-weight: 500; line-height: 18px; color: var(--c-text-mut2); white-space: nowrap; overflow: hidden;
|
|
326
|
-
text-overflow: ellipsis; text-transform: capitalize; }
|
|
326
|
+
text-overflow: ellipsis; text-transform: capitalize; display: flex; align-items: center; gap: 6px; min-width: 0; }
|
|
327
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; }
|
|
328
335
|
/* 16px left inset of the plot area (kept in sync with ruler/playhead/scrub) */
|
|
329
336
|
.tl-prop-track { position: relative; flex: 1; user-select: none; margin-left: 16px; }
|
|
330
337
|
/* timeline line — capsule (fully rounded), Figma #eeeeef default / #e9e9e9 hover */
|
|
@@ -548,6 +555,9 @@
|
|
|
548
555
|
.tl-menu-help svg { display: block; }
|
|
549
556
|
.tl-menu-check { display: flex; color: var(--c-text-strong); flex: none; }
|
|
550
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; }
|
|
551
561
|
|
|
552
562
|
/* ═════ transitions.dev — menu dropdown (verbatim) ═════ */
|
|
553
563
|
.t-dropdown {
|
|
@@ -1115,36 +1125,103 @@
|
|
|
1115
1125
|
return out;
|
|
1116
1126
|
}
|
|
1117
1127
|
|
|
1118
|
-
// ── 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.
|
|
1119
1134
|
class TransitionRegistry {
|
|
1120
|
-
entries=new Map();
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
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(); }
|
|
1124
1144
|
get(id){ return this.entries.get(id); }
|
|
1125
1145
|
getAll(){ return this.snapshot; }
|
|
1126
1146
|
getEffective(id){ return this.effectiveCache.get(id); }
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
m
|
|
1130
|
-
|
|
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);
|
|
1131
1152
|
this._notify();
|
|
1132
1153
|
}
|
|
1133
|
-
clearOverride(id){ this.
|
|
1134
|
-
getPropOverrides(id){ return this.
|
|
1154
|
+
clearOverride(id){ this.laneOverrides.delete(id); this._notify(); }
|
|
1155
|
+
getPropOverrides(id){ return this.laneOverrides.get(id); }
|
|
1135
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
|
+
}
|
|
1136
1190
|
_notify(){
|
|
1137
|
-
this.snapshot=Array.from(this.entries.values());
|
|
1138
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"
|
|
1139
1215
|
for(const[id,entry]of this.entries){
|
|
1140
|
-
|
|
1141
|
-
const
|
|
1142
|
-
const
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
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);
|
|
1147
1223
|
}
|
|
1224
|
+
this.snapshot=items;
|
|
1148
1225
|
for(const fn of this.listeners) fn(this.snapshot);
|
|
1149
1226
|
}
|
|
1150
1227
|
}
|
|
@@ -1259,6 +1336,33 @@
|
|
|
1259
1336
|
for(const p of keys)from[p]=cs.getPropertyValue(p)||cs[_txCamel(p)]||"";
|
|
1260
1337
|
return {from,to};
|
|
1261
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
|
+
}
|
|
1262
1366
|
|
|
1263
1367
|
class PreviewController {
|
|
1264
1368
|
state="idle"; listeners=new Set(); cleanups=[]; animations=[]; progressListeners=new Set(); _rafId=null; scanner=null; _gen=0;
|
|
@@ -1269,7 +1373,25 @@
|
|
|
1269
1373
|
subscribe(fn){this.listeners.add(fn);return()=>this.listeners.delete(fn);}
|
|
1270
1374
|
onProgress(fn){this.progressListeners.add(fn);return()=>this.progressListeners.delete(fn);}
|
|
1271
1375
|
play(entry){this.stop();this._gen++;this._current=entry;
|
|
1272
|
-
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
|
+
}
|
|
1273
1395
|
pause(){if(this.state!=="playing")return;for(const a of this.animations){try{a.pause();}catch{}}this._stopPL();this._setState("paused");}
|
|
1274
1396
|
resume(){if(this.state!=="paused")return;for(const a of this.animations){try{a.play();}catch{}}this._startPL();this._setState("playing");}
|
|
1275
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");}
|
|
@@ -1278,11 +1400,11 @@
|
|
|
1278
1400
|
this.stop();this._gen++;const gen=this._gen;
|
|
1279
1401
|
this._pendingSeek=seekMs;
|
|
1280
1402
|
if(this.scanner)this.scanner.pause();
|
|
1281
|
-
|
|
1403
|
+
const targets=this._targets(entry);
|
|
1404
|
+
if(!targets.length)return;
|
|
1282
1405
|
this.animations=[];
|
|
1283
|
-
const
|
|
1284
|
-
|
|
1285
|
-
const restore=this._arm(el,et);
|
|
1406
|
+
for(const {el,et,toState} of targets){
|
|
1407
|
+
const restore=this._arm(el,et,toState);
|
|
1286
1408
|
requestAnimationFrame(()=>{if(this._gen!==gen)return;const running=el.getAnimations();
|
|
1287
1409
|
for(const a of running){a.pause();a.playbackRate=this.rate;this.animations.push(a);}
|
|
1288
1410
|
const t=this._pendingSeek??seekMs;
|
|
@@ -1307,10 +1429,12 @@
|
|
|
1307
1429
|
// 3. a synthetic opacity/transform pulse to preview the timing,
|
|
1308
1430
|
// 4. click the element (last resort, may have side effects).
|
|
1309
1431
|
// Returns a restore fn.
|
|
1310
|
-
_arm(el,et){
|
|
1432
|
+
_arm(el,et,toState){
|
|
1311
1433
|
const saved=el.style.cssText;
|
|
1312
1434
|
const tv=et.map(t=>`${t.property} ${formatCssTime(t.durationMs)} ${t.easing} ${formatCssTime(t.delayMs)}`).join(", ");
|
|
1313
|
-
|
|
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);
|
|
1314
1438
|
if(states){
|
|
1315
1439
|
const props=et.map(t=>t.property).filter(p=>states.from[p]!=null&&states.to[p]!=null);
|
|
1316
1440
|
if(props.length){
|
|
@@ -1335,12 +1459,17 @@
|
|
|
1335
1459
|
el.style.transition=tv;el.click();
|
|
1336
1460
|
return ()=>{try{el.style.cssText=saved;}catch{}};
|
|
1337
1461
|
}
|
|
1338
|
-
_playCss(entry,gen){
|
|
1339
|
-
const
|
|
1340
|
-
|
|
1341
|
-
|
|
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);
|
|
1342
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();
|
|
1343
|
-
|
|
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();}});
|
|
1344
1473
|
this.cleanups.push(restore);}}
|
|
1345
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);}
|
|
1346
1475
|
_stopPL(){if(this._rafId!==null){cancelAnimationFrame(this._rafId);this._rafId=null;}}
|
|
@@ -1748,17 +1877,19 @@
|
|
|
1748
1877
|
h("div",{className:"tl-refine-foot"},foot)));
|
|
1749
1878
|
}
|
|
1750
1879
|
|
|
1751
|
-
// Diff the active
|
|
1880
|
+
// Diff the active item's effective (edited/refined) lanes against their
|
|
1752
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.
|
|
1753
1884
|
function computeChanges(active){
|
|
1754
1885
|
if(!active)return[];
|
|
1755
|
-
const base=active.
|
|
1756
|
-
const bmap=new Map(base.map(t=>[t.
|
|
1886
|
+
const base=active.baseLanes||[];
|
|
1887
|
+
const bmap=new Map(base.map(t=>[t.laneId,t]));
|
|
1757
1888
|
const out=[];
|
|
1758
1889
|
for(const t of (active.effectiveTimings||[])){
|
|
1759
|
-
const b=bmap.get(t.
|
|
1890
|
+
const b=bmap.get(t.laneId);if(!b)continue;
|
|
1760
1891
|
if(t.durationMs!==b.durationMs||t.delayMs!==b.delayMs||(t.easing||"")!==(b.easing||"")){
|
|
1761
|
-
out.push({property:t.property,
|
|
1892
|
+
out.push({property:t.property,member:t.member||null,selector:t.selector||null,
|
|
1762
1893
|
from:{durationMs:b.durationMs,delayMs:b.delayMs,easing:b.easing},
|
|
1763
1894
|
to:{durationMs:t.durationMs,delayMs:t.delayMs,easing:t.easing}});
|
|
1764
1895
|
}
|
|
@@ -1766,10 +1897,26 @@
|
|
|
1766
1897
|
return out;
|
|
1767
1898
|
}
|
|
1768
1899
|
|
|
1769
|
-
function Header({entries,active,onSelect,onReset,onCopy,copied,loop,setLoop,snap,setSnap,onMinimize,onRefine,refineActive,onAccept,acceptState,acceptDisabled,acceptError}){
|
|
1900
|
+
function Header({entries,active,onSelect,onReset,onCopy,copied,loop,setLoop,snap,setSnap,onMinimize,onRefine,refineActive,onAccept,acceptState,acceptDisabled,acceptError,scanning}){
|
|
1770
1901
|
const[pick,setPick]=useState(false);
|
|
1771
1902
|
const[setg,setSetg]=useState(false);
|
|
1772
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")));
|
|
1773
1920
|
return h("div",{className:"tl-header"},
|
|
1774
1921
|
h("span",{className:"tl-header-label"},"Selected"),
|
|
1775
1922
|
h("button",{ref:pickRef,className:cx("tl-ghost-btn",pick&&"is-active"),disabled:!active,onClick:()=>setPick(v=>!v)},
|
|
@@ -1779,10 +1926,14 @@
|
|
|
1779
1926
|
h(Dropdown,{open:pick,onClose:()=>setPick(false),triggerRef:pickRef,width:Math.max(240,(pickRef.current&&pickRef.current.offsetWidth)||240),align:"left"},
|
|
1780
1927
|
entries.length===0
|
|
1781
1928
|
? h("div",{className:"tl-menu-empty"},"No transitions found")
|
|
1782
|
-
:
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
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"),
|
|
1786
1937
|
h("button",{ref:gearRef,className:cx("tl-icon-btn",setg&&"is-active"),title:"Settings",onClick:()=>setSetg(v=>!v)},h(Ic,{name:"gear"})),
|
|
1787
1938
|
h(Dropdown,{open:setg,onClose:()=>setSetg(false),triggerRef:gearRef,width:210,align:"right"},
|
|
1788
1939
|
h(MenuItem,{onClick:()=>setLoop(v=>!v),right:loop&&h("span",{className:"tl-menu-check"},h(Ic,{name:"check"}))},"Loop playback"),
|
|
@@ -1826,7 +1977,7 @@
|
|
|
1826
1977
|
);
|
|
1827
1978
|
}
|
|
1828
1979
|
|
|
1829
|
-
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}){
|
|
1830
1981
|
const trackRef=useRef(null);
|
|
1831
1982
|
const grid = snap ? 25 : 1;
|
|
1832
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]);
|
|
@@ -1856,7 +2007,9 @@
|
|
|
1856
2007
|
return h("div",{className:cx("tl-prop-row",selected&&"selected",dragging&&"reordering",lockDuration&&"is-spring"),onClick:onSelect},
|
|
1857
2008
|
h("div",{className:"tl-prop-head"},
|
|
1858
2009
|
h("span",{className:"tl-prop-grip",title:"Drag to reorder",onMouseDown:onReorder},h(Ic,{name:"dots"})),
|
|
1859
|
-
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))),
|
|
1860
2013
|
h("div",{className:"tl-prop-track",ref:trackRef},
|
|
1861
2014
|
h("div",{className:"tl-bar",style:{left:delPct+"%",width:durPct+"%"},onMouseDown:e=>startDrag("move",e),
|
|
1862
2015
|
title:lockDuration?"Spring duration is derived \u2014 drag to move, resize is locked":undefined},
|
|
@@ -2272,44 +2425,44 @@
|
|
|
2272
2425
|
return h("div",{className:"tl-scrub-zone",ref:areaRef,onMouseDown:startScrub});
|
|
2273
2426
|
}
|
|
2274
2427
|
|
|
2275
|
-
function Tracks({et,
|
|
2428
|
+
function Tracks({et, selLane, setSelLane, onPropChange, snap, scaleMs}){
|
|
2276
2429
|
const t = usePreviewTime();
|
|
2277
2430
|
const ratio = Math.min(t / scaleMs, 1);
|
|
2278
2431
|
const ROW_H = 48;
|
|
2279
2432
|
const rowsRef = useRef(null);
|
|
2280
2433
|
const [order, setOrder] = useState([]);
|
|
2281
|
-
const [
|
|
2282
|
-
const
|
|
2434
|
+
const [dragLane, setDragLane] = useState(null);
|
|
2435
|
+
const laneKey = et.map(x=>x.laneId).join("|");
|
|
2283
2436
|
useEffect(()=>{
|
|
2284
|
-
const
|
|
2437
|
+
const ids = et.map(x=>x.laneId);
|
|
2285
2438
|
setOrder(prev=>{
|
|
2286
|
-
const kept = prev.filter(p=>
|
|
2287
|
-
const added =
|
|
2439
|
+
const kept = prev.filter(p=>ids.includes(p));
|
|
2440
|
+
const added = ids.filter(p=>!kept.includes(p));
|
|
2288
2441
|
return [...kept, ...added];
|
|
2289
2442
|
});
|
|
2290
|
-
},[
|
|
2291
|
-
const orderedRows = order.map(p=>et.find(x=>x.
|
|
2292
|
-
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)=>{
|
|
2293
2446
|
e.preventDefault(); e.stopPropagation();
|
|
2294
|
-
|
|
2295
|
-
|
|
2447
|
+
setSelLane(laneId);
|
|
2448
|
+
setDragLane(laneId);
|
|
2296
2449
|
const top = rowsRef.current ? rowsRef.current.getBoundingClientRect().top : 0;
|
|
2297
2450
|
const onMove = e2=>{
|
|
2298
2451
|
const idx = Math.floor((e2.clientY - top) / ROW_H);
|
|
2299
2452
|
setOrder(prev=>{
|
|
2300
|
-
const cur = prev.indexOf(
|
|
2453
|
+
const cur = prev.indexOf(laneId);
|
|
2301
2454
|
if(cur<0) return prev;
|
|
2302
2455
|
const target = Math.max(0, Math.min(prev.length-1, idx));
|
|
2303
2456
|
if(target===cur) return prev;
|
|
2304
2457
|
const next = prev.slice();
|
|
2305
2458
|
next.splice(cur,1);
|
|
2306
|
-
next.splice(target,0,
|
|
2459
|
+
next.splice(target,0,laneId);
|
|
2307
2460
|
return next;
|
|
2308
2461
|
});
|
|
2309
2462
|
};
|
|
2310
|
-
const onUp = ()=>{
|
|
2463
|
+
const onUp = ()=>{ setDragLane(null); window.removeEventListener("mousemove",onMove); window.removeEventListener("mouseup",onUp); };
|
|
2311
2464
|
window.addEventListener("mousemove",onMove); window.addEventListener("mouseup",onUp);
|
|
2312
|
-
},[
|
|
2465
|
+
},[setSelLane]);
|
|
2313
2466
|
const majorStep = scaleMs <= 10000 ? 1000 : scaleMs <= 25000 ? 5000 : 10000;
|
|
2314
2467
|
const minorStep = majorStep / 4;
|
|
2315
2468
|
const ruler=[];
|
|
@@ -2327,13 +2480,13 @@
|
|
|
2327
2480
|
h("div",{className:"tl-ruler"},...ruler)),
|
|
2328
2481
|
h("div",{className:"tl-rows",ref:rowsRef},
|
|
2329
2482
|
...orderedRows.map(row=>h(PropTrack,{
|
|
2330
|
-
key:row.
|
|
2483
|
+
key:row.laneId, property:row.property, member:row.member,
|
|
2331
2484
|
delayMs:row.delayMs, durationMs:row.durationMs, lockDuration:!!row.spring,
|
|
2332
|
-
selected:row.
|
|
2333
|
-
onSelect:()=>
|
|
2334
|
-
onReorder:e=>startReorder(row.
|
|
2335
|
-
onDelayChange:ms=>onPropChange(row.
|
|
2336
|
-
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}),
|
|
2337
2490
|
}))),
|
|
2338
2491
|
h(ScrubZone,{scaleMs}),
|
|
2339
2492
|
h("div",{className:"tl-playhead-layer"},
|
|
@@ -2344,40 +2497,42 @@
|
|
|
2344
2497
|
);
|
|
2345
2498
|
}
|
|
2346
2499
|
|
|
2347
|
-
function Inspector({entry,
|
|
2500
|
+
function Inspector({entry, selLane, onPropChange, snap}){
|
|
2348
2501
|
const et = entry.effectiveTimings || [];
|
|
2349
|
-
const sel = et.find(t=>t.
|
|
2502
|
+
const sel = et.find(t=>t.laneId===selLane) || et[0];
|
|
2350
2503
|
if(!sel) return h("div",{className:"tl-inspector"});
|
|
2351
2504
|
const cubic = easingToCubic(sel.easing);
|
|
2352
2505
|
const isSpring = !!sel.spring;
|
|
2353
2506
|
return h("div",{className:"tl-inspector"},
|
|
2354
|
-
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)),
|
|
2355
2510
|
h(ValueField,{label:"Duration",value:sel.durationMs,min:0,max:5000,step:25,tokens:DURATION_TOKENS,snap,
|
|
2356
2511
|
readOnly:isSpring,
|
|
2357
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.",
|
|
2358
|
-
onChange:v=>onPropChange(sel.
|
|
2513
|
+
onChange:v=>onPropChange(sel.laneId,{durationMs:v})}),
|
|
2359
2514
|
h(ValueField,{label:"Delay",value:sel.delayMs,min:0,max:5000,step:25,tokens:DELAY_TOKENS,snap,
|
|
2360
|
-
onChange:v=>onPropChange(sel.
|
|
2361
|
-
h(EasingEditor,{easing:sel.easing, cubic, spring:sel.spring, durationMs:sel.durationMs, propKey:sel.
|
|
2362
|
-
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)}),
|
|
2363
2518
|
);
|
|
2364
2519
|
}
|
|
2365
2520
|
|
|
2366
2521
|
function Body({entry, onPropChange, state, play, pause, resume, restart, stop, speed, setSpeed, snap}){
|
|
2367
2522
|
const et = entry.effectiveTimings || [];
|
|
2368
|
-
const [
|
|
2523
|
+
const [selLane, setSelLane] = useState(et[0]?.laneId ?? null);
|
|
2369
2524
|
const [zoom, setZoom] = useState(ZOOM_DEFAULT);
|
|
2370
2525
|
const scaleMs = scaleFromZoom(zoom);
|
|
2371
2526
|
useEffect(()=>{
|
|
2372
|
-
if(et.length && !et.find(t=>t.
|
|
2373
|
-
},[et,
|
|
2527
|
+
if(et.length && !et.find(t=>t.laneId===selLane)) setSelLane(et[0]?.laneId);
|
|
2528
|
+
},[et,selLane]);
|
|
2374
2529
|
|
|
2375
2530
|
return h("div",{className:"tl-body"},
|
|
2376
2531
|
h("div",{className:"tl-main"},
|
|
2377
2532
|
h(Transport,{state,disabled:!entry,onPlay:play,onPause:pause,onResume:resume,onRestart:restart,onStop:stop,speed,setSpeed,zoom,setZoom}),
|
|
2378
|
-
h(Tracks,{et,
|
|
2533
|
+
h(Tracks,{et,selLane,setSelLane,onPropChange,snap,scaleMs}),
|
|
2379
2534
|
),
|
|
2380
|
-
h(Inspector,{entry,
|
|
2535
|
+
h(Inspector,{entry,selLane,onPropChange,snap}),
|
|
2381
2536
|
);
|
|
2382
2537
|
}
|
|
2383
2538
|
|
|
@@ -2434,6 +2589,9 @@
|
|
|
2434
2589
|
// ── accept (write to source) ──
|
|
2435
2590
|
const[acceptState,setAcceptState]=useState("idle"); // idle | saving | done | error
|
|
2436
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);
|
|
2437
2595
|
const[refineLabel,setRefineLabel]=useState(null);
|
|
2438
2596
|
const[appliedIds,setAppliedIds]=useState({});
|
|
2439
2597
|
const[refineMode,setRefineMode]=useState("llm"); // llm (Agent) | deterministic
|
|
@@ -2467,8 +2625,11 @@
|
|
|
2467
2625
|
const mode=(refineMode==="llm"&&!avail)?"deterministic":refineMode;
|
|
2468
2626
|
if(mode!==refineMode)setRefineMode(mode);
|
|
2469
2627
|
try{
|
|
2470
|
-
const
|
|
2471
|
-
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});
|
|
2472
2633
|
setRefineJobId(id);
|
|
2473
2634
|
}catch(e){
|
|
2474
2635
|
setRefinePhase("error");
|
|
@@ -2500,9 +2661,15 @@
|
|
|
2500
2661
|
if(p.durationMs!=null)o.durationMs=p.durationMs;
|
|
2501
2662
|
if(p.delayMs!=null)o.delayMs=p.delayMs;
|
|
2502
2663
|
if(p.easing!=null)o.easing=p.easing;
|
|
2503
|
-
|
|
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);
|
|
2504
2671
|
setAppliedIds(prev=>({...prev,[s.id]:true}));
|
|
2505
|
-
},[setPropOverride]);
|
|
2672
|
+
},[setPropOverride,active]);
|
|
2506
2673
|
const applyAllSuggestions=useCallback(()=>{
|
|
2507
2674
|
for(const s of refineSuggestions){if(!appliedIds[s.id])applySuggestion(s);}
|
|
2508
2675
|
},[refineSuggestions,appliedIds,applySuggestion]);
|
|
@@ -2524,10 +2691,19 @@
|
|
|
2524
2691
|
const copyValues=useCallback(()=>{
|
|
2525
2692
|
if(!active)return;
|
|
2526
2693
|
const et=active.effectiveTimings||[];
|
|
2527
|
-
const
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
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);});
|
|
2531
2707
|
},[active]);
|
|
2532
2708
|
const resetOverrides=useCallback(()=>{if(active)registry.clearOverride(active.id);},[registry,active]);
|
|
2533
2709
|
// Accept → send an "apply" job so the agent writes the edited timings into
|
|
@@ -2538,7 +2714,9 @@
|
|
|
2538
2714
|
setAcceptState("saving");setAcceptError(null);
|
|
2539
2715
|
try{
|
|
2540
2716
|
const{id}=await relayCreateJob({kind:"apply",transitionId:active.id,label:active.label,
|
|
2541
|
-
selector:active.bindings&&active.bindings.selector,
|
|
2717
|
+
selector:active.bindings&&active.bindings.selector,
|
|
2718
|
+
group:active.groupLabel||null,phase:active.phase||null,component:active.component||null,
|
|
2719
|
+
changes});
|
|
2542
2720
|
let settled=false;
|
|
2543
2721
|
for(let i=0;i<240&&!settled;i++){
|
|
2544
2722
|
await new Promise(r=>setTimeout(r,500));
|
|
@@ -2559,6 +2737,33 @@
|
|
|
2559
2737
|
},[active]);
|
|
2560
2738
|
// reset Accept feedback when switching transitions
|
|
2561
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]);
|
|
2562
2767
|
|
|
2563
2768
|
// whole-component open/close uses the transitions.dev panel reveal:
|
|
2564
2769
|
// keep the panel mounted while it animates, flip data-open on the next
|
|
@@ -2608,7 +2813,8 @@
|
|
|
2608
2813
|
h("div",{className:"tl-panel-main"},
|
|
2609
2814
|
h(Header,{entries,active,onSelect:setActiveId,onReset:resetOverrides,onCopy:copyValues,copied,
|
|
2610
2815
|
loop,setLoop,snap,setSnap,onMinimize:()=>setMinimized(true),onRefine:openRefine,refineActive:refineOpen,
|
|
2611
|
-
onAccept,acceptState,acceptDisabled:computeChanges(active).length===0,acceptError
|
|
2816
|
+
onAccept,acceptState,acceptDisabled:computeChanges(active).length===0,acceptError,
|
|
2817
|
+
scanning:groupScanState==="scanning"}),
|
|
2612
2818
|
active
|
|
2613
2819
|
?h(Body,{entry:active,onPropChange:(prop,o)=>setPropOverride(prop,o),
|
|
2614
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
|
@@ -168,6 +168,14 @@ function parseApplyOutput(stdout) {
|
|
|
168
168
|
return { applied: Boolean(obj.applied), summary: obj.summary ?? null, files: Array.isArray(obj.files) ? obj.files : null };
|
|
169
169
|
}
|
|
170
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
|
+
|
|
171
179
|
function runAgentCmd(cmd, prompt, parse = parseAgentOutput) {
|
|
172
180
|
return new Promise((resolve, reject) => {
|
|
173
181
|
const child = spawn("sh", ["-c", cmd], { stdio: ["pipe", "pipe", "pipe"] });
|
|
@@ -205,11 +213,13 @@ function buildApplyPrompt(job) {
|
|
|
205
213
|
"You are APPLYING an approved transition change to the user's SOURCE CODE. Edit files; do not just suggest.",
|
|
206
214
|
"",
|
|
207
215
|
"Change context (JSON):",
|
|
208
|
-
JSON.stringify({ label: r.label, selector: r.selector, changes: r.changes }, null, 2),
|
|
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.",
|
|
209
219
|
"",
|
|
210
220
|
"Steps:",
|
|
211
|
-
"1. Find where this transition is defined in the source. Search by the selector
|
|
212
|
-
"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. If a CSS variable / design token backs the value, update it at the most sensible single place.",
|
|
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.",
|
|
213
223
|
"3. Make the minimal edit. Do not reformat or change unrelated code.",
|
|
214
224
|
"",
|
|
215
225
|
'Output ONLY a JSON object — no prose, no markdown fences — shaped exactly like:',
|
|
@@ -218,6 +228,27 @@ function buildApplyPrompt(job) {
|
|
|
218
228
|
].join("\n");
|
|
219
229
|
}
|
|
220
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
|
+
|
|
221
252
|
function refineDeterministic(job) {
|
|
222
253
|
// Whole-transition replacement needs usage inference + recipe selection, which
|
|
223
254
|
// only the agent (LLM) path can do. Deterministic can only snap to tokens.
|
|
@@ -240,11 +271,15 @@ async function answerJob(job) {
|
|
|
240
271
|
job.status = "working";
|
|
241
272
|
job.updatedAt = now();
|
|
242
273
|
const isApply = job.request?.kind === "apply";
|
|
274
|
+
const isScan = job.request?.kind === "scan";
|
|
243
275
|
const label = job.request?.label || job.request?.selector || "transition";
|
|
244
276
|
// The browser picks the mode per job via the LLM / Deterministic tabs.
|
|
245
277
|
// Default: LLM when a command is configured, otherwise deterministic.
|
|
246
278
|
const mode = job.request?.mode || (AGENT_CMD ? "llm" : "deterministic");
|
|
247
|
-
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
|
+
});
|
|
248
283
|
try {
|
|
249
284
|
let result;
|
|
250
285
|
if (isApply) {
|
|
@@ -263,6 +298,22 @@ async function answerJob(job) {
|
|
|
263
298
|
console.log(` ✓ apply ${job.id.slice(0, 8)} — applied=${result.applied}`);
|
|
264
299
|
return;
|
|
265
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
|
+
}
|
|
266
317
|
if (mode === "llm") {
|
|
267
318
|
if (!AGENT_CMD) {
|
|
268
319
|
throw new Error(
|
|
@@ -362,8 +413,8 @@ const server = createServer(async (req, res) => {
|
|
|
362
413
|
return send(res, 400, { error: "Body must be { request: {...} }" });
|
|
363
414
|
}
|
|
364
415
|
const job = createJob(body.request);
|
|
365
|
-
// Apply jobs edit source — agent only, never deterministic.
|
|
366
|
-
const mode = job.request.kind === "apply"
|
|
416
|
+
// Apply and scan jobs read/edit source — agent only, never deterministic.
|
|
417
|
+
const mode = (job.request.kind === "apply" || job.request.kind === "scan")
|
|
367
418
|
? "llm"
|
|
368
419
|
: (job.request.mode || (llmAvailable() ? "llm" : "deterministic"));
|
|
369
420
|
job.request.mode = mode;
|
|
@@ -440,11 +491,14 @@ const server = createServer(async (req, res) => {
|
|
|
440
491
|
const body = await readJson(req);
|
|
441
492
|
if (body && Array.isArray(body.suggestions)) {
|
|
442
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 };
|
|
443
497
|
} else if (body && typeof body.applied !== "undefined") {
|
|
444
498
|
// apply-job result from a `/refine live` agent
|
|
445
499
|
job.result = { applied: Boolean(body.applied), summary: body.summary ?? null, files: Array.isArray(body.files) ? body.files : null };
|
|
446
500
|
} else {
|
|
447
|
-
return send(res, 400, { error: "Body must be { suggestions: [...] } or { applied, summary }" });
|
|
501
|
+
return send(res, 400, { error: "Body must be { suggestions: [...] }, { groups: [...] }, or { applied, summary }" });
|
|
448
502
|
}
|
|
449
503
|
job.status = "done";
|
|
450
504
|
job.updatedAt = now();
|