paper-motion 0.1.0 → 0.2.1
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 +14 -0
- package/bin/pamo.mjs +134 -4
- package/examples/halo-explainer/audio/script.txt +8 -0
- package/examples/halo-explainer/audio/words.json +554 -0
- package/examples/halo-explainer/brand/IDENTITY.md +24 -0
- package/examples/halo-explainer/brand/decisions/2026-11-08-type.md +5 -0
- package/examples/halo-explainer/brand/motion.json +9 -0
- package/examples/halo-explainer/brand/shader-set/set.json +104 -0
- package/examples/halo-explainer/brand/sound.json +33 -0
- package/examples/halo-explainer/brand/tokens.json +97 -0
- package/examples/halo-explainer/brief.md +34 -0
- package/examples/halo-explainer/poster.jpg +0 -0
- package/examples/halo-explainer/scene.html +372 -0
- package/examples/orbit-keynote/audio/script.txt +7 -0
- package/examples/orbit-keynote/audio/words.json +494 -0
- package/examples/orbit-keynote/brand/IDENTITY.md +25 -0
- package/examples/orbit-keynote/brand/decisions/2026-11-08-look.md +6 -0
- package/examples/orbit-keynote/brand/decisions/2026-11-08-voice.md +3 -0
- package/examples/orbit-keynote/brand/motion.json +9 -0
- package/examples/orbit-keynote/brand/shader-set/set.json +45 -0
- package/examples/orbit-keynote/brand/sound.json +45 -0
- package/examples/orbit-keynote/brand/tokens.json +106 -0
- package/examples/orbit-keynote/brief.md +25 -0
- package/examples/orbit-keynote/poster.jpg +0 -0
- package/examples/orbit-keynote/scene.html +323 -0
- package/lib/customize.mjs +162 -0
- package/lib/live-board.js +128 -0
- package/lib/registry.mjs +363 -0
- package/lib/render.mjs +3 -3
- package/lib/server.mjs +1 -0
- package/lib/sfx.mjs +5 -3
- package/package.json +1 -1
- package/runtime/core.js +269 -0
- package/runtime/index.js +3 -260
- package/runtime/kit.js +98 -0
- package/runtime/moves/bar-reveal.js +19 -0
- package/runtime/moves/blur-in.js +7 -0
- package/runtime/moves/cells.js +17 -0
- package/runtime/moves/crossfade.js +6 -0
- package/runtime/moves/cut.js +3 -0
- package/runtime/moves/dive-in.js +21 -0
- package/runtime/moves/draw-on.js +12 -0
- package/runtime/moves/drift.js +7 -0
- package/runtime/moves/flash.js +15 -0
- package/runtime/moves/flip-3d.js +9 -0
- package/runtime/moves/flip-in.js +12 -0
- package/runtime/moves/flip-lift.js +24 -0
- package/runtime/moves/glitch.js +16 -0
- package/runtime/moves/iris.js +12 -0
- package/runtime/moves/magic-move.js +49 -0
- package/runtime/moves/mask-reveal.js +10 -0
- package/runtime/moves/odometer.js +32 -0
- package/runtime/moves/orbit.js +8 -0
- package/runtime/moves/pop-in.js +12 -0
- package/runtime/moves/punch.js +9 -0
- package/runtime/moves/push.js +14 -0
- package/runtime/moves/rgb-split.js +7 -0
- package/runtime/moves/roll.js +20 -0
- package/runtime/moves/scan-reveal.js +10 -0
- package/runtime/moves/scramble.js +21 -0
- package/runtime/moves/shake.js +15 -0
- package/runtime/moves/slices.js +14 -0
- package/runtime/moves/snap-scroll.js +23 -0
- package/runtime/moves/squash-drop.js +21 -0
- package/runtime/moves/tile-swirl.js +29 -0
- package/runtime/moves/track-in.js +7 -0
- package/runtime/moves/type-on.js +41 -0
- package/runtime/moves/unfold.js +20 -0
- package/runtime/moves/whip-pan.js +21 -0
- package/runtime/moves/whip.js +14 -0
- package/runtime/moves/wipe.js +9 -0
- package/runtime/moves/word-rise.js +10 -0
- package/runtime/moves/zoom-through.js +13 -0
- package/runtime/moves.js +66 -146
- package/templates/move-demo/scene.html +4 -3
package/README.md
CHANGED
|
@@ -29,6 +29,20 @@ npx paper-motion # every command
|
|
|
29
29
|
|
|
30
30
|
Installed globally (`npm i -g paper-motion`), the same commands run as `paper-motion` or the short `pamo`.
|
|
31
31
|
|
|
32
|
+
### Resources from paper-motion.app
|
|
33
|
+
|
|
34
|
+
The registry at [paper-motion.app](https://paper-motion.vercel.app) holds named moves, backgrounds, palettes, font pairs and recipes. Agents pick them for a brand, propose options as a board link, and install what the user chooses as code the user owns.
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npx paper-motion search --type background --mode dark --near "#0a0f1f,#eef1f8,#3d6bff" # find resources by mood, mode, color fit
|
|
38
|
+
npx paper-motion add magic-move mesh-sage --set mesh-sage:speed=0.8 # install, with your values written in
|
|
39
|
+
npx paper-motion board --url --title "Backgrounds" --look cobalt,geist \
|
|
40
|
+
--option mesh-graphite --option neuro-slate --option grain-slate # a live board link for the user to choose on
|
|
41
|
+
npx paper-motion list background # every resource of a type
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`https://paper-motion.vercel.app/llms.txt` explains the flow to an agent.
|
|
45
|
+
|
|
32
46
|
## Inside a scene
|
|
33
47
|
|
|
34
48
|
```js
|
package/bin/pamo.mjs
CHANGED
|
@@ -33,7 +33,12 @@ Usage: pamo <command> [options] (pamo = npx paper-motion, or node packages/eng
|
|
|
33
33
|
(sound effects declared with s.sfx() are mixed in)
|
|
34
34
|
sfx <scene.html> [--out file.wav] [--from s] [--to s]
|
|
35
35
|
export only the synthesized sound effects
|
|
36
|
-
board
|
|
36
|
+
board --url --title "…" [--ask "…"] [--reply name] [--look palette,font]
|
|
37
|
+
--option id[:key=value,…] [--note "why"] … [--kit] [--site url]
|
|
38
|
+
a live board: a paper-motion.app link the user opens to see
|
|
39
|
+
2–4 options in their look, tune one and choose; with --kit each
|
|
40
|
+
option is a space-separated list of ids (a whole kit)
|
|
41
|
+
board <a.html> <b.html> [...] [--t 2 | --clip 5] [--labels "A · calm|B · loud"] [--cols n] [--motion-blur 6] [--out board.png]
|
|
37
42
|
option board for interviews: 2–6 options side by side,
|
|
38
43
|
lettered A, B, C…; a still (.png) or short clips (.mp4).
|
|
39
44
|
Labels split on | (or on commas when there is no |).
|
|
@@ -61,6 +66,20 @@ Usage: pamo <command> [options] (pamo = npx paper-motion, or node packages/eng
|
|
|
61
66
|
status [--brand brand] what the brand folder has, and the next step on the main flow
|
|
62
67
|
shaders [--json | --md [--out f]] [name]
|
|
63
68
|
catalog of every Paper shader, params and presets
|
|
69
|
+
|
|
70
|
+
Resources (paper-motion.app: moves, backgrounds, palettes, font pairs, recipes)
|
|
71
|
+
add <id> [<id>...] [--set key=value ...] [--force] [--name piece] [--registry url|dir]
|
|
72
|
+
install resources into this project: moves to moves/,
|
|
73
|
+
backgrounds to brand/shader-set/set.json, palettes and fonts
|
|
74
|
+
into brand/tokens.json, recipes to pieces/<name>/;
|
|
75
|
+
--set writes your values in (id:key=value with several ids)
|
|
76
|
+
list [type] [--json] every resource (types: component transition text camera
|
|
77
|
+
background palette font-pair recipe)
|
|
78
|
+
search <words> [--type t] [--mode light|dark] [--mood m] [--motion still|gentle|lively]
|
|
79
|
+
[--role r] [--energy 2-4] [--near "#hex,#hex"] [--json]
|
|
80
|
+
find resources by words, mood, mode, energy; --near sorts by color fit
|
|
81
|
+
registry build [--out dir] [--site url]
|
|
82
|
+
validate registry/ and publish registry.json, r/<id>.json, llms.txt
|
|
64
83
|
`;
|
|
65
84
|
|
|
66
85
|
function parse(argv) {
|
|
@@ -301,9 +320,40 @@ async function main() {
|
|
|
301
320
|
}
|
|
302
321
|
|
|
303
322
|
case 'board': {
|
|
323
|
+
if (args.url) {
|
|
324
|
+
// the live form: a paper-motion.app link the user opens to compare, tune and choose
|
|
325
|
+
const { encodeBoard, boardUrl } = await import('../lib/live-board.js');
|
|
326
|
+
const { getIndex, resolveRegistry, SITE, getItem } = await import('../lib/registry.mjs');
|
|
327
|
+
const { parseSet, setArgs } = await import('../lib/customize.mjs');
|
|
328
|
+
const reg = resolveRegistry(args.registry), { items } = await getIndex(reg), index = Object.fromEntries(items.map((i) => [i.id, i]));
|
|
329
|
+
const opts = process.argv.flatMap((a, i, all) => (a === '--option' ? [all[i + 1]] : a.startsWith('--option=') ? [a.slice(9)] : [])).filter(Boolean);
|
|
330
|
+
if (!opts.length || !args.title || args.help) { console.error('Usage: board --url --title "…" [--ask "…"] [--reply name] [--look palette,font] --option id[:key=value,…] [--note "why"] … [--kit] [--site url]\n Prints a live board link. Values after the colon are your adjustments; the reply line the user pastes carries every non-default value.'); process.exit(args.help ? 0 : 1); }
|
|
331
|
+
const LABELS = ['A', 'B', 'C', 'D'];
|
|
332
|
+
const parseOne = async (spec) => {
|
|
333
|
+
const [id, kv] = spec.split(':'), it = index[id];
|
|
334
|
+
if (!it) throw new Error(`"${id}" is not in the registry (try pamo search)`);
|
|
335
|
+
if (!kv) return { id, values: {} };
|
|
336
|
+
const full = await getItem(id, reg), values = parseSet(full, kv.split(',').map((x) => x.trim()).filter(Boolean));
|
|
337
|
+
// keep only what differs from the defaults, so the link stays small and the reply line honest
|
|
338
|
+
const keep = Object.fromEntries(setArgs(full, values).map((a) => { const m = a.match(/^--set '?(\w+)=(.*?)'?$/); return [m[1], values[m[1]]]; }));
|
|
339
|
+
return { id, values: keep };
|
|
340
|
+
};
|
|
341
|
+
const notes = process.argv.flatMap((a, i, all) => (a === '--note' ? [all[i + 1]] : a.startsWith('--note=') ? [a.slice(7)] : []));
|
|
342
|
+
const options = [];
|
|
343
|
+
for (const [i, spec] of opts.entries()) {
|
|
344
|
+
if (args.kit) options.push({ label: LABELS[i], items: await Promise.all(spec.split(/\s+/).filter(Boolean).map(parseOne)), ...(notes[i] ? { note: notes[i] } : {}) });
|
|
345
|
+
else options.push({ label: LABELS[i], ...(await parseOne(spec)), ...(notes[i] ? { note: notes[i] } : {}) });
|
|
346
|
+
}
|
|
347
|
+
const look = {};
|
|
348
|
+
if (typeof args.look === 'string') for (const spec of args.look.split(',')) { const o = await parseOne(spec.trim()); const t = index[o.id]?.type; if (t === 'palette') look.palette = o; else if (t === 'font-pair') look.font = o; else throw new Error(`--look takes a palette and a font pair, not "${o.id}"`); }
|
|
349
|
+
const board = { v: 1, kind: args.kit ? 'kit' : 'resource', title: String(args.title), ...(args.ask ? { ask: String(args.ask) } : {}), ...(args.reply ? { reply: String(args.reply) } : {}), ...(Object.keys(look).length ? { look } : {}), options };
|
|
350
|
+
try { const frag = await encodeBoard(board, index); console.log(boardUrl(typeof args.site === 'string' ? args.site : SITE, frag)); }
|
|
351
|
+
catch (e) { console.error(e.message); process.exit(1); }
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
304
354
|
await ensureDeps();
|
|
305
355
|
const files = args._.slice(1);
|
|
306
|
-
if (files.length < 2 || files.length > 6) { console.error('Usage: board <a.html> <b.html> [... up to 6] [--t 2 | --clip 5] [--labels "A · x,B · y"] [--out board.png]'); process.exit(1); }
|
|
356
|
+
if (files.length < 2 || files.length > 6 || args.help) { console.error('Usage: board <a.html> <b.html> [... up to 6] [--t 2 | --clip 5] [--labels "A · x,B · y"] [--out board.png] (a rendered board)\n board --url --title "…" --option id[:key=value,…] … [--look palette,font] [--kit] (a live board link)'); process.exit(args.help ? 0 : 1); }
|
|
307
357
|
const { renderStills, renderVideo } = await import('../lib/render.mjs');
|
|
308
358
|
const { composeBoard } = await import('../lib/board.mjs');
|
|
309
359
|
const os = await import('node:os');
|
|
@@ -311,6 +361,7 @@ async function main() {
|
|
|
311
361
|
const sequence = !!args.sequence;
|
|
312
362
|
if (sequence && !clip) { console.error('board: --sequence needs --clip <seconds>'); process.exit(1); }
|
|
313
363
|
const t = Number(args.t ?? 2);
|
|
364
|
+
const blur = args['motion-blur'] ? Number(args['motion-blur'] === true ? 8 : args['motion-blur']) : 1;
|
|
314
365
|
const out = path.resolve(args.out ?? (clip ? 'board.mp4' : 'board.png'));
|
|
315
366
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pm-board-'));
|
|
316
367
|
const raw = args.labels ? String(args.labels) : '';
|
|
@@ -322,10 +373,10 @@ async function main() {
|
|
|
322
373
|
process.stdout.write(`\roption ${labels[i].split(' · ')[0]} · ${i + 1}/${files.length} `);
|
|
323
374
|
if (clip) {
|
|
324
375
|
const o = path.join(tmp, `${i}.mp4`);
|
|
325
|
-
await withServer(file, (url) => renderVideo({ url: url('?render'), out: o, from: 0, to: clip, sfx: sequence, dpr: Number(args.dpr ?? 1) }));
|
|
376
|
+
await withServer(file, (url) => renderVideo({ url: url('?render'), out: o, from: 0, to: clip, sfx: sequence, dpr: Number(args.dpr ?? 1), motionBlur: blur }));
|
|
326
377
|
inputs.push(o);
|
|
327
378
|
} else {
|
|
328
|
-
const r = await withServer(file, (url) => renderStills({ url: url('?render'), times: [t], outDir: path.join(tmp, String(i)), sheet: false, dpr: Number(args.dpr ?? 1) }));
|
|
379
|
+
const r = await withServer(file, (url) => renderStills({ url: url('?render'), times: [t], outDir: path.join(tmp, String(i)), sheet: false, dpr: Number(args.dpr ?? 1), motionBlur: blur }));
|
|
329
380
|
inputs.push(r.files[0]);
|
|
330
381
|
}
|
|
331
382
|
}
|
|
@@ -526,6 +577,85 @@ async function main() {
|
|
|
526
577
|
break;
|
|
527
578
|
}
|
|
528
579
|
|
|
580
|
+
case 'add': {
|
|
581
|
+
const ids = args._.slice(1);
|
|
582
|
+
if (!ids.length || args.help) { console.error('Usage: add <id> [<id>...] [--set key=value ...] [--force] [--name piece] [--brand "Name"] [--registry url|dir]\n --set writes your values into the installed copy (with several ids: --set id:key=value). --force replaces values already in the project. --brand names the brand in brand/tokens.json; palettes, fonts and recipes also write brand/motion.json and brand/sound.json defaults when missing. --registry picks where resources come from (default: the site).'); process.exit(args.help ? 0 : 1); }
|
|
583
|
+
const { add } = await import('../lib/registry.mjs');
|
|
584
|
+
const set = process.argv.flatMap((a, i, all) => (a === '--set' ? [all[i + 1]] : a.startsWith('--set=') ? [a.slice(6)] : [])).filter(Boolean);
|
|
585
|
+
const report = await add(ids.filter((x) => !set.includes(x)), { registry: args.registry, force: !!args.force, name: typeof args.name === 'string' ? args.name : undefined, brand: typeof args.brand === 'string' ? args.brand : undefined, set });
|
|
586
|
+
for (const r of report) {
|
|
587
|
+
console.log(`✓ ${r.id} (${r.type})${r.wrote.length ? ` → ${[...new Set(r.wrote)].join(', ')}` : ''}`);
|
|
588
|
+
for (const f of r.skipped) console.log(` kept your ${f} (it differs; --force to replace)`);
|
|
589
|
+
for (const c of r.conflicts) console.log(` kept your value at ${c} (--force to replace)`);
|
|
590
|
+
for (const n of r.notes.filter((x) => x.startsWith('the brand'))) console.log(` ${n}`);
|
|
591
|
+
}
|
|
592
|
+
const last = report.filter((r) => ids.includes(r.id) && r.usage);
|
|
593
|
+
if (last.length === 1) console.log(`\nUsage:\n${last[0].usage.split('\n').map((l) => ` ${l}`).join('\n')}`);
|
|
594
|
+
break;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
case 'list':
|
|
598
|
+
case 'search': {
|
|
599
|
+
const { search } = await import('../lib/registry.mjs');
|
|
600
|
+
const isList = cmd === 'list';
|
|
601
|
+
if (args.help) { console.log(isList ? 'Usage: list [type] [--json]' : 'Usage: search <words> [--type t] [--mode light|dark] [--mood m] [--motion still|gentle|lively] [--role r] [--energy 2-4] [--near "#hex,#hex"] [--json]\n --near sorts by color fit: the mean distance from each resource color to the nearest of yours (lower is closer); pass your ground, figure and accent.'); break; }
|
|
602
|
+
const items = await search(isList ? '' : args._.slice(1).join(' '), { type: isList ? args._[1] : args.type, mode: args.mode, mood: args.mood, motion: args.motion, role: args.role, energy: args.energy, near: args.near, registry: args.registry });
|
|
603
|
+
if (args.json) { console.log(JSON.stringify(items, null, 2)); break; }
|
|
604
|
+
let type = '';
|
|
605
|
+
if (items.some((i) => i.fit !== undefined)) console.log('fit: mean distance to your nearest color, lower is closer');
|
|
606
|
+
for (const i of items) { if (i.type !== type) { type = i.type; console.log(`\n${type}`); } console.log(` ${i.id.padEnd(18)} ${[i.mode, i.motion, i.role, i.energy !== undefined ? `energy ${i.energy}` : null, i.fit !== undefined ? `fit ${Math.round(i.fit)}` : null].filter(Boolean).join(' · ').padEnd(26)} ${i.summary}`); }
|
|
607
|
+
if (!items.length) console.log('nothing found');
|
|
608
|
+
break;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
case 'registry': {
|
|
612
|
+
const sub = args._[1];
|
|
613
|
+
if (sub === 'previews') {
|
|
614
|
+
// one clip (webm, 1280x720) and one poster (jpg) per code resource, from registry/_demo/index.html?demo=<id>
|
|
615
|
+
const { loadItems, resolveRegistry } = await import('../lib/registry.mjs');
|
|
616
|
+
const { renderVideo } = await import('../lib/render.mjs');
|
|
617
|
+
const os = await import('node:os');
|
|
618
|
+
const reg = resolveRegistry();
|
|
619
|
+
if (!reg.dir) { console.error('registry previews runs inside the paper-motion repo'); process.exit(1); }
|
|
620
|
+
const only = typeof args.only === 'string' ? args.only.split(',') : null;
|
|
621
|
+
const out = path.resolve(args.out ?? path.join(reg.dir, '..', 'apps', 'web', 'public', 'previews'));
|
|
622
|
+
fs.mkdirSync(out, { recursive: true });
|
|
623
|
+
const items = loadItems(reg.dir).filter((i) => ['component', 'transition', 'text', 'camera', 'recipe'].includes(i.type) && (!only || only.includes(i.id)));
|
|
624
|
+
const POSTER = { component: 1.5, transition: 1.4, text: 2.6, camera: 0.9, recipe: 2.5 };
|
|
625
|
+
const demo = path.join(reg.dir, '_demo', 'index.html');
|
|
626
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pm-previews-'));
|
|
627
|
+
try {
|
|
628
|
+
for (const [k, it] of items.entries()) {
|
|
629
|
+
process.stdout.write(`\r${k + 1}/${items.length} ${it.id.padEnd(16)}`);
|
|
630
|
+
const mp4 = path.join(tmp, `${it.id}.mp4`);
|
|
631
|
+
if (it.type === 'recipe') {
|
|
632
|
+
// a recipe renders whole, wired to the demo brand in registry/_demo/brand (served as /brand)
|
|
633
|
+
const here = process.cwd(), copy = path.join(reg.dir, '_demo', `.${it.id}.recipe.html`);
|
|
634
|
+
fs.copyFileSync(path.join(reg.dir, 'recipe', it.id, 'scene.html'), copy);
|
|
635
|
+
process.chdir(path.join(reg.dir, '_demo'));
|
|
636
|
+
try { await withServer(copy, (url) => renderVideo({ url: url('?render'), out: mp4, sfx: false, crf: 18, motionBlur: Number(args['motion-blur'] ?? 3) })); }
|
|
637
|
+
finally { process.chdir(here); fs.rmSync(copy, { force: true }); }
|
|
638
|
+
} else await withServer(demo, (url) => renderVideo({ url: url(`?render&demo=${it.id}`), out: mp4, from: 0, to: 5, sfx: false, crf: 16, motionBlur: Number(args['motion-blur'] ?? 4) }));
|
|
639
|
+
const vf = 'scale=1280:-2';
|
|
640
|
+
const a = spawnSync('ffmpeg', ['-v', 'error', '-y', '-i', mp4, '-vf', vf, '-c:v', 'libvpx-vp9', '-crf', '38', '-b:v', '0', '-row-mt', '1', '-an', path.join(out, `${it.id}.webm`)]);
|
|
641
|
+
const b = spawnSync('ffmpeg', ['-v', 'error', '-y', '-ss', String(it.preview?.at ?? POSTER[it.type] ?? 1.5), '-i', mp4, '-frames:v', '1', '-vf', vf, '-q:v', '4', path.join(out, `${it.id}.jpg`)]);
|
|
642
|
+
if (a.status || b.status) throw new Error(`ffmpeg failed for ${it.id}: ${a.stderr}${b.stderr}`);
|
|
643
|
+
}
|
|
644
|
+
} finally { fs.rmSync(tmp, { recursive: true, force: true }); }
|
|
645
|
+
const kb = Math.round(fs.readdirSync(out).reduce((n, f) => n + fs.statSync(path.join(out, f)).size, 0) / 1024);
|
|
646
|
+
console.log(`\r✓ ${items.length} previews → ${path.relative(process.cwd(), out)} (${kb} KB in all)`);
|
|
647
|
+
break;
|
|
648
|
+
}
|
|
649
|
+
if (sub !== 'build') { console.error('Usage: registry build [--out dir] [--site url] | registry previews [--only ids] [--motion-blur 4]'); process.exit(1); }
|
|
650
|
+
const { build, resolveRegistry, SITE } = await import('../lib/registry.mjs');
|
|
651
|
+
const reg = resolveRegistry();
|
|
652
|
+
if (!reg.dir) { console.error('registry build runs inside the paper-motion repo (no registry/ folder found)'); process.exit(1); }
|
|
653
|
+
const out = path.resolve(args.out ?? path.join(reg.dir, '..', 'apps', 'web', 'public'));
|
|
654
|
+
try { const r = build({ dir: reg.dir, out, site: typeof args.site === 'string' ? args.site.replace(/\/$/, '') : SITE }); console.log(`✓ ${r.count} resources → ${path.relative(process.cwd(), out) || '.'} (registry.json, r/, llms.txt, llms-full.txt)`); }
|
|
655
|
+
catch (e) { console.error(e.message); process.exit(1); }
|
|
656
|
+
break;
|
|
657
|
+
}
|
|
658
|
+
|
|
529
659
|
default:
|
|
530
660
|
console.log(HELP);
|
|
531
661
|
if (cmd && cmd !== 'help' && cmd !== '--help') process.exit(1);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Your solar panels make the most power at noon.
|
|
2
|
+
But you use the most at night.
|
|
3
|
+
Halo stores the difference.
|
|
4
|
+
One: sunlight hits your panels.
|
|
5
|
+
Two: whatever you don't use flows into Halo, a battery the size of a suitcase.
|
|
6
|
+
Three: when the sun goes down, your home runs on stored sunlight.
|
|
7
|
+
Lower bills. Lights on, even in a blackout.
|
|
8
|
+
Halo. Keep the sun after dark.
|