transitions-refine 0.2.0 → 0.3.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/README.md +5 -3
- package/demo.html +248 -203
- package/package.json +1 -1
- package/server/inject.mjs +3 -5
- package/server/relay.mjs +10 -2
package/README.md
CHANGED
|
@@ -4,7 +4,9 @@ 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
|
-
|
|
7
|
+
There's **no play button or scrubber** — the running component *is* the preview. Any edit (a dragged bar, an inspector tweak, or an applied suggestion) is written straight onto the live element as an inline `transition`, so you see it the next time you trigger the transition (open the dropdown, hover the card, …). Reset reverts the element to its source.
|
|
8
|
+
|
|
9
|
+
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. If no agent is live, the panel falls back to the flat DOM scan with no regression.
|
|
8
10
|
|
|
9
11
|
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.
|
|
10
12
|
|
|
@@ -58,7 +60,7 @@ The CLI must have the `transitions-dev` skill available (the prompt tells it to
|
|
|
58
60
|
|
|
59
61
|
## Grouped scan — Open / Close phases
|
|
60
62
|
|
|
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. **
|
|
63
|
+
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. Editing any lane applies **live** as an inline `transition` on that member element, so triggering the component yourself (open/close it) previews the whole phase with your values; **Accept** writes back to the correct state rule (`.is-open` vs `.is-closing`) per member.
|
|
62
64
|
|
|
63
65
|
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
66
|
|
|
@@ -71,7 +73,7 @@ Grouping needs the agent (`/refine live`, `--llm`, or `REFINE_AGENT_CMD`); with
|
|
|
71
73
|
|
|
72
74
|
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
75
|
|
|
74
|
-
Like Replace, Accept needs the agent — run `/refine live` (or `--llm` / `REFINE_AGENT_CMD`). The deterministic answerer can't edit files.
|
|
76
|
+
Like Replace, Accept needs the agent — run `/refine live` (or `--llm` / `REFINE_AGENT_CMD`). The deterministic answerer can't edit files.
|
|
75
77
|
|
|
76
78
|
## Pieces
|
|
77
79
|
|
package/demo.html
CHANGED
|
@@ -249,50 +249,6 @@
|
|
|
249
249
|
@media (prefers-reduced-motion: reduce) {
|
|
250
250
|
.tl-refine-btn:hover:not(:disabled) .tl-refine-sparks i { animation: none; } }
|
|
251
251
|
|
|
252
|
-
/* ── transport row ── */
|
|
253
|
-
.tl-transport { display: flex; align-items: center; gap: 8px; padding: 10px 16px; border-bottom: 1px solid var(--c-line); }
|
|
254
|
-
.tl-transport-left { flex: 1; display: flex; align-items: center; gap: 10px; min-width: 0; }
|
|
255
|
-
.tl-transport-center { flex: 0 0 auto; display: flex; justify-content: center; }
|
|
256
|
-
.tl-transport-right { flex: 1; display: flex; align-items: center; justify-content: flex-end; gap: 12px; }
|
|
257
|
-
.tl-transport .tl-ghost-btn,
|
|
258
|
-
.tl-transport .tl-icon-btn,
|
|
259
|
-
.tl-transport .tl-play-btn { border-radius: 60px; }
|
|
260
|
-
.tl-timecode { font-variant-numeric: tabular-nums; font-size: 13px; font-weight: 500; color: var(--c-text); min-width: 70px; }
|
|
261
|
-
.tl-speed { font-variant-numeric: tabular-nums; }
|
|
262
|
-
.tl-zoom { position: relative; width: 72px; height: 15px; flex: none; }
|
|
263
|
-
/* slider — Logram design system (node 13064:2539): 15px frame, track at y=5, 15px knob at y=0 */
|
|
264
|
-
.tl-zoom-track { position: absolute; left: 0; right: 0; top: 5px; height: 5px; pointer-events: none;
|
|
265
|
-
background: rgba(115,115,115,0.1); border: 1px solid rgba(0,0,0,0.08); border-radius: 7px; box-sizing: border-box; }
|
|
266
|
-
.tl-zoom input[type="range"] { position: relative; z-index: 1; width: 100%; height: 15px; margin: 0;
|
|
267
|
-
-webkit-appearance: none; appearance: none; background: transparent; outline: none; cursor: pointer; }
|
|
268
|
-
.tl-zoom input[type="range"]::-webkit-slider-runnable-track { height: 15px; background: transparent; border: none; }
|
|
269
|
-
.tl-zoom input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none;
|
|
270
|
-
width: 15px; height: 15px; margin-top: 0; border-radius: 50%; background: #fff; cursor: pointer;
|
|
271
|
-
box-shadow: 0 1px 3px rgba(0,0,0,0.08), inset 0 0 0 1px rgba(126,126,126,0.1), inset 0 -1px 0 rgba(0,0,0,0.1); }
|
|
272
|
-
.tl-zoom input[type="range"]::-moz-range-track { height: 15px; background: transparent; border: none; }
|
|
273
|
-
.tl-zoom input[type="range"]::-moz-range-thumb { width: 15px; height: 15px; border: none; border-radius: 50%;
|
|
274
|
-
background: #fff; cursor: pointer;
|
|
275
|
-
box-shadow: 0 1px 3px rgba(0,0,0,0.08), inset 0 0 0 1px rgba(126,126,126,0.1), inset 0 -1px 0 rgba(0,0,0,0.1); }
|
|
276
|
-
/* play/pause circle — Figma: drop shadow + inset ring overlay */
|
|
277
|
-
.tl-play-btn { position: relative; isolation: isolate; overflow: hidden;
|
|
278
|
-
display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px;
|
|
279
|
-
border: none; border-radius: 50%; background: var(--c-circle); color: #17181c;
|
|
280
|
-
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.04); cursor: pointer;
|
|
281
|
-
transition: background 0.12s ease, scale 0.12s ease; }
|
|
282
|
-
.tl-play-btn::after { content: ""; position: absolute; inset: 0; border-radius: inherit;
|
|
283
|
-
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.06), inset 0 -1px 0 0 rgba(0, 0, 0, 0.06),
|
|
284
|
-
inset 0 0 0 1px rgba(196, 196, 196, 0.10); pointer-events: none; }
|
|
285
|
-
.tl-play-btn > * { position: relative; z-index: 1; }
|
|
286
|
-
.tl-play-btn:hover:not(:disabled) { background: var(--c-circle-h); }
|
|
287
|
-
.tl-play-btn:active:not(:disabled) { background: var(--c-circle-a); scale: 0.96; }
|
|
288
|
-
.tl-play-btn:disabled { opacity: 0.5; cursor: default; }
|
|
289
|
-
/* center both icon layers in the swap cell so the differently-sized play (10×12)
|
|
290
|
-
and pause (16²) svgs share one center; nudge ONLY the play triangle right for
|
|
291
|
-
optical centering (skill: play-button triangles). left/position (not transform)
|
|
292
|
-
so it doesn't clash with the icon-swap scale animation; pause stays dead-center. */
|
|
293
|
-
.tl-play-btn .t-icon { place-self: center; }
|
|
294
|
-
.tl-play-btn .t-icon[data-icon="a"] { position: relative; left: 1px; }
|
|
295
|
-
|
|
296
252
|
/* ── body / floating cards row ── */
|
|
297
253
|
.tl-body { display: flex; flex: 1; min-height: 0; gap: 0; }
|
|
298
254
|
.tl-main { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden; }
|
|
@@ -332,7 +288,7 @@
|
|
|
332
288
|
.tl-prop-prop { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
|
333
289
|
.tl-insp-member { display: inline-block; font-size: 11px; font-weight: 600; line-height: 14px; padding: 1px 6px; border-radius: 60px;
|
|
334
290
|
background: var(--c-sec, rgba(0,0,0,0.05)); color: var(--c-text-faint); text-transform: none; margin-right: 6px; vertical-align: middle; }
|
|
335
|
-
/* 16px left inset of the plot area (kept in sync with ruler
|
|
291
|
+
/* 16px left inset of the plot area (kept in sync with the ruler) */
|
|
336
292
|
.tl-prop-track { position: relative; flex: 1; user-select: none; margin-left: 16px; }
|
|
337
293
|
/* timeline line — capsule (fully rounded), Figma #eeeeef default / #e9e9e9 hover */
|
|
338
294
|
.tl-bar { position: absolute; top: 50%; height: 24px; transform: translateY(-50%); background: var(--c-track);
|
|
@@ -352,13 +308,16 @@
|
|
|
352
308
|
.tl-bar-grip { position: absolute; top: 0; bottom: 0; width: 10px; cursor: ew-resize; z-index: 2; }
|
|
353
309
|
.tl-bar-grip.left { left: -5px; } .tl-bar-grip.right { right: -5px; }
|
|
354
310
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
.tl-
|
|
359
|
-
|
|
360
|
-
.tl-
|
|
361
|
-
|
|
311
|
+
/* draggable divider between the label column and the bars column. The hit
|
|
312
|
+
area is wide for easy grabbing; the visible line is thin and only accents
|
|
313
|
+
on hover / while dragging (matches the panel's 0.12s hover house style). */
|
|
314
|
+
.tl-col-resizer { position: absolute; top: 0; bottom: 0; width: 11px; transform: translateX(-50%);
|
|
315
|
+
cursor: col-resize; z-index: 6; display: flex; justify-content: center; }
|
|
316
|
+
.tl-col-resizer-line { width: 1px; height: 100%; background: transparent;
|
|
317
|
+
transition: background 0.12s ease, width 0.12s ease; }
|
|
318
|
+
.tl-col-resizer:hover .tl-col-resizer-line,
|
|
319
|
+
.tl-col-resizer.dragging .tl-col-resizer-line { width: 2px; background: #1A7AFF; }
|
|
320
|
+
@media (prefers-reduced-motion: reduce) { .tl-col-resizer-line { transition: none; } }
|
|
362
321
|
|
|
363
322
|
/* ── value field (slider + input) — exact Figma "Value slider and input" ── */
|
|
364
323
|
.tl-field-wrap { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
|
|
@@ -1019,6 +978,13 @@
|
|
|
1019
978
|
if(cur)parts.push(cur); return parts;
|
|
1020
979
|
}
|
|
1021
980
|
function transitionSignature(props,dur,del,ease) { return [...props].sort().join(",")+"|"+dur+"|"+del+"|"+ease; }
|
|
981
|
+
// Serialise a set of effective lanes back into a CSS `transition` shorthand.
|
|
982
|
+
function transitionLanesToCss(lanes){ return (lanes||[]).map(t=>`${t.property} ${formatCssTime(t.durationMs)} ${t.easing} ${formatCssTime(t.delayMs)}`).join(", "); }
|
|
983
|
+
// Elements we've live-applied edited timings to. Maps element → its ORIGINAL
|
|
984
|
+
// source lanes ({z}), so the DomScanner keeps the entry pinned to the source
|
|
985
|
+
// signature/timings even while our inline `transition` overrides the DOM —
|
|
986
|
+
// otherwise the timing-based id would churn and orphan the user's edits.
|
|
987
|
+
const _TX_LIVE_BASE = new WeakMap();
|
|
1022
988
|
|
|
1023
989
|
const EASING_PRESETS = [
|
|
1024
990
|
{ keyword:"linear", cubic:[0,0,1,1] }, { keyword:"ease", cubic:[0.25,0.1,0.25,1] },
|
|
@@ -1205,6 +1171,7 @@
|
|
|
1205
1171
|
});
|
|
1206
1172
|
const item={id,kind:"phase",groupId:g.id,groupLabel:g.label||g.id,component:g.component||null,
|
|
1207
1173
|
phase:ph.phase||null,phaseLabel:ph.label||ph.phase||"Phase",
|
|
1174
|
+
stateTarget:ph.stateTarget||null,fromState:(ph.fromState??null),toState:(ph.toState??null),
|
|
1208
1175
|
label:(g.label||g.id)+" · "+(ph.label||ph.phase||"Phase"),durationMs:repDur,
|
|
1209
1176
|
members,effectiveTimings:effTimings,baseLanes};
|
|
1210
1177
|
this.effectiveCache.set(id,item);
|
|
@@ -1364,6 +1331,26 @@
|
|
|
1364
1331
|
return {from:f,to:t};
|
|
1365
1332
|
}
|
|
1366
1333
|
|
|
1334
|
+
// Add/remove a single state token (".class" or "[attr=val]"/"[attr]") on an
|
|
1335
|
+
// element. Returns false for tokens we can't toggle (e.g. pseudo ":hover").
|
|
1336
|
+
function _applyStateToken(el,token,add){
|
|
1337
|
+
const s=String(token||"").trim();
|
|
1338
|
+
if(!s)return true;
|
|
1339
|
+
if(s[0]==="."){const c=s.slice(1);if(!c)return false;if(add)el.classList.add(c);else el.classList.remove(c);return true;}
|
|
1340
|
+
if(s[0]==="["){
|
|
1341
|
+
const m=s.match(/^\[\s*([\w-]+)\s*(?:[~|^$*]?=\s*"?([^"\]]*)"?\s*)?\]$/);
|
|
1342
|
+
if(!m)return false;
|
|
1343
|
+
const name=m[1],val=m[2]!=null?m[2]:"";
|
|
1344
|
+
if(add)el.setAttribute(name,val);else el.removeAttribute(name);
|
|
1345
|
+
return true;
|
|
1346
|
+
}
|
|
1347
|
+
return false; // pseudo (:hover/:focus) or unrecognised → not class-toggleable
|
|
1348
|
+
}
|
|
1349
|
+
function _tokenToggleable(token){
|
|
1350
|
+
const s=String(token||"").trim();
|
|
1351
|
+
return !s || s[0]==="."||s[0]==="[";
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1367
1354
|
class PreviewController {
|
|
1368
1355
|
state="idle"; listeners=new Set(); cleanups=[]; animations=[]; progressListeners=new Set(); _rafId=null; scanner=null; _gen=0;
|
|
1369
1356
|
rate=1; loop=false; _current=null;
|
|
@@ -1400,20 +1387,81 @@
|
|
|
1400
1387
|
this.stop();this._gen++;const gen=this._gen;
|
|
1401
1388
|
this._pendingSeek=seekMs;
|
|
1402
1389
|
if(this.scanner)this.scanner.pause();
|
|
1403
|
-
const
|
|
1404
|
-
|
|
1390
|
+
const armed=this._armAll(entry);
|
|
1391
|
+
const els=armed.els;
|
|
1392
|
+
if(!els.length){this._setState("paused");if(seekMs!=null)this._ep(seekMs);return;}
|
|
1393
|
+
this.cleanups.push(armed.restore);
|
|
1405
1394
|
this.animations=[];
|
|
1406
|
-
|
|
1407
|
-
const
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
});
|
|
1413
|
-
this.cleanups.push(restore);}
|
|
1395
|
+
requestAnimationFrame(()=>{if(this._gen!==gen)return;
|
|
1396
|
+
const running=[];for(const el of els){for(const a of el.getAnimations()){a.pause();a.playbackRate=this.rate;running.push(a);}}
|
|
1397
|
+
this.animations=running;
|
|
1398
|
+
const t=this._pendingSeek??seekMs;
|
|
1399
|
+
if(t!=null){for(const a of this.animations){try{a.currentTime=t;}catch{}}this._ep(t);}
|
|
1400
|
+
});
|
|
1414
1401
|
this._setState("paused");
|
|
1415
1402
|
if(seekMs!=null)this._ep(seekMs);
|
|
1416
1403
|
}
|
|
1404
|
+
// Snapshot/restore the toggled state on a root element.
|
|
1405
|
+
_snapshotState(el,toggleTokens){
|
|
1406
|
+
const attrs={},classes={};
|
|
1407
|
+
for(const t of toggleTokens){const s=String(t).trim();
|
|
1408
|
+
if(s[0]===".")classes[s.slice(1)]=el.classList.contains(s.slice(1));
|
|
1409
|
+
else if(s[0]==="["){const m=s.match(/^\[\s*([\w-]+)/);if(m)attrs[m[1]]=el.getAttribute(m[1]);}}
|
|
1410
|
+
return {attrs,classes};
|
|
1411
|
+
}
|
|
1412
|
+
_restoreState(el,snap){
|
|
1413
|
+
for(const c in snap.classes){if(snap.classes[c])el.classList.add(c);else el.classList.remove(c);}
|
|
1414
|
+
for(const n in snap.attrs){const v=snap.attrs[n];if(v==null)el.removeAttribute(n);else el.setAttribute(n,v);}
|
|
1415
|
+
}
|
|
1416
|
+
_setRootState(el,target,toggleTokens){
|
|
1417
|
+
for(const t of toggleTokens)_applyStateToken(el,t,false);
|
|
1418
|
+
if(target)_applyStateToken(el,target,true);
|
|
1419
|
+
}
|
|
1420
|
+
// Arm a whole phase by toggling the REAL driving state on its stateTarget,
|
|
1421
|
+
// so the actual CSS animates every member in the correct direction (open
|
|
1422
|
+
// base→state, close state→base). Edited timings are applied inline on the
|
|
1423
|
+
// member elements so they override the source. Returns {els, restore} or
|
|
1424
|
+
// null to fall back to per-member arming.
|
|
1425
|
+
_armPhase(entry,root){
|
|
1426
|
+
const fromState=entry.fromState??null, toState=entry.toState??null;
|
|
1427
|
+
if(![fromState,toState].every(_tokenToggleable))return null; // pseudo states → legacy
|
|
1428
|
+
const toggleTokens=[fromState,toState].filter(Boolean);
|
|
1429
|
+
if(!toggleTokens.length)return null;
|
|
1430
|
+
const memberEls=[];
|
|
1431
|
+
for(const m of (entry.members||[])){
|
|
1432
|
+
if(!m.selector||!m.lanes||!m.lanes.length)continue;
|
|
1433
|
+
let els=[];try{els=Array.from(document.querySelectorAll(m.selector));}catch{}
|
|
1434
|
+
for(const el of els)memberEls.push({el,lanes:m.lanes});
|
|
1435
|
+
}
|
|
1436
|
+
if(!memberEls.length)return null;
|
|
1437
|
+
const savedRoot=this._snapshotState(root,toggleTokens);
|
|
1438
|
+
const savedStyles=memberEls.map(({el})=>el.style.cssText);
|
|
1439
|
+
// 1. suppress transitions and commit the FROM state
|
|
1440
|
+
for(const {el} of memberEls)el.style.transition="none";
|
|
1441
|
+
this._setRootState(root,fromState,toggleTokens);
|
|
1442
|
+
void root.offsetWidth;for(const {el} of memberEls)void el.offsetWidth;
|
|
1443
|
+
// 2. apply the (possibly edited) per-member timings inline
|
|
1444
|
+
for(const {el,lanes} of memberEls){
|
|
1445
|
+
el.style.transition=lanes.map(t=>`${t.property} ${formatCssTime(t.durationMs)} ${t.easing} ${formatCssTime(t.delayMs)}`).join(", ");
|
|
1446
|
+
}
|
|
1447
|
+
// 3. toggle to the TO state → real CSS transitions fire on every member
|
|
1448
|
+
this._setRootState(root,toState,toggleTokens);
|
|
1449
|
+
const restore=()=>{try{this._restoreState(root,savedRoot);}catch{}
|
|
1450
|
+
memberEls.forEach(({el},i)=>{try{el.style.cssText=savedStyles[i];}catch{}});};
|
|
1451
|
+
return {els:memberEls.map(m=>m.el),restore};
|
|
1452
|
+
}
|
|
1453
|
+
// Resolve an entry to armed element(s) + a restore fn. Phase items with a
|
|
1454
|
+
// toggleable stateTarget use _armPhase; everything else uses per-target arm.
|
|
1455
|
+
_armAll(entry){
|
|
1456
|
+
if(entry&&entry.kind==="phase"&&entry.stateTarget&&typeof document!=="undefined"){
|
|
1457
|
+
let root=null;try{root=document.querySelector(entry.stateTarget);}catch{}
|
|
1458
|
+
if(root){const r=this._armPhase(entry,root);if(r)return r;}
|
|
1459
|
+
}
|
|
1460
|
+
const targets=this._targets(entry);
|
|
1461
|
+
const els=[],restores=[];
|
|
1462
|
+
for(const {el,et,toState} of targets){restores.push(this._arm(el,et,toState));els.push(el);}
|
|
1463
|
+
return {els,restore:()=>{for(const c of restores){try{c();}catch{}}}};
|
|
1464
|
+
}
|
|
1417
1465
|
seek(timeMs){
|
|
1418
1466
|
if(this.state==="idle")return;
|
|
1419
1467
|
if(this.state==="playing"){for(const a of this.animations){try{a.pause();}catch{}}this._stopPL();this._setState("paused");}
|
|
@@ -1460,17 +1508,17 @@
|
|
|
1460
1508
|
return ()=>{try{el.style.cssText=saved;}catch{}};
|
|
1461
1509
|
}
|
|
1462
1510
|
_playCss(entry,gen){
|
|
1463
|
-
const
|
|
1511
|
+
const armed=this._armAll(entry);
|
|
1512
|
+
const els=armed.els;
|
|
1464
1513
|
this.animations=[];
|
|
1465
|
-
if(!
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
const
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
this.cleanups.push(restore);}}
|
|
1514
|
+
if(!els.length){this._finish();return;}
|
|
1515
|
+
this.cleanups.push(armed.restore);
|
|
1516
|
+
requestAnimationFrame(()=>{if(this._gen!==gen)return;
|
|
1517
|
+
const running=[];for(const el of els){for(const a of el.getAnimations()){a.playbackRate=this.rate;running.push(a);}}
|
|
1518
|
+
this.animations=running;this._startPL();
|
|
1519
|
+
const onDone=()=>{if(this._gen!==gen||this.state!=="playing")return;if(this.loop&&this._current){this.play(this._current);}else{this._finish();}};
|
|
1520
|
+
if(running.length>0){Promise.allSettled(running.map(a=>a.finished)).then(onDone);}else{onDone();}
|
|
1521
|
+
});}
|
|
1474
1522
|
_startPL(){this._stopPL();const tick=()=>{if(this.animations.length>0){let cur=0;for(const a of this.animations){const c=Number(a.currentTime)||0;if(c>cur)cur=c;}this._ep(cur);}this._rafId=requestAnimationFrame(tick);};this._rafId=requestAnimationFrame(tick);}
|
|
1475
1523
|
_stopPL(){if(this._rafId!==null){cancelAnimationFrame(this._rafId);this._rafId=null;}}
|
|
1476
1524
|
_ep(p){for(const fn of this.progressListeners)fn(p);}
|
|
@@ -1482,7 +1530,7 @@
|
|
|
1482
1530
|
observer=null;rafId=null;running=false;paused=false;
|
|
1483
1531
|
constructor(root,reg){this.root=root;this.registry=reg;}
|
|
1484
1532
|
pause(){this.paused=true;} unpause(){this.paused=false;this._sched();}
|
|
1485
|
-
start(){if(this.running)return;this.running=true;
|
|
1533
|
+
start(){if(this.running)return;this.running=true;this.scan();this.observer=new MutationObserver(()=>this._sched());this.observer.observe(this.root,{childList:true,subtree:true,attributes:true,attributeFilter:["class","style"]});}
|
|
1486
1534
|
stop(){this.running=false;this.observer?.disconnect();this.observer=null;if(this.rafId!==null){cancelAnimationFrame(this.rafId);this.rafId=null;}}
|
|
1487
1535
|
_sched(){if(this.rafId!==null)return;this.rafId=requestAnimationFrame(()=>{this.rafId=null;if(this.running&&!this.paused)this.scan();});}
|
|
1488
1536
|
scan(){const seen=new Map();const w=document.createTreeWalker(this.root,NodeFilter.SHOW_ELEMENT);let n=w.currentNode;while(n){if(n instanceof HTMLElement)this._proc(n,seen);n=w.nextNode();}this.registry.replaceAll(Array.from(seen.values()));}
|
|
@@ -1494,7 +1542,11 @@
|
|
|
1494
1542
|
const durs=(s.transitionDuration||"0s").split(",");
|
|
1495
1543
|
const dels=(s.transitionDelay||"0s").split(",");
|
|
1496
1544
|
const eass=splitCssValues(s.transitionTimingFunction||"ease");
|
|
1497
|
-
|
|
1545
|
+
let z=zipTransitionLists(props,durs,dels,eass);
|
|
1546
|
+
// If we've live-applied edited timings here, keep the entry pinned to the
|
|
1547
|
+
// ORIGINAL source lanes so its id/overrides stay stable across rescans.
|
|
1548
|
+
const _lb=_TX_LIVE_BASE.get(el);
|
|
1549
|
+
if(_lb){ if(_lb.z&&_lb.z.length) z=_lb.z; else return; }
|
|
1498
1550
|
if(!z.length||z.every(x=>x.durationMs===0&&x.delayMs===0))return;
|
|
1499
1551
|
const pDur=z[0].durationMs,pDel=z[0].delayMs,pEase=z[0].easing;
|
|
1500
1552
|
const allP=z.map(x=>x.property);
|
|
@@ -1512,19 +1564,11 @@
|
|
|
1512
1564
|
const TimelineCtx = createContext(null);
|
|
1513
1565
|
function useReg(){const{registry}=useContext(TimelineCtx);return useSyncExternalStore(useCallback(cb=>registry.subscribe(cb),[registry]),useCallback(()=>registry.getAll(),[registry]),useCallback(()=>registry.getAll(),[registry]));}
|
|
1514
1566
|
function useActive(){const{activeId,setActiveId,registry}=useContext(TimelineCtx);const active=useSyncExternalStore(useCallback(cb=>registry.subscribe(cb),[registry]),useCallback(()=>activeId?registry.getEffective(activeId):undefined,[registry,activeId]),useCallback(()=>activeId?registry.getEffective(activeId):undefined,[registry,activeId]));return{active,setActiveId};}
|
|
1515
|
-
function usePlayback(){const{preview,registry,activeId}=useContext(TimelineCtx);const state=useSyncExternalStore(useCallback(cb=>preview.subscribe(cb),[preview]),useCallback(()=>preview.getState(),[preview]),useCallback(()=>preview.getState(),[preview]));
|
|
1516
|
-
return{state,play:useCallback(()=>{if(!activeId)return;const e=registry.getEffective(activeId);if(e)preview.play(e);},[preview,registry,activeId]),pause:useCallback(()=>preview.pause(),[preview]),resume:useCallback(()=>preview.resume(),[preview]),restart:useCallback(()=>{if(!activeId)return;const e=registry.getEffective(activeId);if(e)preview.restart(e);},[preview,registry,activeId]),stop:useCallback(()=>preview.stop(),[preview])};}
|
|
1517
1567
|
function usePropOverride(){const{registry,activeId}=useContext(TimelineCtx);return{setPropOverride:useCallback((prop,o)=>{if(activeId)registry.setPropOverride(activeId,prop,o);},[registry,activeId])};}
|
|
1518
1568
|
|
|
1519
1569
|
// ── components ──
|
|
1520
|
-
const BASE_SCALE_MS = 5000;
|
|
1521
|
-
const ZOOM_MIN = 25;
|
|
1522
|
-
const ZOOM_MAX = 400;
|
|
1523
|
-
const ZOOM_DEFAULT = 100;
|
|
1524
|
-
const scaleFromZoom = zoom => Math.round(BASE_SCALE_MS * 100 / zoom);
|
|
1525
1570
|
const LABEL_W = 150;
|
|
1526
1571
|
const CLOSE_MS = 150;
|
|
1527
|
-
const SPEEDS = [0.25, 0.5, 1, 2];
|
|
1528
1572
|
const DURATION_TOKENS = [
|
|
1529
1573
|
{label:"Duration-fast", ms:150, usage:"Quick state changes — hovers, toggles, button presses, dropdown & modal close, text swaps."},
|
|
1530
1574
|
{label:"Duration-medium", ms:250, usage:"Standard UI motion — icon swaps, dropdown & modal open, sliding tabs, page slides."},
|
|
@@ -1538,8 +1582,6 @@
|
|
|
1538
1582
|
{label:"Long", ms:300, usage:"Pronounced wait — use sparingly to draw attention."},
|
|
1539
1583
|
];
|
|
1540
1584
|
function cx(...a){ return a.filter(Boolean).join(" "); }
|
|
1541
|
-
function fmtTimecode(ms){ const m=Math.floor(ms/60000); const s=Math.floor((ms%60000)/1000); const c=Math.floor((ms%1000)/10); const p=n=>String(n).padStart(2,"0"); return p(m)+":"+p(s)+":"+p(c); }
|
|
1542
|
-
function fmtSpeed(s){ return s+"x"; }
|
|
1543
1585
|
|
|
1544
1586
|
// ── icons ──
|
|
1545
1587
|
// Exact SVGs exported from the Logram ❖ Design System Figma file (no recreations).
|
|
@@ -1574,13 +1616,6 @@
|
|
|
1574
1616
|
style:{display:"block"},dangerouslySetInnerHTML:{__html:ic.svg}});
|
|
1575
1617
|
}
|
|
1576
1618
|
|
|
1577
|
-
function usePreviewTime(){
|
|
1578
|
-
const{preview}=useContext(TimelineCtx);
|
|
1579
|
-
const[ms,setMs]=useState(0);
|
|
1580
|
-
useEffect(()=>preview.onProgress(setMs),[preview]);
|
|
1581
|
-
return ms;
|
|
1582
|
-
}
|
|
1583
|
-
|
|
1584
1619
|
// portaled, origin-aware dropdown surface (transitions.dev menu-dropdown)
|
|
1585
1620
|
function Dropdown({open,onClose,triggerRef,width,align,children}){
|
|
1586
1621
|
const ref=useRef(null);
|
|
@@ -1897,7 +1932,7 @@
|
|
|
1897
1932
|
return out;
|
|
1898
1933
|
}
|
|
1899
1934
|
|
|
1900
|
-
function Header({entries,active,onSelect,onReset,onCopy,copied,
|
|
1935
|
+
function Header({entries,active,onSelect,onReset,onCopy,copied,snap,setSnap,onMinimize,onRefine,refineActive,onAccept,acceptState,acceptDisabled,acceptError,scanning}){
|
|
1901
1936
|
const[pick,setPick]=useState(false);
|
|
1902
1937
|
const[setg,setSetg]=useState(false);
|
|
1903
1938
|
const pickRef=useRef(null), gearRef=useRef(null);
|
|
@@ -1936,7 +1971,6 @@
|
|
|
1936
1971
|
scanning?"Grouping…":entries.length+" transition"+(entries.length===1?"":"s")+" found"),
|
|
1937
1972
|
h("button",{ref:gearRef,className:cx("tl-icon-btn",setg&&"is-active"),title:"Settings",onClick:()=>setSetg(v=>!v)},h(Ic,{name:"gear"})),
|
|
1938
1973
|
h(Dropdown,{open:setg,onClose:()=>setSetg(false),triggerRef:gearRef,width:210,align:"right"},
|
|
1939
|
-
h(MenuItem,{onClick:()=>setLoop(v=>!v),right:loop&&h("span",{className:"tl-menu-check"},h(Ic,{name:"check"}))},"Loop playback"),
|
|
1940
1974
|
h(MenuItem,{onClick:()=>setSnap(v=>!v),right:snap&&h("span",{className:"tl-menu-check"},h(Ic,{name:"check"}))},"Snap to grid")),
|
|
1941
1975
|
h("span",{className:"t-tt-wrap"},
|
|
1942
1976
|
h("button",{className:"tl-sec-btn t-tt-trigger",disabled:!active,onClick:onReset},"Reset"),
|
|
@@ -1977,7 +2011,7 @@
|
|
|
1977
2011
|
);
|
|
1978
2012
|
}
|
|
1979
2013
|
|
|
1980
|
-
function PropTrack({property, member, delayMs, durationMs, selected, dragging, onSelect, onReorder, onDelayChange, onDurationChange, snap, scaleMs, lockDuration}){
|
|
2014
|
+
function PropTrack({property, member, delayMs, durationMs, selected, dragging, onSelect, onReorder, onDelayChange, onDurationChange, snap, scaleMs, lockDuration, labelW}){
|
|
1981
2015
|
const trackRef=useRef(null);
|
|
1982
2016
|
const grid = snap ? 25 : 1;
|
|
1983
2017
|
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]);
|
|
@@ -2005,7 +2039,7 @@
|
|
|
2005
2039
|
|
|
2006
2040
|
const delPct=(delayMs/scaleMs)*100; const durPct=(durationMs/scaleMs)*100;
|
|
2007
2041
|
return h("div",{className:cx("tl-prop-row",selected&&"selected",dragging&&"reordering",lockDuration&&"is-spring"),onClick:onSelect},
|
|
2008
|
-
h("div",{className:"tl-prop-head"},
|
|
2042
|
+
h("div",{className:"tl-prop-head",style:labelW!=null?{flexBasis:labelW+"px"}:undefined},
|
|
2009
2043
|
h("span",{className:"tl-prop-grip",title:"Drag to reorder",onMouseDown:onReorder},h(Ic,{name:"dots"})),
|
|
2010
2044
|
h("span",{className:"tl-prop-label"},
|
|
2011
2045
|
member&&h("span",{className:"tl-prop-member"},member),
|
|
@@ -2388,50 +2422,31 @@
|
|
|
2388
2422
|
);
|
|
2389
2423
|
}
|
|
2390
2424
|
|
|
2391
|
-
function ScrubZone({scaleMs}){
|
|
2392
|
-
const { preview, registry, activeId } = useContext(TimelineCtx);
|
|
2393
|
-
const areaRef = useRef(null);
|
|
2394
|
-
const startedRef = useRef(false);
|
|
2395
|
-
|
|
2396
|
-
const pxToMs = useCallback(clientX=>{
|
|
2397
|
-
if(!areaRef.current) return 0;
|
|
2398
|
-
const rect = areaRef.current.getBoundingClientRect();
|
|
2399
|
-
const ratio = Math.max(0, Math.min((clientX - rect.left) / rect.width, 1));
|
|
2400
|
-
return ratio * scaleMs;
|
|
2401
|
-
},[scaleMs]);
|
|
2402
|
-
|
|
2403
|
-
const doSeek = useCallback(ms=>{
|
|
2404
|
-
if(preview.getState()==="idle" && !startedRef.current){
|
|
2405
|
-
if(!activeId) return;
|
|
2406
|
-
const entry = registry.getEffective(activeId);
|
|
2407
|
-
if(!entry) return;
|
|
2408
|
-
startedRef.current = true;
|
|
2409
|
-
preview.playPaused(entry, ms);
|
|
2410
|
-
} else {
|
|
2411
|
-
preview.seek(ms);
|
|
2412
|
-
}
|
|
2413
|
-
},[preview,registry,activeId]);
|
|
2414
|
-
|
|
2415
|
-
const startScrub = useCallback(e=>{
|
|
2416
|
-
e.preventDefault();
|
|
2417
|
-
startedRef.current = false;
|
|
2418
|
-
doSeek(pxToMs(e.clientX));
|
|
2419
|
-
const onMove = e2 => doSeek(pxToMs(e2.clientX));
|
|
2420
|
-
const onUp = () => { startedRef.current = false; window.removeEventListener("mousemove",onMove); window.removeEventListener("mouseup",onUp); };
|
|
2421
|
-
window.addEventListener("mousemove",onMove);
|
|
2422
|
-
window.addEventListener("mouseup",onUp);
|
|
2423
|
-
},[doSeek,pxToMs]);
|
|
2424
|
-
|
|
2425
|
-
return h("div",{className:"tl-scrub-zone",ref:areaRef,onMouseDown:startScrub});
|
|
2426
|
-
}
|
|
2427
|
-
|
|
2428
2425
|
function Tracks({et, selLane, setSelLane, onPropChange, snap, scaleMs}){
|
|
2429
|
-
const t = usePreviewTime();
|
|
2430
|
-
const ratio = Math.min(t / scaleMs, 1);
|
|
2431
2426
|
const ROW_H = 48;
|
|
2432
2427
|
const rowsRef = useRef(null);
|
|
2428
|
+
const tracksRef = useRef(null);
|
|
2433
2429
|
const [order, setOrder] = useState([]);
|
|
2434
2430
|
const [dragLane, setDragLane] = useState(null);
|
|
2431
|
+
// Width of the label column (shared by the ruler spacer + every prop-row
|
|
2432
|
+
// head). Draggable via the divider; the bars column is flex:1 so it just
|
|
2433
|
+
// takes the remaining space and the bars stay proportional to scaleMs.
|
|
2434
|
+
const LABEL_MIN = 96, LABEL_TRACK_MIN = 160;
|
|
2435
|
+
const [labelW, setLabelW] = useState(LABEL_W);
|
|
2436
|
+
const [colDragging, setColDragging] = useState(false);
|
|
2437
|
+
const startColResize = useCallback(e=>{
|
|
2438
|
+
e.preventDefault(); e.stopPropagation();
|
|
2439
|
+
const startX = e.clientX, startW = labelW;
|
|
2440
|
+
const maxW = tracksRef.current
|
|
2441
|
+
? Math.max(LABEL_MIN, tracksRef.current.getBoundingClientRect().width - LABEL_TRACK_MIN)
|
|
2442
|
+
: 360;
|
|
2443
|
+
setColDragging(true);
|
|
2444
|
+
document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none";
|
|
2445
|
+
const onMove = e2 => setLabelW(Math.max(LABEL_MIN, Math.min(maxW, startW + (e2.clientX - startX))));
|
|
2446
|
+
const onUp = () => { setColDragging(false); document.body.style.cursor=""; document.body.style.userSelect="";
|
|
2447
|
+
window.removeEventListener("mousemove",onMove); window.removeEventListener("mouseup",onUp); };
|
|
2448
|
+
window.addEventListener("mousemove",onMove); window.addEventListener("mouseup",onUp);
|
|
2449
|
+
},[labelW]);
|
|
2435
2450
|
const laneKey = et.map(x=>x.laneId).join("|");
|
|
2436
2451
|
useEffect(()=>{
|
|
2437
2452
|
const ids = et.map(x=>x.laneId);
|
|
@@ -2463,7 +2478,7 @@
|
|
|
2463
2478
|
const onUp = ()=>{ setDragLane(null); window.removeEventListener("mousemove",onMove); window.removeEventListener("mouseup",onUp); };
|
|
2464
2479
|
window.addEventListener("mousemove",onMove); window.addEventListener("mouseup",onUp);
|
|
2465
2480
|
},[setSelLane]);
|
|
2466
|
-
const majorStep = scaleMs <= 10000 ? 1000 : scaleMs <= 25000 ? 5000 : 10000;
|
|
2481
|
+
const majorStep = scaleMs <= 2000 ? 500 : scaleMs <= 10000 ? 1000 : scaleMs <= 25000 ? 5000 : 10000;
|
|
2467
2482
|
const minorStep = majorStep / 4;
|
|
2468
2483
|
const ruler=[];
|
|
2469
2484
|
for(let ms=0; ms<=scaleMs; ms+=majorStep){
|
|
@@ -2474,26 +2489,23 @@
|
|
|
2474
2489
|
if(ms%majorStep===0) continue;
|
|
2475
2490
|
ruler.push(h("span",{key:"t"+ms,className:"tick",style:{left:((ms/scaleMs)*100)+"%"}}));
|
|
2476
2491
|
}
|
|
2477
|
-
return h("div",{className:"tl-tracks"},
|
|
2492
|
+
return h("div",{className:"tl-tracks",ref:tracksRef},
|
|
2478
2493
|
h("div",{className:"tl-ruler-row"},
|
|
2479
|
-
h("div",{className:"tl-ruler-spacer"}),
|
|
2494
|
+
h("div",{className:"tl-ruler-spacer",style:{flexBasis:labelW+"px"}}),
|
|
2480
2495
|
h("div",{className:"tl-ruler"},...ruler)),
|
|
2481
2496
|
h("div",{className:"tl-rows",ref:rowsRef},
|
|
2482
2497
|
...orderedRows.map(row=>h(PropTrack,{
|
|
2483
2498
|
key:row.laneId, property:row.property, member:row.member,
|
|
2484
2499
|
delayMs:row.delayMs, durationMs:row.durationMs, lockDuration:!!row.spring,
|
|
2485
|
-
selected:row.laneId===selLane, dragging:row.laneId===dragLane, snap, scaleMs,
|
|
2500
|
+
selected:row.laneId===selLane, dragging:row.laneId===dragLane, snap, scaleMs, labelW,
|
|
2486
2501
|
onSelect:()=>setSelLane(row.laneId),
|
|
2487
2502
|
onReorder:e=>startReorder(row.laneId,e),
|
|
2488
2503
|
onDelayChange:ms=>onPropChange(row.laneId,{delayMs:ms}),
|
|
2489
2504
|
onDurationChange:ms=>onPropChange(row.laneId,{durationMs:ms}),
|
|
2490
2505
|
}))),
|
|
2491
|
-
h(
|
|
2492
|
-
|
|
2493
|
-
h("
|
|
2494
|
-
h("svg",{className:"tl-playhead-head",viewBox:"0 0 16.666 24",width:"16.666",height:"24",fill:"none"},
|
|
2495
|
-
h("path",{d:"M13.666 7.333C13.666 11.609 9.333 15.666 9.333 19.942L9.333 24L7.333 24L7.333 19.942C7.333 15.666 3 11.609 3 7.333C3 4.387 5.387 2 8.333 2C11.279 2 13.666 4.387 13.666 7.333Z",fill:"#1A7AFF"}))),
|
|
2496
|
-
),
|
|
2506
|
+
h("div",{className:cx("tl-col-resizer",colDragging&&"dragging"),style:{left:labelW+"px"},
|
|
2507
|
+
onMouseDown:startColResize,title:"Drag to resize columns","aria-label":"Resize label column",role:"separator"},
|
|
2508
|
+
h("span",{className:"tl-col-resizer-line"})),
|
|
2497
2509
|
);
|
|
2498
2510
|
}
|
|
2499
2511
|
|
|
@@ -2518,65 +2530,34 @@
|
|
|
2518
2530
|
);
|
|
2519
2531
|
}
|
|
2520
2532
|
|
|
2521
|
-
function Body({entry, onPropChange,
|
|
2533
|
+
function Body({entry, onPropChange, snap}){
|
|
2522
2534
|
const et = entry.effectiveTimings || [];
|
|
2523
2535
|
const [selLane, setSelLane] = useState(et[0]?.laneId ?? null);
|
|
2524
|
-
|
|
2525
|
-
|
|
2536
|
+
// No playback: the real component is the preview. The track scale just
|
|
2537
|
+
// auto-fits the longest lane (duration+delay) with a little headroom so
|
|
2538
|
+
// the static bars stay readable across short and long transitions.
|
|
2539
|
+
const maxEnd = et.reduce((m,t)=>Math.max(m,(t.durationMs||0)+(t.delayMs||0)),0);
|
|
2540
|
+
const scaleMs = Math.max(500, Math.ceil((maxEnd*1.15)/250)*250);
|
|
2526
2541
|
useEffect(()=>{
|
|
2527
2542
|
if(et.length && !et.find(t=>t.laneId===selLane)) setSelLane(et[0]?.laneId);
|
|
2528
2543
|
},[et,selLane]);
|
|
2529
2544
|
|
|
2530
2545
|
return h("div",{className:"tl-body"},
|
|
2531
2546
|
h("div",{className:"tl-main"},
|
|
2532
|
-
h(Transport,{state,disabled:!entry,onPlay:play,onPause:pause,onResume:resume,onRestart:restart,onStop:stop,speed,setSpeed,zoom,setZoom}),
|
|
2533
2547
|
h(Tracks,{et,selLane,setSelLane,onPropChange,snap,scaleMs}),
|
|
2534
2548
|
),
|
|
2535
2549
|
h(Inspector,{entry,selLane,onPropChange,snap}),
|
|
2536
2550
|
);
|
|
2537
2551
|
}
|
|
2538
2552
|
|
|
2539
|
-
function Transport({state,disabled,onPlay,onPause,onResume,onRestart,onStop,speed,setSpeed,zoom,setZoom}){
|
|
2540
|
-
const t = usePreviewTime();
|
|
2541
|
-
const spRef = useRef(null);
|
|
2542
|
-
const [sp,setSp] = useState(false);
|
|
2543
|
-
const playing = state==="playing";
|
|
2544
|
-
const onMain = () => playing ? onPause() : state==="paused" ? onResume() : onPlay();
|
|
2545
|
-
return h("div",{className:"tl-transport"},
|
|
2546
|
-
h("div",{className:"tl-transport-left"},
|
|
2547
|
-
h("span",{className:"tl-timecode"}, fmtTimecode(t)),
|
|
2548
|
-
h("button",{ref:spRef,className:cx("tl-ghost-btn","tl-speed",sp&&"is-active"),onClick:()=>setSp(v=>!v)},
|
|
2549
|
-
fmtSpeed(speed), h("span",{className:"tl-ghost-chev"},h(Ic,{name:"chevron"}))),
|
|
2550
|
-
h(Dropdown,{open:sp,onClose:()=>setSp(false),triggerRef:spRef,width:120,align:"left"},
|
|
2551
|
-
SPEEDS.map(s=>h(MenuItem,{key:s,active:s===speed,onClick:()=>{setSpeed(s);setSp(false);},
|
|
2552
|
-
right:s===speed&&h("span",{className:"tl-menu-check"},h(Ic,{name:"check"}))},fmtSpeed(s)))) ),
|
|
2553
|
-
h("div",{className:"tl-transport-center"},
|
|
2554
|
-
h("button",{className:"tl-play-btn",disabled,onClick:onMain,title:playing?"Pause":"Play"},
|
|
2555
|
-
h("span",{className:"t-icon-swap","data-state":playing?"b":"a"},
|
|
2556
|
-
h("span",{className:"t-icon","data-icon":"a"},h(Ic,{name:"play",size:12})),
|
|
2557
|
-
h("span",{className:"t-icon","data-icon":"b"},h(Ic,{name:"pause",size:16}))))),
|
|
2558
|
-
h("div",{className:"tl-transport-right"},
|
|
2559
|
-
h("div",{className:"tl-zoom",title:"Timeline zoom"},
|
|
2560
|
-
h("div",{className:"tl-zoom-track","aria-hidden":true}),
|
|
2561
|
-
h("input",{type:"range",min:ZOOM_MIN,max:ZOOM_MAX,value:zoom,
|
|
2562
|
-
onChange:e=>setZoom(Number(e.target.value))})),
|
|
2563
|
-
h("span",{className:"t-tt-wrap"},
|
|
2564
|
-
h("button",{className:"tl-icon-btn ghost t-tt-trigger",disabled,"aria-label":"Replay",onClick:onRestart},h(Ic,{name:"restart"})),
|
|
2565
|
-
h("span",{className:"t-tt tl-tt-below",role:"tooltip"},"Replay")),
|
|
2566
|
-
),
|
|
2567
|
-
);
|
|
2568
|
-
}
|
|
2569
|
-
|
|
2570
2553
|
function TimelinePanel(){
|
|
2571
2554
|
const entries=useReg(); const{active,setActiveId}=useActive();
|
|
2572
|
-
const{
|
|
2573
|
-
const{registry
|
|
2555
|
+
const{setPropOverride}=usePropOverride();
|
|
2556
|
+
const{registry}=useContext(TimelineCtx);
|
|
2574
2557
|
const[copied,setCopied]=useState(false);
|
|
2575
2558
|
const[minimized,setMinimized]=useState(false);
|
|
2576
2559
|
const[panelHeight,setPanelHeight]=useState(440);
|
|
2577
2560
|
const[resizing,setResizing]=useState(false);
|
|
2578
|
-
const[speed,setSpeed]=useState(1);
|
|
2579
|
-
const[loop,setLoop]=useState(false);
|
|
2580
2561
|
const[snap,setSnap]=useState(true);
|
|
2581
2562
|
// ── refine ──
|
|
2582
2563
|
const[refineOpen,setRefineOpen]=useState(false);
|
|
@@ -2676,8 +2657,59 @@
|
|
|
2676
2657
|
const panelMinH=200;
|
|
2677
2658
|
const panelMaxH=useCallback(()=>Math.round(window.innerHeight*0.92),[]);
|
|
2678
2659
|
useEffect(()=>{if(!active&&entries.length>0)setActiveId(entries[0].id);},[active,entries,setActiveId]);
|
|
2679
|
-
|
|
2680
|
-
|
|
2660
|
+
// ── live styling ──────────────────────────────────────────────────────
|
|
2661
|
+
// With playback gone, the real component IS the preview. So whenever a
|
|
2662
|
+
// transition has edits, mirror its effective timings onto the live
|
|
2663
|
+
// element(s) as an inline `transition`, and revert when edits are cleared.
|
|
2664
|
+
// Interacting with the component then animates with the edited values.
|
|
2665
|
+
const liveAppliedRef=useRef(new Map()); // el → {prevInline, css}
|
|
2666
|
+
useEffect(()=>{
|
|
2667
|
+
if(typeof document==="undefined")return;
|
|
2668
|
+
const applied=liveAppliedRef.current;
|
|
2669
|
+
const desired=new Map(); // el → css
|
|
2670
|
+
const add=(els,css)=>{ for(const el of els){ if(!el||(el.closest&&el.closest("[data-timeline-panel]")))continue; desired.set(el,css); } };
|
|
2671
|
+
for(const item of entries){
|
|
2672
|
+
const ov=registry.getPropOverrides(item.id);
|
|
2673
|
+
if(!ov||!Object.keys(ov).length)continue; // only edited transitions go live
|
|
2674
|
+
if(item.kind==="phase"){
|
|
2675
|
+
for(const m of (item.members||[])){
|
|
2676
|
+
if(!m.selector||!m.lanes||!m.lanes.length)continue;
|
|
2677
|
+
let els=[];try{els=Array.from(document.querySelectorAll(m.selector));}catch{}
|
|
2678
|
+
add(els,transitionLanesToCss(m.lanes));
|
|
2679
|
+
}
|
|
2680
|
+
}else{
|
|
2681
|
+
const lanes=item.effectiveTimings||[];
|
|
2682
|
+
if(!lanes.length)continue;
|
|
2683
|
+
const els=((item.bindings&&item.bindings.elements)||[]).map(w=>w.deref&&w.deref()).filter(Boolean);
|
|
2684
|
+
add(els,transitionLanesToCss(lanes));
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
// apply new / changed
|
|
2688
|
+
for(const [el,css] of desired){
|
|
2689
|
+
const cur=applied.get(el);
|
|
2690
|
+
if(cur&&cur.css===css)continue;
|
|
2691
|
+
if(!cur){
|
|
2692
|
+
const s=getComputedStyle(el);
|
|
2693
|
+
const z=zipTransitionLists((s.transitionProperty||"").split(","),(s.transitionDuration||"0s").split(","),(s.transitionDelay||"0s").split(","),splitCssValues(s.transitionTimingFunction||"ease"));
|
|
2694
|
+
_TX_LIVE_BASE.set(el,{z});
|
|
2695
|
+
applied.set(el,{prevInline:el.style.transition||"",css});
|
|
2696
|
+
}else applied.set(el,{prevInline:cur.prevInline,css});
|
|
2697
|
+
try{el.style.transition=css;}catch{}
|
|
2698
|
+
}
|
|
2699
|
+
// revert elements that no longer have edits
|
|
2700
|
+
for(const [el,rec] of Array.from(applied)){
|
|
2701
|
+
if(desired.has(el))continue;
|
|
2702
|
+
try{el.style.transition=rec.prevInline;}catch{}
|
|
2703
|
+
_TX_LIVE_BASE.delete(el);
|
|
2704
|
+
applied.delete(el);
|
|
2705
|
+
}
|
|
2706
|
+
},[entries,registry]);
|
|
2707
|
+
// restore everything on unmount
|
|
2708
|
+
useEffect(()=>()=>{
|
|
2709
|
+
const applied=liveAppliedRef.current;
|
|
2710
|
+
for(const [el,rec] of applied){ try{el.style.transition=rec.prevInline;}catch{} _TX_LIVE_BASE.delete(el); }
|
|
2711
|
+
applied.clear();
|
|
2712
|
+
},[]);
|
|
2681
2713
|
const startResize=useCallback(e=>{
|
|
2682
2714
|
e.preventDefault();
|
|
2683
2715
|
const startY=e.clientY; const startH=panelHeight;
|
|
@@ -2740,11 +2772,24 @@
|
|
|
2740
2772
|
// Auto-run the grouped scan once, after the flat DOM scan has populated.
|
|
2741
2773
|
// The agent reads the source and returns Open/Close phases; if no agent is
|
|
2742
2774
|
// live (or the relay is down) we silently keep the flat DOM scan.
|
|
2775
|
+
// Lifecycle is tied to `registry` only (a stable ref), never to
|
|
2776
|
+
// `entries.length`. On dynamic pages the DOM scanner makes entries.length
|
|
2777
|
+
// fluctuate; keying the effect on it tore down the in-flight poll before
|
|
2778
|
+
// the agent answered and the once-guard blocked a restart → permanent
|
|
2779
|
+
// "Grouping…". Here a single run waits for flat entries to appear, fires
|
|
2780
|
+
// one scan job, then polls to completion regardless of DOM churn; only an
|
|
2781
|
+
// actual unmount cancels it.
|
|
2743
2782
|
useEffect(()=>{
|
|
2744
|
-
if(didGroupScanRef.current
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2783
|
+
if(didGroupScanRef.current)return;
|
|
2784
|
+
let stopped=false;
|
|
2785
|
+
const run=async()=>{
|
|
2786
|
+
while(!stopped){
|
|
2787
|
+
const flat0=registry.getAll().filter(e=>e.kind!=="phase");
|
|
2788
|
+
if(flat0.length)break;
|
|
2789
|
+
await new Promise(r=>setTimeout(r,300));
|
|
2790
|
+
}
|
|
2791
|
+
if(stopped||didGroupScanRef.current)return;
|
|
2792
|
+
didGroupScanRef.current=true;
|
|
2748
2793
|
setGroupScanState("scanning");
|
|
2749
2794
|
const flat=registry.getAll().filter(e=>e.kind!=="phase");
|
|
2750
2795
|
const raw=flat.map(e=>({label:e.label,selector:e.bindings&&e.bindings.selector,
|
|
@@ -2752,18 +2797,19 @@
|
|
|
2752
2797
|
timings:(e.baseLanes||[]).map(l=>({property:l.property,durationMs:l.durationMs,delayMs:l.delayMs,easing:l.easing}))}));
|
|
2753
2798
|
try{
|
|
2754
2799
|
const{id}=await relayCreateJob({kind:"scan",url:location.href,raw});
|
|
2755
|
-
for(let i=0;i<
|
|
2800
|
+
for(let i=0;i<520&&!stopped;i++){
|
|
2756
2801
|
await new Promise(r=>setTimeout(r,500));
|
|
2757
2802
|
const job=await relayGetJob(id);
|
|
2758
|
-
if(
|
|
2803
|
+
if(stopped)return;
|
|
2759
2804
|
if(job.status==="done"){const groups=(job.result&&job.result.groups)||[];if(groups.length)registry.setGroups(groups);setGroupScanState("done");return;}
|
|
2760
2805
|
if(job.status==="error"){setGroupScanState("error");return;}
|
|
2761
2806
|
}
|
|
2762
|
-
if(!
|
|
2763
|
-
}catch(e){ if(!
|
|
2764
|
-
}
|
|
2765
|
-
|
|
2766
|
-
|
|
2807
|
+
if(!stopped)setGroupScanState("error");
|
|
2808
|
+
}catch(e){ if(!stopped)setGroupScanState("error"); /* relay down → stay flat */ }
|
|
2809
|
+
};
|
|
2810
|
+
run();
|
|
2811
|
+
return ()=>{stopped=true;};
|
|
2812
|
+
},[registry]);
|
|
2767
2813
|
|
|
2768
2814
|
// whole-component open/close uses the transitions.dev panel reveal:
|
|
2769
2815
|
// keep the panel mounted while it animates, flip data-open on the next
|
|
@@ -2812,12 +2858,11 @@
|
|
|
2812
2858
|
h("div",{className:"tl-panel-body"},
|
|
2813
2859
|
h("div",{className:"tl-panel-main"},
|
|
2814
2860
|
h(Header,{entries,active,onSelect:setActiveId,onReset:resetOverrides,onCopy:copyValues,copied,
|
|
2815
|
-
|
|
2861
|
+
snap,setSnap,onMinimize:()=>setMinimized(true),onRefine:openRefine,refineActive:refineOpen,
|
|
2816
2862
|
onAccept,acceptState,acceptDisabled:computeChanges(active).length===0,acceptError,
|
|
2817
2863
|
scanning:groupScanState==="scanning"}),
|
|
2818
2864
|
active
|
|
2819
|
-
?h(Body,{entry:active,onPropChange:(prop,o)=>setPropOverride(prop,o),
|
|
2820
|
-
state,play,pause,resume,restart,stop,speed,setSpeed,snap})
|
|
2865
|
+
?h(Body,{entry:active,onPropChange:(prop,o)=>setPropOverride(prop,o),snap})
|
|
2821
2866
|
:h("div",{className:"tl-empty"},"Select a transition to inspect and edit it")),
|
|
2822
2867
|
h(RefinePanel,{open:refineOpen,onClose:()=>setRefineOpen(false),phase:refinePhase,label:refineLabel,
|
|
2823
2868
|
refineType,onType:changeRefineType,suggestions:refineSuggestions,summary:refineSummary,error:refineError,
|
|
@@ -2937,16 +2982,16 @@
|
|
|
2937
2982
|
}
|
|
2938
2983
|
|
|
2939
2984
|
function App(){
|
|
2940
|
-
const rootRef=useRef(null);const registry=useMemo(()=>new TransitionRegistry(),[]);const
|
|
2941
|
-
useEffect(()=>{const root=rootRef.current??document.body;const scanner=new DomScanner(root,registry);
|
|
2942
|
-
const ctx=useMemo(()=>({registry,
|
|
2985
|
+
const rootRef=useRef(null);const registry=useMemo(()=>new TransitionRegistry(),[]);const[activeId,setActiveId]=useState(null);
|
|
2986
|
+
useEffect(()=>{const root=rootRef.current??document.body;const scanner=new DomScanner(root,registry);scanner.start();return()=>{scanner.stop();};},[registry]);
|
|
2987
|
+
const ctx=useMemo(()=>({registry,activeId,setActiveId}),[registry,activeId]);
|
|
2943
2988
|
// Demo-only tweak controls: hidden by default. Append ?controls to the URL
|
|
2944
2989
|
// to show them for testing. (This whole block is below the inject CUT_MARKER,
|
|
2945
2990
|
// so it never ships in the injected build.)
|
|
2946
2991
|
const showControls=(()=>{try{return new URLSearchParams(location.search).has("controls");}catch(e){return false;}})();
|
|
2947
2992
|
return h(TimelineCtx.Provider,{value:ctx},
|
|
2948
2993
|
showControls&&h(PanelControls),
|
|
2949
|
-
h("div",{ref:rootRef,className:"demo-root"},h("div",{className:"demo-header"},h("h1",null,"Timeline Inspector \u2014 Demo"),h("p",null,"CSS transitions are automatically detected. Select one below, drag the bars to edit timing, then
|
|
2994
|
+
h("div",{ref:rootRef,className:"demo-root"},h("div",{className:"demo-header"},h("h1",null,"Timeline Inspector \u2014 Demo"),h("p",null,"CSS transitions are automatically detected. Select one below, drag the bars to edit timing, then interact with the component itself to preview it.")),
|
|
2950
2995
|
h("div",{className:"demo-grid"},h(BoxResize),h(BoxOpacity),h(BoxSlide),h(BoxColor))),
|
|
2951
2996
|
h(TimelinePanel));
|
|
2952
2997
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "transitions-refine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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/inject.mjs
CHANGED
|
@@ -86,15 +86,13 @@ function buildEpilogue(css) {
|
|
|
86
86
|
|
|
87
87
|
function InjectedRoot(){
|
|
88
88
|
const registry = useMemo(() => new TransitionRegistry(), []);
|
|
89
|
-
const preview = useMemo(() => new PreviewController(), []);
|
|
90
89
|
const [activeId, setActiveId] = useState(null);
|
|
91
90
|
useEffect(() => {
|
|
92
91
|
const scanner = new DomScanner(document.body, registry);
|
|
93
|
-
preview.setScanner(scanner);
|
|
94
92
|
scanner.start();
|
|
95
|
-
return () => { scanner.stop();
|
|
96
|
-
}, [registry
|
|
97
|
-
const ctx = useMemo(() => ({ registry,
|
|
93
|
+
return () => { scanner.stop(); };
|
|
94
|
+
}, [registry]);
|
|
95
|
+
const ctx = useMemo(() => ({ registry, activeId, setActiveId }), [registry, activeId]);
|
|
98
96
|
return h(TimelineCtx.Provider, { value: ctx }, h(TimelinePanel));
|
|
99
97
|
}
|
|
100
98
|
|
package/server/relay.mjs
CHANGED
|
@@ -241,10 +241,18 @@ function buildScanPrompt(job) {
|
|
|
241
241
|
"Steps:",
|
|
242
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
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.
|
|
244
|
+
"3. PHASE STATE — how the phase is driven (REQUIRED for playback to work). For each phase provide:",
|
|
245
|
+
" - `stateTarget`: a CSS selector for the ONE element whose class/attribute is toggled to drive the whole phase (e.g. the dropdown root, the `.modal`, the element with `[data-open]`). It MUST resolve in the live DOM RIGHT NOW, in any state — so it must NOT itself contain the toggled state (write `.t-morph`, never `.t-morph[data-open=\"true\"]`).",
|
|
246
|
+
" - `fromState` and `toState`: the class/attribute on `stateTarget` at the START and END of this phase, as a token: a class `\".is-open\"`, an attribute `\"[data-open=\\\"true\\\"]\"`, or `null`/`\"\"` for the base/no-class state. OPEN usually goes base→open (`fromState:null`, `toState:\".is-open\"`); CLOSE goes open→base (`fromState:\".is-open\"`, `toState:null`). Get the DIRECTION right — open must animate into the open look, close must animate back out.",
|
|
247
|
+
"4. For each phase, list its MEMBER elements (panel, backdrop, the staggered items…). Give each member a stable `id`, a human `label`, a CSS `selector`, and its real `propertyTimings`. The member `selector` MUST resolve in the live DOM RIGHT NOW regardless of phase — use the BASE element selector and do NOT bake the phase's toggled class/attribute into it (write `.t-morph .t-morph-plus`, never `.t-morph[data-open=\"true\"] .t-morph-plus`). The toggled state belongs only in the phase's `stateTarget`/`toState`.",
|
|
248
|
+
"5. TIMINGS MUST BE EXACT AND PER-PROPERTY. This is the most common mistake — do not make it:",
|
|
249
|
+
" - List one `propertyTimings` entry per animated property. Read EACH property's own duration/delay/easing from the shorthand `transition:` list (or the property-specific longhand). Do NOT copy one property's duration onto the others, and do NOT use the phase's longest/representative duration for every lane.",
|
|
250
|
+
" - Resolve CSS custom properties (e.g. `var(--morph-fade-dur)`) to concrete numbers by following the `:root`/scope where they're defined; convert `s`→ms (`0.25s`→250). Never emit a `var(...)` or a guess.",
|
|
251
|
+
" - It is normal and expected for properties within one phase to differ (e.g. opacity/filter 200ms but transform 350ms). If every property in a phase ends up identical, re-read the source — you probably collapsed them by mistake.",
|
|
252
|
+
" - Open and close usually have DIFFERENT durations/easings; report each from its own rule.",
|
|
245
253
|
"",
|
|
246
254
|
"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","
|
|
255
|
+
'{"summary":"Grouped 3 components.","groups":[{"id":"dropdown","label":"Dropdown","component":"src/Dropdown.tsx","phases":[{"id":"dropdown:open","phase":"open","label":"Open","stateTarget":".dropdown","fromState":null,"toState":".is-open","members":[{"id":"panel","label":"Panel","selector":".dropdown .dropdown-panel","propertyTimings":[{"property":"opacity","durationMs":200,"delayMs":0,"easing":"ease-out"},{"property":"transform","durationMs":200,"delayMs":0,"easing":"cubic-bezier(0.22, 1, 0.36, 1)"}]}]},{"id":"dropdown:close","phase":"close","label":"Close","stateTarget":".dropdown","fromState":".is-open","toState":null,"members":[{"id":"panel","label":"Panel","selector":".dropdown .dropdown-panel","propertyTimings":[{"property":"opacity","durationMs":150,"delayMs":0,"easing":"ease-in"},{"property":"transform","durationMs":150,"delayMs":0,"easing":"ease-in"}]}]}]}]}',
|
|
248
256
|
"If you cannot confidently group anything, return an empty groups array with a short summary; the panel keeps its flat DOM scan.",
|
|
249
257
|
].join("\n");
|
|
250
258
|
}
|