umpordez 1.2.1 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/.claude/settings.local.json +3 -1
  2. package/README.md +28 -1
  3. package/cli.mjs +4 -2
  4. package/create.mjs +72 -6
  5. package/package.json +1 -1
  6. package/template/CLAUDE.md +1 -1
  7. package/template/misc/build-local.sh +82 -0
  8. package/template/mobile/{{PROJECT_SLUG}}/App.tsx +5 -2
  9. package/template/mobile/{{PROJECT_SLUG}}/CHANGELOG.md +28 -0
  10. package/template/mobile/{{PROJECT_SLUG}}/CLAUDE.md +10 -1
  11. package/template/mobile/{{PROJECT_SLUG}}/docs/releases.md +78 -0
  12. package/template/mobile/{{PROJECT_SLUG}}/docs/screenshots.md +84 -0
  13. package/template/mobile/{{PROJECT_SLUG}}/docs/store-copy.md +122 -0
  14. package/template/mobile/{{PROJECT_SLUG}}/docs/store-listing.md +107 -0
  15. package/template/mobile/{{PROJECT_SLUG}}/src/config.ts +6 -0
  16. package/template/scripts/README.md +18 -1
  17. package/template/scripts/appstore-resize.sh +70 -0
  18. package/template/scripts/frame-shots.mjs +246 -0
  19. package/template/scripts/gen-mockups.mjs +120 -0
  20. package/template/scripts/screenshots-capture.sh +50 -0
  21. package/template/scripts/screenshots.flow.yaml +54 -0
  22. package/template/ui/site/_variants/app/en.ts +131 -0
  23. package/template/ui/site/_variants/app/footer.ejs +46 -0
  24. package/template/ui/site/_variants/app/header.ejs +102 -0
  25. package/template/ui/site/_variants/app/home.ejs +237 -0
  26. package/template/ui/site/_variants/app/not-found.ejs +22 -0
  27. package/template/ui/site/_variants/app/pt-BR.ts +203 -0
  28. package/template/ui/site/tailwind.config.js +9 -1
  29. package/template-variables.mjs +34 -0
  30. package/update.mjs +30 -1
@@ -0,0 +1,122 @@
1
+ # {{PROJECT_NAME}} — Store Copy (pt-BR · en)
2
+
3
+ > **This is the single canonical source for ship copy** — app name,
4
+ > subtitle, promotional text, keywords, short description and full
5
+ > descriptions (App Store + Google Play, per locale). Paste straight from
6
+ > here into App Store Connect / Google Play Console.
7
+ >
8
+ > Pricing, category, compliance reasoning and the publishing checklist
9
+ > live in [`store-listing.md`](./store-listing.md); release / "What's New"
10
+ > copy lives in [`releases.md`](./releases.md). This file is just the
11
+ > descriptive text.
12
+
13
+ **Positioning.** _One or two sentences on what {{PROJECT_NAME}} is and the
14
+ promise a user feels immediately. Lead with that promise in every field
15
+ below._
16
+
17
+ **House style**: never a hyphen as a separator; use `;` `,` `.` instead.
18
+ Write each store's description separately — never reuse one verbatim, since
19
+ "App Store" reads wrong on Play and vice-versa.
20
+
21
+ > ⚠️ **Confirm before submit**: support email and privacy-policy URL are
22
+ > live (e.g. `contato@{{DOMAIN}}`, `https://{{DOMAIN}}/privacidade` and
23
+ > `/privacy`).
24
+
25
+ ---
26
+
27
+ ## Format reference
28
+
29
+ ### App Store Connect (per locale)
30
+ | Field | Limit | Editable post-submit? |
31
+ |---|---|---|
32
+ | App Name | 30 chars | needs new binary |
33
+ | Subtitle | 30 chars | needs new binary |
34
+ | Promotional Text | 170 chars | **yes** — change anytime |
35
+ | Description | 4000 chars | needs new binary |
36
+ | Keywords | 100 chars (CSV, no spaces) | needs new binary |
37
+ | What's New | 4000 chars | per release (see releases.md) |
38
+
39
+ ### Google Play Console (per locale)
40
+ | Field | Limit |
41
+ |---|---|
42
+ | App name | 30 chars |
43
+ | Short description | 80 chars |
44
+ | Full description | 4000 chars |
45
+
46
+ ---
47
+
48
+ # 🇧🇷 pt-BR — Português (Brasil)
49
+
50
+ **App name** (≤30)
51
+ ```
52
+ {{PROJECT_NAME}}
53
+ ```
54
+
55
+ **Subtitle — App Store** (≤30)
56
+ ```
57
+
58
+ ```
59
+
60
+ **Promotional Text — App Store** (≤170)
61
+ ```
62
+
63
+ ```
64
+
65
+ **Keywords — App Store** (≤100, CSV no spaces)
66
+ ```
67
+
68
+ ```
69
+
70
+ **Short description — Google Play** (≤80)
71
+ ```
72
+
73
+ ```
74
+
75
+ ### Full description — App Store (iOS)
76
+ ```
77
+
78
+ ```
79
+
80
+ ### Full description — Google Play (Android, keyword-rich)
81
+ ```
82
+
83
+ ```
84
+
85
+ ---
86
+
87
+ # 🇺🇸 en — English
88
+
89
+ **App name** (≤30)
90
+ ```
91
+ {{PROJECT_NAME}}
92
+ ```
93
+
94
+ **Subtitle — App Store** (≤30)
95
+ ```
96
+
97
+ ```
98
+
99
+ **Promotional Text — App Store** (≤170)
100
+ ```
101
+
102
+ ```
103
+
104
+ **Keywords — App Store** (≤100, CSV no spaces)
105
+ ```
106
+
107
+ ```
108
+
109
+ **Short description — Google Play** (≤80)
110
+ ```
111
+
112
+ ```
113
+
114
+ ### Full description — App Store (iOS)
115
+ ```
116
+
117
+ ```
118
+
119
+ ### Full description — Google Play (Android, keyword-rich)
120
+ ```
121
+
122
+ ```
@@ -0,0 +1,107 @@
1
+ # Store listing — {{PROJECT_NAME}}
2
+
3
+ Everything needed to publish {{PROJECT_NAME}} on both stores: the pricing
4
+ model, the category/compliance reasoning, the ASO strategy and the
5
+ publishing checklist.
6
+
7
+ > **The actual ship copy** (app name, subtitle, promotional text,
8
+ > keywords, short description and full descriptions per locale) lives in
9
+ > [`store-copy.md`](./store-copy.md). Release / "What's New" copy lives in
10
+ > [`releases.md`](./releases.md). Paste straight from there into App Store
11
+ > Connect / Google Play Console.
12
+
13
+ House style for the marketing copy: never a hyphen as a separator; use
14
+ `;` `,` `.` instead.
15
+
16
+ ---
17
+
18
+ ## Pricing
19
+
20
+ Pick the model before you write the copy — it decides what trust signals
21
+ lead the listing.
22
+
23
+ | Model | What it unlocks | Notes |
24
+ |---|---|---|
25
+ | Free | Everything, no purchase | Lead with "free, no account, no ads". |
26
+ | Paid once | The whole app, one payment | Lowest paid bracket = impulse buy. Lead with "pay once, yours forever". |
27
+ | Freemium (IAP) | Free core + Premium unlock | Product IDs must match `src/services/iap/index.ts` exactly. |
28
+
29
+ If you use IAP, declare the product IDs here and keep them in lockstep
30
+ with the app:
31
+
32
+ ```
33
+ {{PROJECT_SLUG}}_premium_monthly
34
+ {{PROJECT_SLUG}}_premium_annual
35
+ ```
36
+
37
+ The app is anonymous-first: install creates a device account; a purchase
38
+ ties Premium to the user's Apple/Google account at buy time.
39
+
40
+ ---
41
+
42
+ ## Category
43
+
44
+ - **Apple App Store:** primary category — pick where comparable apps rank.
45
+ - **Google Play:** primary category + up to 5 tags from Google's curated
46
+ list.
47
+
48
+ ### Compliance note
49
+
50
+ Keep metadata at the **widest age rating you can**. Age gates and
51
+ sensitive-topic keywords (drugs, gambling, mature themes) push the rating
52
+ up on Apple and trip Google Play's IARC questionnaire toward Mature —
53
+ both trim country availability. If the app content is neutral, keep the
54
+ listing neutral and stay at 4+ / Everyone for the widest reach.
55
+
56
+ ---
57
+
58
+ ## ASO strategy
59
+
60
+ Field limits (hard caps):
61
+
62
+ | Field | Apple App Store | Google Play |
63
+ |---|---|---|
64
+ | Title | 30 | 30 |
65
+ | Subtitle | 30 | — |
66
+ | Short description | — | 80 |
67
+ | Promotional text | 170 | — |
68
+ | Keywords | 100 (commas, no spaces, single words) | — |
69
+ | Full description | 4000 (not indexed) | 4000 (indexed) |
70
+
71
+ Principles:
72
+
73
+ - **Front-load the title.** Apple weights the first word most; brand
74
+ first, then the strongest keyword phrase (e.g. `{{PROJECT_NAME}}: <keyword>`).
75
+ - **Apple keywords never repeat** title/subtitle words (repeats add no
76
+ ranking weight); single words, comma-separated, no spaces.
77
+ - **Play indexes the full description**; Apple does not. Weave keywords
78
+ naturally into the Play description; keep the Apple description pure
79
+ conversion.
80
+ - **Lead with trust**: no ads, no tracking, no account (whichever apply),
81
+ works offline. State the price model plainly.
82
+
83
+ ---
84
+
85
+ ## Publishing checklist (store console clickwork)
86
+
87
+ Neither store exposes paid pricing or metadata via API, so these are
88
+ manual:
89
+
90
+ - ⬜ Create the App Store Connect record; accept Apple Developer terms
91
+ - ⬜ Create the Play Console app record
92
+ - ⬜ Set the price / free (both stores)
93
+ - ⬜ (If IAP) create the subscription/products with the exact IDs above
94
+ - ⬜ Paste the metadata for each language from [`store-copy.md`](./store-copy.md)
95
+ - ⬜ Set the category on both stores
96
+ - ⬜ Upload icon + screenshots (see [`screenshots.md`](./screenshots.md))
97
+ - ⬜ Privacy policy URL live (e.g. `https://{{DOMAIN}}/privacidade`)
98
+ - ⬜ Data safety / privacy nutrition label filled in honestly
99
+ - ⬜ First build submitted via `../../scripts/release-android.sh` (Play)
100
+ and EAS / Xcode (App Store)
101
+
102
+ Bundle IDs (must match `app.json`):
103
+
104
+ ```
105
+ iOS {{IOS_BUNDLE_ID}}
106
+ Android {{ANDROID_PACKAGE}}
107
+ ```
@@ -41,6 +41,12 @@ const APP_VERSION = '0.1.0';
41
41
  const GOOGLE_CLIENT_ID = 'TODO_GOOGLE_WEB_CLIENT_ID';
42
42
  const GOOGLE_IOS_CLIENT_ID = 'TODO_GOOGLE_IOS_CLIENT_ID';
43
43
 
44
+ // Splash behavior. `false` (instant show-up) hides the native splash the
45
+ // moment fonts load — the app feels immediate, no decorative delay.
46
+ // `true` plays the AnimatedSplash (glow + logo + pulse) before the app
47
+ // appears. Set at scaffold time via `umpordez create`.
48
+ export const SPLASH_ANIMATED = {{SPLASH_ANIMATED}};
49
+
44
50
  if (__DEV__) {
45
51
  console.log(
46
52
  `[config] API: ${baseApiUrl} | prod=${isProdBuild} `
@@ -16,15 +16,32 @@ you find every command in one place.
16
16
  | `generate-icons.sh` | (Re)generate the icon/splash asset set. From-scratch (uses primary color + first letter) or from a source PNG. |
17
17
  | `extract-play-key.js` | Pull `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` out of `server/.env` and write it to `mobile/<slug>/google-play-key.json` for `eas submit`. Called automatically by `release-android.sh`. |
18
18
 
19
+ ## Store screenshots
20
+
21
+ | Script | What it does |
22
+ |---|---|
23
+ | `gen-mockups.mjs` | Render headless store-screen mockups (branded, no simulator) → `mobile/<slug>/screenshots/raw[/-tablet]`. Starting point — edit `SCREENS` + `LABELS` to match your app. |
24
+ | `screenshots-capture.sh` | Drive the Maestro flow on a booted sim/emulator for both locales → raw PNGs. `phone` or `tablet`. Tune labels to your nav. |
25
+ | `screenshots.flow.yaml` | The Maestro flow (reset → onboarding → language → walk tabs → capture). |
26
+ | `frame-shots.mjs` | Frame raw PNGs into branded, captioned, store-ready sizes (iPhone/Android/iPad/tablet). `--selftest`, `--preset=`, `--locale=`. |
27
+ | `appstore-resize.sh` | Re-encode iPhone PNG screenshots to the exact App Store Connect JPEG sizes (6.5/6.7/6.9in), stripping alpha. |
28
+
29
+ See [`../mobile/<slug>/docs/screenshots.md`](../mobile) for the full
30
+ capture → frame → upload workflow, and `docs/store-listing.md` /
31
+ `store-copy.md` / `releases.md` for the listing + release copy.
32
+
19
33
  ## Server / web stack
20
34
 
21
35
  These live at the project root (one level up from this dir):
22
36
 
37
+ These live at the project root (one level up from this dir):
38
+
23
39
  | Script | What it does |
24
40
  |---|---|
25
41
  | `install.sh` | Install npm deps for every sub-project. Pass `--native` to also run `expo prebuild`, `pod install`, and `install-native.sh` for mobile. |
26
42
  | `seed.sh` | Create the database, run migrations, seed admin user. |
27
- | `dev.sh` | Start every service that exists (server, admin UI, site UI). Pass `--mobile` to also start Expo Metro. |
43
+ | `dev.sh` | Start every service that exists (server, admin UI, site UI). Mobile Metro starts by default; pass `--no-mobile` to skip it. |
44
+ | `misc/build-local.sh` | Production build of server + admin SPA + site into `../builds/` with a build number. `--push` to commit + push the builds repo. |
28
45
 
29
46
  ## Conventions
30
47
 
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Resize iPhone screenshots to App Store Connect sizes.
4
+ #
5
+ # Usage:
6
+ # ./appstore-resize.sh <file1.png> [file2.png ...]
7
+ # ./appstore-resize.sh *.png
8
+ #
9
+ # Outputs each input as three new files in the same folder:
10
+ # <name>-6.5in.jpg 1242 x 2688 (iPhone XS Max / 11 Pro Max)
11
+ # <name>-6.7in.jpg 1290 x 2796 (iPhone 14 Pro Max class)
12
+ # <name>-6.9in.jpg 1320 x 2868 (iPhone 16 Pro Max)
13
+ #
14
+ # Why JPEG? macOS screenshots are PNG with an alpha channel.
15
+ # App Store Connect rejects PNGs that carry alpha — and reports
16
+ # the failure as "dimensions are wrong", which is misleading.
17
+ # JPEG has no alpha by definition, so re-encoding sidesteps the
18
+ # whole issue. Quality at formatOptions=best is visually lossless
19
+ # for typical UI screenshots.
20
+
21
+ set -euo pipefail
22
+
23
+ if [ $# -eq 0 ]; then
24
+ echo "usage: $0 <screenshot.png> [more.png ...]"
25
+ exit 1
26
+ fi
27
+
28
+ # size_label width height
29
+ SIZES=(
30
+ "6.5in 1242 2688"
31
+ "6.7in 1290 2796"
32
+ "6.9in 1320 2868"
33
+ )
34
+
35
+ for src in "$@"; do
36
+ if [ ! -f "$src" ]; then
37
+ echo "skip: $src (not found)"
38
+ continue
39
+ fi
40
+
41
+ dir=$(dirname "$src")
42
+ base=$(basename "$src")
43
+ name="${base%.*}"
44
+
45
+ w=$(sips -g pixelWidth "$src" | awk '/pixelWidth/ {print $2}')
46
+ h=$(sips -g pixelHeight "$src" | awk '/pixelHeight/ {print $2}')
47
+ if [ -z "$w" ] || [ -z "$h" ]; then
48
+ echo "skip: $src (couldn't read dimensions)"
49
+ continue
50
+ fi
51
+ ratio=$(awk -v a="$w" -v b="$h" 'BEGIN { printf "%.4f", a/b }')
52
+ echo "→ $base ${w}x${h} ratio=$ratio"
53
+
54
+ for entry in "${SIZES[@]}"; do
55
+ read -r label tw th <<< "$entry"
56
+ out="$dir/$name-$label.jpg"
57
+ # One-shot: convert to JPEG at max quality AND resize.
58
+ # `-s format jpeg` drops the alpha channel; --resampleHeightWidth
59
+ # is height-then-width per sips' argument order.
60
+ sips \
61
+ -s format jpeg \
62
+ -s formatOptions best \
63
+ --resampleHeightWidth "$th" "$tw" \
64
+ "$src" \
65
+ --out "$out" > /dev/null
66
+ echo " wrote $out (${tw}x${th})"
67
+ done
68
+ done
69
+
70
+ echo "done."
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * frame-shots — turn raw app screenshots into branded, captioned,
4
+ * store-ready images at the exact sizes Apple / Google require.
5
+ *
6
+ * No design tools: each raw capture is composited into an SVG (brand
7
+ * gradient background + caption + the screenshot with rounded corners
8
+ * and a soft shadow) and rendered to PNG with @resvg/resvg-js.
9
+ * Deterministic and re-runnable.
10
+ *
11
+ * Raw screens come from either:
12
+ * • scripts/gen-mockups.mjs (headless, no simulator), or
13
+ * • Maestro capture (scripts/screenshots-capture.sh)
14
+ * both write to mobile/{{PROJECT_SLUG}}/screenshots/raw[/-tablet]/<locale>/.
15
+ *
16
+ * Usage:
17
+ * node scripts/frame-shots.mjs # all presets, all locales
18
+ * node scripts/frame-shots.mjs --preset=iphone
19
+ * node scripts/frame-shots.mjs --locale=en
20
+ * node scripts/frame-shots.mjs --selftest # render a demo frame
21
+ *
22
+ * Numeric file prefixes (00-*, 01-*, …) drive order + caption lookup.
23
+ * Edit CAPTIONS below to mirror docs/store-copy.md.
24
+ */
25
+
26
+ import {
27
+ readFileSync, writeFileSync, mkdirSync, readdirSync, existsSync
28
+ } from 'node:fs';
29
+ import { dirname, join, basename } from 'node:path';
30
+ import { fileURLToPath } from 'node:url';
31
+ import { createRequire } from 'node:module';
32
+
33
+ const REPO = join(dirname(fileURLToPath(import.meta.url)), '..');
34
+ const APP = join(REPO, 'mobile', '{{PROJECT_SLUG}}');
35
+ const require = createRequire(join(APP, 'package.json'));
36
+ const { Resvg } = require('@resvg/resvg-js');
37
+
38
+ const SHOTS = join(APP, 'screenshots');
39
+ // Optional bundled brand fonts — drop .ttf files in assets/fonts to use
40
+ // them. Without any, resvg falls back to a system sans-serif.
41
+ const FONTS = ['Display.ttf', 'Body.ttf']
42
+ .map(f => join(APP, 'assets', 'fonts', f))
43
+ .filter(existsSync);
44
+ const FONT_DISPLAY = 'sans-serif';
45
+ const FONT_BODY = 'sans-serif';
46
+
47
+ // ── Brand gradient (from the project's primary color) ───────────────
48
+ const BRAND_TOP = '{{PRIMARY_COLOR_DARK}}'; // lifted primary (lighter)
49
+ const BRAND_BOT = '{{PRIMARY_COLOR}}'; // primary
50
+ const CAPTION_COLOR = '#FFFFFF';
51
+
52
+ // ── Store size presets ──────────────────────────────────────────────
53
+ const PRESETS = {
54
+ // Apple App Store — iPhone 6.9" (1290×2796) / 6.5" accepted.
55
+ iphone: { source: 'phone', w: 1290, h: 2796, radius: 62, capSize: 80, capTop: 150, sideMargin: 130, gap: 66 },
56
+ // Google Play phone (9:19.5).
57
+ android: { source: 'phone', w: 1080, h: 2340, radius: 52, capSize: 66, capTop: 120, sideMargin: 120, gap: 56 },
58
+ // Apple 13" iPad — required when supportsTablet=true.
59
+ ipad: { source: 'tablet', w: 2048, h: 2732, radius: 44, capSize: 92, capTop: 170, sideMargin: 260, gap: 90 },
60
+ // Google Play tablet (portrait 16:10).
61
+ androidtablet: { source: 'tablet', w: 1600, h: 2560, radius: 40, capSize: 74, capTop: 150, sideMargin: 200, gap: 74 },
62
+ };
63
+
64
+ // ── Captions: headline + subtitle per shot ──────────────────────────
65
+ // Keyed by the numeric filename prefix. Mirror docs/store-copy.md; the
66
+ // first + last carry the strongest download pitch (most visitors only
67
+ // see the first 2–3 frames). Add keys as you add screens.
68
+ const CAPTIONS = {
69
+ '00': {
70
+ 'pt-BR': { h: 'A tela inicial', s: 'Tudo o que importa, à mão' },
71
+ en: { h: 'Your home screen', s: 'Everything that matters, at hand' },
72
+ },
73
+ '01': {
74
+ 'pt-BR': { h: 'Explore e encontre', s: 'Busque em um instante' },
75
+ en: { h: 'Explore and find', s: 'Search in an instant' },
76
+ },
77
+ '02': {
78
+ 'pt-BR': { h: 'Sua biblioteca', s: 'Organizado do seu jeito' },
79
+ en: { h: 'Your library', s: 'Organized your way' },
80
+ },
81
+ '03': {
82
+ 'pt-BR': { h: 'Feito para você', s: 'Local, privado, sem anúncios' },
83
+ en: { h: 'Made for you', s: 'Local, private, no ads' },
84
+ },
85
+ };
86
+
87
+ // ── Helpers ─────────────────────────────────────────────────────────
88
+ function pngSize(buf) {
89
+ return { w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) };
90
+ }
91
+
92
+ function wrap(text, maxChars, maxLines = 2) {
93
+ const words = text.split(' ');
94
+ const lines = [];
95
+ let cur = '';
96
+ for (const word of words) {
97
+ const next = cur ? `${cur} ${word}` : word;
98
+ if (next.length > maxChars && cur) {
99
+ lines.push(cur);
100
+ cur = word;
101
+ } else {
102
+ cur = next;
103
+ }
104
+ }
105
+ if (cur) {
106
+ lines.push(cur);
107
+ }
108
+ return lines.slice(0, maxLines);
109
+ }
110
+
111
+ function esc(s) {
112
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
113
+ }
114
+
115
+ function buildSvg(preset, caption, imgDataUri, imgW, imgH) {
116
+ const { w, h, radius, capSize, capTop, sideMargin, gap } = preset;
117
+ const head = caption?.h ?? '';
118
+ const sub = caption?.s ?? '';
119
+
120
+ const maxImgW = w - sideMargin * 2;
121
+ const maxTextW = w - Math.round(sideMargin * 0.7) * 2;
122
+ const headLines = wrap(head, Math.floor(maxTextW / (capSize * 0.54)));
123
+ const subSize = Math.round(capSize * 0.5);
124
+ const subLines = sub ? wrap(sub, Math.floor(maxTextW / (subSize * 0.52))) : [];
125
+ const headLineH = capSize * 1.16;
126
+ const subLineH = subSize * 1.4;
127
+ const headBottom = capTop + capSize + (headLines.length - 1) * headLineH;
128
+ const subTop = headBottom + (subLines.length ? subSize * 1.7 : 0);
129
+ const capBlock = subLines.length
130
+ ? subTop + (subLines.length - 1) * subLineH + subSize * 0.4
131
+ : headBottom + capSize * 0.2;
132
+ const availH = h - capBlock - gap - Math.round(h * 0.03);
133
+
134
+ let drawW = maxImgW;
135
+ let drawH = (drawW * imgH) / imgW;
136
+ if (drawH > availH) {
137
+ drawH = availH;
138
+ drawW = (drawH * imgW) / imgH;
139
+ }
140
+ const drawX = (w - drawW) / 2;
141
+ const drawY = capBlock + gap;
142
+
143
+ const caps = headLines.map((line, i) =>
144
+ `<text x="${w / 2}" y="${capTop + capSize + i * headLineH}" text-anchor="middle" ` +
145
+ `font-family="${FONT_DISPLAY}" font-weight="700" font-size="${capSize}" fill="${CAPTION_COLOR}">${esc(line)}</text>`
146
+ ).concat(subLines.map((line, i) =>
147
+ `<text x="${w / 2}" y="${subTop + i * subLineH}" text-anchor="middle" ` +
148
+ `font-family="${FONT_BODY}" font-weight="600" font-size="${subSize}" fill="${CAPTION_COLOR}" fill-opacity="0.82">${esc(line)}</text>`
149
+ )).join('\n');
150
+
151
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
152
+ <defs>
153
+ <linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
154
+ <stop offset="0" stop-color="${BRAND_TOP}"/>
155
+ <stop offset="1" stop-color="${BRAND_BOT}"/>
156
+ </linearGradient>
157
+ <clipPath id="round"><rect x="${drawX}" y="${drawY}" width="${drawW}" height="${drawH}" rx="${radius}" ry="${radius}"/></clipPath>
158
+ <filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
159
+ <feDropShadow dx="0" dy="24" stdDeviation="40" flood-color="#000000" flood-opacity="0.30"/>
160
+ </filter>
161
+ </defs>
162
+ <rect width="${w}" height="${h}" fill="url(#bg)"/>
163
+ <ellipse cx="${w / 2}" cy="${drawY + drawH * 0.28}" rx="${w * 0.64}" ry="${h * 0.26}" fill="#FFFFFF" opacity="0.08"/>
164
+ ${caps}
165
+ <rect x="${drawX}" y="${drawY}" width="${drawW}" height="${drawH}" rx="${radius}" ry="${radius}" fill="#000" filter="url(#shadow)"/>
166
+ <image href="${imgDataUri}" x="${drawX}" y="${drawY}" width="${drawW}" height="${drawH}" preserveAspectRatio="xMidYMid slice" clip-path="url(#round)"/>
167
+ <rect x="${drawX}" y="${drawY}" width="${drawW}" height="${drawH}" rx="${radius}" ry="${radius}" fill="none" stroke="#FFFFFF" stroke-opacity="0.16" stroke-width="2"/>
168
+ </svg>`;
169
+ }
170
+
171
+ function render(svg, w) {
172
+ return new Resvg(svg, {
173
+ fitTo: { mode: 'width', value: w },
174
+ font: {
175
+ fontFiles: FONTS,
176
+ loadSystemFonts: FONTS.length === 0,
177
+ defaultFontFamily: 'sans-serif'
178
+ },
179
+ }).render().asPng();
180
+ }
181
+
182
+ const RAW_DIR = { phone: 'raw', tablet: 'raw-tablet' };
183
+
184
+ function selftest() {
185
+ const demo = `<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="2340">
186
+ <rect width="1080" height="2340" fill="#0F1416"/>
187
+ <rect x="80" y="300" width="920" height="240" rx="34" fill="${BRAND_BOT}"/>
188
+ <text x="130" y="440" font-family="${FONT_DISPLAY}" font-weight="700" font-size="120" fill="#FFFFFF">1</text>
189
+ </svg>`;
190
+ const raw = render(demo, 1080);
191
+ const { w, h } = pngSize(raw);
192
+ const uri = `data:image/png;base64,${raw.toString('base64')}`;
193
+ const outDir = join(SHOTS, 'store', '_selftest');
194
+ mkdirSync(outDir, { recursive: true });
195
+ for (const [name, preset] of Object.entries(PRESETS)) {
196
+ writeFileSync(join(outDir, `${name}.png`),
197
+ render(buildSvg(preset, CAPTIONS['00']['pt-BR'], uri, w, h), preset.w));
198
+ console.log(` ✓ ${name}.png (${preset.w}×${preset.h})`);
199
+ }
200
+ console.log(`Self-test frames → ${outDir}`);
201
+ }
202
+
203
+ function frameAll(opts) {
204
+ const presets = Object.entries(PRESETS).filter(([n]) => !opts.preset || n === opts.preset);
205
+ const anyRaw = Object.values(RAW_DIR).some(d => existsSync(join(SHOTS, d)));
206
+ if (!anyRaw) {
207
+ console.error('No raw screenshots under mobile/{{PROJECT_SLUG}}/screenshots/raw[/-tablet].');
208
+ console.error('Generate them: node scripts/gen-mockups.mjs');
209
+ console.error('Or preview framing: node scripts/frame-shots.mjs --selftest');
210
+ process.exit(1);
211
+ }
212
+ let count = 0;
213
+ for (const [presetName, preset] of presets) {
214
+ const rawRoot = join(SHOTS, RAW_DIR[preset.source]);
215
+ if (!existsSync(rawRoot)) {
216
+ console.log(` – ${presetName}: skipped (no ${RAW_DIR[preset.source]}/ yet)`);
217
+ continue;
218
+ }
219
+ const locales = readdirSync(rawRoot).filter(d => !opts.locale || d === opts.locale);
220
+ for (const locale of locales) {
221
+ const files = readdirSync(join(rawRoot, locale)).filter(f => f.endsWith('.png')).sort();
222
+ const outDir = join(SHOTS, 'store', presetName, locale);
223
+ mkdirSync(outDir, { recursive: true });
224
+ for (const file of files) {
225
+ const key = basename(file).slice(0, 2);
226
+ const caption = CAPTIONS[key]?.[locale] ?? CAPTIONS[key]?.en ?? { h: '', s: '' };
227
+ const raw = readFileSync(join(rawRoot, locale, file));
228
+ const { w, h } = pngSize(raw);
229
+ const uri = `data:image/png;base64,${raw.toString('base64')}`;
230
+ writeFileSync(join(outDir, file), render(buildSvg(preset, caption, uri, w, h), preset.w));
231
+ count++;
232
+ }
233
+ console.log(` ✓ ${presetName}/${locale}: ${files.length} frames`);
234
+ }
235
+ }
236
+ console.log(`Done — ${count} store images under mobile/{{PROJECT_SLUG}}/screenshots/store/`);
237
+ }
238
+
239
+ const opts = Object.fromEntries(
240
+ process.argv.slice(2).map(a => a.replace(/^--/, '').split('=')).map(([k, v]) => [k, v ?? true])
241
+ );
242
+ if (opts.selftest) {
243
+ selftest();
244
+ } else {
245
+ frameAll(opts);
246
+ }