@aksp/opencrew 1.4.1 → 1.5.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +116 -0
  2. package/README.md +38 -17
  3. package/bin/opencrew.js +4 -4
  4. package/package.json +4 -3
  5. package/src/cli.js +93 -43
  6. package/src/commands/init.js +79 -53
  7. package/src/commands/update.js +44 -17
  8. package/src/lib/errors.js +12 -0
  9. package/src/lib/fsx.js +18 -9
  10. package/src/lib/ides.js +9 -34
  11. package/templates/AGENTS.md +42 -63
  12. package/templates/_opencrew/.opencrew-version +1 -1
  13. package/templates/_opencrew/core/best-practices/copywriting.md +4 -1
  14. package/templates/_opencrew/core/best-practices/image-design.md +5 -5
  15. package/templates/_opencrew/core/best-practices/instagram-feed.md +4 -4
  16. package/templates/_opencrew/core/best-practices/instagram-reels.md +1 -1
  17. package/templates/_opencrew/core/best-practices/review.md +7 -0
  18. package/templates/_opencrew/core/best-practices/social-networks-publishing.md +8 -8
  19. package/templates/_opencrew/core/prompts/build.prompt.md +25 -0
  20. package/templates/_opencrew/core/prompts/design.prompt.md +9 -5
  21. package/templates/_opencrew/core/runner.pipeline.md +56 -7
  22. package/templates/_opencrew/core/scripts/verificar/leitura.mjs +99 -0
  23. package/templates/_opencrew/core/scripts/verificar/regras.mjs +127 -0
  24. package/templates/_opencrew/core/scripts/verificar.mjs +118 -0
  25. package/templates/gitignore +3 -1
  26. package/templates/skills/image-ai-generator/SKILL.md +9 -5
  27. package/templates/skills/image-creator/SKILL.md +5 -3
  28. package/templates/skills/image-fetcher/SKILL.md +1 -1
  29. package/templates/skills/instagram-publisher/SKILL.md +34 -16
  30. package/templates/skills/instagram-publisher/scripts/publish.js +58 -27
  31. package/templates/skills/template-designer/SKILL.md +3 -3
  32. package/templates/skills/template-designer/base-templates/model-a.html +1 -1
  33. package/templates/skills/template-designer/base-templates/model-b.html +1 -1
  34. package/templates/skills/template-designer/base-templates/model-c.html +1 -1
@@ -14,7 +14,7 @@ type: script
14
14
  version: "1.0.0"
15
15
  script:
16
16
  path: scripts/generate.py
17
- runtime: python3
17
+ runtime: python
18
18
  invoke: "python3 {skill_path}/scripts/generate.py --prompt \"{prompt}\" --output \"{output}\" --mode \"{mode}\""
19
19
  env:
20
20
  - OPENROUTER_API_KEY
@@ -52,10 +52,14 @@ Use the Image Generator when you need to create visual assets from text prompts.
52
52
 
53
53
  ## Instructions
54
54
 
55
+ `{skill_path}` is this skill's folder (normally `skills/image-ai-generator`). The commands below use
56
+ `python3` (macOS/Linux). **On Windows** use `py -3` instead (or `python` if the `py` launcher is
57
+ not installed).
58
+
55
59
  ### Single image generation
56
60
 
57
61
  ```bash
58
- python3 skills/image-generator/scripts/generate.py \
62
+ python3 {skill_path}/scripts/generate.py \
59
63
  --prompt "A detailed description of the image to generate" \
60
64
  --output "crews/{crew}/output/{run_id}/assets/image-name.jpg" \
61
65
  --mode test
@@ -66,7 +70,7 @@ python3 skills/image-generator/scripts/generate.py \
66
70
  Use `--reference` to send a local image to the model as visual context. The model will incorporate the referenced image (e.g., a logo or mascot) into the generated output.
67
71
 
68
72
  ```bash
69
- python3 skills/image-generator/scripts/generate.py \
73
+ python3 {skill_path}/scripts/generate.py \
70
74
  --prompt "A social media banner featuring the company logo prominently in the center" \
71
75
  --output "crews/{crew}/output/{run_id}/assets/banner.jpg" \
72
76
  --reference "crews/{crew}/assets/logo.png" \
@@ -78,7 +82,7 @@ Supported reference formats: PNG, JPEG, WEBP, GIF.
78
82
  ### Batch generation
79
83
 
80
84
  ```bash
81
- python3 skills/image-generator/scripts/generate.py \
85
+ python3 {skill_path}/scripts/generate.py \
82
86
  --batch "crews/{crew}/output/{run_id}/assets/batch.json" \
83
87
  --mode production
84
88
  ```
@@ -96,7 +100,7 @@ Each item can optionally include a `"reference": "path/to/ref.png"` field.
96
100
  ### Prompt guidelines
97
101
 
98
102
  - Be specific about composition, lighting, style, and mood
99
- - Specify aspect ratio or orientation when relevant (e.g., "portrait 3:4", "landscape 16:9")
103
+ - Specify aspect ratio or orientation when relevant (e.g., "portrait 4:5", "landscape 16:9")
100
104
  - Include "hyper realistic, 4K quality" for photographic styles
101
105
  - Include "clean composition" to avoid cluttered outputs
102
106
  - Avoid requesting text in images — AI models struggle with text rendering
@@ -46,7 +46,8 @@ Use the Visual Renderer when you need to generate production-ready images from H
46
46
  4. **Render** -- Use Playwright to:
47
47
  - `browser_navigate` to `http://localhost:8765/slide-01.html` (filename only, not full path)
48
48
  - `browser_resize` to target viewport dimensions
49
- - `browser_take_screenshot` to save as PNG
49
+ - `browser_take_screenshot` to save as PNG — **except when the images will be published to
50
+ Instagram**: Instagram accepts JPEG only, so save with `type: "jpeg"` (`slide-01.jpg`)
50
51
 
51
52
  5. **Verify** -- Read the screenshot to confirm quality. Re-render if needed.
52
53
 
@@ -59,7 +60,7 @@ Use the Visual Renderer when you need to generate production-ready images from H
59
60
 
60
61
  Use these standard dimensions:
61
62
  - Instagram Post: 1080 x 1080
62
- - Instagram Carousel: 1080 x 1440
63
+ - Instagram Carousel: 1080 x 1350
63
64
  - Instagram Story/Reel: 1080 x 1920
64
65
  - Facebook Post: 1200 x 630
65
66
  - Twitter/X Post: 1200 x 675
@@ -85,7 +86,7 @@ Example minimal structure:
85
86
  <meta charset="UTF-8">
86
87
  <style>
87
88
  * { margin: 0; padding: 0; box-sizing: border-box; }
88
- body { width: 1080px; height: 1440px; overflow: hidden; }
89
+ body { width: 1080px; height: 1350px; overflow: hidden; }
89
90
  /* ... your design ... */
90
91
  </style>
91
92
  </head>
@@ -103,6 +104,7 @@ For multi-image outputs like carousels:
103
104
  3. Render each slide sequentially (step 4 repeated per slide)
104
105
  4. Stop the HTTP server **once** after all slides are done (step 6 of Core Workflow)
105
106
  5. Name output files with zero-padded numbers: slide-01.png, slide-02.png, slide-03.png
107
+ (`.jpg` when the destination is Instagram — see step 4)
106
108
  6. Keep all slides at the same viewport dimensions
107
109
 
108
110
  ### Best Practices
@@ -47,7 +47,7 @@ When taking a screenshot:
47
47
  1. Navigate to the URL with `browser_navigate`
48
48
  2. Set viewport: `browser_resize` with width/height for target format
49
49
  - Instagram post: 1080x1080
50
- - Instagram carousel: 1080x1440
50
+ - Instagram carousel: 1080x1350
51
51
  - Story/Reel: 1080x1920
52
52
  - Generic: 1280x720
53
53
  3. Wait for page load (`browser_wait_for` if needed)
@@ -20,7 +20,8 @@ version: "1.0.0"
20
20
  script:
21
21
  path: scripts/publish.js
22
22
  runtime: node
23
- invoke: "node --env-file=.env {skill_path}/scripts/publish.js --images \"{images}\" --caption \"{caption}\""
23
+ invoke: "node --env-file=.env {skill_path}/scripts/publish.js --images \"{images}\" --caption-file \"{caption_file}\""
24
+ side_effects: irreversible
24
25
  env:
25
26
  - INSTAGRAM_ACCESS_TOKEN
26
27
  - INSTAGRAM_USER_ID
@@ -39,36 +40,53 @@ Use the Instagram Publisher when you need to publish carousel posts directly to
39
40
 
40
41
  ### Workflow
41
42
 
42
- 1. List JPEG files in `crews/{crew}/output/images/` sorted by name.
43
- If no files found: stop and ask the user to add images before continuing.
44
- 2. Present the image list to the user to confirm order (use your IDE's native interactive-choice mechanism if it has one; otherwise a numbered list).
45
- 3. Extract the caption from the content draft:
46
- - Use the hook slide text + CTA slide text
47
- - Max 2200 characters (Instagram limit)
48
- 4. Run the publish script:
43
+ Publishing is **irreversible**: a post cannot be taken back once it is live. This step
44
+ runs only at the end of the pipeline, after the Review and the Final Approval checkpoint,
45
+ and is **never** retried automatically.
46
+
47
+ 1. Find the images produced by the rendering step **of this run** in
48
+ `crews/{crew}/output/{run_id}/` (use the latest `vN/` folder if there are versions),
49
+ JPEG only, sorted by name. If there are no `.jpg`/`.jpeg` files: stop and ask the user
50
+ (PNG renders must be re-rendered as JPEG — Instagram accepts JPEG only).
51
+ 2. Extract the caption from the approved content draft (hook slide text + CTA slide text,
52
+ max 2200 characters) and **write it to a file**:
53
+ `crews/{crew}/output/{run_id}/caption.txt`. Never put the caption inside a shell command.
54
+ 3. **Preview**: show the user the ordered image list and the full caption, and let them
55
+ confirm the order (use your IDE's native interactive-choice mechanism if it has one;
56
+ otherwise a numbered list).
57
+ 4. **Dry run** — validates images, credentials and containers without posting:
49
58
  ```
50
- node --env-file=.env crews/{crew}/tools/publish.js \
59
+ node --env-file=.env {skill_path}/scripts/publish.js \
51
60
  --images "<comma-separated-ordered-paths>" \
52
- --caption "<caption>"
61
+ --caption-file "crews/{crew}/output/{run_id}/caption.txt" \
62
+ --dry-run
53
63
  ```
54
- Add `--dry-run` to test the full flow without actually publishing.
55
- 5. On success: save the post URL and post ID to the step output file.
56
- 6. On failure: display the error and ask the user how to proceed.
64
+ If it fails: show the error and stop.
65
+ 5. **Ask for explicit confirmation** before going live: the user must answer with the
66
+ word **publish** (or **publicar**). Any other answer — including silence, "ok" or an
67
+ ambiguous reply — means do not publish.
68
+ 6. **Live publish** — the same command without `--dry-run`. Run it **once**.
69
+ 7. On success: save the post URL and post ID to the step output file immediately.
70
+ 8. On failure or missing output: do NOT run the command again. Tell the user the post may
71
+ already be live, ask them to check the Instagram profile, and let them decide.
57
72
 
58
73
  ### Constraints
59
74
 
60
- - Images: JPEG only, 2-10 per carousel
75
+ - Images: JPEG only (`.jpg`/`.jpeg`), 2-10 per carousel, inside `crews/*/output/` — the
76
+ script refuses anything else before uploading
77
+ - Images are hosted on imgBB for 24h only (enough for Instagram to fetch them)
61
78
  - Caption: max 2200 characters
62
79
  - Requires Instagram Business account (not Personal or Creator)
63
80
  - Rate limit: 25 API-published posts per 24 hours
64
81
 
65
82
  ### Setup (first-time)
66
83
 
67
- Copy `.env.example` to `.env` and fill in the two required variables:
84
+ Copy `.env.example` to `.env` and fill in the three required variables:
68
85
 
69
86
  ```
70
87
  INSTAGRAM_ACCESS_TOKEN=
71
88
  INSTAGRAM_USER_ID=
89
+ IMGBB_API_KEY=
72
90
  ```
73
91
 
74
92
  #### INSTAGRAM_ACCESS_TOKEN
@@ -115,5 +133,5 @@ Pré-requisito: conta Instagram Business conectada a uma Página do Facebook, e
115
133
 
116
134
  - **Publish Carousel** -- Upload images and publish a carousel post to Instagram
117
135
  - **Dry Run** -- Test the full publishing flow without actually posting (use `--dry-run` flag)
118
- - **Image Upload** -- Upload local JPEG images to imgBB (requires API key)
136
+ - **Image Upload** -- Upload the crew's JPEG renders to imgBB for 24h (requires API key)
119
137
  - **Status Check** -- Monitor media container processing status before publishing
@@ -1,20 +1,32 @@
1
1
  #!/usr/bin/env node
2
2
  // Instagram Carousel Publisher
3
- // Usage: node --env-file=.env publish.js --images "slide1.jpg,slide2.jpg" --caption "..." [--dry-run]
3
+ // Usage: node --env-file=.env publish.js --images "a.jpg,b.jpg" --caption-file caption.txt [--dry-run]
4
+ //
5
+ // Safety rules (specs/fase-1-hotfix.md F1-10):
6
+ // - the caption comes from a FILE, never from a shell-interpolated argument;
7
+ // - only .jpg/.jpeg files inside crews/*/output/ can be uploaded (public hosting);
8
+ // - imgBB uploads expire; the Graph API token goes in POST bodies, not URLs.
4
9
 
5
10
  import { readFileSync } from 'node:fs';
6
- import { resolve } from 'node:path';
11
+ import { readFile } from 'node:fs/promises';
12
+ import { resolve, relative, isAbsolute, sep, extname } from 'node:path';
7
13
  import { fileURLToPath } from 'node:url';
8
14
 
15
+ const IMGBB_EXPIRATION_SECONDS = 86_400; // Instagram fetches the images right away; 1 day is plenty.
16
+
9
17
  // ── Argument parsing ──────────────────────────────────────────
10
18
 
11
19
  export function parseArgs(argv) {
12
- const args = { images: [], caption: '', dryRun: false };
20
+ const args = { images: [], caption: '', captionFile: '', dryRun: false };
13
21
  for (let i = 2; i < argv.length; i++) {
14
22
  if (argv[i] === '--images') {
15
- if (i + 1 < argv.length) args.images = argv[++i].split(',').map(s => s.trim());
23
+ if (i + 1 < argv.length) args.images = argv[++i].split(',').map(s => s.trim()).filter(Boolean);
24
+ }
25
+ else if (argv[i] === '--caption-file') {
26
+ if (i + 1 < argv.length) args.captionFile = argv[++i];
16
27
  }
17
28
  else if (argv[i] === '--caption') {
29
+ // Legacy: prefer --caption-file (a caption in a shell command can execute code).
18
30
  if (i + 1 < argv.length) args.caption = argv[++i];
19
31
  }
20
32
  else if (argv[i] === '--dry-run') args.dryRun = true;
@@ -22,15 +34,32 @@ export function parseArgs(argv) {
22
34
  return args;
23
35
  }
24
36
 
37
+ export async function readCaption(args) {
38
+ return args.captionFile ? readFile(args.captionFile, 'utf8') : args.caption;
39
+ }
40
+
41
+ /** Throws unless every path is a .jpg/.jpeg inside <cwd>/crews/<crew>/output/. */
42
+ export function validateImagePaths(images, cwd = process.cwd()) {
43
+ const crewsDir = resolve(cwd, 'crews');
44
+ for (const img of images) {
45
+ const rel = relative(crewsDir, resolve(cwd, img));
46
+ const parts = rel.split(sep);
47
+ const inside = rel && !rel.startsWith('..') && !isAbsolute(rel) && parts.length >= 3 && parts[1] === 'output';
48
+ const jpeg = ['.jpg', '.jpeg'].includes(extname(img).toLowerCase());
49
+ if (!inside || !jpeg) {
50
+ throw new Error(`Refusing to upload ${img}: only .jpg/.jpeg files inside crews/*/output/ are allowed.`);
51
+ }
52
+ }
53
+ }
54
+
25
55
  // ── Image upload (imgBB) ──────────────────────────────────────
26
56
 
27
57
  export async function uploadToImgBB(imagePath, apiKey) {
28
- const absolutePath = resolve(imagePath);
29
- const fileBuffer = readFileSync(absolutePath);
30
- const base64Image = fileBuffer.toString('base64');
58
+ const base64Image = readFileSync(resolve(imagePath)).toString('base64');
31
59
  const form = new FormData();
32
60
  form.append('key', apiKey);
33
61
  form.append('image', base64Image);
62
+ form.append('expiration', String(IMGBB_EXPIRATION_SECONDS));
34
63
  const res = await fetch('https://api.imgbb.com/1/upload', {
35
64
  method: 'POST',
36
65
  body: form,
@@ -45,15 +74,19 @@ export async function uploadToImgBB(imagePath, apiKey) {
45
74
 
46
75
  const IG_BASE = 'https://graph.facebook.com/v21.0';
47
76
 
77
+ // POST with form-encoded body: keeps the access token out of URLs (and out of server/proxy logs).
78
+ async function igPost(path, params, label) {
79
+ const res = await fetch(`${IG_BASE}/${path}`, { method: 'POST', body: new URLSearchParams(params) });
80
+ if (!res.ok) throw new Error(`${label} failed [${res.status}]: ${await res.text()}`);
81
+ return (await res.json()).id;
82
+ }
83
+
48
84
  export async function createChildContainer(userId, imageUrl, accessToken) {
49
- const params = new URLSearchParams({
85
+ return igPost(`${userId}/media`, {
50
86
  image_url: imageUrl,
51
87
  is_carousel_item: 'true',
52
88
  access_token: accessToken,
53
- });
54
- const res = await fetch(`${IG_BASE}/${userId}/media?${params}`, { method: 'POST' });
55
- if (!res.ok) throw new Error(`createChildContainer failed [${res.status}]: ${await res.text()}`);
56
- return (await res.json()).id;
89
+ }, 'createChildContainer');
57
90
  }
58
91
 
59
92
  export async function getContainerStatus(containerId, accessToken) {
@@ -75,22 +108,16 @@ export async function pollUntilFinished(containerId, accessToken, timeoutMs = 60
75
108
  }
76
109
 
77
110
  export async function createCarouselContainer(userId, childIds, caption, accessToken) {
78
- const params = new URLSearchParams({
111
+ return igPost(`${userId}/media`, {
79
112
  media_type: 'CAROUSEL',
80
113
  children: childIds.join(','),
81
114
  caption,
82
115
  access_token: accessToken,
83
- });
84
- const res = await fetch(`${IG_BASE}/${userId}/media?${params}`, { method: 'POST' });
85
- if (!res.ok) throw new Error(`createCarouselContainer failed [${res.status}]: ${await res.text()}`);
86
- return (await res.json()).id;
116
+ }, 'createCarouselContainer');
87
117
  }
88
118
 
89
119
  export async function publishMedia(userId, containerId, accessToken) {
90
- const params = new URLSearchParams({ creation_id: containerId, access_token: accessToken });
91
- const res = await fetch(`${IG_BASE}/${userId}/media_publish?${params}`, { method: 'POST' });
92
- if (!res.ok) throw new Error(`publishMedia failed [${res.status}]: ${await res.text()}`);
93
- return (await res.json()).id;
120
+ return igPost(`${userId}/media_publish`, { creation_id: containerId, access_token: accessToken }, 'publishMedia');
94
121
  }
95
122
 
96
123
  export async function getPermalink(mediaId, accessToken) {
@@ -103,25 +130,29 @@ export async function getPermalink(mediaId, accessToken) {
103
130
 
104
131
  // ── Main ──────────────────────────────────────────────────────
105
132
 
106
- async function main() {
107
- const { images, caption, dryRun } = parseArgs(process.argv);
133
+ export async function main(argv = process.argv, { env = process.env, cwd = process.cwd() } = {}) {
134
+ const args = parseArgs(argv);
135
+ const { images, dryRun } = args;
108
136
 
109
137
  if (!images.length) throw new Error('--images is required (e.g. --images "slide1.jpg,slide2.jpg")');
110
- if (!caption) throw new Error('--caption is required');
111
138
  if (images.length < 2 || images.length > 10) {
112
139
  throw new Error(`Instagram carousels require 2–10 images (got ${images.length})`);
113
140
  }
141
+ validateImagePaths(images, cwd);
142
+
143
+ const caption = await readCaption(args);
144
+ if (!caption) throw new Error('--caption-file is required');
114
145
  if (caption.length > 2200) {
115
146
  throw new Error(`Caption exceeds Instagram's 2200-character limit (got ${caption.length})`);
116
147
  }
117
148
 
118
- const { INSTAGRAM_ACCESS_TOKEN, INSTAGRAM_USER_ID, IMGBB_API_KEY } = process.env;
149
+ const { INSTAGRAM_ACCESS_TOKEN, INSTAGRAM_USER_ID, IMGBB_API_KEY } = env;
119
150
  if (!INSTAGRAM_ACCESS_TOKEN) throw new Error('INSTAGRAM_ACCESS_TOKEN is not set in environment');
120
151
  if (!INSTAGRAM_USER_ID) throw new Error('INSTAGRAM_USER_ID is not set in environment');
121
152
  if (!IMGBB_API_KEY) throw new Error('IMGBB_API_KEY is not set in environment. Get one at https://api.imgbb.com/');
122
153
 
123
- console.log(`📸 Uploading ${images.length} image(s) to imgBB...`);
124
- const imageUrls = await Promise.all(images.map(p => uploadToImgBB(p, IMGBB_API_KEY)));
154
+ console.log(`📸 Uploading ${images.length} image(s) to imgBB (expire in 24h)...`);
155
+ const imageUrls = await Promise.all(images.map(p => uploadToImgBB(resolve(cwd, p), IMGBB_API_KEY)));
125
156
  imageUrls.forEach((url, i) => console.log(` [${i + 1}] ${url}`));
126
157
 
127
158
  console.log('\n📦 Creating Instagram media containers...');
@@ -46,7 +46,7 @@ Before generating any template, read and internalize the design best practices:
46
46
  - `_opencrew/core/best-practices/image-design.md` — **REQUIRED reading**. Contains platform-specific minimum font sizes, typography rules, spacing guidelines, color palette constraints, contrast requirements, and layout methodology. Every template you generate MUST comply with these rules.
47
47
 
48
48
  Key rules to always follow:
49
- - **Font sizes**: Hero 58px, Heading 43px, Body 34px, Caption 24px minimum for Instagram carousel (1080x1440). Absolute minimum 20px for any readable text on any platform.
49
+ - **Font sizes**: Hero 58px, Heading 43px, Body 34px, Caption 24px minimum for Instagram carousel (1080x1350). Absolute minimum 20px for any readable text on any platform.
50
50
  - **Font weight**: 500 or higher for body text and above.
51
51
  - **Colors**: Maximum 5 colors per design system (primary, secondary, accent, background, text).
52
52
  - **Contrast**: WCAG AA minimum 4.5:1 for all text against background.
@@ -61,7 +61,7 @@ You should also apply general web design best practices: proper white space, vis
61
61
  These rules are NON-NEGOTIABLE. Every template must comply:
62
62
 
63
63
  **Fixed Dimensions (never use height: auto or flexible height):**
64
- - Instagram Carousel: `width: 1080px; height: 1440px` (3:4 portrait)
64
+ - Instagram Carousel: `width: 1080px; height: 1350px` (4:5 portrait)
65
65
  - Instagram Story/Reel: `width: 1080px; height: 1920px` (9:16 portrait)
66
66
  - Instagram Post: `width: 1080px; height: 1080px` (1:1 square)
67
67
  - LinkedIn Post: `width: 1200px; height: 627px` (1.91:1 horizontal)
@@ -155,7 +155,7 @@ When the user approves, create two files:
155
155
 
156
156
  Save to: `crews/{code}/pipeline/data/template-reference.html`
157
157
 
158
- The complete, self-contained HTML/CSS of the approved template at full resolution (e.g., 1080x1440). This is the literal example the design agent will use.
158
+ The complete, self-contained HTML/CSS of the approved template at full resolution (e.g., 1080x1350). This is the literal example the design agent will use.
159
159
 
160
160
  ### 2. Visual Identity Rules
161
161
 
@@ -2,7 +2,7 @@
2
2
  <!-- Style: Black background, tweet-style layout with avatar, verified badge, editorial text, contextual image -->
3
3
  <!-- Fonts: Inter (body), weight 400-700 -->
4
4
  <!-- Colors: #000 bg, #fff text, #1D9BF0 verified, #71767B muted, #2a2a2a borders/avatar, #1a1a2e image gradient -->
5
- <div style="width:1080px;height:1440px;background:#000;color:#fff;font-family:'Inter',sans-serif;display:flex;flex-direction:column;padding:72px;gap:0;position:relative;overflow:hidden;">
5
+ <div style="width:1080px;height:1350px;background:#000;color:#fff;font-family:'Inter',sans-serif;display:flex;flex-direction:column;padding:72px;gap:0;position:relative;overflow:hidden;">
6
6
  <!-- Tweet header -->
7
7
  <div style="display:flex;gap:24px;align-items:center;">
8
8
  <div style="width:80px;height:80px;border-radius:50%;background:#2a2a2a;flex-shrink:0;"></div>
@@ -2,7 +2,7 @@
2
2
  <!-- Style: Warm cream background, bold uppercase title, numbered cards with warm gradient progression -->
3
3
  <!-- Fonts: Montserrat 900 (title), Inter 500-700 (body) -->
4
4
  <!-- Colors: #FDF6EE bg, #E85D2A primary orange, #D4742A/#C05A1A/#A04010 gradient steps, #FFF5E6-#D07030 card backgrounds -->
5
- <div style="width:1080px;height:1440px;background:#FDF6EE;color:#111;font-family:'Inter',sans-serif;display:flex;flex-direction:column;padding:72px;justify-content:center;gap:48px;position:relative;overflow:hidden;">
5
+ <div style="width:1080px;height:1350px;background:#FDF6EE;color:#111;font-family:'Inter',sans-serif;display:flex;flex-direction:column;padding:72px;justify-content:center;gap:48px;position:relative;overflow:hidden;">
6
6
  <!-- Title -->
7
7
  <div style="text-align:center;">
8
8
  <h1 style="font-size:58px;font-weight:900;line-height:1.15;margin:0;text-transform:uppercase;font-family:'Montserrat',sans-serif;">
@@ -2,7 +2,7 @@
2
2
  <!-- Style: Purple-dark gradient, data metric cards, decorative glow effects -->
3
3
  <!-- Fonts: Montserrat 900 (title, metrics), Inter 500 (body, labels) -->
4
4
  <!-- Colors: gradient #0f0326-#1a0a3e-#0d0d0d bg, #F59E0B yellow, #EC4899 pink, #7C3AED purple, #4ADE80 green accents -->
5
- <div style="width:1080px;height:1440px;background:linear-gradient(160deg,#0f0326,#1a0a3e 40%,#0d0d0d);color:#fff;font-family:'Inter',sans-serif;display:flex;flex-direction:column;padding:72px;position:relative;overflow:hidden;">
5
+ <div style="width:1080px;height:1350px;background:linear-gradient(160deg,#0f0326,#1a0a3e 40%,#0d0d0d);color:#fff;font-family:'Inter',sans-serif;display:flex;flex-direction:column;padding:72px;position:relative;overflow:hidden;">
6
6
  <!-- Glow effects -->
7
7
  <div style="position:absolute;top:-120px;right:-120px;width:480px;height:480px;background:radial-gradient(circle,#7C3AED33,transparent 70%);pointer-events:none;"></div>
8
8
  <div style="position:absolute;bottom:-100px;left:-100px;width:400px;height:400px;background:radial-gradient(circle,#F59E0B22,transparent 70%);pointer-events:none;"></div>