create-effect-motion 0.4.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 ADDED
@@ -0,0 +1,49 @@
1
+ # create-effect-motion
2
+
3
+ Scaffold a new [effect-motion](https://github.com/julia-script/effect-motion) project:
4
+
5
+ ```sh
6
+ pnpm create effect-motion
7
+ # or: npm create effect-motion / yarn create effect-motion / bun create effect-motion
8
+ ```
9
+
10
+ The prompts ask for a target directory, a package manager, and whether to set up [Biome](https://biomejs.dev) for linting/formatting. The generated project:
11
+
12
+ ```
13
+ my-motion-project/
14
+ ├─ src/
15
+ │ ├─ scenes/hello-world.ts # a scene: a module exporting `scene`
16
+ │ ├─ assets/ # static files
17
+ │ └─ main.ts # the movie — an ordinary scene composing the others
18
+ ├─ motion.config.ts # render targets
19
+ ├─ AGENTS.md # authoring rules for AI coding agents
20
+ ├─ biome.json # if Biome was selected
21
+ ├─ package.json # EXACT pins of effect-motion + effect
22
+ └─ tsconfig.json
23
+ ```
24
+
25
+ Answering `.` scaffolds into the current directory and names the project after it. A `git init` runs automatically unless the directory already sits inside a repository. Then:
26
+
27
+ ```sh
28
+ pnpm studio # preview scenes with hot reload
29
+ pnpm render # render targets from motion.config.ts to MP4
30
+ ```
31
+
32
+ ## Flags
33
+
34
+ Every prompt has a flag twin, so the scaffolder runs non-interactively in scripts and CI:
35
+
36
+ ```sh
37
+ create-effect-motion my-app --pm pnpm --no-biome --no-install
38
+ create-effect-motion --yes # accept every default (-y)
39
+ ```
40
+
41
+ - `[directory]` — target directory (`.` for the current one)
42
+ - `--pm <pnpm|npm|yarn|bun>` — package manager (prompt defaults to the one that invoked the scaffolder)
43
+ - `--biome` / `--no-biome` — include or skip the Biome setup (prompt defaults to yes)
44
+ - `--no-install` — skip dependency installation
45
+ - `--yes` / `-y` — accept the default answer for every prompt not answered by a flag
46
+
47
+ ## Version pinning
48
+
49
+ Dependency versions are pinned **exactly** (no ranges): each release of this package scaffolds the matching versions of `effect-motion`, `@effect-motion/react`, `@effect-motion/export`, `@effect-motion/cli`, and `effect`. The `effect` pin is a determinism invariant — upgrading effect can change seeded random sequences — so upgrade it and effect-motion together, deliberately.
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Every failure mode of the scaffolder, as a `reason` union on a single
3
+ * tagged error — same pattern as @effect-motion/cli's MotionCliError (a
4
+ * deliberate small copy; the two packages share no code).
5
+ */
6
+ export type MotionCliReason = "ScaffoldTargetNotEmpty" | "ScaffoldFailed" | "InstallFailed";
7
+ declare const MotionCliError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
8
+ readonly _tag: "MotionCliError";
9
+ } & Readonly<A>;
10
+ /**
11
+ * The one error type of `create-effect-motion`: either wraps an upstream
12
+ * failure (`cause` carries it) or states a custom one. `message` MUST name
13
+ * the offender — the directory or command that failed — because it is the
14
+ * only line shown without `--verbose`.
15
+ */
16
+ export declare class MotionCliError extends MotionCliError_base<{
17
+ readonly reason: MotionCliReason;
18
+ readonly message: string;
19
+ readonly cause?: unknown;
20
+ }> {
21
+ }
22
+ /** Render an error for the terminal: message always, cause chain on verbose. */
23
+ export declare const renderForTerminal: (error: MotionCliError, verbose: boolean) => string;
24
+ export {};
@@ -0,0 +1,21 @@
1
+ import * as Data from "effect/Data";
2
+ /**
3
+ * The one error type of `create-effect-motion`: either wraps an upstream
4
+ * failure (`cause` carries it) or states a custom one. `message` MUST name
5
+ * the offender — the directory or command that failed — because it is the
6
+ * only line shown without `--verbose`.
7
+ */
8
+ export class MotionCliError extends Data.TaggedError("MotionCliError") {
9
+ }
10
+ /** Render an error for the terminal: message always, cause chain on verbose. */
11
+ export const renderForTerminal = (error, verbose) => {
12
+ const lines = [`error(${error.reason}): ${error.message}`];
13
+ if (verbose) {
14
+ let cause = error.cause;
15
+ while (cause !== undefined && cause !== null) {
16
+ lines.push(`caused by: ${cause instanceof Error ? (cause.stack ?? cause.message) : String(cause)}`);
17
+ cause = cause instanceof Error ? cause.cause : undefined;
18
+ }
19
+ }
20
+ return lines.join("\n");
21
+ };
package/dist/bin.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/bin.js ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ import { NodeRuntime, NodeServices } from "@effect/platform-node";
3
+ import * as Effect from "effect/Effect";
4
+ import { Command } from "effect/unstable/cli";
5
+ import { CLI_VERSION, reportErrors, rootCommand } from "./create.js";
6
+ // read pre-parse so the reporter works even when parsing itself fails
7
+ const verbose = process.argv.includes("--verbose");
8
+ const program = reportErrors(Command.run(rootCommand, { version: CLI_VERSION }), verbose);
9
+ // every typed failure is handled by reportErrors, so the default reporter
10
+ // only ever fires for defects — bugs in the scaffolder itself
11
+ NodeRuntime.runMain(Effect.provide(program, NodeServices.layer));
@@ -0,0 +1,27 @@
1
+ import * as Effect from "effect/Effect";
2
+ import * as Option from "effect/Option";
3
+ import { CliError, Command, Prompt } from "effect/unstable/cli";
4
+ import { ChildProcessSpawner } from "effect/unstable/process";
5
+ import { MotionCliError } from "./MotionCliError.js";
6
+ /**
7
+ * `git init` the fresh project, unless it is already inside a work tree
8
+ * (scaffolding into a subdirectory of an existing repo). Git being absent
9
+ * or failing is never fatal — the scaffold is complete without it.
10
+ */
11
+ export declare const gitInit: (dir: string) => Effect.Effect<void, never, ChildProcessSpawner.ChildProcessSpawner>;
12
+ export declare const rootCommand: Command.Command<"create-effect-motion", {
13
+ readonly directory: Option.Option<string>;
14
+ readonly pm: Option.Option<"bun" | "npm" | "pnpm" | "yarn">;
15
+ readonly biome: boolean;
16
+ readonly noBiome: boolean;
17
+ readonly noInstall: boolean;
18
+ readonly yes: boolean;
19
+ }, {}, MotionCliError, ChildProcessSpawner.ChildProcessSpawner | Prompt.Environment>;
20
+ export declare const CLI_VERSION: string;
21
+ /**
22
+ * The single exhaustive failure boundary: MotionCliError prints its message
23
+ * (cause chain under --verbose) and sets a non-zero exit; Command API errors
24
+ * print their diagnostic (help output was already rendered for ShowHelp).
25
+ * Anything past this boundary is a defect.
26
+ */
27
+ export declare const reportErrors: <A, R>(program: Effect.Effect<A, MotionCliError | CliError.CliError, R>, verbose: boolean) => Effect.Effect<A | undefined, never, R>;
package/dist/create.js ADDED
@@ -0,0 +1,160 @@
1
+ import * as Console from "effect/Console";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Option from "effect/Option";
4
+ import { Path } from "effect/Path";
5
+ import { Argument, CliError, Command, Flag, GlobalFlag, Prompt, } from "effect/unstable/cli";
6
+ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
7
+ import { MotionCliError, renderForTerminal } from "./MotionCliError.js";
8
+ import { VERSION } from "./pins.js";
9
+ import { ensureEmptyDir, resolveProjectDir, scaffoldProject, } from "./scaffold.js";
10
+ const DEFAULT_DIRECTORY = "my-motion-project";
11
+ const PACKAGE_MANAGERS = ["pnpm", "npm", "yarn", "bun"];
12
+ /** The manager that invoked us (`pnpm create …` etc.), if detectable. */
13
+ const detectPackageManager = () => {
14
+ const agent = process.env.npm_config_user_agent ?? "";
15
+ return PACKAGE_MANAGERS.find((pm) => agent.startsWith(pm));
16
+ };
17
+ const flags = {
18
+ directory: Argument.string("directory").pipe(Argument.withDescription('Target directory ("." scaffolds into the current directory)'), Argument.optional),
19
+ pm: Flag.optional(Flag.choice("pm", PACKAGE_MANAGERS).pipe(Flag.withDescription("Package manager (skips the prompt)"))),
20
+ biome: Flag.boolean("biome").pipe(Flag.withDescription("Set up Biome for linting/formatting (skips the prompt)")),
21
+ noBiome: Flag.boolean("no-biome").pipe(Flag.withDescription("Skip the Biome setup (skips the prompt)")),
22
+ noInstall: Flag.boolean("no-install").pipe(Flag.withDescription("Skip dependency installation")),
23
+ yes: Flag.boolean("yes").pipe(Flag.withAlias("y"), Flag.withDescription("Accept the default answer for every prompt not answered by a flag")),
24
+ };
25
+ const promptDirectory = Prompt.text({
26
+ message: 'Where should the project be created? ("." for the current directory)',
27
+ default: DEFAULT_DIRECTORY,
28
+ });
29
+ const promptPackageManager = Effect.suspend(() => {
30
+ const detected = detectPackageManager();
31
+ // detected manager listed first so plain Enter picks it
32
+ const ordered = [
33
+ ...(detected ? [detected] : []),
34
+ ...PACKAGE_MANAGERS.filter((pm) => pm !== detected),
35
+ ];
36
+ return Prompt.select({
37
+ message: "Which package manager?",
38
+ choices: ordered.map((pm) => ({ title: pm, value: pm })),
39
+ });
40
+ });
41
+ const promptBiome = Prompt.confirm({
42
+ message: "Add Biome for linting/formatting?",
43
+ initial: true,
44
+ });
45
+ const runInstall = (pm, dir) => Effect.gen(function* () {
46
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
47
+ const command = ChildProcess.make(pm, ["install"], {
48
+ cwd: dir,
49
+ stdin: "inherit",
50
+ stdout: "inherit",
51
+ stderr: "inherit",
52
+ });
53
+ yield* Effect.scoped(Effect.gen(function* () {
54
+ const handle = yield* spawner.spawn(command);
55
+ const code = yield* handle.exitCode;
56
+ if (code !== 0) {
57
+ return yield* new MotionCliError({
58
+ reason: "InstallFailed",
59
+ message: `${pm} install exited with code ${code} in ${dir} — run it manually`,
60
+ cause: code,
61
+ });
62
+ }
63
+ })).pipe(Effect.catchTag("PlatformError", (cause) => Effect.fail(new MotionCliError({
64
+ reason: "InstallFailed",
65
+ message: `could not run "${pm} install" in ${dir} — is ${pm} installed?`,
66
+ cause,
67
+ }))));
68
+ });
69
+ /** Exit code of a git command in `dir`; fails with PlatformError if git is absent. */
70
+ const gitExitCode = (dir, args) => Effect.scoped(Effect.gen(function* () {
71
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
72
+ const handle = yield* spawner.spawn(ChildProcess.make("git", args, {
73
+ cwd: dir,
74
+ stdin: "ignore",
75
+ stdout: "ignore",
76
+ stderr: "ignore",
77
+ }));
78
+ return yield* handle.exitCode;
79
+ }));
80
+ /**
81
+ * `git init` the fresh project, unless it is already inside a work tree
82
+ * (scaffolding into a subdirectory of an existing repo). Git being absent
83
+ * or failing is never fatal — the scaffold is complete without it.
84
+ */
85
+ export const gitInit = (dir) => Effect.gen(function* () {
86
+ const inside = yield* gitExitCode(dir, [
87
+ "rev-parse",
88
+ "--is-inside-work-tree",
89
+ ]);
90
+ if (inside === 0)
91
+ return;
92
+ yield* gitExitCode(dir, ["init"]);
93
+ }).pipe(Effect.ignore);
94
+ const handler = (input) => Effect.gen(function* () {
95
+ const path = yield* Path;
96
+ const cwd = process.cwd();
97
+ const dirInput = Option.getOrUndefined(input.directory) ??
98
+ (input.yes ? DEFAULT_DIRECTORY : yield* promptDirectory);
99
+ const { dir, name } = resolveProjectDir(path, cwd, dirInput);
100
+ yield* ensureEmptyDir(dir);
101
+ const pm = Option.getOrUndefined(input.pm) ??
102
+ (input.yes
103
+ ? (detectPackageManager() ?? "npm")
104
+ : yield* promptPackageManager);
105
+ // explicit flags win over --yes; --no-biome beats --biome if both are passed
106
+ const biome = input.noBiome
107
+ ? false
108
+ : input.biome || input.yes || (yield* promptBiome);
109
+ yield* scaffoldProject(dir, name, { biome });
110
+ yield* gitInit(dir);
111
+ yield* Console.log(`Scaffolded ${name} in ${dir}`);
112
+ if (input.noInstall) {
113
+ yield* Console.log([
114
+ "",
115
+ "Next steps:",
116
+ dir === cwd ? "" : ` cd ${path.relative(cwd, dir)}`,
117
+ ` ${pm} install`,
118
+ ` ${pm === "npm" ? "npm run" : pm} studio`,
119
+ ]
120
+ .filter((line) => line !== "")
121
+ .join("\n"));
122
+ return;
123
+ }
124
+ yield* runInstall(pm, dir);
125
+ yield* Console.log([
126
+ "",
127
+ `${name} is ready.`,
128
+ dir === cwd ? "" : ` cd ${path.relative(cwd, dir)}`,
129
+ ` ${pm === "npm" ? "npm run" : pm} studio # preview scenes with hot reload`,
130
+ ` ${pm === "npm" ? "npm run" : pm} render # render targets from motion.config.ts`,
131
+ ]
132
+ .filter((line) => line !== "")
133
+ .join("\n"));
134
+ }).pipe(
135
+ // ctrl-c in a prompt is an interruption, not a failure
136
+ Effect.catchTag("QuitError", () => Effect.interrupt));
137
+ // registered globally so `--verbose` parses anywhere on the command line;
138
+ // the reporter reads argv directly because it sits outside handler context
139
+ const verboseFlag = GlobalFlag.setting("verbose")({
140
+ flag: Flag.boolean("verbose").pipe(Flag.withDescription("Print full error cause chains")),
141
+ });
142
+ export const rootCommand = Command.make("create-effect-motion", flags, handler).pipe(Command.withDescription("Scaffold a new effect-motion project"), Command.withGlobalFlags([verboseFlag]));
143
+ export const CLI_VERSION = VERSION;
144
+ /**
145
+ * The single exhaustive failure boundary: MotionCliError prints its message
146
+ * (cause chain under --verbose) and sets a non-zero exit; Command API errors
147
+ * print their diagnostic (help output was already rendered for ShowHelp).
148
+ * Anything past this boundary is a defect.
149
+ */
150
+ export const reportErrors = (program, verbose) => program.pipe(Effect.catchTag("MotionCliError", (error) => Effect.gen(function* () {
151
+ yield* Console.error(renderForTerminal(error, verbose));
152
+ process.exitCode = 1;
153
+ return undefined;
154
+ })), Effect.catchIf(CliError.isCliError, (error) => error._tag === "ShowHelp"
155
+ ? Effect.succeed(undefined)
156
+ : Effect.gen(function* () {
157
+ yield* Console.error(error.message);
158
+ process.exitCode = 1;
159
+ return undefined;
160
+ })));
package/dist/pins.d.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Exact versions a scaffolded project is pinned to — derived from this
3
+ * scaffolder build's own package.json so they can never go stale. All
4
+ * publishable packages release in lockstep (the changesets `fixed` group),
5
+ * so this package's own version IS the pin for the others. The effect pin
6
+ * is a determinism invariant (upgrading effect can change seeded random
7
+ * sequences), so scaffolds never use ranges or `latest`.
8
+ */
9
+ /** This scaffolder's own version (also the lockstep version of every pin). */
10
+ export declare const VERSION: string;
11
+ export declare const PINS: {
12
+ readonly effect: string;
13
+ readonly "effect-motion": string;
14
+ readonly "@effect-motion/react": string;
15
+ readonly "@effect-motion/export": string;
16
+ readonly "@effect-motion/cli": string;
17
+ };
18
+ /** Non-determinism-critical companions; ranges are fine here. */
19
+ export declare const COMPANIONS: {
20
+ readonly "@biomejs/biome": "^2.5.3";
21
+ readonly react: "^19.2.0";
22
+ readonly "react-dom": "^19.2.0";
23
+ readonly typescript: "^7.0.2";
24
+ readonly "@types/react": "^19.2.0";
25
+ readonly "@types/react-dom": "^19.2.0";
26
+ readonly "@types/node": "^26.1.1";
27
+ };
package/dist/pins.js ADDED
@@ -0,0 +1,29 @@
1
+ import { readFileSync } from "node:fs";
2
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
3
+ /**
4
+ * Exact versions a scaffolded project is pinned to — derived from this
5
+ * scaffolder build's own package.json so they can never go stale. All
6
+ * publishable packages release in lockstep (the changesets `fixed` group),
7
+ * so this package's own version IS the pin for the others. The effect pin
8
+ * is a determinism invariant (upgrading effect can change seeded random
9
+ * sequences), so scaffolds never use ranges or `latest`.
10
+ */
11
+ /** This scaffolder's own version (also the lockstep version of every pin). */
12
+ export const VERSION = pkg.version;
13
+ export const PINS = {
14
+ effect: pkg.dependencies.effect,
15
+ "effect-motion": pkg.version,
16
+ "@effect-motion/react": pkg.version,
17
+ "@effect-motion/export": pkg.version,
18
+ "@effect-motion/cli": pkg.version,
19
+ };
20
+ /** Non-determinism-critical companions; ranges are fine here. */
21
+ export const COMPANIONS = {
22
+ "@biomejs/biome": "^2.5.3",
23
+ react: "^19.2.0",
24
+ "react-dom": "^19.2.0",
25
+ typescript: "^7.0.2",
26
+ "@types/react": "^19.2.0",
27
+ "@types/react-dom": "^19.2.0",
28
+ "@types/node": "^26.1.1",
29
+ };
@@ -0,0 +1,23 @@
1
+ import * as Effect from "effect/Effect";
2
+ import { FileSystem } from "effect/FileSystem";
3
+ import { Path } from "effect/Path";
4
+ import { MotionCliError } from "./MotionCliError.js";
5
+ /**
6
+ * The non-interactive core of the scaffolder: everything except the prompts,
7
+ * so tests can drive it directly. Copies templates/default into the target
8
+ * directory and generates package.json from the pinned versions.
9
+ */
10
+ /** Directory of the shipped templates (dist/scaffold.js → ../templates). */
11
+ export declare const templatesDir: (path: Path) => string;
12
+ /** `.` means "here"; the project is named after the resolved directory. */
13
+ export declare const resolveProjectDir: (path: Path, cwd: string, input: string) => {
14
+ dir: string;
15
+ name: string;
16
+ };
17
+ /** Non-empty means anything but dotfiles (a fresh `git init` is fine). */
18
+ export declare const ensureEmptyDir: (dir: string) => Effect.Effect<undefined, MotionCliError, FileSystem>;
19
+ export type ScaffoldOptions = {
20
+ readonly biome: boolean;
21
+ };
22
+ /** Copy the template tree + write the generated package.json (+ biome.json). */
23
+ export declare const scaffoldProject: (dir: string, name: string, options: ScaffoldOptions) => Effect.Effect<void, MotionCliError, FileSystem | Path>;
@@ -0,0 +1,116 @@
1
+ import * as Effect from "effect/Effect";
2
+ import { FileSystem } from "effect/FileSystem";
3
+ import { Path } from "effect/Path";
4
+ import { MotionCliError } from "./MotionCliError.js";
5
+ import { COMPANIONS, PINS } from "./pins.js";
6
+ /**
7
+ * The non-interactive core of the scaffolder: everything except the prompts,
8
+ * so tests can drive it directly. Copies templates/default into the target
9
+ * directory and generates package.json from the pinned versions.
10
+ */
11
+ /** Directory of the shipped templates (dist/scaffold.js → ../templates). */
12
+ export const templatesDir = (path) => path.join(path.dirname(new URL(import.meta.url).pathname), "..", "templates", "default");
13
+ /** `.` means "here"; the project is named after the resolved directory. */
14
+ export const resolveProjectDir = (path, cwd, input) => {
15
+ const dir = path.resolve(cwd, input);
16
+ return { dir, name: path.basename(dir) };
17
+ };
18
+ /** Non-empty means anything but dotfiles (a fresh `git init` is fine). */
19
+ export const ensureEmptyDir = (dir) => Effect.gen(function* () {
20
+ const fs = yield* FileSystem;
21
+ if (!(yield* fs.exists(dir)))
22
+ return;
23
+ const entries = yield* fs.readDirectory(dir);
24
+ const meaningful = entries.filter((entry) => !entry.startsWith("."));
25
+ if (meaningful.length > 0) {
26
+ return yield* new MotionCliError({
27
+ reason: "ScaffoldTargetNotEmpty",
28
+ message: `${dir} is not empty (found ${meaningful.slice(0, 3).join(", ")}${meaningful.length > 3 ? ", …" : ""}) — choose an empty or new directory`,
29
+ });
30
+ }
31
+ }).pipe(wrapFsError("ScaffoldFailed", `could not inspect ${dir}`));
32
+ const wrapFsError = (reason, message) => (effect) => Effect.mapError(effect, (cause) => cause instanceof MotionCliError
33
+ ? cause
34
+ : new MotionCliError({ reason, message, cause }));
35
+ const packageJson = (name, options) => `${JSON.stringify({
36
+ name,
37
+ private: true,
38
+ version: "0.0.0",
39
+ type: "module",
40
+ scripts: {
41
+ studio: "motion studio",
42
+ render: "motion render",
43
+ ...(options.biome
44
+ ? { lint: "biome check .", "lint:fix": "biome check --fix ." }
45
+ : {}),
46
+ },
47
+ dependencies: {
48
+ "@effect-motion/export": PINS["@effect-motion/export"],
49
+ "@effect-motion/react": PINS["@effect-motion/react"],
50
+ effect: PINS.effect,
51
+ "effect-motion": PINS["effect-motion"],
52
+ react: COMPANIONS.react,
53
+ "react-dom": COMPANIONS["react-dom"],
54
+ },
55
+ devDependencies: {
56
+ ...(options.biome
57
+ ? { "@biomejs/biome": COMPANIONS["@biomejs/biome"] }
58
+ : {}),
59
+ "@effect-motion/cli": PINS["@effect-motion/cli"],
60
+ "@types/node": COMPANIONS["@types/node"],
61
+ "@types/react": COMPANIONS["@types/react"],
62
+ "@types/react-dom": COMPANIONS["@types/react-dom"],
63
+ typescript: COMPANIONS.typescript,
64
+ },
65
+ }, null, "\t")}\n`;
66
+ // Formatter settings must match how the template files are formatted (tabs,
67
+ // double quotes) so a fresh scaffold passes `biome check` with no diagnostics.
68
+ const biomeJson = `${JSON.stringify({
69
+ $schema: "https://biomejs.dev/schemas/2.5.3/schema.json",
70
+ vcs: { enabled: true, clientKind: "git", useIgnoreFile: true },
71
+ files: { ignoreUnknown: true },
72
+ formatter: { enabled: true, indentStyle: "tab" },
73
+ linter: {
74
+ enabled: true,
75
+ rules: {
76
+ preset: "recommended",
77
+ // a scene with no animation yet is a generator without yield
78
+ correctness: { useYield: "off" },
79
+ },
80
+ },
81
+ javascript: { formatter: { quoteStyle: "double" } },
82
+ assist: {
83
+ enabled: true,
84
+ actions: { source: { organizeImports: "on" } },
85
+ },
86
+ }, null, "\t")}\n`;
87
+ /** Copy the template tree + write the generated package.json (+ biome.json). */
88
+ export const scaffoldProject = (dir, name, options) => Effect.gen(function* () {
89
+ const fs = yield* FileSystem;
90
+ const path = yield* Path;
91
+ const templates = templatesDir(path);
92
+ yield* fs.makeDirectory(dir, { recursive: true });
93
+ yield* copyTree(fs, path, templates, dir);
94
+ // npm mangles nested .gitignore/package.json files in published
95
+ // tarballs, so both ship outside the template tree
96
+ yield* fs.writeFileString(path.join(dir, "package.json"), packageJson(name, options));
97
+ if (options.biome) {
98
+ yield* fs.writeFileString(path.join(dir, "biome.json"), biomeJson);
99
+ }
100
+ yield* fs.rename(path.join(dir, "_gitignore"), path.join(dir, ".gitignore"));
101
+ }).pipe(wrapFsError("ScaffoldFailed", `could not scaffold ${dir}`));
102
+ const copyTree = (fs, path, from, to) => Effect.gen(function* () {
103
+ const entries = yield* fs.readDirectory(from);
104
+ for (const entry of entries) {
105
+ const src = path.join(from, entry);
106
+ const dst = path.join(to, entry);
107
+ const info = yield* fs.stat(src);
108
+ if (info.type === "Directory") {
109
+ yield* fs.makeDirectory(dst, { recursive: true });
110
+ yield* copyTree(fs, path, src, dst);
111
+ }
112
+ else {
113
+ yield* fs.copyFile(src, dst);
114
+ }
115
+ }
116
+ });
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "create-effect-motion",
3
+ "version": "0.4.0",
4
+ "description": "Scaffold a new effect-motion project — run with `pnpm create effect-motion` (or npm/yarn/bun create)",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/julia-script/effect-motion.git",
10
+ "directory": "packages/create-effect-motion"
11
+ },
12
+ "homepage": "https://github.com/julia-script/effect-motion#readme",
13
+ "bugs": "https://github.com/julia-script/effect-motion/issues",
14
+ "keywords": [
15
+ "effect",
16
+ "motion",
17
+ "create",
18
+ "scaffold",
19
+ "template",
20
+ "motion-graphics"
21
+ ],
22
+ "bin": {
23
+ "create-effect-motion": "./dist/bin.js"
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "templates"
28
+ ],
29
+ "dependencies": {
30
+ "@effect/platform-node": "4.0.0-beta.98",
31
+ "effect": "4.0.0-beta.98"
32
+ },
33
+ "devDependencies": {
34
+ "@biomejs/biome": "^2.5.3",
35
+ "@types/node": "^26.1.1",
36
+ "typescript": "^7.0.2",
37
+ "vitest": "^4.1.10"
38
+ },
39
+ "scripts": {
40
+ "build": "tsc -p tsconfig.build.json",
41
+ "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
42
+ "test": "vitest run",
43
+ "check": "tsc --noEmit"
44
+ }
45
+ }
@@ -0,0 +1,59 @@
1
+ # Working in this project
2
+
3
+ This is an [effect-motion](https://github.com/julia-script/effect-motion) project: motion graphics written as deterministic, frame-exact scenes in TypeScript, rendered to video. Read this before writing or editing scenes.
4
+
5
+ ## Layout and commands
6
+
7
+ - `src/scenes/*.ts` — one scene per module, each exporting `scene`. Any file here is previewable without registration.
8
+ - `src/main.ts` — the movie: an ordinary scene that sequences the others (`Scene.play` + `handle.finished`). Nothing is special about it.
9
+ - `motion.config.ts` — render targets. Output is always `<output>/<name>.mp4`; never write output paths by hand.
10
+ - `src/assets/` — static files (images, fonts).
11
+ - `motion studio` — browser preview with hot reload (scene picker lists config targets plus unregistered scenes).
12
+ - `motion render [name...]` — render targets; `motion render ./src/scenes/foo.ts` renders one file with defaults. Flags beat config beat library defaults. `--verbose` prints full error cause chains.
13
+
14
+ Verify a scene change by rendering it (`motion render <target>`) or checking it in the running studio — not by reading code alone.
15
+
16
+ ## Writing scenes
17
+
18
+ A scene is an Effect generator: instantiate entities, then yield animations.
19
+
20
+ ```ts
21
+ import { Color, Motion, Physics, Scene, Shapes } from "effect-motion";
22
+
23
+ export const scene = Scene.make(function* () {
24
+ const dot = yield* Scene.instantiate(Shapes.Circle, {
25
+ x: 300, y: 540, radius: 80, fill: Color.hex("#7f5af0"),
26
+ });
27
+ yield* Motion.tweenTo(dot, { x: 1620 }, "1200 millis", "easeInOutCubic");
28
+ yield* Physics.springTo(dot, { y: 300 }, Physics.springs.wobbly);
29
+ });
30
+ ```
31
+
32
+ - **Animators come in pairs**: `verb(instance, from, to, …)` (explicit origin) and `verbTo(instance, to, …)` (origin read from the instance). Prefer the `To` form unless you need a fixed origin.
33
+ - **Prefer semantic helpers** (`Motion.moveTo`, `Motion.fadeTo`, `Physics.springTo`) over raw `tweenTo` when one exists — they carry per-entity meaning (moving a Line translates both endpoints; moving a Group carries its subtree). Use `tweenTo` for fields without a trait (`radius`, `width`, custom fields).
34
+ - **Springs have no duration** — length emerges from the simulation (presets in `Physics.springs`). Springy motion on raw fields uses elastic/bounce *easings*, not physics.
35
+ - **Every animator is a dual**: `Motion.tweenTo(dot, …)` or `dot.pipe(Motion.tweenTo(…))` — both are idiomatic.
36
+ - **Composition**: sequence by yielding one animation after another; `Scene.all([...])` runs them together; `Scene.chain`/`Scene.stagger` sequence with schedules; `Scene.fork` starts a branch you can join later; `Scene.play(otherScene)` mounts a whole scene (await `handle.finished`). `Scene.finish` marks a scene's semantic end — anything after it is a tail that keeps playing without being waited on.
37
+
38
+ ## Determinism rules (non-negotiable)
39
+
40
+ - **Never** use `Math.random()`, `Date.now()`, or any wall-clock/OS state in a scene — every run must be byte-identical. Use the provided seeded random (`Effect.random`, seeded from `settings.seed`).
41
+ - Durations land exactly on target on the final frame; springs snap on settle. Don't add "fudge" frames.
42
+ - Scene coordinates are the `settings.width`/`height` of the target that renders them (this template: 1920×1080). `dpr` scales output pixels, not coordinates.
43
+ - The `effect` dependency is pinned **exactly** — upgrading it can change seeded-random sequences. Never bump it casually; upgrade `effect` and `effect-motion` together, deliberately.
44
+
45
+ ## Config
46
+
47
+ ```ts
48
+ export default defineConfig({
49
+ targets: [{
50
+ name: "intro", // unique — doubles as the output basename
51
+ scene: "./src/scenes/intro.ts",
52
+ settings: { width: 1920, height: 1080, frameRate: 60, dpr: 1 },
53
+ output: "./output", // a DIRECTORY; file name is derived
54
+ // frames: 600 // REQUIRED if the scene is infinite
55
+ }],
56
+ });
57
+ ```
58
+
59
+ A scene used by several targets renders once per target (e.g. different resolutions). An infinite scene (one that never finishes) must set `frames`, or rendering would never end.
@@ -0,0 +1,3 @@
1
+ node_modules/
2
+ output/
3
+ .motion/
@@ -0,0 +1,20 @@
1
+ import { defineConfig } from "@effect-motion/cli";
2
+
3
+ // Each target is one rendered video: <output>/<name>.mp4
4
+ // `motion render` renders all of them; `motion render <name>` picks one.
5
+ export default defineConfig({
6
+ targets: [
7
+ {
8
+ name: "hello-world",
9
+ scene: "./src/scenes/hello-world.ts",
10
+ settings: { width: 1920, height: 1080, frameRate: 60 },
11
+ output: "./output",
12
+ },
13
+ {
14
+ name: "main",
15
+ scene: "./src/main.ts",
16
+ settings: { width: 1920, height: 1080, frameRate: 60 },
17
+ output: "./output",
18
+ },
19
+ ],
20
+ });
File without changes
@@ -0,0 +1,12 @@
1
+ import { Scene } from "effect-motion";
2
+ import { scene as helloWorld } from "./scenes/hello-world";
3
+
4
+ // The movie: an ordinary scene that sequences the scenes in src/scenes.
5
+ // Nothing is special about this file — it is one more target in
6
+ // motion.config.ts. Add scenes and chain them here.
7
+ export const scene = Scene.make(function* () {
8
+ const hello = yield* Scene.play(helloWorld);
9
+ yield* hello.finished;
10
+ // const next = yield* Scene.play(anotherScene);
11
+ // yield* next.finished;
12
+ });
@@ -0,0 +1,15 @@
1
+ import { Color, Motion, Scene, Shapes } from "effect-motion";
2
+
3
+ // A scene is a generator: instantiate entities, then yield animations.
4
+ // Preview it with `motion studio`, render it with `motion render`.
5
+ export const scene = Scene.make(function* () {
6
+ const circle = yield* Scene.instantiate(Shapes.Circle, {
7
+ x: 300,
8
+ y: 540,
9
+ radius: 80,
10
+ fill: Color.hex("#7f5af0"),
11
+ });
12
+
13
+ yield* Motion.tweenTo(circle, { x: 1620 }, "1200 millis", "easeInOutCubic");
14
+ yield* Motion.fadeTo(circle, 0, "400 millis");
15
+ });
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "exactOptionalPropertyTypes": true,
8
+ "noUncheckedIndexedAccess": true,
9
+ "skipLibCheck": true,
10
+ "noEmit": true,
11
+ "lib": ["ES2022"],
12
+ "types": ["node"]
13
+ },
14
+ "include": ["src", "motion.config.ts"]
15
+ }