transitions-refine 0.1.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.
Files changed (32) hide show
  1. package/.agents/skills/refine-live/SKILL.md +205 -0
  2. package/.agents/skills/transitions-dev/01-card-resize.md +53 -0
  3. package/.agents/skills/transitions-dev/02-number-pop-in.md +119 -0
  4. package/.agents/skills/transitions-dev/03-notification-badge.md +110 -0
  5. package/.agents/skills/transitions-dev/04-text-states-swap.md +97 -0
  6. package/.agents/skills/transitions-dev/05-menu-dropdown.md +105 -0
  7. package/.agents/skills/transitions-dev/06-modal.md +94 -0
  8. package/.agents/skills/transitions-dev/07-panel-reveal.md +81 -0
  9. package/.agents/skills/transitions-dev/08-page-side-by-side.md +100 -0
  10. package/.agents/skills/transitions-dev/09-icon-swap.md +78 -0
  11. package/.agents/skills/transitions-dev/10-success-check.md +169 -0
  12. package/.agents/skills/transitions-dev/11-avatar-group-hover.md +200 -0
  13. package/.agents/skills/transitions-dev/12-error-state-shake.md +202 -0
  14. package/.agents/skills/transitions-dev/13-input-clear-dissolve.md +276 -0
  15. package/.agents/skills/transitions-dev/14-skeleton-reveal.md +149 -0
  16. package/.agents/skills/transitions-dev/15-shimmer-text.md +95 -0
  17. package/.agents/skills/transitions-dev/16-tabs-sliding.md +146 -0
  18. package/.agents/skills/transitions-dev/17-tooltip.md +103 -0
  19. package/.agents/skills/transitions-dev/18-texts-reveal.md +110 -0
  20. package/.agents/skills/transitions-dev/19-card-tilt.md +170 -0
  21. package/.agents/skills/transitions-dev/20-plus-menu-morph.md +167 -0
  22. package/.agents/skills/transitions-dev/21-accordion.md +124 -0
  23. package/.agents/skills/transitions-dev/SKILL.md +225 -0
  24. package/.agents/skills/transitions-dev/_root.css +204 -0
  25. package/README.md +89 -0
  26. package/bin/cli.mjs +264 -0
  27. package/demo.html +2531 -0
  28. package/package.json +37 -0
  29. package/server/inject.mjs +116 -0
  30. package/server/motion-tokens.mjs +106 -0
  31. package/server/refine-agent.mjs +86 -0
  32. package/server/relay.mjs +421 -0
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "transitions-refine",
3
+ "version": "0.1.0",
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
+ "type": "module",
6
+ "bin": {
7
+ "refine": "./bin/cli.mjs"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "server",
12
+ "demo.html",
13
+ ".agents/skills"
14
+ ],
15
+ "scripts": {
16
+ "prepack": "node scripts/sync-skill.mjs",
17
+ "live": "node bin/cli.mjs live",
18
+ "relay": "node server/relay.mjs"
19
+ },
20
+ "keywords": [
21
+ "transitions",
22
+ "css",
23
+ "motion",
24
+ "animation",
25
+ "timeline",
26
+ "refine",
27
+ "cursor",
28
+ "agent"
29
+ ],
30
+ "homepage": "https://transitions.dev/",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/Jakubantalik/transitions.dev.git",
34
+ "directory": "refine"
35
+ },
36
+ "license": "MIT"
37
+ }
@@ -0,0 +1,116 @@
1
+ // Builds a self-contained, self-mounting timeline module from the working demo.
2
+ //
3
+ // Instead of maintaining a second copy of the timeline UI, we transform the
4
+ // already-tested code inside demo.html into a single ES module that can be
5
+ // dropped onto ANY page via:
6
+ //
7
+ // <script type="module" src="http://localhost:7331/inject.js"></script>
8
+ //
9
+ // The module imports React from absolute esm.sh URLs (so the host page does
10
+ // not need an import map), injects the timeline CSS, then mounts the panel
11
+ // into its own container while scanning document.body for CSS transitions.
12
+
13
+ import { readFile } from "node:fs/promises";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ const DEMO_PATH = fileURLToPath(new URL("../demo.html", import.meta.url));
17
+
18
+ // Absolute module URLs so the injected script works without an import map.
19
+ const REACT_URL = "https://esm.sh/react@19";
20
+ const REACT_DOM_URL = "https://esm.sh/react-dom@19";
21
+ const REACT_DOM_CLIENT_URL = "https://esm.sh/react-dom@19/client";
22
+
23
+ const CUT_MARKER = "// ── demo boxes ──";
24
+
25
+ function extractBetween(src, openRe, closeTag) {
26
+ const open = src.match(openRe);
27
+ if (!open) return null;
28
+ const start = open.index + open[0].length;
29
+ const end = src.indexOf(closeTag, start);
30
+ if (end === -1) return null;
31
+ return src.slice(start, end);
32
+ }
33
+
34
+ // Remove every /* @inject-skip-start */ … /* @inject-skip-end */ region.
35
+ function stripSkipRegions(css) {
36
+ return css.replace(/\/\*\s*@inject-skip-start[\s\S]*?@inject-skip-end\s*\*\//g, "").trim();
37
+ }
38
+
39
+ function buildJs(scriptSrc) {
40
+ let js = scriptSrc;
41
+ // Drop everything from the demo-only boxes onward (App + createRoot render).
42
+ const cut = js.indexOf(CUT_MARKER);
43
+ if (cut !== -1) js = js.slice(0, cut);
44
+
45
+ // Rewrite the demo's bare-specifier imports to absolute URLs.
46
+ js = js
47
+ .replace(/import\s+React\s+from\s+["']react["'];?/, `import React from "${REACT_URL}";`)
48
+ .replace(/import\s+\{\s*createRoot\s*\}\s+from\s+["']react-dom\/client["'];?/, `import { createRoot } from "${REACT_DOM_CLIENT_URL}";`)
49
+ .replace(/import\s+\{\s*createPortal\s*\}\s+from\s+["']react-dom["'];?/, `import { createPortal } from "${REACT_DOM_URL}";`);
50
+
51
+ // Point the relay client at whatever origin served this module, so the panel
52
+ // works on any port the CLI chose (the script is served BY the relay).
53
+ js = js.replace(
54
+ /(import\s+\{\s*createPortal\s*\}\s+from\s+"[^"]+";)/,
55
+ `$1\n try { if (typeof window !== "undefined" && !window.REFINE_RELAY_URL) window.REFINE_RELAY_URL = new URL(import.meta.url).origin; } catch (e) {}`
56
+ );
57
+
58
+ return js.trim();
59
+ }
60
+
61
+ function buildEpilogue(css) {
62
+ // JSON.stringify handles all escaping for the embedded stylesheet.
63
+ const cssLiteral = JSON.stringify(css);
64
+ return `
65
+ // ── injected timeline mount ──
66
+ (function mountInjectedTimeline(){
67
+ if (typeof document === "undefined") return;
68
+ if (document.getElementById("tl-inject-root")) return; // idempotent
69
+
70
+ if (!document.getElementById("tl-inject-style")) {
71
+ const st = document.createElement("style");
72
+ st.id = "tl-inject-style";
73
+ // Scoped reset so panel layout is correct without touching the host page.
74
+ st.textContent = "[data-timeline-panel],[data-timeline-panel] *,.tl-refine-panel,.tl-refine-panel *{box-sizing:border-box;}\\n" + ${cssLiteral};
75
+ document.head.appendChild(st);
76
+ }
77
+
78
+ const mount = document.createElement("div");
79
+ mount.id = "tl-inject-root";
80
+ document.body.appendChild(mount);
81
+
82
+ function InjectedRoot(){
83
+ const registry = useMemo(() => new TransitionRegistry(), []);
84
+ const preview = useMemo(() => new PreviewController(), []);
85
+ const [activeId, setActiveId] = useState(null);
86
+ useEffect(() => {
87
+ const scanner = new DomScanner(document.body, registry);
88
+ preview.setScanner(scanner);
89
+ scanner.start();
90
+ return () => { scanner.stop(); preview.setScanner(null); };
91
+ }, [registry, preview]);
92
+ const ctx = useMemo(() => ({ registry, preview, activeId, setActiveId }), [registry, preview, activeId]);
93
+ return h(TimelineCtx.Provider, { value: ctx }, h(TimelinePanel));
94
+ }
95
+
96
+ createRoot(mount).render(h(InjectedRoot));
97
+ })();
98
+ `;
99
+ }
100
+
101
+ let _cache = null;
102
+ export async function buildInjectModule({ noCache = false } = {}) {
103
+ if (_cache && !noCache) return _cache;
104
+ const html = await readFile(DEMO_PATH, "utf8");
105
+
106
+ const styleSrc = extractBetween(html, /<style>/, "</style>");
107
+ const scriptSrc = extractBetween(html, /<script\s+type="module">/, "</script>");
108
+ if (!styleSrc || !scriptSrc) {
109
+ throw new Error("inject: could not locate <style> or module <script> in demo.html");
110
+ }
111
+
112
+ const css = stripSkipRegions(styleSrc);
113
+ const js = buildJs(scriptSrc);
114
+ _cache = `${js}\n${buildEpilogue(css)}`;
115
+ return _cache;
116
+ }
@@ -0,0 +1,106 @@
1
+ // The transitions.dev motion-token vocabulary, plus a deterministic refine pass
2
+ // that maps a transition's current values onto the nearest token and proposes
3
+ // the differences. This mirrors the `transitions refine` behaviour in
4
+ // ~/.agents/skills/transitions-dev/SKILL.md so the reference agent can answer
5
+ // without an LLM. A real agent driven by the skill can do better — it infers
6
+ // *usage* (modal close vs dropdown open) from the surrounding code rather than
7
+ // snapping to the nearest number.
8
+
9
+ // Durations — from the skill's "## Motion tokens" table.
10
+ export const DURATION_TOKENS = [
11
+ { ms: 40, name: "Stagger", usage: "per-item stagger offset" },
12
+ { ms: 80, name: "Micro", usage: "tooltip delay, shake segment, large stagger" },
13
+ { ms: 150, name: "Quick", usage: "modal close, dropdown close, text swap, tooltip appear" },
14
+ { ms: 250, name: "Fast", usage: "icon swap, dropdown open, modal open, tabs sliding, page slide" },
15
+ { ms: 350, name: "Medium", usage: "panel close, toast close" },
16
+ { ms: 400, name: "Slow", usage: "panel open, skeleton content reveal, input clear" },
17
+ { ms: 500, name: "Very slow", usage: "emphasis, badge appear, text reveal, success check" },
18
+ ];
19
+
20
+ // The transitions.dev default ease-out — "Smooth ease out" in the skill.
21
+ export const SMOOTH_OUT = "cubic-bezier(0.22, 1, 0.36, 1)";
22
+
23
+ // Easing values that ARE motion tokens — leave these alone.
24
+ const TOKEN_EASINGS = new Set(
25
+ [
26
+ SMOOTH_OUT,
27
+ "ease-in-out",
28
+ "ease-out",
29
+ "linear",
30
+ "cubic-bezier(0.34, 1.36, 0.64, 1)", // bouncy overshoot (badge pop)
31
+ "cubic-bezier(0.34, 3.85, 0.64, 1)", // strong bouncy overshoot (avatar return)
32
+ ].map(normEase)
33
+ );
34
+
35
+ function normEase(s) {
36
+ return String(s || "").replace(/\s+/g, "").toLowerCase();
37
+ }
38
+
39
+ function nearestDuration(ms) {
40
+ let best = DURATION_TOKENS[0];
41
+ let bestDelta = Infinity;
42
+ for (const t of DURATION_TOKENS) {
43
+ const d = Math.abs(t.ms - ms);
44
+ if (d < bestDelta) {
45
+ bestDelta = d;
46
+ best = t;
47
+ }
48
+ }
49
+ return { token: best, delta: bestDelta };
50
+ }
51
+
52
+ // A generic/non-token easing the skill would nudge toward the default ease-out.
53
+ function shouldRefineEasing(easing) {
54
+ const n = normEase(easing);
55
+ if (!n) return false;
56
+ if (TOKEN_EASINGS.has(n)) return false;
57
+ // "ease", "ease-in", or any hand-rolled cubic-bezier that isn't a token.
58
+ return n === "ease" || n === "ease-in" || n.startsWith("cubic-bezier") || n.startsWith("linear(");
59
+ }
60
+
61
+ /**
62
+ * Produce token-alignment suggestions for a list of property timings.
63
+ * @param {{property:string,durationMs:number,delayMs:number,easing:string}[]} timings
64
+ * @returns {object[]} suggestions
65
+ */
66
+ export function refineTimings(timings) {
67
+ const suggestions = [];
68
+ if (!Array.isArray(timings)) return suggestions;
69
+
70
+ for (const t of timings) {
71
+ const prop = t.property || "all";
72
+
73
+ // Duration → nearest token (skip if already on-grid or within 10ms).
74
+ if (Number.isFinite(t.durationMs)) {
75
+ const { token, delta } = nearestDuration(t.durationMs);
76
+ if (delta > 10) {
77
+ suggestions.push({
78
+ id: `${prop}-duration`,
79
+ kind: "duration",
80
+ property: prop,
81
+ title: `Duration → ${token.name}`,
82
+ from: `${t.durationMs}ms`,
83
+ to: `${token.ms}ms`,
84
+ patch: { property: prop, durationMs: token.ms },
85
+ reason: `${token.name} (${token.ms}ms) is the closest motion token — used for ${token.usage}. ${t.durationMs}ms is off-grid.`,
86
+ });
87
+ }
88
+ }
89
+
90
+ // Easing → the transitions.dev default ease-out.
91
+ if (shouldRefineEasing(t.easing)) {
92
+ suggestions.push({
93
+ id: `${prop}-easing`,
94
+ kind: "easing",
95
+ property: prop,
96
+ title: `Easing → Smooth ease out`,
97
+ from: t.easing,
98
+ to: SMOOTH_OUT,
99
+ patch: { property: prop, easing: SMOOTH_OUT },
100
+ reason: `"${t.easing}" is a generic curve. The transitions.dev standard ease-out reads more intentional on opens, closes, slides, and resizes.`,
101
+ });
102
+ }
103
+ }
104
+
105
+ return suggestions;
106
+ }
@@ -0,0 +1,86 @@
1
+ // Optional external poller — only for REFINE_AUTO=0 mode.
2
+ //
3
+ // By default the relay answers each job itself with one run (see
4
+ // server/relay.mjs), so you don't need this. It exists for the advanced setup
5
+ // where you start the relay with REFINE_AUTO=0 and want a separate standing
6
+ // process to claim jobs via GET /jobs/next. This implementation is the
7
+ // deterministic, no-LLM one (snaps to the nearest motion token).
8
+ //
9
+ // Run: REFINE_AUTO=0 npm run relay (in one terminal)
10
+ // npm run refine-poller (in another)
11
+ // Don't run it while the relay is in the default auto mode — jobs are already
12
+ // answered, so it would never receive any.
13
+
14
+ import { refineTimings } from "./motion-tokens.mjs";
15
+
16
+ const RELAY = process.env.REFINE_RELAY_URL || "http://localhost:7331";
17
+ const IDLE_MS = 800; // poll cadence when there's no work
18
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
19
+
20
+ async function post(path, body) {
21
+ await fetch(`${RELAY}${path}`, {
22
+ method: "POST",
23
+ headers: { "Content-Type": "application/json" },
24
+ body: JSON.stringify(body),
25
+ });
26
+ }
27
+
28
+ async function handle(job) {
29
+ const { id, request } = job;
30
+ const label = request?.label || request?.selector || "transition";
31
+ console.log(`\n▸ job ${id.slice(0, 8)} — ${label}`);
32
+
33
+ await post(`/jobs/${id}/status`, { message: `Scanning "${label}"…` });
34
+ await sleep(500);
35
+ await post(`/jobs/${id}/status`, { message: "Matching values to the motion tokens…" });
36
+ await sleep(600);
37
+
38
+ const suggestions = refineTimings(request?.timings || []);
39
+
40
+ await post(`/jobs/${id}/status`, {
41
+ message: suggestions.length
42
+ ? `Found ${suggestions.length} refinement${suggestions.length === 1 ? "" : "s"}.`
43
+ : "Already aligned to the motion tokens.",
44
+ });
45
+ await post(`/jobs/${id}/result`, {
46
+ suggestions,
47
+ summary: suggestions.length
48
+ ? `${suggestions.length} value${suggestions.length === 1 ? "" : "s"} differ from the transitions.dev tokens.`
49
+ : "Nothing to refine — values already match the tokens.",
50
+ });
51
+ console.log(` ✓ posted ${suggestions.length} suggestion(s)`);
52
+ }
53
+
54
+ async function loop() {
55
+ console.log(`refine agent polling ${RELAY}/jobs/next …`);
56
+ // eslint-disable-next-line no-constant-condition
57
+ while (true) {
58
+ let res;
59
+ try {
60
+ res = await fetch(`${RELAY}/jobs/next`);
61
+ } catch {
62
+ console.log(" (relay not reachable — retrying)");
63
+ await sleep(1500);
64
+ continue;
65
+ }
66
+ if (res.status === 204) {
67
+ await sleep(IDLE_MS);
68
+ continue;
69
+ }
70
+ if (!res.ok) {
71
+ await sleep(IDLE_MS);
72
+ continue;
73
+ }
74
+ const job = await res.json();
75
+ try {
76
+ await handle(job);
77
+ } catch (e) {
78
+ console.error(" ✗ job failed:", e.message);
79
+ try {
80
+ await post(`/jobs/${job.id}/error`, { message: String(e.message || e) });
81
+ } catch {}
82
+ }
83
+ }
84
+ }
85
+
86
+ loop();