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 +75 -0
- package/brand.yml +39 -0
- package/carousels/demo/slides.yml +24 -0
- package/package.json +31 -0
- package/src/render.js +184 -0
- package/styles/graph-paper/style.yml +26 -0
- package/styles/graph-paper/templates/blue.html +41 -0
- package/styles/graph-paper/templates/cover.html +43 -0
- package/styles/graph-paper/templates/cta.html +49 -0
- package/styles/graph-paper/templates/light.html +33 -0
- package/styles/graph-paper/templates/photo.html +46 -0
- package/styles/graph-paper/templates/resource.html +67 -0
- package/styles/graph-paper/templates/text.html +32 -0
- package/styles/polka-dot/style.yml +14 -0
- package/styles/polka-dot/templates/blue.html +41 -0
- package/styles/polka-dot/templates/cover.html +43 -0
- package/styles/polka-dot/templates/cta.html +49 -0
- package/styles/polka-dot/templates/light.html +33 -0
- package/styles/polka-dot/templates/photo.html +46 -0
- package/styles/polka-dot/templates/resource.html +67 -0
- package/styles/polka-dot/templates/text.html +32 -0
- package/worker/cli.js +47 -0
- package/worker/index.js +215 -0
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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");
|
package/worker/index.js
ADDED
|
@@ -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
|
+
}
|