transitions-agent 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.
- package/README.md +95 -0
- package/bin/transitions-agent.mjs +105 -0
- package/lib/fix.mjs +180 -0
- package/lib/report.mjs +84 -0
- package/lib/rules.mjs +135 -0
- package/lib/scan.mjs +45 -0
- package/lib/walk.mjs +45 -0
- package/package.json +36 -0
package/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# transitions-agent
|
|
2
|
+
|
|
3
|
+
Scan your codebase for missing, janky, or inconsistent UI transitions. Get a motion score (0-100), findings with matching [transitions.dev](https://transitions.dev) recipes, AI-proposed fixes shown as diffs, and an optional pull request. Nothing is ever changed without your explicit confirmation, and nothing is ever pushed to your main branch.
|
|
4
|
+
|
|
5
|
+
## Terminal
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx transitions-agent # scan: motion score + findings + recipes
|
|
9
|
+
npx transitions-agent signup you@x.com # free plan: license key arrives by email
|
|
10
|
+
npx transitions-agent fix # propose fixes as diffs, confirm, apply
|
|
11
|
+
npx transitions-agent fix --pr # after applying: branch, commit, push, open a PR
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The `fix` command sends the affected files to the hosted fix service (your license key, our AI). Without a license key it writes `transitions-agent-fixes.md`, a ready-made task for your own Claude Code, Cursor or Codex.
|
|
15
|
+
|
|
16
|
+
### Fix modes
|
|
17
|
+
|
|
18
|
+
| Mode | What it does | Risk |
|
|
19
|
+
|---|---|---|
|
|
20
|
+
| `--mode polish` (default) | Small safe adjustments only: durations onto motion tokens, `prefers-reduced-motion` guard, named properties instead of `transition: all`, missing transition lines on hover bases. Never restructures anything. | Minimal, few-line diffs |
|
|
21
|
+
| `--mode revamp` (Business plan) | Superset of polish: confident recipe matches (modals, tooltips, dropdowns) get the recipe's full enter and exit motion, keyframes, and easing; every other finding gets the polish treatment. Logic is never touched. | Larger diffs, review the PR |
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npx transitions-agent fix # polish: safe token-level cleanup
|
|
25
|
+
npx transitions-agent fix --mode revamp # recipe-level motion rewrite
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Flags: `--json`, `--md`, `--dir <path>`, `--min-score <n>` (exit 2 below n, for CI), `--mode polish|revamp`, `--license <key>` (or `TRANSITIONS_AGENT_LICENSE`), `--yes` (skip prompts).
|
|
29
|
+
|
|
30
|
+
## GitHub Action
|
|
31
|
+
|
|
32
|
+
Copy [templates/transitions-agent.yml](templates/transitions-agent.yml) to `.github/workflows/` in your repository. Every pull request then gets a motion score comment, and `min-score` can block merges when motion quality drops:
|
|
33
|
+
|
|
34
|
+
```yaml
|
|
35
|
+
- uses: actions/checkout@v4
|
|
36
|
+
- uses: Jakubantalik/transitions.dev/agent@main
|
|
37
|
+
with:
|
|
38
|
+
min-score: "75"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## What it checks
|
|
42
|
+
|
|
43
|
+
| Rule | What it catches |
|
|
44
|
+
|---|---|
|
|
45
|
+
| untransitioned-overlay | Modals, tooltips, dropdowns that pop in and out with no transition |
|
|
46
|
+
| hover-without-transition | Hover states that snap instead of easing |
|
|
47
|
+
| transition-all | `transition: all`, which animates layout and hurts performance |
|
|
48
|
+
| hardcoded-duration | Literal durations instead of shared motion tokens |
|
|
49
|
+
| no-reduced-motion | Animation with no `prefers-reduced-motion` guard |
|
|
50
|
+
| inconsistent-durations | Too many different duration values across the project |
|
|
51
|
+
|
|
52
|
+
## MCP server (your AI, our recipes)
|
|
53
|
+
|
|
54
|
+
Prefer your own Claude to do the fixing? Connect the Transitions Agent MCP server and your assistant gets the scanner contract, the fix guidance, and the real recipe sources (Pro included on Business) - fixing runs on your subscription, not our meters:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
claude mcp add --transport http transitions-agent https://api.transitions.dev/v1/agent/mcp --header "Authorization: Bearer $TRANSITIONS_AGENT_LICENSE"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Tools: `scan_instructions`, `fix_guidance(mode)`, `list_recipes`, `get_recipe(slug, variant)`. Free recipes need no key; Pro recipe sources need a paid plan (Pro, Business, or Enterprise).
|
|
61
|
+
|
|
62
|
+
### Fixing from CI
|
|
63
|
+
|
|
64
|
+
Two workflow templates, pick by who supplies the AI:
|
|
65
|
+
|
|
66
|
+
- **[transitions-fix.yml](templates/transitions-fix.yml)** (Business, zero friction): one secret - the license key. Fixes run through the hosted service on our AI, metered against the plan's 200/month, and land as a pull request.
|
|
67
|
+
- **[transitions-fix-own-claude.yml](templates/transitions-fix-own-claude.yml)** (Enterprise license required): runs `claude-code-action` on your own Anthropic key, connected to this MCP server. Unmetered, and your code never flows through our fix service - it goes only to Anthropic under your own agreement. CI automation on your own model keys is licensed on the Enterprise plan (org-wide license, custom rules, priority support); Business licenses hosted CI fixing only.
|
|
68
|
+
|
|
69
|
+
The score/gate Action stays AI-free either way.
|
|
70
|
+
|
|
71
|
+
## Safety model
|
|
72
|
+
|
|
73
|
+
1. Proposed fixes are shown as diffs first; nothing is written without a yes.
|
|
74
|
+
2. `--pr` opens a pull request on a new branch; your team reviews, your tests run, a human merges.
|
|
75
|
+
3. Everything is git; any merged fix is one `git revert` away.
|
|
76
|
+
4. Fixes touch motion only (CSS transitions, animation guards), never logic.
|
|
77
|
+
|
|
78
|
+
## Hosted fix service
|
|
79
|
+
|
|
80
|
+
`worker/` contains the Cloudflare Worker behind `api.transitions.dev/v1/agent/fix`: it validates the license key, meters monthly fix quota, and calls Claude with the service key. Teams never handle an AI key. See [worker/wrangler.toml](worker/wrangler.toml) for deployment.
|
|
81
|
+
|
|
82
|
+
In revamp mode the service feeds the model the real transitions.dev recipe sources, Pro recipes included, so rewrites match the library exactly instead of an approximation. [worker/pack-recipes.mjs](worker/pack-recipes.mjs) packs the free and Pro recipe files into the RECIPES KV namespace; re-run it whenever recipes change.
|
|
83
|
+
|
|
84
|
+
## Plans
|
|
85
|
+
|
|
86
|
+
| | Scan + score + Action | Fix via your own Claude Code/Cursor | Hosted polish fixes | Hosted revamp (Pro recipes) |
|
|
87
|
+
|---|---|---|---|---|
|
|
88
|
+
| **Free** (sign-up) | unlimited | unlimited | 10/month, 2/day | no |
|
|
89
|
+
| **Business** ($59/month, includes Transitions Pro for 5 seats) | unlimited | unlimited | 200/month | yes |
|
|
90
|
+
|
|
91
|
+
The Transitions.dev **Pro** plan ($9/month) includes the free Agent tier; the paid Agent comes with the **Business** plan. In the license KV the paid tier is still stored as `plan:"team"`.
|
|
92
|
+
|
|
93
|
+
Free hosted fixes run on a faster model and share a global monthly capacity pool (`FREE_GLOBAL_MONTHLY` in [worker/wrangler.toml](worker/wrangler.toml)), so free-tier AI spend has a hard ceiling. Team traffic is never affected by the pool.
|
|
94
|
+
|
|
95
|
+
MIT for the scanner and CLI. The fix service requires a Transitions Agent license from [transitions.dev](https://transitions.dev).
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// transitions-agent: scan a codebase for missing or janky UI transitions.
|
|
3
|
+
//
|
|
4
|
+
// npx transitions-agent scan + motion score + findings
|
|
5
|
+
// npx transitions-agent signup you@x.co free plan: license key by email
|
|
6
|
+
// npx transitions-agent fix propose AI fixes as diffs, confirm, apply
|
|
7
|
+
// npx transitions-agent fix --pr after applying, open a pull request
|
|
8
|
+
//
|
|
9
|
+
// Fix modes (--mode):
|
|
10
|
+
// polish (default) small safe adjustments only: motion tokens, reduced-motion
|
|
11
|
+
// guard, named transition properties. Never restructures.
|
|
12
|
+
// revamp full rewrite where a finding matches a transitions.dev
|
|
13
|
+
// recipe (modals, tooltips, dropdowns, hovers).
|
|
14
|
+
//
|
|
15
|
+
// Flags:
|
|
16
|
+
// --json machine-readable report on stdout
|
|
17
|
+
// --md GitHub-flavored markdown report on stdout
|
|
18
|
+
// --min-score <n> exit 2 when the score is below n (CI gate)
|
|
19
|
+
// --dir <path> project root to scan (default: cwd)
|
|
20
|
+
// --license <key> fix service license (or TRANSITIONS_AGENT_LICENSE)
|
|
21
|
+
// --api <url> fix service URL (default https://api.transitions.dev)
|
|
22
|
+
// --yes skip confirmation prompts (CI)
|
|
23
|
+
//
|
|
24
|
+
// No dependencies. Node 18+.
|
|
25
|
+
import { scan } from "../lib/scan.mjs";
|
|
26
|
+
import { renderTerminal, renderMarkdown } from "../lib/report.mjs";
|
|
27
|
+
import { runFix } from "../lib/fix.mjs";
|
|
28
|
+
import { resolve } from "node:path";
|
|
29
|
+
|
|
30
|
+
const args = process.argv.slice(2);
|
|
31
|
+
const flags = {};
|
|
32
|
+
const positional = [];
|
|
33
|
+
for (let i = 0; i < args.length; i++) {
|
|
34
|
+
if (args[i] === "--dir") flags.dir = args[++i];
|
|
35
|
+
else if (args[i] === "--mode") flags.mode = args[++i];
|
|
36
|
+
else if (args[i] === "--api") flags.api = args[++i];
|
|
37
|
+
else if (args[i] === "--license") flags.license = args[++i];
|
|
38
|
+
else if (args[i] === "--min-score") flags.minScore = parseInt(args[++i], 10);
|
|
39
|
+
else if (args[i].startsWith("--")) flags[args[i].slice(2)] = true;
|
|
40
|
+
else positional.push(args[i]);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const root = resolve(flags.dir || ".");
|
|
44
|
+
const command = positional[0] || "scan";
|
|
45
|
+
const api = (flags.api || process.env.TRANSITIONS_AGENT_API || "https://api.transitions.dev").replace(/\/$/, "");
|
|
46
|
+
|
|
47
|
+
if (command === "signup") {
|
|
48
|
+
const email = (positional[1] || "").trim();
|
|
49
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
50
|
+
console.error("Usage: transitions-agent signup you@company.com");
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
let res;
|
|
54
|
+
try {
|
|
55
|
+
res = await fetch(api + "/agent/signup", {
|
|
56
|
+
method: "POST",
|
|
57
|
+
headers: { "content-type": "application/json" },
|
|
58
|
+
body: JSON.stringify({ email }),
|
|
59
|
+
});
|
|
60
|
+
} catch (e) {
|
|
61
|
+
console.error("Could not reach " + api + ": " + e.message);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
if (!res.ok) {
|
|
65
|
+
const err = await res.json().catch(() => ({}));
|
|
66
|
+
console.error("Signup failed: " + (err.error || res.status));
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
console.log(`Check ${email} for your free license key (10 hosted polish fixes/month).`);
|
|
70
|
+
console.log("Then: export TRANSITIONS_AGENT_LICENSE=<key from the email>");
|
|
71
|
+
process.exit(0);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const result = scan(root);
|
|
75
|
+
|
|
76
|
+
if (command === "scan") {
|
|
77
|
+
if (flags.json) console.log(JSON.stringify(result, null, 2));
|
|
78
|
+
else if (flags.md) console.log(renderMarkdown(result));
|
|
79
|
+
else console.log(renderTerminal(result));
|
|
80
|
+
if (Number.isFinite(flags.minScore) && result.score < flags.minScore) {
|
|
81
|
+
console.error(`Motion score ${result.score} is below the required minimum ${flags.minScore}.`);
|
|
82
|
+
process.exit(2);
|
|
83
|
+
}
|
|
84
|
+
process.exit(0);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (command === "fix") {
|
|
88
|
+
const mode = flags.mode || "polish";
|
|
89
|
+
if (mode !== "polish" && mode !== "revamp") {
|
|
90
|
+
console.error(`Unknown mode "${mode}". Use --mode polish or --mode revamp.`);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
console.log(renderTerminal(result));
|
|
94
|
+
const code = await runFix(root, result, {
|
|
95
|
+
mode,
|
|
96
|
+
api,
|
|
97
|
+
license: flags.license || process.env.TRANSITIONS_AGENT_LICENSE || "",
|
|
98
|
+
yes: !!flags.yes,
|
|
99
|
+
pr: !!flags.pr,
|
|
100
|
+
});
|
|
101
|
+
process.exit(code);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
console.error(`Unknown command "${command}". Use: transitions-agent [scan|fix|signup]`);
|
|
105
|
+
process.exit(1);
|
package/lib/fix.mjs
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Fix flow: send findings + files to the transitions.dev fix service (option C:
|
|
2
|
+
// the service holds the AI key), show proposed diffs, apply only after an
|
|
3
|
+
// explicit yes, optionally open a pull request after a second explicit yes.
|
|
4
|
+
import { readFileSync, writeFileSync, mkdtempSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
8
|
+
import readline from "node:readline";
|
|
9
|
+
|
|
10
|
+
const MAX_FILES = 12;
|
|
11
|
+
const MAX_FILE_BYTES = 40_000;
|
|
12
|
+
|
|
13
|
+
const c = {
|
|
14
|
+
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
15
|
+
bold: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
16
|
+
green: (s) => `\x1b[32m${s}\x1b[0m`,
|
|
17
|
+
red: (s) => `\x1b[31m${s}\x1b[0m`,
|
|
18
|
+
yellow: (s) => `\x1b[33m${s}\x1b[0m`,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// polish: token-level adjustments only. revamp: adds structural recipe rewrites.
|
|
22
|
+
const POLISH_RULES = new Set([
|
|
23
|
+
"hardcoded-duration", "transition-all", "no-reduced-motion",
|
|
24
|
+
"inconsistent-durations", "hover-without-transition",
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
export function findingsForMode(findings, mode) {
|
|
28
|
+
if (mode === "revamp") return findings;
|
|
29
|
+
return findings.filter((f) => POLISH_RULES.has(f.rule));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function runFix(root, result, opts) {
|
|
33
|
+
const { api, license, yes, pr, mode = "polish" } = opts;
|
|
34
|
+
const findings = findingsForMode(result.findings, mode);
|
|
35
|
+
const skipped = result.findings.length - findings.length;
|
|
36
|
+
if (skipped > 0) {
|
|
37
|
+
console.log(c.dim(`${skipped} structural findings need --mode revamp and are skipped in polish mode.`));
|
|
38
|
+
}
|
|
39
|
+
const fixable = findings.filter((f) => f.path !== "(project)");
|
|
40
|
+
if (!fixable.length && !findings.length) {
|
|
41
|
+
console.log(c.green("Nothing to fix" + (mode === "polish" ? " in polish mode." : ".")));
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const paths = [...new Set(fixable.map((f) => f.path))].slice(0, MAX_FILES);
|
|
46
|
+
const dropped = [...new Set(fixable.map((f) => f.path))].length - paths.length;
|
|
47
|
+
const files = [];
|
|
48
|
+
for (const p of paths) {
|
|
49
|
+
try {
|
|
50
|
+
const content = readFileSync(join(root, p), "utf8");
|
|
51
|
+
if (Buffer.byteLength(content) <= MAX_FILE_BYTES) files.push({ path: p, content });
|
|
52
|
+
} catch { /* file may be gone */ }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!license) {
|
|
56
|
+
writePromptFallback(root, findings, files, mode);
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
console.log(c.dim(`Requesting ${mode} fixes for ${files.length} files${dropped > 0 ? ` (${dropped} deferred to a later run)` : ""}...`));
|
|
61
|
+
let res;
|
|
62
|
+
try {
|
|
63
|
+
res = await fetch(api + "/v1/agent/fix", {
|
|
64
|
+
method: "POST",
|
|
65
|
+
headers: { "content-type": "application/json", authorization: "Bearer " + license },
|
|
66
|
+
body: JSON.stringify({ mode, findings, files, score: result.score }),
|
|
67
|
+
});
|
|
68
|
+
} catch (e) {
|
|
69
|
+
console.error(c.red("✗ ") + "Could not reach the fix service: " + e.message);
|
|
70
|
+
return 1;
|
|
71
|
+
}
|
|
72
|
+
if (res.status === 401) { console.error(c.red("✗ ") + "License key not valid. Check TRANSITIONS_AGENT_LICENSE."); return 1; }
|
|
73
|
+
if (res.status === 403 || res.status === 429) {
|
|
74
|
+
const err = await res.json().catch(() => ({}));
|
|
75
|
+
if (err.error === "revamp requires team") {
|
|
76
|
+
console.error(c.yellow("Revamp mode is a Team plan feature") + " (full recipe rewrites, Pro library).");
|
|
77
|
+
console.error("Your free plan includes polish mode. Upgrade at " + c.bold("transitions.dev/pro") + " or run without --mode revamp.");
|
|
78
|
+
} else {
|
|
79
|
+
console.error(c.red("✗ ") + (err.detail || "Monthly fix quota reached. Upgrade at transitions.dev/pro."));
|
|
80
|
+
}
|
|
81
|
+
return 1;
|
|
82
|
+
}
|
|
83
|
+
if (!res.ok) { console.error(c.red("✗ ") + `Fix service error (${res.status}).`); return 1; }
|
|
84
|
+
const data = await res.json();
|
|
85
|
+
const proposed = (data.files || []).filter((f) => f.path && typeof f.content === "string");
|
|
86
|
+
if (!proposed.length) { console.log(c.yellow("The service proposed no changes.")); return 0; }
|
|
87
|
+
|
|
88
|
+
// Show each proposal as a diff. Nothing is written yet.
|
|
89
|
+
for (const p of proposed) showDiff(root, p);
|
|
90
|
+
if (data.summary) console.log("\n" + data.summary + "\n");
|
|
91
|
+
if (data.usage) console.log(c.dim(`Fixes used this month: ${data.usage.used}/${data.usage.quota}`));
|
|
92
|
+
|
|
93
|
+
const apply = yes || await confirm(`Apply these changes to ${proposed.length} files? [y/N] `);
|
|
94
|
+
if (!apply) { console.log(c.dim("Nothing changed.")); return 0; }
|
|
95
|
+
for (const p of proposed) writeFileSync(join(root, p.path), p.content);
|
|
96
|
+
console.log(c.green("✓ ") + `Applied ${proposed.length} files.`);
|
|
97
|
+
|
|
98
|
+
if (!pr) {
|
|
99
|
+
console.log(c.dim("Review with git diff. Re-run with --pr to open a pull request."));
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
return openPr(root, result, proposed, { yes, mode });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function showDiff(root, proposal) {
|
|
106
|
+
// git diff --no-index gives a familiar colored diff without touching the repo.
|
|
107
|
+
const tmp = mkdtempSync(join(tmpdir(), "tagent-"));
|
|
108
|
+
const tmpFile = join(tmp, "proposed");
|
|
109
|
+
writeFileSync(tmpFile, proposal.content);
|
|
110
|
+
const out = spawnSync("git", ["diff", "--no-index", "--color=always", "--", proposal.path, tmpFile], {
|
|
111
|
+
cwd: root, encoding: "utf8", maxBuffer: 10_000_000,
|
|
112
|
+
});
|
|
113
|
+
console.log("\n" + c.bold(proposal.path));
|
|
114
|
+
const body = (out.stdout || "").split("\n").slice(4).join("\n").trim();
|
|
115
|
+
console.log(body || c.dim("(no change)"));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function openPr(root, result, proposed, { yes, mode }) {
|
|
119
|
+
const go = yes || await confirm("Create a branch, commit, push, and open a pull request? [y/N] ");
|
|
120
|
+
if (!go) { console.log(c.dim("Changes stay local. Commit them yourself when ready.")); return 0; }
|
|
121
|
+
const branch = `transitions-agent/${mode}-` + new Date().toISOString().slice(0, 10);
|
|
122
|
+
const title = mode === "revamp"
|
|
123
|
+
? `Revamp UI transitions with transitions.dev recipes (motion score ${result.score} before fixes)`
|
|
124
|
+
: `Polish UI transitions (motion score ${result.score} before fixes)`;
|
|
125
|
+
const body = [
|
|
126
|
+
`Automated ${mode} pass by [Transitions Agent](https://transitions.dev).`,
|
|
127
|
+
"",
|
|
128
|
+
...proposed.map((p) => `- \`${p.path}\``),
|
|
129
|
+
"",
|
|
130
|
+
"Every change was shown as a diff and confirmed in the terminal before this PR was opened.",
|
|
131
|
+
].join("\n");
|
|
132
|
+
try {
|
|
133
|
+
execFileSync("git", ["checkout", "-b", branch], { cwd: root, stdio: "pipe" });
|
|
134
|
+
execFileSync("git", ["add", ...proposed.map((p) => p.path)], { cwd: root, stdio: "pipe" });
|
|
135
|
+
execFileSync("git", ["commit", "-m", `fix(motion): transitions-agent ${mode} pass`], { cwd: root, stdio: "pipe" });
|
|
136
|
+
execFileSync("git", ["push", "-u", "origin", branch], { cwd: root, stdio: "inherit" });
|
|
137
|
+
} catch (e) {
|
|
138
|
+
console.error(c.red("✗ ") + "Git step failed: " + (e.stderr?.toString() || e.message));
|
|
139
|
+
return 1;
|
|
140
|
+
}
|
|
141
|
+
const gh = spawnSync("gh", ["pr", "create", "--title", title, "--body", body], { cwd: root, stdio: "inherit" });
|
|
142
|
+
if (gh.status !== 0) {
|
|
143
|
+
console.log(c.yellow("Could not open the PR automatically (is GitHub CLI installed?)."));
|
|
144
|
+
console.log("Branch " + c.bold(branch) + " is pushed. Open the PR from GitHub with this description:\n\n" + body);
|
|
145
|
+
}
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function writePromptFallback(root, findings, files, mode) {
|
|
150
|
+
// No license: hand the work to the user's own coding agent instead.
|
|
151
|
+
const out = join(root, "transitions-agent-fixes.md");
|
|
152
|
+
const modeBrief = mode === "revamp"
|
|
153
|
+
? "Where a finding names a recipe, replace the existing motion wholesale with that transitions.dev recipe pattern. You may add keyframes and classes, but never change component logic."
|
|
154
|
+
: "Make only small, safe adjustments: move durations to motion tokens, add a prefers-reduced-motion guard, replace transition: all with named properties, add missing transition declarations. Do not restructure markup, components, or keyframes.";
|
|
155
|
+
const lines = [
|
|
156
|
+
`# ${mode === "revamp" ? "Revamp" : "Polish"} the UI transitions in this repository`,
|
|
157
|
+
"",
|
|
158
|
+
"You are working in this repository. Apply fixes for the findings below using",
|
|
159
|
+
"production-quality CSS transitions (respect prefers-reduced-motion, use motion",
|
|
160
|
+
"tokens, animate transform and opacity rather than layout). Recipes: https://transitions.dev",
|
|
161
|
+
"",
|
|
162
|
+
modeBrief,
|
|
163
|
+
"",
|
|
164
|
+
"## Findings",
|
|
165
|
+
...findings.map((f) => `- ${f.path}${f.line ? ":" + f.line : ""} [${f.rule}] ${f.message}${f.recipe ? ` (recipe: https://transitions.dev/transitions/${f.recipe})` : ""}`),
|
|
166
|
+
"",
|
|
167
|
+
"Affected files: " + files.map((f) => f.path).join(", "),
|
|
168
|
+
];
|
|
169
|
+
writeFileSync(out, lines.join("\n"));
|
|
170
|
+
console.log(c.yellow("No license key found") + c.dim(" (set TRANSITIONS_AGENT_LICENSE for hosted fixes)."));
|
|
171
|
+
console.log("Wrote " + c.bold("transitions-agent-fixes.md") + ". Hand it to Claude Code or Cursor:");
|
|
172
|
+
console.log(c.dim(" claude \"apply transitions-agent-fixes.md\""));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function confirm(question) {
|
|
176
|
+
return new Promise((resolve) => {
|
|
177
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
178
|
+
rl.question(question, (answer) => { rl.close(); resolve(/^y(es)?$/i.test(answer.trim())); });
|
|
179
|
+
});
|
|
180
|
+
}
|
package/lib/report.mjs
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Report rendering: terminal (colored) and GitHub-flavored markdown.
|
|
2
|
+
|
|
3
|
+
const c = {
|
|
4
|
+
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
5
|
+
bold: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
6
|
+
green: (s) => `\x1b[32m${s}\x1b[0m`,
|
|
7
|
+
yellow: (s) => `\x1b[33m${s}\x1b[0m`,
|
|
8
|
+
red: (s) => `\x1b[31m${s}\x1b[0m`,
|
|
9
|
+
cyan: (s) => `\x1b[36m${s}\x1b[0m`,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const RULE_TITLES = {
|
|
13
|
+
"untransitioned-overlay": "Overlays with no enter/exit transition",
|
|
14
|
+
"hover-without-transition": "Hover states that snap",
|
|
15
|
+
"transition-all": "transition: all",
|
|
16
|
+
"hardcoded-duration": "Hardcoded durations (no motion tokens)",
|
|
17
|
+
"no-reduced-motion": "No prefers-reduced-motion guard",
|
|
18
|
+
"inconsistent-durations": "Inconsistent duration scale",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const SEV_ICON = { major: c.red("●"), warn: c.yellow("●"), minor: c.dim("●"), info: c.cyan("●") };
|
|
22
|
+
|
|
23
|
+
export function renderTerminal(result) {
|
|
24
|
+
const lines = [];
|
|
25
|
+
const scoreColor = result.score >= 90 ? c.green : result.score >= 75 ? c.cyan : result.score >= 50 ? c.yellow : c.red;
|
|
26
|
+
lines.push("");
|
|
27
|
+
lines.push(c.bold(" transitions-agent") + c.dim(` scanned ${result.scannedFiles} files`));
|
|
28
|
+
lines.push("");
|
|
29
|
+
lines.push(" Motion score " + scoreColor(c.bold(`${result.score} / 100`)) + c.dim(` (${result.grade})`));
|
|
30
|
+
// Gauge: 20 cells, filled portion in the score color.
|
|
31
|
+
const filled = Math.round(result.score / 5);
|
|
32
|
+
lines.push(" " + scoreColor("█".repeat(filled)) + c.dim("░".repeat(20 - filled)));
|
|
33
|
+
lines.push("");
|
|
34
|
+
if (!result.findings.length) {
|
|
35
|
+
lines.push(c.green(" No issues found. Your motion is in good shape."));
|
|
36
|
+
lines.push("");
|
|
37
|
+
return lines.join("\n");
|
|
38
|
+
}
|
|
39
|
+
const byRule = groupBy(result.findings, (f) => f.rule);
|
|
40
|
+
for (const [rule, list] of Object.entries(byRule)) {
|
|
41
|
+
lines.push(" " + SEV_ICON[list[0].severity] + " " + c.bold(RULE_TITLES[rule] || rule) + c.dim(` (${list.length})`));
|
|
42
|
+
for (const f of list.slice(0, 5)) {
|
|
43
|
+
const loc = f.line ? `${f.path}:${f.line}` : f.path;
|
|
44
|
+
lines.push(c.dim(" " + loc) + " " + f.message.slice(0, 110));
|
|
45
|
+
if (f.recipe) lines.push(c.dim(" fix recipe: ") + c.cyan(`transitions.dev/transitions/${f.recipe}`));
|
|
46
|
+
}
|
|
47
|
+
if (list.length > 5) lines.push(c.dim(` ... and ${list.length - 5} more`));
|
|
48
|
+
lines.push("");
|
|
49
|
+
}
|
|
50
|
+
lines.push(c.dim(" Run ") + c.bold("npx transitions-agent fix") + c.dim(" to get fixes proposed as diffs, then an optional PR."));
|
|
51
|
+
lines.push("");
|
|
52
|
+
return lines.join("\n");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function renderMarkdown(result, opts = {}) {
|
|
56
|
+
const { previousScore } = opts;
|
|
57
|
+
const lines = [];
|
|
58
|
+
lines.push("<!-- transitions-agent -->");
|
|
59
|
+
lines.push(`### ✨ Transitions Agent`);
|
|
60
|
+
lines.push("");
|
|
61
|
+
const delta = previousScore != null ? ` (was ${previousScore})` : "";
|
|
62
|
+
lines.push(`**Motion score: ${result.score} / 100** (${result.grade})${delta}, scanned ${result.scannedFiles} files.`);
|
|
63
|
+
lines.push("");
|
|
64
|
+
if (!result.findings.length) {
|
|
65
|
+
lines.push("No motion issues found. 🎉");
|
|
66
|
+
return lines.join("\n");
|
|
67
|
+
}
|
|
68
|
+
const byRule = groupBy(result.findings, (f) => f.rule);
|
|
69
|
+
lines.push("| Issue | Count | Where |");
|
|
70
|
+
lines.push("|---|---|---|");
|
|
71
|
+
for (const [rule, list] of Object.entries(byRule)) {
|
|
72
|
+
const where = list.slice(0, 3).map((f) => f.line ? `\`${f.path}:${f.line}\`` : `\`${f.path}\``).join("<br>");
|
|
73
|
+
lines.push(`| ${RULE_TITLES[rule] || rule} | ${list.length} | ${where}${list.length > 3 ? "<br>..." : ""} |`);
|
|
74
|
+
}
|
|
75
|
+
lines.push("");
|
|
76
|
+
lines.push("Run `npx transitions-agent` locally for details, or `npx transitions-agent fix` to get fixes as a pull request.");
|
|
77
|
+
return lines.join("\n");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function groupBy(arr, key) {
|
|
81
|
+
const out = {};
|
|
82
|
+
for (const x of arr) (out[key(x)] ||= []).push(x);
|
|
83
|
+
return out;
|
|
84
|
+
}
|
package/lib/rules.mjs
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Deterministic motion rules. Each rule returns findings:
|
|
2
|
+
// { rule, severity, path, line, message, snippet?, recipe? }
|
|
3
|
+
// severity: "major" | "warn" | "minor" | "info"
|
|
4
|
+
import { lineOf } from "./walk.mjs";
|
|
5
|
+
|
|
6
|
+
const CSSISH = new Set([".css", ".scss", ".less", ".html", ".vue", ".svelte", ".jsx", ".tsx", ".js", ".mjs", ".ts"]);
|
|
7
|
+
const STYLESHEET = new Set([".css", ".scss", ".less"]);
|
|
8
|
+
const JSXISH = new Set([".jsx", ".tsx", ".js", ".mjs", ".ts"]);
|
|
9
|
+
|
|
10
|
+
// Matches "transition: opacity 300ms" / "animation-duration: .3s" with a literal time.
|
|
11
|
+
const DURATION_RE = /\b(transition|animation)(?:-duration)?\s*:\s*([^;}"']*\b\d*\.?\d+m?s\b[^;}"']*)/g;
|
|
12
|
+
const TIME_RE = /(\d*\.?\d+)(ms|s)\b/g;
|
|
13
|
+
|
|
14
|
+
export function runRules(files) {
|
|
15
|
+
const findings = [];
|
|
16
|
+
const durations = []; // { value(ms), path, line }
|
|
17
|
+
let hasAnimation = false;
|
|
18
|
+
let hasReducedMotion = false;
|
|
19
|
+
|
|
20
|
+
for (const f of files) {
|
|
21
|
+
if (!CSSISH.has(f.ext)) continue;
|
|
22
|
+
const src = f.content;
|
|
23
|
+
|
|
24
|
+
if (/prefers-reduced-motion/.test(src)) hasReducedMotion = true;
|
|
25
|
+
if (/@keyframes|\banimation(?:-name)?\s*:/.test(src)) hasAnimation = true;
|
|
26
|
+
|
|
27
|
+
// hardcoded-duration + duration collection
|
|
28
|
+
DURATION_RE.lastIndex = 0;
|
|
29
|
+
let m;
|
|
30
|
+
while ((m = DURATION_RE.exec(src))) {
|
|
31
|
+
const valuePart = m[2];
|
|
32
|
+
TIME_RE.lastIndex = 0;
|
|
33
|
+
let t;
|
|
34
|
+
let realTimes = 0;
|
|
35
|
+
while ((t = TIME_RE.exec(valuePart))) {
|
|
36
|
+
const ms = t[2] === "s" ? parseFloat(t[1]) * 1000 : parseFloat(t[1]);
|
|
37
|
+
// Values of 20ms or less are effectively instant; they are almost always
|
|
38
|
+
// prefers-reduced-motion overrides, not real motion.
|
|
39
|
+
if (ms <= 20) continue;
|
|
40
|
+
realTimes++;
|
|
41
|
+
durations.push({ ms, path: f.path, line: lineOf(src, m.index) });
|
|
42
|
+
}
|
|
43
|
+
if (realTimes > 0 && !/var\(/.test(valuePart)) {
|
|
44
|
+
findings.push({
|
|
45
|
+
rule: "hardcoded-duration",
|
|
46
|
+
severity: "minor",
|
|
47
|
+
path: f.path,
|
|
48
|
+
line: lineOf(src, m.index),
|
|
49
|
+
message: `Literal duration in "${m[0].trim().slice(0, 60)}". Use a motion token (var(--transition-fast) etc.) so the whole app moves consistently.`,
|
|
50
|
+
recipe: "motion-tokens",
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// transition-all
|
|
56
|
+
const allRe = /transition\s*:\s*all\b/g;
|
|
57
|
+
while ((m = allRe.exec(src))) {
|
|
58
|
+
findings.push({
|
|
59
|
+
rule: "transition-all",
|
|
60
|
+
severity: "warn",
|
|
61
|
+
path: f.path,
|
|
62
|
+
line: lineOf(src, m.index),
|
|
63
|
+
message: "\"transition: all\" animates every property including layout. Name the properties (opacity, transform) for smoother, cheaper motion.",
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// hover-without-transition (stylesheets only, heuristic)
|
|
68
|
+
if (STYLESHEET.has(f.ext)) {
|
|
69
|
+
const hoverRe = /([^{}\n]{1,120}?):hover[^{]*\{([^}]*)\}/g;
|
|
70
|
+
while ((m = hoverRe.exec(src))) {
|
|
71
|
+
const body = m[2];
|
|
72
|
+
if (!/(transform|opacity|background|color|box-shadow|scale|translate|border)/.test(body)) continue;
|
|
73
|
+
if (/transition/.test(body)) continue;
|
|
74
|
+
const base = m[1].trim().split(/[\s>+~,]/).pop();
|
|
75
|
+
if (!base || base.length < 2) continue;
|
|
76
|
+
const esc = base.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
77
|
+
const baseHasTransition = new RegExp(esc + "[^{}]*\\{[^}]*transition", "s").test(src);
|
|
78
|
+
if (!baseHasTransition) {
|
|
79
|
+
findings.push({
|
|
80
|
+
rule: "hover-without-transition",
|
|
81
|
+
severity: "warn",
|
|
82
|
+
path: f.path,
|
|
83
|
+
line: lineOf(src, m.index),
|
|
84
|
+
message: `"${m[1].trim().slice(0, 50)}:hover" changes visual state with no transition on the base selector. The change snaps instead of easing.`,
|
|
85
|
+
recipe: "learn-more-hover",
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// untransitioned-overlay (JSX-ish files, per-file heuristic)
|
|
92
|
+
if (JSXISH.has(f.ext) || f.ext === ".vue" || f.ext === ".svelte") {
|
|
93
|
+
const overlay = src.match(/<(Modal|Dialog|Drawer|Sheet|Popover|Tooltip|Toast|Snackbar|Dropdown|DropdownMenu|Menu)\b/);
|
|
94
|
+
const conditional = /\{\s*\w[\w.]*\s*&&\s*</.test(src) || /\bopen\s*[=?]/.test(src) || /\bisOpen\b/.test(src) || /\bshow\w*\s*[=?]/.test(src);
|
|
95
|
+
const animated = /(framer-motion|AnimatePresence|motion\.|react-spring|transition|animate|@keyframes|data-\[state|data-state|duration-)/i.test(src);
|
|
96
|
+
if (overlay && conditional && !animated) {
|
|
97
|
+
findings.push({
|
|
98
|
+
rule: "untransitioned-overlay",
|
|
99
|
+
severity: "major",
|
|
100
|
+
path: f.path,
|
|
101
|
+
line: lineOf(src, overlay.index),
|
|
102
|
+
message: `<${overlay[1]}> appears and disappears with no enter or exit transition. It pops instead of animating.`,
|
|
103
|
+
recipe: overlay[1].toLowerCase().includes("tooltip") ? "tooltip" : "modal-open-close",
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// no-reduced-motion (project-level)
|
|
110
|
+
if (hasAnimation && !hasReducedMotion) {
|
|
111
|
+
findings.push({
|
|
112
|
+
rule: "no-reduced-motion",
|
|
113
|
+
severity: "warn",
|
|
114
|
+
path: "(project)",
|
|
115
|
+
line: 0,
|
|
116
|
+
message: "The project animates but never checks prefers-reduced-motion. Users who turn motion off in their OS still get full animation. Add one media query guard.",
|
|
117
|
+
recipe: "motion-tokens",
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// inconsistent-durations (project-level)
|
|
122
|
+
const unique = [...new Set(durations.map((d) => d.ms))].sort((a, b) => a - b);
|
|
123
|
+
if (unique.length > 5) {
|
|
124
|
+
findings.push({
|
|
125
|
+
rule: "inconsistent-durations",
|
|
126
|
+
severity: "info",
|
|
127
|
+
path: "(project)",
|
|
128
|
+
line: 0,
|
|
129
|
+
message: `${unique.length} different animation durations in use (${unique.slice(0, 8).map((v) => v + "ms").join(", ")}${unique.length > 8 ? ", ..." : ""}). Consolidate to 2 or 3 motion tokens.`,
|
|
130
|
+
recipe: "motion-tokens",
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return findings;
|
|
135
|
+
}
|
package/lib/scan.mjs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Scan orchestration: walk files, run rules, compute motion score.
|
|
2
|
+
import { collectFiles } from "./walk.mjs";
|
|
3
|
+
import { runRules } from "./rules.mjs";
|
|
4
|
+
|
|
5
|
+
const WEIGHTS = { major: 8, warn: 4, minor: 1, info: 2 };
|
|
6
|
+
// Per-rule penalty caps so one noisy rule cannot zero the score.
|
|
7
|
+
const CAPS = { "hardcoded-duration": 15, "hover-without-transition": 20, "transition-all": 12, "untransitioned-overlay": 32 };
|
|
8
|
+
|
|
9
|
+
export function scan(root) {
|
|
10
|
+
const files = collectFiles(root);
|
|
11
|
+
const findings = runRules(files);
|
|
12
|
+
|
|
13
|
+
const perRule = {};
|
|
14
|
+
let penalty = 0;
|
|
15
|
+
for (const f of findings) {
|
|
16
|
+
const w = WEIGHTS[f.severity] || 1;
|
|
17
|
+
perRule[f.rule] = (perRule[f.rule] || 0) + w;
|
|
18
|
+
}
|
|
19
|
+
for (const [rule, total] of Object.entries(perRule)) {
|
|
20
|
+
penalty += Math.min(total, CAPS[rule] ?? total);
|
|
21
|
+
}
|
|
22
|
+
const score = Math.max(0, 100 - penalty);
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
score,
|
|
26
|
+
grade: grade(score),
|
|
27
|
+
scannedFiles: files.length,
|
|
28
|
+
findings,
|
|
29
|
+
counts: countBy(findings, (f) => f.rule),
|
|
30
|
+
severities: countBy(findings, (f) => f.severity),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function grade(score) {
|
|
35
|
+
if (score >= 90) return "smooth";
|
|
36
|
+
if (score >= 75) return "decent";
|
|
37
|
+
if (score >= 50) return "janky";
|
|
38
|
+
return "static";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function countBy(arr, key) {
|
|
42
|
+
const out = {};
|
|
43
|
+
for (const x of arr) out[key(x)] = (out[key(x)] || 0) + 1;
|
|
44
|
+
return out;
|
|
45
|
+
}
|
package/lib/walk.mjs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Recursive file walker with sensible defaults. Zero dependencies.
|
|
2
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { join, extname, relative } from "node:path";
|
|
4
|
+
|
|
5
|
+
const SCAN_EXTS = new Set([".css", ".scss", ".less", ".html", ".jsx", ".tsx", ".js", ".mjs", ".ts", ".vue", ".svelte"]);
|
|
6
|
+
const IGNORE_DIRS = new Set([
|
|
7
|
+
"node_modules", ".git", "dist", "build", "out", ".next", ".nuxt", ".output",
|
|
8
|
+
"coverage", "vendor", ".cache", ".turbo", ".vercel", "storybook-static", ".claude",
|
|
9
|
+
]);
|
|
10
|
+
const MAX_FILE_BYTES = 400_000;
|
|
11
|
+
|
|
12
|
+
export function collectFiles(root) {
|
|
13
|
+
const files = [];
|
|
14
|
+
walk(root, root, files, 0);
|
|
15
|
+
return files;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function walk(root, dir, files, depth) {
|
|
19
|
+
if (depth > 12) return;
|
|
20
|
+
let entries;
|
|
21
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
22
|
+
for (const e of entries) {
|
|
23
|
+
if (e.name.startsWith(".") && e.name !== ".") continue;
|
|
24
|
+
const full = join(dir, e.name);
|
|
25
|
+
if (e.isDirectory()) {
|
|
26
|
+
if (!IGNORE_DIRS.has(e.name)) walk(root, full, files, depth + 1);
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const ext = extname(e.name);
|
|
30
|
+
if (!SCAN_EXTS.has(ext)) continue;
|
|
31
|
+
if (/\.min\.(js|css)$/.test(e.name) || /\.d\.ts$/.test(e.name)) continue;
|
|
32
|
+
let size;
|
|
33
|
+
try { size = statSync(full).size; } catch { continue; }
|
|
34
|
+
if (size > MAX_FILE_BYTES) continue;
|
|
35
|
+
let content;
|
|
36
|
+
try { content = readFileSync(full, "utf8"); } catch { continue; }
|
|
37
|
+
files.push({ path: relative(root, full), ext, content });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function lineOf(content, index) {
|
|
42
|
+
let line = 1;
|
|
43
|
+
for (let i = 0; i < index && i < content.length; i++) if (content[i] === "\n") line++;
|
|
44
|
+
return line;
|
|
45
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "transitions-agent",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Scan your codebase for missing, janky, or inconsistent UI transitions. Get a motion score, ready-to-apply fixes from transitions.dev recipes, and an optional pull request with the fixes.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"transitions-agent": "bin/transitions-agent.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"lib"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"test": "node --test test/*.test.mjs"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"css",
|
|
21
|
+
"transitions",
|
|
22
|
+
"animations",
|
|
23
|
+
"motion",
|
|
24
|
+
"lint",
|
|
25
|
+
"scanner",
|
|
26
|
+
"ui",
|
|
27
|
+
"accessibility"
|
|
28
|
+
],
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/Jakubantalik/transitions.dev.git",
|
|
33
|
+
"directory": "agent"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://transitions.dev"
|
|
36
|
+
}
|