transitions-refine 0.1.3 → 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/.agents/skills/refine-live/SKILL.md +96 -12
- package/README.md +12 -2
- package/demo.html +515 -264
- package/package.json +1 -1
- package/server/inject.mjs +3 -5
- package/server/relay.mjs +69 -7
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
|
@@ -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,35 @@ 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. 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.",
|
|
253
|
+
"",
|
|
254
|
+
"Output ONLY a JSON object — no prose, no markdown fences — shaped exactly like:",
|
|
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"}]}]}]}]}',
|
|
256
|
+
"If you cannot confidently group anything, return an empty groups array with a short summary; the panel keeps its flat DOM scan.",
|
|
257
|
+
].join("\n");
|
|
258
|
+
}
|
|
259
|
+
|
|
221
260
|
function refineDeterministic(job) {
|
|
222
261
|
// Whole-transition replacement needs usage inference + recipe selection, which
|
|
223
262
|
// only the agent (LLM) path can do. Deterministic can only snap to tokens.
|
|
@@ -240,11 +279,15 @@ async function answerJob(job) {
|
|
|
240
279
|
job.status = "working";
|
|
241
280
|
job.updatedAt = now();
|
|
242
281
|
const isApply = job.request?.kind === "apply";
|
|
282
|
+
const isScan = job.request?.kind === "scan";
|
|
243
283
|
const label = job.request?.label || job.request?.selector || "transition";
|
|
244
284
|
// The browser picks the mode per job via the LLM / Deterministic tabs.
|
|
245
285
|
// Default: LLM when a command is configured, otherwise deterministic.
|
|
246
286
|
const mode = job.request?.mode || (AGENT_CMD ? "llm" : "deterministic");
|
|
247
|
-
job.statusLog.push({
|
|
287
|
+
job.statusLog.push({
|
|
288
|
+
message: isApply ? `Writing "${label}" to your code…` : isScan ? "Grouping transitions from your source…" : `Scanning "${label}"…`,
|
|
289
|
+
at: now(),
|
|
290
|
+
});
|
|
248
291
|
try {
|
|
249
292
|
let result;
|
|
250
293
|
if (isApply) {
|
|
@@ -263,6 +306,22 @@ async function answerJob(job) {
|
|
|
263
306
|
console.log(` ✓ apply ${job.id.slice(0, 8)} — applied=${result.applied}`);
|
|
264
307
|
return;
|
|
265
308
|
}
|
|
309
|
+
if (isScan) {
|
|
310
|
+
// Reading source to group transitions can only be done by the agent.
|
|
311
|
+
if (!AGENT_CMD) {
|
|
312
|
+
throw new Error(
|
|
313
|
+
"Grouping transitions needs the agent. Run `/refine live` in your editor, " +
|
|
314
|
+
"or start the relay with REFINE_AGENT_CMD set."
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
job.statusLog.push({ message: "Reading components from source…", at: now() });
|
|
318
|
+
result = await runAgentCmd(AGENT_CMD, buildScanPrompt(job), parseScanOutput);
|
|
319
|
+
job.result = { groups: result.groups, summary: result.summary };
|
|
320
|
+
job.status = "done";
|
|
321
|
+
job.updatedAt = now();
|
|
322
|
+
console.log(` ✓ scan ${job.id.slice(0, 8)} — ${result.groups.length} group(s)`);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
266
325
|
if (mode === "llm") {
|
|
267
326
|
if (!AGENT_CMD) {
|
|
268
327
|
throw new Error(
|
|
@@ -362,8 +421,8 @@ const server = createServer(async (req, res) => {
|
|
|
362
421
|
return send(res, 400, { error: "Body must be { request: {...} }" });
|
|
363
422
|
}
|
|
364
423
|
const job = createJob(body.request);
|
|
365
|
-
// Apply jobs edit source — agent only, never deterministic.
|
|
366
|
-
const mode = job.request.kind === "apply"
|
|
424
|
+
// Apply and scan jobs read/edit source — agent only, never deterministic.
|
|
425
|
+
const mode = (job.request.kind === "apply" || job.request.kind === "scan")
|
|
367
426
|
? "llm"
|
|
368
427
|
: (job.request.mode || (llmAvailable() ? "llm" : "deterministic"));
|
|
369
428
|
job.request.mode = mode;
|
|
@@ -440,11 +499,14 @@ const server = createServer(async (req, res) => {
|
|
|
440
499
|
const body = await readJson(req);
|
|
441
500
|
if (body && Array.isArray(body.suggestions)) {
|
|
442
501
|
job.result = { suggestions: body.suggestions, summary: body.summary ?? null };
|
|
502
|
+
} else if (body && Array.isArray(body.groups)) {
|
|
503
|
+
// scan-job result from a `/refine live` agent
|
|
504
|
+
job.result = { groups: body.groups, summary: body.summary ?? null };
|
|
443
505
|
} else if (body && typeof body.applied !== "undefined") {
|
|
444
506
|
// apply-job result from a `/refine live` agent
|
|
445
507
|
job.result = { applied: Boolean(body.applied), summary: body.summary ?? null, files: Array.isArray(body.files) ? body.files : null };
|
|
446
508
|
} else {
|
|
447
|
-
return send(res, 400, { error: "Body must be { suggestions: [...] } or { applied, summary }" });
|
|
509
|
+
return send(res, 400, { error: "Body must be { suggestions: [...] }, { groups: [...] }, or { applied, summary }" });
|
|
448
510
|
}
|
|
449
511
|
job.status = "done";
|
|
450
512
|
job.updatedAt = now();
|