goosetools-worker 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 ADDED
@@ -0,0 +1,75 @@
1
+ # carousel-maker
2
+
3
+ Brand-aware Instagram carousel generator. Photos + words in → ready-to-post 1080×1350 PNGs out.
4
+
5
+ ## How it works
6
+
7
+ 1. **`brand.yml`** — your colors, fonts, handle. Set once; every slide inherits it.
8
+ 2. **A style** — `styles/<name>/` holds a template set + `style.yml` (font/color overrides) + the reference images that inspired it. Like Recraft: drop inspo screenshots in `styles/<name>/refs/`, have the agent distill them into templates, reuse forever. `brand.yml → defaultStyle` picks the default; a carousel can override with `style: <name>` in its slides.yml.
9
+ 3. **A carousel folder** — `carousels/<name>/` with a `slides.yml` (and photos alongside it).
10
+ 4. **Render** — HTML templates filled with your brand tokens, screenshotted headless at 2x.
11
+
12
+ ## Usage
13
+
14
+ ```bash
15
+ npm install && npx playwright install chromium # first time only
16
+
17
+ node src/render.js carousels/my-post
18
+ # → carousels/my-post/output/slide-01.png, slide-02.png, ...
19
+ ```
20
+
21
+ ## slides.yml
22
+
23
+ ```yaml
24
+ slides:
25
+ - template: cover # bold hook on brand color + "swipe →"
26
+ text: |-
27
+ the hook
28
+ goes here
29
+ - template: photo # your photo, text over a dark gradient
30
+ photo: photos/desk.jpg
31
+ text: caption over the photo
32
+ - template: text # dark slide, *asterisks* render in accent color
33
+ text: |-
34
+ body copy with
35
+ *highlighted* words
36
+ - template: cta # gradient closer with follow pill — put last
37
+ text: your call to action
38
+ ```
39
+
40
+ ## Templates
41
+
42
+ | template | look |
43
+ |---|---|
44
+ | `cover` | brand gradient, big bold hook |
45
+ | `photo` | full-bleed photo, gradient scrim, caption at bottom |
46
+ | `text` | dark background, bold copy, `*text*` → accent color |
47
+ | `light` | off-white background, dark copy, `*text*` → primary color |
48
+ | `cta` | brand gradient, centered text + "follow @handle" pill |
49
+
50
+ Every slide gets a progress bar + page counter baked in, and a swipe chevron on the right edge — except the last slide, where the missing chevron and full bar signal the end. Alternate dark/light templates for visual rhythm.
51
+
52
+ Add a template by dropping a new `templates/<name>.html` — it gets the full brand.yml context plus `{{text}}`, `{{photoSrc}}`, `{{handle}}`, `{{ui}}` (the progress bar + chevron; include it in every template). If the new template has a light background, add its name to `LIGHT_TEMPLATES` in `src/render.js` so the chrome adapts.
53
+
54
+ ## Goose Tools worker
55
+
56
+ This repo doubles as the **worker** behind goosetools.com's Carousel Maker.
57
+ The web app queues jobs; a worker on your own computer claims them, drafts
58
+ the slide copy with your local Claude Code login (your subscription), renders
59
+ with the engine above, and uploads the finished PNGs.
60
+
61
+ ```bash
62
+ # one-time: install Claude Code + sign in, then
63
+ node worker/index.js --url https://goosetools.com --token gt_... # token from the website
64
+ ```
65
+
66
+ Optional env: `VOICE_DIR` points at a writing-voice folder the drafts should
67
+ follow (see `worker/index.js`). Erin's Mac runs it permanently via launchd —
68
+ `scripts/com.ern.carousel-maker.worker.plist` (config in `worker/.env`).
69
+
70
+ ## Roadmap
71
+
72
+ - Secondary font (TBD in brand.yml)
73
+ - Per-platform handle overrides
74
+ - Draft the words too (hook + slide copy from a rough idea)
75
+ - Hook into build-in-public as a skill
package/brand.yml ADDED
@@ -0,0 +1,39 @@
1
+ # Brand tokens — every slide inherits these.
2
+ # Colors pulled from erinkerr.me (blue bento portfolio), 2026-07-13.
3
+
4
+ colors:
5
+ # Electric blue from Erin's CREATOR portfolio erin-codes.com (theme-color
6
+ # meta tag) — the brand source of truth. NOT erinkerr.me's #2563eb.
7
+ primary: "#001AFF"
8
+ primaryLight: "#4d5eff"
9
+ primaryDark: "#0010a6" # CTA text, hard shadows
10
+ accent: "#b3bcff" # pale electric accent
11
+ dark: "#05071f" # near-black w/ electric tint — dark slide bg
12
+ lightBg: "#f4f5ff" # cool off-white — light slide bg (never pure #fff)
13
+ lightBorder: "#e2e5ff"
14
+ text: "#ffffff"
15
+ textOnLight: "#05071f"
16
+
17
+ # HARD RULE (Erin, 2026-07-14): NO GRADIENTS, ever. Flat color or paper-grain
18
+ # texture only — gradients read as AI-generated design. See style refs
19
+ # (pikacodes: flat electric blue / grainy off-white).
20
+
21
+ # Default style — each style lives in styles/<name>/ (templates + style.yml
22
+ # overrides + the reference images that inspired it). Override per carousel
23
+ # with `style: <name>` at the top of its slides.yml.
24
+ defaultStyle: graph-paper
25
+
26
+ fonts:
27
+ # Brand-level defaults; the active style's style.yml can override.
28
+ heading: "Space Grotesk"
29
+ headingWeight: 700
30
+ body: "Inter"
31
+ bodyWeight: 400
32
+ mono: "Space Mono"
33
+
34
+ handle: "@erin.codes" # per-platform override later
35
+
36
+ style:
37
+ vibe: "modern bold"
38
+ padding: 96 # px inside each 1080x1350 slide
39
+ radius: 24 # border radius for cards/photo frames
@@ -0,0 +1,24 @@
1
+ slides:
2
+ - template: cover
3
+ text: |-
4
+ i built a tool
5
+ that makes
6
+ these slides
7
+ - template: text
8
+ text: |-
9
+ it reads my *brand colors*
10
+ and fonts from one file
11
+
12
+ then turns plain text
13
+ into ready-to-post slides
14
+ - template: light
15
+ text: |-
16
+ light + dark slides
17
+ alternate for rhythm
18
+
19
+ the *progress bar* fills
20
+ as you swipe
21
+ - template: cta
22
+ text: |-
23
+ photos + words in
24
+ pngs out
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "goosetools-worker",
3
+ "version": "0.1.0",
4
+ "description": "The Goose Tools carousel worker — your computer drafts and renders carousel slides for goosetools.com using your own Claude account.",
5
+ "repository": "github:ernkerr/carousel-maker",
6
+ "files": [
7
+ "worker",
8
+ "src",
9
+ "styles",
10
+ "carousels/demo/slides.yml",
11
+ "brand.yml",
12
+ "README.md"
13
+ ],
14
+ "type": "module",
15
+ "bin": {
16
+ "goosetools-worker": "worker/cli.js"
17
+ },
18
+ "engines": {
19
+ "node": ">=20"
20
+ },
21
+ "scripts": {
22
+ "render": "node src/render.js",
23
+ "demo": "node src/render.js carousels/demo",
24
+ "worker": "node worker/index.js"
25
+ },
26
+ "dependencies": {
27
+ "@vercel/blob": "^0.27.0",
28
+ "js-yaml": "^4.1.0",
29
+ "playwright": "^1.49.0"
30
+ }
31
+ }
package/src/render.js ADDED
@@ -0,0 +1,184 @@
1
+ import { readFileSync, readdirSync, mkdirSync, existsSync } from "node:fs";
2
+ import { join, dirname, resolve, extname } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import yaml from "js-yaml";
5
+ import { chromium } from "playwright";
6
+
7
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
8
+
9
+ // usage: node src/render.js <carousel-dir> [--aspect 4:5|1:1|9:16] [--out <dir>]
10
+ const ASPECTS = { "4:5": [1080, 1350], "1:1": [1080, 1080], "9:16": [1080, 1920] };
11
+ const args = process.argv.slice(2);
12
+ const carouselDir = args.find((a) => !a.startsWith("--"));
13
+ function flagValue(name) {
14
+ const i = args.indexOf(name);
15
+ return i !== -1 ? args[i + 1] : undefined;
16
+ }
17
+ const aspect = flagValue("--aspect") ?? "4:5";
18
+ if (!ASPECTS[aspect]) {
19
+ console.error(`Unknown aspect "${aspect}" — expected ${Object.keys(ASPECTS).join(" | ")}`);
20
+ process.exit(1);
21
+ }
22
+ const [WIDTH, HEIGHT] = ASPECTS[aspect];
23
+ const outOverride = flagValue("--out");
24
+
25
+ if (!carouselDir) {
26
+ console.error("Usage: node src/render.js <carousel-dir> [--aspect 4:5|1:1|9:16] [--out <dir>]");
27
+ process.exit(1);
28
+ }
29
+ const dir = resolve(carouselDir);
30
+ const brandBase = yaml.load(readFileSync(join(ROOT, "brand.yml"), "utf8"));
31
+ const spec = yaml.load(readFileSync(join(dir, "slides.yml"), "utf8"));
32
+
33
+ // resolve active style: slides.yml `style:` > brand.yml defaultStyle
34
+ const styleName = spec.style ?? brandBase.defaultStyle;
35
+ const styleDir = join(ROOT, "styles", styleName);
36
+ if (!existsSync(styleDir)) {
37
+ console.error(`Unknown style "${styleName}" — expected ${styleDir}`);
38
+ process.exit(1);
39
+ }
40
+ const stylePath = join(styleDir, "style.yml");
41
+ const styleSpec = existsSync(stylePath) ? yaml.load(readFileSync(stylePath, "utf8")) : {};
42
+ // shallow merge per top-level key (fonts/colors/style), style wins
43
+ const brand = { ...brandBase };
44
+ for (const key of ["fonts", "colors", "style"]) {
45
+ if (styleSpec[key]) brand[key] = { ...brandBase[key], ...styleSpec[key] };
46
+ }
47
+ const slides = spec.slides ?? [];
48
+ if (!slides.length) {
49
+ console.error("slides.yml has no slides");
50
+ process.exit(1);
51
+ }
52
+
53
+ // {{a.b.c}} substitution against a merged context
54
+ function fill(template, ctx) {
55
+ return template.replace(/\{\{([\w.]+)\}\}/g, (_, path) => {
56
+ const val = path.split(".").reduce((o, k) => o?.[k], ctx);
57
+ return val ?? "";
58
+ });
59
+ }
60
+
61
+ function escapeHtml(s) {
62
+ return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
63
+ }
64
+
65
+ // *emphasis* → <em> (color pop), _emphasis_ → <i> (italic) — templates style both
66
+ function richText(s) {
67
+ return escapeHtml(s)
68
+ .replace(/_([^_]+)_/g, "<i>$1</i>")
69
+ .replace(/\*([^*]+)\*/g, "<em>$1</em>");
70
+ }
71
+
72
+ function photoDataUri(photoPath) {
73
+ const p = resolve(dir, photoPath);
74
+ const mime = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp" }[extname(p).toLowerCase()];
75
+ if (!mime) throw new Error(`Unsupported photo type: ${photoPath}`);
76
+ return `data:${mime};base64,${readFileSync(p).toString("base64")}`;
77
+ }
78
+
79
+ // which templates sit on a light background (UI chrome adapts)
80
+ const LIGHT_TEMPLATES = new Set(["light", "resource", "cover", "text", "cta"]);
81
+
82
+ // no per-slide chrome — arrows, counters, and progress bars all rejected (Erin, 2026-07-14)
83
+ function slideUi() {
84
+ return "";
85
+ }
86
+
87
+ // per-family axis specs — some families don't ship every weight
88
+ const FONT_SPECS = {
89
+ "Instrument Serif": "ital@0;1",
90
+ "Fraunces": "ital,wght@0,400;0,500;0,600;1,400;1,500",
91
+ "Silkscreen": "wght@400;700",
92
+ "Pixelify Sans": "wght@400;500;700",
93
+ "Doto": "wght@400;700;900",
94
+ "VT323": "wght@400",
95
+ "Space Mono": "wght@400;700",
96
+ "Geist Mono": "wght@400;500",
97
+ };
98
+ const fontFamilies = [brand.fonts.heading, brand.fonts.body, brand.fonts.mono, brand.fonts.pixel]
99
+ .filter(Boolean)
100
+ .map((f) => `family=${f.replace(/ /g, "+")}:${FONT_SPECS[f] ?? "wght@400;500;700"}`)
101
+ .join("&");
102
+
103
+ // style background — flat patterns only (no visual gradients)
104
+ const [gr, gg, gb] = (brand.colors.primary.match(/\w\w/g) ?? []).map((h) => parseInt(h, 16));
105
+ function styleBg() {
106
+ if (styleSpec.background === "dots") {
107
+ // offset polka dots in pale brand blue
108
+ const dot = `%3Csvg xmlns='http://www.w3.org/2000/svg' width='88' height='88'%3E%3Ccircle cx='22' cy='22' r='9' fill='rgba(${gr},${gg},${gb},0.14)'/%3E%3Ccircle cx='66' cy='66' r='9' fill='rgba(${gr},${gg},${gb},0.14)'/%3E%3C/svg%3E`;
109
+ return `
110
+ background-color: ${brand.colors.lightBg};
111
+ background-image: url("data:image/svg+xml,${dot}");`;
112
+ }
113
+ // default: graph paper (flat grid lines, minor + major rules)
114
+ return `
115
+ background-color: ${brand.colors.lightBg};
116
+ background-image:
117
+ linear-gradient(to right, rgba(${gr}, ${gg}, ${gb}, 0.10) 1px, transparent 1px),
118
+ linear-gradient(to bottom, rgba(${gr}, ${gg}, ${gb}, 0.10) 1px, transparent 1px),
119
+ linear-gradient(to right, rgba(${gr}, ${gg}, ${gb}, 0.20) 2px, transparent 2px),
120
+ linear-gradient(to bottom, rgba(${gr}, ${gg}, ${gb}, 0.20) 2px, transparent 2px);
121
+ background-size: 36px 36px, 36px 36px, 180px 180px, 180px 180px;`;
122
+ }
123
+ const GRID_BG = styleBg();
124
+
125
+ function pageHtml(slideHtml) {
126
+ return `<!doctype html><html><head><meta charset="utf-8">
127
+ <link href="https://fonts.googleapis.com/css2?${fontFamilies}&display=block" rel="stylesheet">
128
+ <style>
129
+ * { margin: 0; box-sizing: border-box; }
130
+ html, body { width: ${WIDTH}px; height: ${HEIGHT}px; overflow: hidden; }
131
+ .slide { position: relative; width: ${WIDTH}px; height: ${HEIGHT}px; padding: ${brand.style.padding}px; overflow: hidden; }
132
+ </style></head><body>${slideHtml}</body></html>`;
133
+ }
134
+
135
+ const outDir = outOverride ? resolve(outOverride) : join(dir, "output");
136
+ mkdirSync(outDir, { recursive: true });
137
+
138
+ const browser = await chromium.launch();
139
+ const page = await browser.newPage({ viewport: { width: WIDTH, height: HEIGHT }, deviceScaleFactor: 2 });
140
+
141
+ // live screenshot of a resource's site, embedded as a receipt on the slide
142
+ async function siteScreenshot(url) {
143
+ const shotPage = await browser.newPage({ viewport: { width: 1280, height: 860 }, deviceScaleFactor: 2 });
144
+ try {
145
+ await shotPage.goto(url, { waitUntil: "networkidle", timeout: 30000 }).catch(() => {});
146
+ await shotPage.waitForTimeout(1500);
147
+ const buf = await shotPage.screenshot();
148
+ return `data:image/png;base64,${buf.toString("base64")}`;
149
+ } finally {
150
+ await shotPage.close();
151
+ }
152
+ }
153
+
154
+ for (const [i, slide] of slides.entries()) {
155
+ const templatePath = join(styleDir, "templates", `${slide.template}.html`);
156
+ if (!existsSync(templatePath)) throw new Error(`Unknown template: ${slide.template}`);
157
+ const isLight = LIGHT_TEMPLATES.has(slide.template);
158
+ if (slide.url && !slide.screenshot) {
159
+ console.log(` capturing ${slide.url} ...`);
160
+ }
161
+ const ctx = {
162
+ ...brand,
163
+ gridBg: GRID_BG,
164
+ text: richText(slide.text ?? ""),
165
+ name: escapeHtml(slide.name ?? ""),
166
+ subtitle: escapeHtml(slide.subtitle ?? ""),
167
+ handle: brand.handle,
168
+ photoSrc: slide.photo ? photoDataUri(slide.photo) : "",
169
+ screenshotSrc: slide.screenshot
170
+ ? photoDataUri(slide.screenshot)
171
+ : slide.url
172
+ ? await siteScreenshot(slide.url)
173
+ : "",
174
+ ui: slideUi(i, slides.length, isLight, i === slides.length - 1),
175
+ };
176
+ const html = pageHtml(fill(readFileSync(templatePath, "utf8"), ctx));
177
+ await page.setContent(html, { waitUntil: "networkidle" });
178
+ const out = join(outDir, `slide-${String(i + 1).padStart(2, "0")}.png`);
179
+ await page.screenshot({ path: out });
180
+ console.log(`✓ ${out}`);
181
+ }
182
+
183
+ await browser.close();
184
+ console.log(`\nDone — ${slides.length} slides in ${outDir}`);
@@ -0,0 +1,26 @@
1
+ # graph-paper — STYLE #1, locked 2026-07-14 after 8 feedback rounds.
2
+ # Born from @pikacodes refs (build-in-public data/local/references/instagram/
3
+ # pikacodes-carousels.md), toned down per Erin: "like pika and use my colors,
4
+ # maybe a graph paper background."
5
+ # Locked look: graph-paper bg, Instrument Serif blue/black headlines,
6
+ # VT323 caps accents, real site screenshots, no chrome, no gradients.
7
+
8
+ name: graph-paper
9
+
10
+ # Overrides merged over brand.yml (brand colors stay unless overridden here)
11
+ fonts:
12
+ # Instrument Serif for headlines (Fraunces tried 2026-07-14, rejected: "clunky").
13
+ # Geist Mono from her creator portfolio (erin-codes.com).
14
+ heading: "Instrument Serif"
15
+ headingWeight: 400
16
+ mono: "Geist Mono"
17
+ # pixel-font journey: Silkscreen (too chunky) → Pixelify Sans (not it) →
18
+ # Doto dot-matrix (too hard to read) → VT323 terminal pixel (readable)
19
+ pixel: "VT323"
20
+
21
+ notes: |
22
+ - graph-paper bg (fine blue grid, heavier major lines) on off-white
23
+ - headlines mix brand blue + near-black; _x_ = italic, *x* = color pop
24
+ - [ @handle ] labels in pixel font
25
+ - flat brand-blue pop (blue.html) + CTA slides; hard-shadow pixel button
26
+ - NO gradients (global rule)
@@ -0,0 +1,41 @@
1
+ <div class="slide blue-slide">
2
+ <div class="handle">[ {{handle}} ]</div>
3
+ <div class="body">{{text}}</div>
4
+ {{ui}}
5
+ </div>
6
+ <style>
7
+ .blue-slide {
8
+ background: {{colors.primary}};
9
+ color: {{colors.text}};
10
+ display: flex;
11
+ flex-direction: column;
12
+ align-items: center;
13
+ text-align: center;
14
+ }
15
+ .blue-slide .handle {
16
+ font-family: "{{fonts.pixel}}", monospace;
17
+ text-transform: uppercase;
18
+ font-weight: 400;
19
+ font-size: 42px;
20
+ color: rgba(255, 255, 255, 0.85);
21
+ }
22
+ .blue-slide .body {
23
+ font-family: "{{fonts.heading}}", serif;
24
+ font-weight: {{fonts.headingWeight}};
25
+ font-size: 84px;
26
+ line-height: 1.15;
27
+ margin-top: auto;
28
+ margin-bottom: auto;
29
+ padding-bottom: 60px;
30
+ white-space: pre-wrap;
31
+ }
32
+ .blue-slide .body em {
33
+ font-style: normal;
34
+ color: {{colors.primary}};
35
+ background: {{colors.lightBg}};
36
+ padding: 0 20px;
37
+ box-decoration-break: clone;
38
+ -webkit-box-decoration-break: clone;
39
+ }
40
+ .blue-slide .body i { font-style: italic; }
41
+ </style>
@@ -0,0 +1,43 @@
1
+ <div class="slide cover">
2
+ <div class="handle">[ {{handle}} ]</div>
3
+ <div class="hook">{{text}}<div class="subtitle">{{subtitle}}</div></div>
4
+ {{ui}}
5
+ </div>
6
+ <style>
7
+ .cover {
8
+ {{gridBg}}
9
+ color: {{colors.textOnLight}};
10
+ display: flex;
11
+ flex-direction: column;
12
+ align-items: center;
13
+ text-align: center;
14
+ }
15
+ .cover .handle {
16
+ font-family: "{{fonts.pixel}}", monospace;
17
+ text-transform: uppercase;
18
+ font-weight: 400;
19
+ font-size: 42px;
20
+ color: {{colors.primary}};
21
+ }
22
+ .cover .hook {
23
+ font-family: "{{fonts.heading}}", serif;
24
+ font-weight: {{fonts.headingWeight}};
25
+ font-size: 128px;
26
+ line-height: 1.05;
27
+ letter-spacing: -0.01em;
28
+ margin-top: auto;
29
+ margin-bottom: auto;
30
+ padding-bottom: 60px;
31
+ white-space: pre-wrap;
32
+ }
33
+ .cover .hook em { font-style: normal; color: {{colors.primary}}; }
34
+ .cover .hook i { font-style: italic; }
35
+ .cover .subtitle:empty { display: none; }
36
+ .cover .subtitle {
37
+ margin-top: 48px;
38
+ font-family: "{{fonts.mono}}", monospace;
39
+ font-size: 40px;
40
+ letter-spacing: 0;
41
+ color: {{colors.primary}};
42
+ }
43
+ </style>
@@ -0,0 +1,49 @@
1
+ <div class="slide cta">
2
+ <div class="inner">
3
+ <svg class="floppy" viewBox="0 0 44 44" fill="{{colors.primary}}" shape-rendering="crispEdges">
4
+ <rect x="2" y="2" width="34" height="4"/>
5
+ <rect x="36" y="6" width="4" height="4"/>
6
+ <rect x="38" y="10" width="4" height="32"/>
7
+ <rect x="2" y="2" width="4" height="40"/>
8
+ <rect x="2" y="38" width="40" height="4"/>
9
+ <rect x="12" y="2" width="18" height="14"/>
10
+ <rect x="22" y="4" width="4" height="10" fill="{{colors.lightBg}}"/>
11
+ <rect x="10" y="24" width="24" height="4"/>
12
+ <rect x="10" y="32" width="16" height="4"/>
13
+ </svg>
14
+ <div class="text">{{text}}</div>
15
+ <div class="follow">follow {{handle}}</div>
16
+ </div>
17
+ {{ui}}
18
+ </div>
19
+ <style>
20
+ .cta {
21
+ {{gridBg}}
22
+ color: {{colors.textOnLight}};
23
+ display: flex;
24
+ align-items: center;
25
+ justify-content: center;
26
+ text-align: center;
27
+ }
28
+ .cta .floppy {
29
+ width: 96px;
30
+ height: 96px;
31
+ margin-bottom: 56px;
32
+ }
33
+ .cta .text {
34
+ font-family: "{{fonts.heading}}", serif;
35
+ font-weight: {{fonts.headingWeight}};
36
+ font-size: 100px;
37
+ line-height: 1.1;
38
+ white-space: pre-wrap;
39
+ margin-bottom: 72px;
40
+ }
41
+ .cta .text i { font-style: italic; }
42
+ .cta .text em { font-style: normal; color: {{colors.primary}}; }
43
+ .cta .follow {
44
+ font-family: "{{fonts.pixel}}", monospace;
45
+ font-size: 42px;
46
+ text-transform: uppercase;
47
+ color: {{colors.primary}};
48
+ }
49
+ </style>
@@ -0,0 +1,33 @@
1
+ <div class="slide light-slide">
2
+ <div class="handle">{{handle}}</div>
3
+ <div class="body">{{text}}</div>
4
+ {{ui}}
5
+ </div>
6
+ <style>
7
+ .light-slide {
8
+ background: {{colors.lightBg}};
9
+ color: {{colors.textOnLight}};
10
+ display: flex;
11
+ flex-direction: column;
12
+ }
13
+ .light-slide .handle {
14
+ font-family: "{{fonts.body}}", sans-serif;
15
+ font-size: 30px;
16
+ color: {{colors.primary}};
17
+ }
18
+ .light-slide .body {
19
+ font-family: "{{fonts.heading}}", sans-serif;
20
+ font-weight: {{fonts.headingWeight}};
21
+ font-size: 60px;
22
+ line-height: 1.25;
23
+ letter-spacing: -0.01em;
24
+ margin-top: auto;
25
+ margin-bottom: auto;
26
+ padding-bottom: 60px;
27
+ white-space: pre-wrap;
28
+ }
29
+ .light-slide .body em {
30
+ font-style: normal;
31
+ color: {{colors.primary}};
32
+ }
33
+ </style>
@@ -0,0 +1,46 @@
1
+ <div class="slide photo-slide">
2
+ <img class="bg" src="{{photoSrc}}" alt="" />
3
+ <div class="scrim"></div>
4
+ <div class="handle">{{handle}}</div>
5
+ <div class="caption">{{text}}</div>
6
+ {{ui}}
7
+ </div>
8
+ <style>
9
+ .photo-slide {
10
+ padding: 0;
11
+ color: {{colors.text}};
12
+ }
13
+ .photo-slide .bg {
14
+ position: absolute;
15
+ inset: 0;
16
+ width: 100%;
17
+ height: 100%;
18
+ object-fit: cover;
19
+ }
20
+ .photo-slide .scrim {
21
+ position: absolute;
22
+ inset: 0;
23
+ background: linear-gradient(to top, rgba(15, 23, 42, 0.88) 0%, rgba(15, 23, 42, 0.3) 45%, transparent 70%);
24
+ }
25
+ .photo-slide .handle {
26
+ position: absolute;
27
+ top: {{style.padding}}px;
28
+ left: {{style.padding}}px;
29
+ font-family: "{{fonts.body}}", sans-serif;
30
+ font-size: 30px;
31
+ color: rgba(255, 255, 255, 0.85);
32
+ text-shadow: 0 2px 12px rgba(15, 23, 42, 0.6);
33
+ }
34
+ .photo-slide .caption {
35
+ position: absolute;
36
+ left: {{style.padding}}px;
37
+ right: {{style.padding}}px;
38
+ bottom: 200px;
39
+ font-family: "{{fonts.heading}}", sans-serif;
40
+ font-weight: {{fonts.headingWeight}};
41
+ font-size: 64px;
42
+ line-height: 1.15;
43
+ letter-spacing: -0.01em;
44
+ white-space: pre-wrap;
45
+ }
46
+ </style>
@@ -0,0 +1,67 @@
1
+ <div class="slide resource">
2
+ <div class="handle">[ {{handle}} ]</div>
3
+ <div class="name">{{name}}</div>
4
+ <div class="desc">{{text}}</div>
5
+ <div class="window">
6
+ <div class="chrome"><span class="dot r"></span><span class="dot y"></span><span class="dot g"></span></div>
7
+ <img class="shot" src="{{screenshotSrc}}" alt="" />
8
+ </div>
9
+ {{ui}}
10
+ </div>
11
+ <style>
12
+ .resource {
13
+ {{gridBg}}
14
+ color: {{colors.textOnLight}};
15
+ display: flex;
16
+ flex-direction: column;
17
+ align-items: center;
18
+ text-align: center;
19
+ }
20
+ .resource .handle {
21
+ align-self: flex-start;
22
+ font-family: "{{fonts.pixel}}", monospace;
23
+ text-transform: uppercase;
24
+ font-weight: 400;
25
+ font-size: 42px;
26
+ color: {{colors.primary}};
27
+ }
28
+ .resource .name {
29
+ margin-top: 40px;
30
+ font-family: "{{fonts.heading}}", serif;
31
+ font-weight: {{fonts.headingWeight}};
32
+ font-size: 110px;
33
+ letter-spacing: -0.01em;
34
+ color: {{colors.primary}};
35
+ }
36
+ .resource .desc {
37
+ margin-top: 36px;
38
+ font-family: "{{fonts.mono}}", monospace;
39
+ font-size: 36px;
40
+ line-height: 1.5;
41
+ max-width: 840px;
42
+ white-space: pre-wrap;
43
+ color: {{colors.textOnLight}};
44
+ }
45
+ .resource .desc em { font-style: normal; color: {{colors.primary}}; }
46
+ .resource .window {
47
+ margin-top: auto;
48
+ margin-bottom: 60px;
49
+ width: 880px;
50
+ border-radius: 18px;
51
+ overflow: hidden;
52
+ box-shadow: 0 24px 64px rgba(15, 23, 42, 0.3);
53
+ }
54
+ .resource .chrome {
55
+ height: 52px;
56
+ background: #1e293b;
57
+ display: flex;
58
+ align-items: center;
59
+ gap: 14px;
60
+ padding-left: 24px;
61
+ }
62
+ .resource .dot { width: 18px; height: 18px; border-radius: 50%; }
63
+ .resource .dot.r { background: #ff5f57; }
64
+ .resource .dot.y { background: #febc2e; }
65
+ .resource .dot.g { background: #28c840; }
66
+ .resource .shot { display: block; width: 100%; height: 560px; object-fit: cover; object-position: top; }
67
+ </style>
@@ -0,0 +1,32 @@
1
+ <div class="slide text-slide">
2
+ <div class="handle">[ {{handle}} ]</div>
3
+ <div class="body">{{text}}</div>
4
+ {{ui}}
5
+ </div>
6
+ <style>
7
+ .text-slide {
8
+ {{gridBg}}
9
+ color: {{colors.primary}};
10
+ display: flex;
11
+ flex-direction: column;
12
+ }
13
+ .text-slide .handle {
14
+ font-family: "{{fonts.pixel}}", monospace;
15
+ text-transform: uppercase;
16
+ font-weight: 400;
17
+ font-size: 42px;
18
+ color: {{colors.primary}};
19
+ }
20
+ .text-slide .body {
21
+ font-family: "{{fonts.heading}}", serif;
22
+ font-weight: {{fonts.headingWeight}};
23
+ font-size: 72px;
24
+ line-height: 1.2;
25
+ margin-top: auto;
26
+ margin-bottom: auto;
27
+ padding-bottom: 60px;
28
+ white-space: pre-wrap;
29
+ }
30
+ .text-slide .body em { font-style: normal; color: {{colors.textOnLight}}; }
31
+ .text-slide .body i { font-style: italic; }
32
+ </style>
@@ -0,0 +1,14 @@
1
+ # polka-dot — STYLE #2 (2026-07-14), variation of graph-paper (style #1).
2
+ # Same recipe (Instrument Serif blue/black headlines, VT323 caps accents,
3
+ # Geist Mono descriptions, real screenshots, no chrome, no gradients) with a
4
+ # polka-dot background instead of the grid. First pass done without a
5
+ # reference — Erin may send one to calibrate dot size/spacing/color.
6
+
7
+ name: polka-dot
8
+ background: dots
9
+
10
+ fonts:
11
+ heading: "Instrument Serif"
12
+ headingWeight: 400
13
+ mono: "Geist Mono"
14
+ pixel: "VT323"
@@ -0,0 +1,41 @@
1
+ <div class="slide blue-slide">
2
+ <div class="handle">[ {{handle}} ]</div>
3
+ <div class="body">{{text}}</div>
4
+ {{ui}}
5
+ </div>
6
+ <style>
7
+ .blue-slide {
8
+ background: {{colors.primary}};
9
+ color: {{colors.text}};
10
+ display: flex;
11
+ flex-direction: column;
12
+ align-items: center;
13
+ text-align: center;
14
+ }
15
+ .blue-slide .handle {
16
+ font-family: "{{fonts.pixel}}", monospace;
17
+ text-transform: uppercase;
18
+ font-weight: 400;
19
+ font-size: 42px;
20
+ color: rgba(255, 255, 255, 0.85);
21
+ }
22
+ .blue-slide .body {
23
+ font-family: "{{fonts.heading}}", serif;
24
+ font-weight: {{fonts.headingWeight}};
25
+ font-size: 84px;
26
+ line-height: 1.15;
27
+ margin-top: auto;
28
+ margin-bottom: auto;
29
+ padding-bottom: 60px;
30
+ white-space: pre-wrap;
31
+ }
32
+ .blue-slide .body em {
33
+ font-style: normal;
34
+ color: {{colors.primary}};
35
+ background: {{colors.lightBg}};
36
+ padding: 0 20px;
37
+ box-decoration-break: clone;
38
+ -webkit-box-decoration-break: clone;
39
+ }
40
+ .blue-slide .body i { font-style: italic; }
41
+ </style>
@@ -0,0 +1,43 @@
1
+ <div class="slide cover">
2
+ <div class="handle">[ {{handle}} ]</div>
3
+ <div class="hook">{{text}}<div class="subtitle">{{subtitle}}</div></div>
4
+ {{ui}}
5
+ </div>
6
+ <style>
7
+ .cover {
8
+ {{gridBg}}
9
+ color: {{colors.textOnLight}};
10
+ display: flex;
11
+ flex-direction: column;
12
+ align-items: center;
13
+ text-align: center;
14
+ }
15
+ .cover .handle {
16
+ font-family: "{{fonts.pixel}}", monospace;
17
+ text-transform: uppercase;
18
+ font-weight: 400;
19
+ font-size: 42px;
20
+ color: {{colors.primary}};
21
+ }
22
+ .cover .hook {
23
+ font-family: "{{fonts.heading}}", serif;
24
+ font-weight: {{fonts.headingWeight}};
25
+ font-size: 128px;
26
+ line-height: 1.05;
27
+ letter-spacing: -0.01em;
28
+ margin-top: auto;
29
+ margin-bottom: auto;
30
+ padding-bottom: 60px;
31
+ white-space: pre-wrap;
32
+ }
33
+ .cover .hook em { font-style: normal; color: {{colors.primary}}; }
34
+ .cover .hook i { font-style: italic; }
35
+ .cover .subtitle:empty { display: none; }
36
+ .cover .subtitle {
37
+ margin-top: 48px;
38
+ font-family: "{{fonts.mono}}", monospace;
39
+ font-size: 40px;
40
+ letter-spacing: 0;
41
+ color: {{colors.primary}};
42
+ }
43
+ </style>
@@ -0,0 +1,49 @@
1
+ <div class="slide cta">
2
+ <div class="inner">
3
+ <svg class="floppy" viewBox="0 0 44 44" fill="{{colors.primary}}" shape-rendering="crispEdges">
4
+ <rect x="2" y="2" width="34" height="4"/>
5
+ <rect x="36" y="6" width="4" height="4"/>
6
+ <rect x="38" y="10" width="4" height="32"/>
7
+ <rect x="2" y="2" width="4" height="40"/>
8
+ <rect x="2" y="38" width="40" height="4"/>
9
+ <rect x="12" y="2" width="18" height="14"/>
10
+ <rect x="22" y="4" width="4" height="10" fill="{{colors.lightBg}}"/>
11
+ <rect x="10" y="24" width="24" height="4"/>
12
+ <rect x="10" y="32" width="16" height="4"/>
13
+ </svg>
14
+ <div class="text">{{text}}</div>
15
+ <div class="follow">follow {{handle}}</div>
16
+ </div>
17
+ {{ui}}
18
+ </div>
19
+ <style>
20
+ .cta {
21
+ {{gridBg}}
22
+ color: {{colors.textOnLight}};
23
+ display: flex;
24
+ align-items: center;
25
+ justify-content: center;
26
+ text-align: center;
27
+ }
28
+ .cta .floppy {
29
+ width: 96px;
30
+ height: 96px;
31
+ margin-bottom: 56px;
32
+ }
33
+ .cta .text {
34
+ font-family: "{{fonts.heading}}", serif;
35
+ font-weight: {{fonts.headingWeight}};
36
+ font-size: 100px;
37
+ line-height: 1.1;
38
+ white-space: pre-wrap;
39
+ margin-bottom: 72px;
40
+ }
41
+ .cta .text i { font-style: italic; }
42
+ .cta .text em { font-style: normal; color: {{colors.primary}}; }
43
+ .cta .follow {
44
+ font-family: "{{fonts.pixel}}", monospace;
45
+ font-size: 42px;
46
+ text-transform: uppercase;
47
+ color: {{colors.primary}};
48
+ }
49
+ </style>
@@ -0,0 +1,33 @@
1
+ <div class="slide light-slide">
2
+ <div class="handle">{{handle}}</div>
3
+ <div class="body">{{text}}</div>
4
+ {{ui}}
5
+ </div>
6
+ <style>
7
+ .light-slide {
8
+ background: {{colors.lightBg}};
9
+ color: {{colors.textOnLight}};
10
+ display: flex;
11
+ flex-direction: column;
12
+ }
13
+ .light-slide .handle {
14
+ font-family: "{{fonts.body}}", sans-serif;
15
+ font-size: 30px;
16
+ color: {{colors.primary}};
17
+ }
18
+ .light-slide .body {
19
+ font-family: "{{fonts.heading}}", sans-serif;
20
+ font-weight: {{fonts.headingWeight}};
21
+ font-size: 60px;
22
+ line-height: 1.25;
23
+ letter-spacing: -0.01em;
24
+ margin-top: auto;
25
+ margin-bottom: auto;
26
+ padding-bottom: 60px;
27
+ white-space: pre-wrap;
28
+ }
29
+ .light-slide .body em {
30
+ font-style: normal;
31
+ color: {{colors.primary}};
32
+ }
33
+ </style>
@@ -0,0 +1,46 @@
1
+ <div class="slide photo-slide">
2
+ <img class="bg" src="{{photoSrc}}" alt="" />
3
+ <div class="scrim"></div>
4
+ <div class="handle">{{handle}}</div>
5
+ <div class="caption">{{text}}</div>
6
+ {{ui}}
7
+ </div>
8
+ <style>
9
+ .photo-slide {
10
+ padding: 0;
11
+ color: {{colors.text}};
12
+ }
13
+ .photo-slide .bg {
14
+ position: absolute;
15
+ inset: 0;
16
+ width: 100%;
17
+ height: 100%;
18
+ object-fit: cover;
19
+ }
20
+ .photo-slide .scrim {
21
+ position: absolute;
22
+ inset: 0;
23
+ background: linear-gradient(to top, rgba(15, 23, 42, 0.88) 0%, rgba(15, 23, 42, 0.3) 45%, transparent 70%);
24
+ }
25
+ .photo-slide .handle {
26
+ position: absolute;
27
+ top: {{style.padding}}px;
28
+ left: {{style.padding}}px;
29
+ font-family: "{{fonts.body}}", sans-serif;
30
+ font-size: 30px;
31
+ color: rgba(255, 255, 255, 0.85);
32
+ text-shadow: 0 2px 12px rgba(15, 23, 42, 0.6);
33
+ }
34
+ .photo-slide .caption {
35
+ position: absolute;
36
+ left: {{style.padding}}px;
37
+ right: {{style.padding}}px;
38
+ bottom: 200px;
39
+ font-family: "{{fonts.heading}}", sans-serif;
40
+ font-weight: {{fonts.headingWeight}};
41
+ font-size: 64px;
42
+ line-height: 1.15;
43
+ letter-spacing: -0.01em;
44
+ white-space: pre-wrap;
45
+ }
46
+ </style>
@@ -0,0 +1,67 @@
1
+ <div class="slide resource">
2
+ <div class="handle">[ {{handle}} ]</div>
3
+ <div class="name">{{name}}</div>
4
+ <div class="desc">{{text}}</div>
5
+ <div class="window">
6
+ <div class="chrome"><span class="dot r"></span><span class="dot y"></span><span class="dot g"></span></div>
7
+ <img class="shot" src="{{screenshotSrc}}" alt="" />
8
+ </div>
9
+ {{ui}}
10
+ </div>
11
+ <style>
12
+ .resource {
13
+ {{gridBg}}
14
+ color: {{colors.textOnLight}};
15
+ display: flex;
16
+ flex-direction: column;
17
+ align-items: center;
18
+ text-align: center;
19
+ }
20
+ .resource .handle {
21
+ align-self: flex-start;
22
+ font-family: "{{fonts.pixel}}", monospace;
23
+ text-transform: uppercase;
24
+ font-weight: 400;
25
+ font-size: 42px;
26
+ color: {{colors.primary}};
27
+ }
28
+ .resource .name {
29
+ margin-top: 40px;
30
+ font-family: "{{fonts.heading}}", serif;
31
+ font-weight: {{fonts.headingWeight}};
32
+ font-size: 110px;
33
+ letter-spacing: -0.01em;
34
+ color: {{colors.primary}};
35
+ }
36
+ .resource .desc {
37
+ margin-top: 36px;
38
+ font-family: "{{fonts.mono}}", monospace;
39
+ font-size: 36px;
40
+ line-height: 1.5;
41
+ max-width: 840px;
42
+ white-space: pre-wrap;
43
+ color: {{colors.textOnLight}};
44
+ }
45
+ .resource .desc em { font-style: normal; color: {{colors.primary}}; }
46
+ .resource .window {
47
+ margin-top: auto;
48
+ margin-bottom: 60px;
49
+ width: 880px;
50
+ border-radius: 18px;
51
+ overflow: hidden;
52
+ box-shadow: 0 24px 64px rgba(15, 23, 42, 0.3);
53
+ }
54
+ .resource .chrome {
55
+ height: 52px;
56
+ background: #1e293b;
57
+ display: flex;
58
+ align-items: center;
59
+ gap: 14px;
60
+ padding-left: 24px;
61
+ }
62
+ .resource .dot { width: 18px; height: 18px; border-radius: 50%; }
63
+ .resource .dot.r { background: #ff5f57; }
64
+ .resource .dot.y { background: #febc2e; }
65
+ .resource .dot.g { background: #28c840; }
66
+ .resource .shot { display: block; width: 100%; height: 560px; object-fit: cover; object-position: top; }
67
+ </style>
@@ -0,0 +1,32 @@
1
+ <div class="slide text-slide">
2
+ <div class="handle">[ {{handle}} ]</div>
3
+ <div class="body">{{text}}</div>
4
+ {{ui}}
5
+ </div>
6
+ <style>
7
+ .text-slide {
8
+ {{gridBg}}
9
+ color: {{colors.primary}};
10
+ display: flex;
11
+ flex-direction: column;
12
+ }
13
+ .text-slide .handle {
14
+ font-family: "{{fonts.pixel}}", monospace;
15
+ text-transform: uppercase;
16
+ font-weight: 400;
17
+ font-size: 42px;
18
+ color: {{colors.primary}};
19
+ }
20
+ .text-slide .body {
21
+ font-family: "{{fonts.heading}}", serif;
22
+ font-weight: {{fonts.headingWeight}};
23
+ font-size: 72px;
24
+ line-height: 1.2;
25
+ margin-top: auto;
26
+ margin-bottom: auto;
27
+ padding-bottom: 60px;
28
+ white-space: pre-wrap;
29
+ }
30
+ .text-slide .body em { font-style: normal; color: {{colors.textOnLight}}; }
31
+ .text-slide .body i { font-style: italic; }
32
+ </style>
package/worker/cli.js ADDED
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ // goosetools-worker entrypoint — makes first run painless for non-technical
3
+ // users: checks for Claude Code + the Playwright browser, installs the
4
+ // browser if missing, then starts the worker loop.
5
+
6
+ import { execFileSync, execSync } from "node:child_process";
7
+ import { dirname, join, resolve } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
11
+
12
+ function has(cmd) {
13
+ try {
14
+ execSync(process.platform === "win32" ? `where ${cmd}` : `command -v ${cmd}`, {
15
+ stdio: "ignore",
16
+ shell: true,
17
+ });
18
+ return true;
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ if (!has("claude")) {
25
+ console.error(
26
+ "\nClaude Code isn't installed yet (it writes your slides).\n" +
27
+ "Install it with: npm install -g @anthropic-ai/claude-code\n" +
28
+ "Then run: claude (once, to sign in — type /exit to leave)\n" +
29
+ "Then run this command again.\n",
30
+ );
31
+ process.exit(1);
32
+ }
33
+
34
+ // Make sure the rendering browser is present (quiet no-op when it is).
35
+ try {
36
+ console.log("Checking the rendering browser (first run can take a minute)…");
37
+ execFileSync("npx", ["--yes", "playwright", "install", "chromium"], {
38
+ cwd: ROOT,
39
+ stdio: "inherit",
40
+ shell: process.platform === "win32",
41
+ });
42
+ } catch {
43
+ console.error("Couldn't install the rendering browser — check your internet connection and retry.");
44
+ process.exit(1);
45
+ }
46
+
47
+ await import("./index.js");
@@ -0,0 +1,215 @@
1
+ // Goose Tools worker — polls the cloud queue, drafts slide copy with the
2
+ // local Claude Code login, renders with the carousel-maker engine, uploads
3
+ // results. Runs on the user's own machine; talks to the server only over
4
+ // HTTPS with its worker token.
5
+
6
+ import { execFile } from "node:child_process";
7
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
8
+ import { join, dirname, resolve, extname } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { promisify } from "node:util";
11
+ import yaml from "js-yaml";
12
+ import { upload } from "@vercel/blob/client";
13
+
14
+ const execFileAsync = promisify(execFile);
15
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
16
+
17
+ // ── Config: flags > env > worker/.env ───────────────────────────────────────
18
+ function loadDotEnv() {
19
+ const p = join(ROOT, "worker", ".env");
20
+ if (!existsSync(p)) return {};
21
+ return Object.fromEntries(
22
+ readFileSync(p, "utf8")
23
+ .split("\n")
24
+ .filter((l) => l.includes("=") && !l.trim().startsWith("#"))
25
+ .map((l) => [l.slice(0, l.indexOf("=")).trim(), l.slice(l.indexOf("=") + 1).trim()]),
26
+ );
27
+ }
28
+ const dotenv = loadDotEnv();
29
+ function flag(name) {
30
+ const i = process.argv.indexOf(name);
31
+ return i !== -1 ? process.argv[i + 1] : undefined;
32
+ }
33
+ const BASE_URL = (flag("--url") ?? process.env.GOOSETOOLS_URL ?? dotenv.GOOSETOOLS_URL ?? "https://goosetools.com").replace(/\/$/, "");
34
+ const TOKEN = flag("--token") ?? process.env.WORKER_TOKEN ?? dotenv.WORKER_TOKEN;
35
+ const VOICE_DIR = process.env.VOICE_DIR ?? dotenv.VOICE_DIR; // optional
36
+ const POLL_MS = 30_000;
37
+
38
+ if (!TOKEN) {
39
+ console.error("Missing worker token. Run with --token gt_... (get one at " + BASE_URL + "/dashboard/carousel)");
40
+ process.exit(1);
41
+ }
42
+
43
+ // ── Server API ───────────────────────────────────────────────────────────────
44
+ async function api(path, body) {
45
+ const res = await fetch(`${BASE_URL}/api/carousel/worker/${path}`, {
46
+ method: "POST",
47
+ headers: {
48
+ authorization: `Bearer ${TOKEN}`,
49
+ "content-type": "application/json",
50
+ },
51
+ body: body ? JSON.stringify(body) : undefined,
52
+ });
53
+ if (res.status === 204) return null;
54
+ if (res.status === 401) {
55
+ console.error("Worker token rejected — generate a new one at " + BASE_URL + "/dashboard/carousel");
56
+ process.exit(1);
57
+ }
58
+ if (!res.ok) throw new Error(`${path} failed: ${res.status} ${await res.text()}`);
59
+ return res.json();
60
+ }
61
+
62
+ // ── Drafting via local Claude Code ──────────────────────────────────────────
63
+ function readIfExists(p) {
64
+ return existsSync(p) ? readFileSync(p, "utf8") : "";
65
+ }
66
+
67
+ function voiceContext() {
68
+ if (!VOICE_DIR) return "";
69
+ const files = [
70
+ "data/voice/never-words.md",
71
+ "data/voice/non-negotiables.md",
72
+ "data/voice/voice.md",
73
+ "data/local/voice/never-words.md",
74
+ "data/local/voice/non-negotiables.md",
75
+ "data/local/voice/voice.md",
76
+ "data/local/voice/instagram/caption.md",
77
+ ];
78
+ const parts = files
79
+ .map((f) => readIfExists(join(VOICE_DIR, f)))
80
+ .filter(Boolean);
81
+ if (parts.length === 0) return "";
82
+ return `\n## Voice rules (follow strictly — later files win on conflict)\n\n${parts.join("\n\n---\n\n")}\n`;
83
+ }
84
+
85
+ function draftingPrompt(job, templates, photoFiles) {
86
+ const exemplar = readFileSync(join(ROOT, "carousels", "demo", "slides.yml"), "utf8");
87
+ return `You are drafting the slide copy for an Instagram carousel. Return ONLY a YAML document (no markdown fences, no commentary) in exactly this format:
88
+
89
+ ${exemplar}
90
+
91
+ ## Rules
92
+ - Exactly ${job.slideCount} slides total.
93
+ - Allowed "template" values (this style only): ${templates.join(", ")}.
94
+ - First slide should be a cover-style hook; last slide a closer/CTA if a cta template exists.
95
+ - Text markup: *word* renders in the accent color, _word_ renders italic. No other markup. No emoji. No em dashes. No ellipsis characters.
96
+ - Keep each slide's text short — it is display type, max ~12 words per slide, use line breaks (YAML |- blocks).
97
+ - Resource-style slides (template "resource") need: name, url (a real site — only include real, well-known URLs you are certain exist), and a short text description.
98
+ ${photoFiles.length > 0 ? `- The user attached photos, available as: ${photoFiles.join(", ")}. Use the "photo" template with photo: photos/<filename> for slides that should feature them.` : `- No photos were attached — do not use the "photo" template.`}
99
+ - Never invent statistics, follower counts, or claims. Plain honest copy.
100
+ ${voiceContext()}
101
+ ## The carousel to draft
102
+
103
+ ${job.prompt ?? "(no prompt — build the carousel around the attached photos)"}
104
+ `;
105
+ }
106
+
107
+ async function draftSlides(job, styleDir, photoFiles) {
108
+ const templates = readdirSync(join(styleDir, "templates"))
109
+ .filter((f) => f.endsWith(".html"))
110
+ .map((f) => f.replace(/\.html$/, ""));
111
+
112
+ const prompt = draftingPrompt(job, templates, photoFiles);
113
+
114
+ for (let attempt = 1; attempt <= 2; attempt++) {
115
+ const { stdout } = await execFileAsync(
116
+ "claude",
117
+ ["-p", prompt, "--output-format", "text"],
118
+ { maxBuffer: 10 * 1024 * 1024, timeout: 10 * 60 * 1000 },
119
+ );
120
+ const raw = stdout.replace(/^```(?:yaml)?\n?/m, "").replace(/\n?```\s*$/m, "").trim();
121
+ try {
122
+ const spec = yaml.load(raw);
123
+ const slides = spec?.slides;
124
+ if (!Array.isArray(slides) || slides.length === 0) throw new Error("no slides array");
125
+ for (const slide of slides) {
126
+ if (!templates.includes(slide.template)) throw new Error(`unknown template ${slide.template}`);
127
+ if (slide.photo && !photoFiles.includes(slide.photo.replace(/^photos\//, ""))) {
128
+ throw new Error(`unknown photo ${slide.photo}`);
129
+ }
130
+ }
131
+ return { spec: { style: job.styleSlug, slides }, raw };
132
+ } catch (e) {
133
+ console.error(`Draft attempt ${attempt} invalid: ${e.message}`);
134
+ if (attempt === 2) throw new Error(`Drafting failed: ${e.message}`);
135
+ }
136
+ }
137
+ }
138
+
139
+ // ── Job fulfillment ──────────────────────────────────────────────────────────
140
+ async function runJob(job) {
141
+ console.log(`▶ job ${job.id}: "${(job.prompt ?? "").slice(0, 60)}" [${job.styleSlug}]`);
142
+
143
+ const styleDir = join(ROOT, "styles", job.styleSlug);
144
+ if (!existsSync(styleDir)) throw new Error(`Style "${job.styleSlug}" isn't installed on this computer yet`);
145
+
146
+ const dir = join(ROOT, "carousels", `web-${job.id.slice(0, 8)}`);
147
+ mkdirSync(join(dir, "photos"), { recursive: true });
148
+
149
+ // Download attached photos.
150
+ const photoFiles = [];
151
+ for (const url of job.photoUrls ?? []) {
152
+ const res = await fetch(url);
153
+ if (!res.ok) throw new Error(`Photo download failed: ${url}`);
154
+ const name = decodeURIComponent(new URL(url).pathname.split("/").pop() ?? "photo.jpg");
155
+ const safe = name.replace(/[^\w.-]/g, "_");
156
+ writeFileSync(join(dir, "photos", safe), Buffer.from(await res.arrayBuffer()));
157
+ photoFiles.push(safe);
158
+ }
159
+
160
+ // Draft (Claude writes YAML to stdout only; we validate + write files).
161
+ const { spec } = await draftSlides(job, styleDir, photoFiles);
162
+ const slidesYml = yaml.dump(spec, { lineWidth: 100 });
163
+ writeFileSync(join(dir, "slides.yml"), slidesYml);
164
+
165
+ // Render.
166
+ const outDir = join(dir, "output");
167
+ await execFileAsync(
168
+ "node",
169
+ [join(ROOT, "src", "render.js"), dir, "--aspect", job.aspect, "--out", outDir],
170
+ { maxBuffer: 10 * 1024 * 1024, timeout: 15 * 60 * 1000 },
171
+ );
172
+
173
+ // Upload results (client-upload tokens are minted by the server per file,
174
+ // scoped to this job; the PNG bytes go straight to Blob storage).
175
+ const pngs = readdirSync(outDir).filter((f) => f.endsWith(".png")).sort();
176
+ const resultUrls = [];
177
+ for (const png of pngs) {
178
+ const blob = await upload(`carousel/results/${job.id}/${png}`, readFileSync(join(outDir, png)), {
179
+ access: "public",
180
+ handleUploadUrl: `${BASE_URL}/api/carousel/upload`,
181
+ clientPayload: JSON.stringify({ workerToken: TOKEN }),
182
+ contentType: "image/png",
183
+ });
184
+ resultUrls.push(blob.url);
185
+ }
186
+
187
+ await api("complete", { jobId: job.id, ok: true, slidesYml, resultUrls });
188
+ console.log(`✓ job ${job.id}: ${resultUrls.length} slides uploaded`);
189
+ }
190
+
191
+ // ── Main loop ────────────────────────────────────────────────────────────────
192
+ console.log(`Goose Tools worker connected to ${BASE_URL} — waiting for jobs (Ctrl+C to stop)`);
193
+ let firstPoll = true;
194
+
195
+ while (true) {
196
+ try {
197
+ const job = await api("claim");
198
+ if (firstPoll) {
199
+ console.log("✓ Checked in — the website should show this computer as Connected now.");
200
+ firstPoll = false;
201
+ }
202
+ if (job) {
203
+ try {
204
+ await runJob(job);
205
+ } catch (e) {
206
+ console.error(`✗ job ${job.id}: ${e.message}`);
207
+ await api("complete", { jobId: job.id, ok: false, error: e.message }).catch(() => {});
208
+ }
209
+ continue; // check for the next job immediately
210
+ }
211
+ } catch (e) {
212
+ console.error(`Poll failed (will retry): ${e.message}`);
213
+ }
214
+ await new Promise((r) => setTimeout(r, POLL_MS));
215
+ }