storeframe-mcp 0.1.0 → 0.2.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/dist/local.js CHANGED
@@ -64,6 +64,67 @@ metadata, and never submits. Feel free to inspect it \u2014 it's five lines.
64
64
 
65
65
  ## Troubleshooting
66
66
 
67
+ - **"Could not find app with bundle identifier"** \u2014 the Bundle ID above doesn't match an
68
+ app on the team you signed in with. Re-export from Storeframe with the right Bundle ID.
69
+ - **Screenshots land under the wrong language** \u2014 re-export with the right locale; the
70
+ folder name under \`fastlane/screenshots/\` is the App Store locale code.
71
+ - **Wrong order in the store** \u2014 the \`NN_\` prefix controls order; re-arrange the strip in
72
+ Storeframe and re-export rather than renaming files by hand.
73
+ `;
74
+ var MULTI_LOCALE_README_TEMPLATE = `# Upload these screenshots to App Store Connect
75
+
76
+ This bundle was exported from Storeframe Studio for **{{BUNDLE_ID}}** \u2014
77
+ {{LOCALE_COUNT}} locales ({{LOCALES}}) \xB7 {{PER_LOCALE_COUNT}} screenshots each \xB7 {{PANEL_LABEL}}.
78
+
79
+ You upload it yourself with [Fastlane](https://fastlane.tools), signed in with **your own
80
+ Apple account or API key**. Your credentials never touch Storeframe.
81
+
82
+ ## Upload
83
+
84
+ \`\`\`
85
+ cd <this folder>
86
+ fastlane deliver
87
+ \`\`\`
88
+
89
+ Fastlane will:
90
+
91
+ 1. Ask you to sign in (Apple ID with 2FA, or an App Store Connect API key if you have
92
+ fastlane configured with one).
93
+ 2. Detect the device size automatically from the image dimensions.
94
+ 3. Upload screenshots for **every locale** below in one run \u2014 it reads the locale from each
95
+ \`fastlane/screenshots/<locale>/\` folder name.
96
+ 4. Show you an **HTML preview** of exactly what will be uploaded and ask
97
+ \`Does the Preview look okay for you? (y/n)\` \u2014 nothing is sent until you confirm.
98
+ 5. Upload the screenshots to your app's **editable (unreleased) version**, replacing the
99
+ existing set for each device size and locale.
100
+
101
+ **Nothing is ever submitted for review.** This bundle only updates screenshots on a draft
102
+ version; you review and submit in App Store Connect yourself, whenever you're ready.
103
+
104
+ ## First time?
105
+
106
+ - Install fastlane: \`brew install fastlane\` (or \`gem install fastlane\`).
107
+ - You need an **editable version** of your app in App Store Connect (any version that
108
+ isn't live yet). If every version is released, create a new version in App Store
109
+ Connect first \u2014 deliver can't attach screenshots to a released version.
110
+
111
+ ## What's in this bundle
112
+
113
+ \`\`\`
114
+ README.md this file
115
+ fastlane/
116
+ Deliverfile pre-filled config \u2014 screenshots only, nothing else
117
+ {{LOCALE_FOLDERS}}
118
+ \`\`\`
119
+
120
+ Each locale folder holds that language's screenshots; the \`NN_\` filename prefix is the
121
+ display order in the App Store.
122
+
123
+ The \`Deliverfile\` is deliberately minimal and read-safe: it skips binary upload, skips
124
+ metadata, and never submits. Feel free to inspect it \u2014 it's a handful of lines.
125
+
126
+ ## Troubleshooting
127
+
67
128
  - **"Could not find app with bundle identifier"** \u2014 the Bundle ID above doesn't match an
68
129
  app on the team you signed in with. Re-export from Storeframe with the right Bundle ID.
69
130
  - **Screenshots land under the wrong language** \u2014 re-export with the right locale; the
@@ -75,7 +136,11 @@ function render(template, vars) {
75
136
  return template.replace(/\{\{(\w+)\}\}/g, (match, key) => vars[key] ?? match);
76
137
  }
77
138
  function generateDeliverfile(opts) {
78
- return render(DELIVERFILE_TEMPLATE, { BUNDLE_ID: opts.bundleId });
139
+ const base = render(DELIVERFILE_TEMPLATE, { BUNDLE_ID: opts.bundleId });
140
+ const locales = opts.locales?.filter((l) => l.length > 0) ?? [];
141
+ if (locales.length === 0) return base;
142
+ return `# Locales in this bundle: ${locales.join(", ")} (one fastlane/screenshots/<locale>/ folder each).
143
+ ${base}`;
79
144
  }
80
145
  function generateBundleReadme(opts) {
81
146
  return render(README_TEMPLATE, {
@@ -85,6 +150,17 @@ function generateBundleReadme(opts) {
85
150
  COUNT: String(opts.count)
86
151
  });
87
152
  }
153
+ function generateMultiLocaleBundleReadme(opts) {
154
+ const folderLines = opts.locales.map((l) => ` fastlane/screenshots/${l}/`.padEnd(33) + `your ${l} screenshots`).join("\n");
155
+ return render(MULTI_LOCALE_README_TEMPLATE, {
156
+ BUNDLE_ID: opts.bundleId,
157
+ LOCALES: opts.locales.join(", "),
158
+ LOCALE_COUNT: String(opts.locales.length),
159
+ PER_LOCALE_COUNT: String(opts.perLocaleCount),
160
+ PANEL_LABEL: opts.panelLabel,
161
+ LOCALE_FOLDERS: folderLines
162
+ });
163
+ }
88
164
  function screenshotFilename(index) {
89
165
  return `${String(index).padStart(2, "0")}_storeframe.png`;
90
166
  }
@@ -113,6 +189,265 @@ function buildFastlaneBundleZip(slots, opts) {
113
189
  }
114
190
  return zipSync(files, { level: 0 });
115
191
  }
192
+ function buildMultiLocaleFastlaneBundleZip(localeSlots, opts) {
193
+ const locales = localeSlots.map((ls) => ls.locale);
194
+ const files = {
195
+ "README.md": strToU8(
196
+ generateMultiLocaleBundleReadme({
197
+ bundleId: opts.bundleId,
198
+ locales,
199
+ panelLabel: opts.panelLabel,
200
+ // Structure is shared across locales (invariant 2), so every locale has the same slot
201
+ // count — take the first (0 for the degenerate empty-input case).
202
+ perLocaleCount: localeSlots[0]?.slots.length ?? 0
203
+ })
204
+ ),
205
+ "fastlane/Deliverfile": strToU8(generateDeliverfile({ bundleId: opts.bundleId, locales }))
206
+ };
207
+ for (const { locale: locale2, slots } of localeSlots) {
208
+ for (const slot2 of slots) {
209
+ files[`fastlane/screenshots/${locale2}/${screenshotFilename(slot2.displayPosition)}`] = slot2.bytes;
210
+ }
211
+ }
212
+ return zipSync(files, { level: 0 });
213
+ }
214
+
215
+ // ../mockup-engine/appstore.ts
216
+ var DEFAULT_LOOK = {
217
+ angle: "front",
218
+ cameraPos: null,
219
+ roll: "0",
220
+ phoneHeight: "72",
221
+ hOffset: "0",
222
+ vOffset: "0",
223
+ material: "real",
224
+ colorway: "silver",
225
+ customColor: "F5F5F5",
226
+ finish: "0",
227
+ clearcoat: "0",
228
+ clayTone: "grey",
229
+ clayCustom: "B0B0B0",
230
+ flatScreen: false,
231
+ glare: false,
232
+ lighting: true,
233
+ reflections: false
234
+ };
235
+ function normalizePanelBackgrounds(raw) {
236
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
237
+ const out = {};
238
+ for (const [panelId, entry] of Object.entries(raw)) {
239
+ if (!entry || typeof entry !== "object") continue;
240
+ const e = entry;
241
+ if (e.kind === "solid" && typeof e.color === "string") {
242
+ out[panelId] = { kind: "solid", color: e.color };
243
+ } else if (e.kind === "gradient" && typeof e.from === "string" && typeof e.to === "string" && (e.dir === "horizontal" || e.dir === "vertical")) {
244
+ out[panelId] = { kind: "gradient", from: e.from, to: e.to, dir: e.dir };
245
+ }
246
+ }
247
+ return out;
248
+ }
249
+ var DEFAULT_CAPTION_ANCHOR = { x: 0.5, y: 0.06 };
250
+ function isAutoAnchor(anchor) {
251
+ return !anchor || anchor.x === DEFAULT_CAPTION_ANCHOR.x && anchor.y === DEFAULT_CAPTION_ANCHOR.y;
252
+ }
253
+ function captionLayerStyle(layer) {
254
+ const { text: _text, anchor, ...rest } = layer;
255
+ return isAutoAnchor(anchor) ? { ...rest } : { ...rest, anchor };
256
+ }
257
+ var DEFAULT_CAPTION_STYLE = {
258
+ fontId: "inter",
259
+ sizePt: 44,
260
+ color: "FFFFFF",
261
+ align: "center",
262
+ anchor: DEFAULT_CAPTION_ANCHOR,
263
+ maxWidth: 0.86
264
+ };
265
+ var LEGACY_SUBTITLE_SCALE = 0.55;
266
+ var DEFAULT_LOCALE = "en-US";
267
+ function normalizeCaptionStyles(raw) {
268
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
269
+ const out = {};
270
+ for (const [panelId, entry] of Object.entries(raw)) {
271
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
272
+ out[panelId] = normalizeCaptionLayerStyle(entry);
273
+ }
274
+ return out;
275
+ }
276
+ function normalizeAnchor(raw) {
277
+ if (!raw || typeof raw !== "object") return void 0;
278
+ const a = raw;
279
+ const x = typeof a.x === "number" ? a.x : DEFAULT_CAPTION_ANCHOR.x;
280
+ const y = typeof a.y === "number" ? a.y : DEFAULT_CAPTION_ANCHOR.y;
281
+ return isAutoAnchor({ x, y }) ? void 0 : { x, y };
282
+ }
283
+ function normalizeCaptionLayerStyle(raw) {
284
+ const e = raw && typeof raw === "object" ? raw : {};
285
+ const anchor = normalizeAnchor(e.anchor);
286
+ const merged = { ...DEFAULT_CAPTION_STYLE, ...e };
287
+ if (anchor) merged.anchor = anchor;
288
+ else delete merged.anchor;
289
+ return merged;
290
+ }
291
+ function normalizeCaptionLayer(raw) {
292
+ const e = raw && typeof raw === "object" ? raw : {};
293
+ return { ...normalizeCaptionLayerStyle(e), text: typeof e.text === "string" ? e.text : "" };
294
+ }
295
+ function normalizeCaptionLayerStyles(raw) {
296
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
297
+ const out = {};
298
+ for (const [panelId, entry] of Object.entries(raw)) {
299
+ if (Array.isArray(entry)) {
300
+ if (entry.length > 0) out[panelId] = entry.map(normalizeCaptionLayerStyle);
301
+ } else if (entry && typeof entry === "object") {
302
+ out[panelId] = [normalizeCaptionLayerStyle(entry)];
303
+ }
304
+ }
305
+ return out;
306
+ }
307
+ function layersFromCaptionText(text, style2) {
308
+ const layers = [{ ...normalizeCaptionLayerStyle(style2), text: text.headline }];
309
+ if (typeof text.subtitle === "string" && text.subtitle.trim()) {
310
+ layers.push({ ...normalizeCaptionLayerStyle({ ...style2, sizePt: style2.sizePt * LEGACY_SUBTITLE_SCALE }), text: text.subtitle });
311
+ }
312
+ return layers;
313
+ }
314
+ function normalizeCaptionText(raw, captionStyles) {
315
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
316
+ const styles = normalizeCaptionStyles(captionStyles);
317
+ const styleFor = (panelId) => styles[panelId] ?? DEFAULT_CAPTION_STYLE;
318
+ const innerToLayers = (inner) => {
319
+ if (!inner || typeof inner !== "object" || Array.isArray(inner)) return {};
320
+ const out2 = {};
321
+ for (const [panelId, value] of Object.entries(inner)) {
322
+ if (Array.isArray(value)) {
323
+ if (value.length > 0) out2[panelId] = value.map(normalizeCaptionLayer);
324
+ } else if (value && typeof value === "object" && typeof value.headline === "string") {
325
+ out2[panelId] = layersFromCaptionText(value, styleFor(panelId));
326
+ }
327
+ }
328
+ return out2;
329
+ };
330
+ const entries = Object.entries(raw);
331
+ const looksFlat = entries.some(
332
+ ([, value]) => !!value && typeof value === "object" && typeof value.headline === "string"
333
+ );
334
+ if (looksFlat) return { [DEFAULT_LOCALE]: innerToLayers(raw) };
335
+ const out = {};
336
+ for (const [locale2, inner] of entries) out[locale2] = innerToLayers(inner);
337
+ return out;
338
+ }
339
+ function resolveCaptionText(captionText, locale2) {
340
+ return captionText[locale2 ?? DEFAULT_LOCALE] ?? captionText[DEFAULT_LOCALE] ?? {};
341
+ }
342
+ function deriveLocales(captionText, explicit) {
343
+ const dedupe = (list) => {
344
+ const seen = /* @__PURE__ */ new Set();
345
+ const out = [];
346
+ for (const l of list) if (typeof l === "string" && l && !seen.has(l)) {
347
+ seen.add(l);
348
+ out.push(l);
349
+ }
350
+ return out;
351
+ };
352
+ if (Array.isArray(explicit)) {
353
+ const cleaned = dedupe(explicit);
354
+ if (cleaned.length > 0) return cleaned;
355
+ }
356
+ return dedupe([DEFAULT_LOCALE, ...Object.keys(captionText)]);
357
+ }
358
+ function captionStylesFromLayers(captionText) {
359
+ const out = {};
360
+ const locales = Object.keys(captionText);
361
+ const ordered = locales.includes(DEFAULT_LOCALE) ? [DEFAULT_LOCALE, ...locales.filter((l) => l !== DEFAULT_LOCALE)] : locales;
362
+ for (const locale2 of ordered) {
363
+ const inner = captionText[locale2];
364
+ if (!inner) continue;
365
+ for (const [panelId, layers] of Object.entries(inner)) {
366
+ if (out[panelId] === void 0 && Array.isArray(layers) && layers.length > 0) out[panelId] = layers.map(captionLayerStyle);
367
+ }
368
+ }
369
+ return out;
370
+ }
371
+ function shotsInPanelOrder(shots, panels) {
372
+ return panels.flatMap((panel) => shots.filter((s) => s.panelId === panel.id));
373
+ }
374
+ function isLocaleVariants(entry) {
375
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
376
+ const locales = entry.locales;
377
+ return !!locales && typeof locales === "object" && !Array.isArray(locales);
378
+ }
379
+ function resolveLocaleVariant(entry, locale2) {
380
+ if (!isLocaleVariants(entry)) return entry;
381
+ const variants = entry.locales;
382
+ const declared = Object.keys(variants);
383
+ if (declared.length === 0) {
384
+ throw new Error(
385
+ 'a per-locale screenshot entry has an empty "locales" map \u2014 provide at least one locale, e.g. { "locales": { "en-US": { "ref": "\u2026" } } }'
386
+ );
387
+ }
388
+ return variants[locale2 ?? DEFAULT_LOCALE] ?? variants[DEFAULT_LOCALE] ?? variants[declared[0]];
389
+ }
390
+ function resolveLocaleVariantSlots(slots, locale2) {
391
+ return slots.map((slot2) => slot2.map((entry) => resolveLocaleVariant(entry, locale2)));
392
+ }
393
+ function collectVariantLocales(entries) {
394
+ const seen = /* @__PURE__ */ new Set();
395
+ const out = [];
396
+ for (const entry of entries) {
397
+ if (!isLocaleVariants(entry)) continue;
398
+ for (const locale2 of Object.keys(entry.locales)) {
399
+ if (!seen.has(locale2)) {
400
+ seen.add(locale2);
401
+ out.push(locale2);
402
+ }
403
+ }
404
+ }
405
+ return out;
406
+ }
407
+ var PROJECT_SCHEMA_VERSION = 2;
408
+ function serializeProject(state) {
409
+ const ordered = shotsInPanelOrder(state.shots, state.panels);
410
+ const shots = ordered.map((s) => ({
411
+ id: s.id,
412
+ panelId: s.panelId,
413
+ frameNodeId: s.frameNodeId,
414
+ frameName: s.frameName,
415
+ look: s.look,
416
+ inputAnchor: s.inputAnchor,
417
+ // v2 provenance — persisted so drift survives reload
418
+ outputAnchor: s.outputAnchor
419
+ }));
420
+ const captionText = normalizeCaptionText(state.captionText, state.captionStyles);
421
+ return {
422
+ schema: PROJECT_SCHEMA_VERSION,
423
+ shots,
424
+ panels: state.panels,
425
+ selectedShotId: state.selectedShotId,
426
+ // Caption TEXT is content: emitted here on the project record, deliberately NOT via
427
+ // extractStripStyle — so words can never leak into serializeLook's styling blob (invariant 2).
428
+ captionText,
429
+ // The language list is content too — placed here (not the extractStripStyle spread) so languages
430
+ // never leak into a Look (invariant 3). Per-locale screenshot BYTES (state.localeScreens) are
431
+ // DROPPED, exactly as Shot.bytes are: bytes never enter the record (rule 1). They persist only in
432
+ // the byte-cache and re-join by frameName on load.
433
+ locales: deriveLocales(captionText, state.locales),
434
+ ...extractStripStyle(state, captionText)
435
+ };
436
+ }
437
+ function extractStripStyle(state, captionText) {
438
+ return {
439
+ panelPresetId: state.panelPresetId,
440
+ bgMode: state.bgMode,
441
+ gradientFrom: state.gradientFrom,
442
+ gradientTo: state.gradientTo,
443
+ gradientDir: state.gradientDir,
444
+ panelColors: state.panelColors,
445
+ shadow: state.shadow,
446
+ floorReflection: state.floorReflection ?? false,
447
+ panelBackgrounds: state.panelBackgrounds ?? {},
448
+ captionStyles: { ...normalizeCaptionLayerStyles(state.captionStyles), ...captionStylesFromLayers(captionText) }
449
+ };
450
+ }
116
451
 
117
452
  // src/screenshotInput.ts
118
453
  import { readFile } from "node:fs/promises";
@@ -309,15 +644,18 @@ function panelDimensionsFor(presetId) {
309
644
  var nameField = z.string().max(200).optional().describe(
310
645
  `The screenshot's original filename (e.g. "03_statistics.png"). Pass it so that, if this render is saved as a project, the user's own screenshots folder re-loads exactly this file by name. Optional if the ref already carries a name (from request_screenshot_upload({ names })); omit entirely and the record falls back to a positional name (screen-N.png).`
311
646
  );
312
- var screenshotEntry = z.union([
313
- z.string().min(1).describe('Inline base64-encoded PNG (no "data:" prefix). Small payloads only.'),
314
- z.object({ ref: z.string().min(1), name: nameField }).strict().describe("A ref returned by request_screenshot_upload \u2014 resolved server-side, never re-sent inline."),
315
- z.object({ url: z.string().min(1), name: nameField }).strict().describe("An https URL to a PNG \u2014 fetched server-side (image/png, no redirects, ~20MB cap)."),
316
- z.object({ path: z.string().min(1), name: nameField }).strict().describe(
317
- "A local filesystem path to a PNG, read straight off disk \u2014 ONLY available over the local stdio server (npx storeframe-mcp); the hosted server rejects this entry shape."
318
- )
319
- ]);
320
- var screenshotsDescription = 'An array of App Store slots in display order (1\u201310; the store allows up to 10 per size). Each slot is itself an array of screenshots: one entry = one phone in that slot, several entries = multiple phones composited into that one slot. Each screenshot entry is EITHER an inline base64 PNG string (small payloads only \u2014 large inline payloads are rejected with a clear error), OR { "ref": "..." } from request_screenshot_upload, OR { "url": "https://..." }, OR (local stdio mode only) { "path": "/abs/or/relative/path.png" } to read a file straight off disk. Prefer ref/url for real screenshots so the bytes never transit this conversation.';
647
+ var inlineBase64Entry = z.string().min(1).describe('Inline base64-encoded PNG (no "data:" prefix). Small payloads only.');
648
+ var refEntry = z.object({ ref: z.string().min(1), name: nameField }).strict().describe("A ref returned by request_screenshot_upload \u2014 resolved server-side, never re-sent inline.");
649
+ var urlEntry = z.object({ url: z.string().min(1), name: nameField }).strict().describe("An https URL to a PNG \u2014 fetched server-side (image/png, no redirects, ~20MB cap).");
650
+ var pathEntry = z.object({ path: z.string().min(1), name: nameField }).strict().describe(
651
+ "A local filesystem path to a PNG, read straight off disk \u2014 ONLY available over the local stdio server (npx storeframe-mcp); the hosted server rejects this entry shape."
652
+ );
653
+ var singleScreenshotEntry = z.union([inlineBase64Entry, refEntry, urlEntry, pathEntry]);
654
+ var localeScreenshotEntry = z.object({ locales: z.record(z.string(), singleScreenshotEntry) }).strict().describe(
655
+ 'Per-locale variants of ONE screenshot: { "locales": { "en-US": <entry>, "de-DE": <entry> } } (each variant is an inline base64 / ref / url / path entry). render_strip picks the top-level `locale`\'s variant; emit_bundle with `locales` renders/packages every listed locale. A locale with no variant of its own falls back to the en-US variant (else the first declared), so you can localize only some screenshots.'
656
+ );
657
+ var screenshotEntry = z.union([inlineBase64Entry, refEntry, urlEntry, pathEntry, localeScreenshotEntry]);
658
+ var screenshotsDescription = 'An array of App Store slots in display order (1\u201310; the store allows up to 10 per size). Each slot is itself an array of screenshots: one entry = one phone in that slot, several entries = multiple phones composited into that one slot. Each screenshot entry is EITHER an inline base64 PNG string (small payloads only \u2014 large inline payloads are rejected with a clear error), OR { "ref": "..." } from request_screenshot_upload, OR { "url": "https://..." }, OR (local stdio mode only) { "path": "/abs/or/relative/path.png" } to read a file straight off disk, OR { "locales": { "<locale>": <any of those> } } to vary that screenshot per App Store locale (missing locales fall back to the en-US variant). Prefer ref/url for real screenshots so the bytes never transit this conversation.';
321
659
  var slot = z.array(screenshotEntry).min(1).max(6).describe(
322
660
  "One App Store slot: 1\u20136 screenshots. Several entries = several phones composited into that one slot."
323
661
  );
@@ -326,8 +664,12 @@ var screenshotsForSave = z.array(slot).min(1).max(10).describe(
326
664
  `${screenshotsDescription} NOTE: save_project does not render \u2014 it uses only each slot's SHAPE (phones per panel) and each entry's \`name\` (source filename, for re-load matching). The image bytes are never fetched or stored, so pass the same entries you rendered with (esp. their \`name\`s).`
327
665
  );
328
666
  var screenshotsOptional = z.array(slot).min(1).max(10).optional().describe(`${screenshotsDescription} Required unless \`panels\` (pre-rendered refs) is given instead.`);
329
- var panelRefs = z.array(z.object({ ref: z.string().min(1) }).strict()).min(1).max(10).optional().describe(
330
- 'Pre-rendered panel refs from a prior render_strip call made with `output: "urls"` \u2014 packages them directly into the bundle WITHOUT rendering again. Mutually exclusive with `screenshots`; one of the two is required.'
667
+ var panelRef = z.object({ ref: z.string().min(1) }).strict();
668
+ var localePanelRef = z.object({ locales: z.record(z.string(), panelRef) }).strict().describe(
669
+ 'Per-locale variants of ONE pre-rendered panel: { "locales": { "en-US": { "ref": "\u2026" }, "de-DE": { "ref": "\u2026" } } }. Used with `locales` to compose a multi-locale bundle from per-locale render_strip results without re-rendering; a missing locale falls back to the en-US variant (else the first declared).'
670
+ );
671
+ var panelRefs = z.array(z.union([panelRef, localePanelRef])).min(1).max(10).optional().describe(
672
+ 'Pre-rendered panel refs from a prior render_strip call made with `output: "urls"` \u2014 packages them directly into the bundle WITHOUT rendering again. Mutually exclusive with `screenshots`; one of the two is required. An entry may also be { "locales": { "<locale>": { "ref": "\u2026" } } } to vary that panel per locale (see `locales`).'
331
673
  );
332
674
  var output = z.enum(["inline", "urls"]).optional().describe(
333
675
  'How to return rendered bytes: "inline" (base64), "urls" (uploaded, short-lived signed download URLs \u2014 use for real/full-resolution renders so bytes never transit this conversation), or omit for auto (inline under ~200KB total, urls above).'
@@ -337,11 +679,14 @@ var preview = z.boolean().optional().describe(
337
679
  );
338
680
  var panelPresetId = z.enum(PANEL_PRESET_IDS).optional().describe("App Store screenshot size. Default r69 (6.9\u2033 iPhone, 1290\xD72796).");
339
681
  var look = z.record(z.string(), z.unknown()).optional().describe(
340
- 'A Storeframe "look" (styling only) JSON, exactly as returned by read_look, applied to the strip. Omit for default styling.'
682
+ 'A Storeframe "look" (styling only) JSON, exactly as returned by read_look, applied to the strip. Omit for default styling. Unlike style.shotLook (one device for the whole strip), a look carries shots[] \u2014 one entry per panel in slot order, each with its own look \u2014 so you can style each panel differently (mixed-device / continuous-scene). See describe_look.'
341
683
  );
342
684
  var useSavedLook = z.boolean().optional().describe("If true, style the strip with the project's saved look (ignored when `look`/`style` is given).");
343
685
  var locale = z.string().optional().describe(
344
- 'App Store locale, e.g. "de-DE" (default en-US). Labels the render and, for emit_bundle, selects the fastlane screenshots folder. Does not select caption text \u2014 pass the copy for this locale yourself via `style.captions[].text` (see the README\'s `captions.<locale>.json` convention).'
686
+ 'App Store locale, e.g. "de-DE" (default en-US). Labels the render, selects which { "locales": \u2026 } screenshot variants render, and, for emit_bundle, selects the fastlane screenshots folder. Does not select caption text \u2014 pass the copy for this locale yourself via `style.captions[].text` (see the README\'s `captions.<locale>.json` convention).'
687
+ );
688
+ var localesField = z.array(z.string().min(1)).min(1).max(40).optional().describe(
689
+ 'Emit ONE multi-locale bundle: render (or, with `panels`, just package) each listed App Store locale and lay them out as fastlane/screenshots/<locale>/ folders in a single zip. Screenshot/panel entries may vary per locale via { "locales": \u2026 }; a plain entry \u2014 or a missing locale variant \u2014 falls back to the en-US variant, so a locale with no screens of its own ships the fallback pixels in its own folder (correct for fastlane). With `screenshots` this renders once PER locale (slow \u2014 prefer per-locale render_strip calls with output:"urls", then compose here with per-locale `panels`). A single-entry list behaves exactly like `locale`. Caption text is per-RENDER input: in this one-call form the same style.captions apply to every locale, so for localized captions use the render-per-locale-then-compose flow.'
345
690
  );
346
691
  var CAPTION_FONT_IDS = ["inter", "manrope", "poppins", "fraunces", "space-grotesk"];
347
692
  var DEFAULT_SHOT_LOOK = {
@@ -355,6 +700,7 @@ var DEFAULT_SHOT_LOOK = {
355
700
  colorway: "silver",
356
701
  customColor: "F5F5F5",
357
702
  finish: "0",
703
+ clearcoat: "0",
358
704
  clayTone: "grey",
359
705
  clayCustom: "B0B0B0",
360
706
  flatScreen: false,
@@ -362,7 +708,7 @@ var DEFAULT_SHOT_LOOK = {
362
708
  lighting: true,
363
709
  reflections: false
364
710
  };
365
- var DEFAULT_CAPTION_STYLE = {
711
+ var DEFAULT_CAPTION_STYLE2 = {
366
712
  fontId: "inter",
367
713
  sizePt: 44,
368
714
  color: "FFFFFF",
@@ -382,6 +728,7 @@ var shotLook = z.object({
382
728
  colorway: z.enum(["orange", "blue", "silver", "custom"]).optional().describe('Body colour (material "real"). Default silver.'),
383
729
  customColor: z.string().optional().describe('Hex body colour when colorway is "custom".'),
384
730
  finish: numeric.optional().describe("0 = matte \u2026 1 = glossy. Default 0."),
731
+ clearcoat: numeric.optional().describe("0 = none \u2026 1 = glossy clear-coat lacquer over the body (a wet/ceramic sheen, on top of finish). Default 0."),
385
732
  clayTone: z.enum(["grey", "white", "charcoal", "custom"]).optional().describe('Clay tone (material "clay"). Default grey.'),
386
733
  clayCustom: z.string().optional().describe('Hex clay colour when clayTone is "custom".'),
387
734
  flatScreen: z.boolean().optional().describe("Render the screen flat (no curvature). Default false."),
@@ -395,7 +742,8 @@ var background = z.object({
395
742
  gradientTo: z.string().optional().describe("Gradient end (hex). Default #0a0a14."),
396
743
  gradientDir: z.enum(["vertical", "horizontal"]).optional().describe("Default vertical."),
397
744
  panelColors: z.array(z.string().nullable()).max(10).optional().describe('Per-panel flat colours (mode "perPanel"), one entry per slot in order; null = default.'),
398
- shadow: z.boolean().optional().describe("Drop shadow under the phones. Default true.")
745
+ shadow: z.boolean().optional().describe("Drop shadow under the phones. Default true."),
746
+ floorReflection: z.boolean().optional().describe("Flipped, faded floor reflection under each phone. Default false.")
399
747
  }).strict();
400
748
  var captionEntry = z.object({
401
749
  fontId: z.enum(CAPTION_FONT_IDS).optional().describe("Bundled font. Default inter."),
@@ -410,7 +758,13 @@ var captionEntry = z.object({
410
758
  var style = z.object({
411
759
  shotLook: shotLook.optional().describe("Device/camera styling, applied to every phone in the strip."),
412
760
  background: background.optional(),
413
- captions: z.array(captionEntry.nullable()).max(10).optional().describe("One entry PER PANEL in slot order; null = no caption on that panel.")
761
+ // plan-20 Phase 8 (D9): one entry PER PANEL, and each entry is EITHER a single caption object OR
762
+ // an ORDERED ARRAY of caption layers (a multi-line strip: headline + subline + …). Both are
763
+ // accepted forever — this is a public wire contract (published npm + hosted Fly server), so the
764
+ // union is additive, never a breaking swap. null = no caption on that panel.
765
+ captions: z.array(z.union([captionEntry, z.array(captionEntry).max(8)]).nullable()).max(10).optional().describe(
766
+ "One entry PER PANEL in slot order; null = no caption on that panel. An entry is EITHER a single caption object OR an array of caption layers (stacked, in order) on that panel."
767
+ )
414
768
  }).strict().optional().describe(
415
769
  "Structured styling \u2014 the discoverable alternative to the opaque `look` param (call describe_look for the full field catalog + defaults). Wins over `look`/`useSavedLook`."
416
770
  );
@@ -453,6 +807,7 @@ var emitBundleShape = {
453
807
  "Your app\u2019s reverse-DNS bundle identifier, e.g. com.acme.app. Pre-fills the fastlane Deliverfile ONLY \u2014 it is never sent to any store from here."
454
808
  ),
455
809
  locale,
810
+ locales: localesField,
456
811
  projectName: z.string().optional().describe('A name for the bundle (used for the zip filename + any share link). Default "storeframe".'),
457
812
  share: z.boolean().optional().describe(
458
813
  "Also create an unguessable, 14-day share link to a landing page (preview + bundle download + fastlane how-to), attributed to your account. Default false."
@@ -478,6 +833,17 @@ var saveProjectShape = {
478
833
  sourceDir
479
834
  };
480
835
  var readLookShape = { project };
836
+ var readProjectShape = { project };
837
+ var screenshotFileList = z.array(singleScreenshotEntry).min(1).max(60).describe(
838
+ `A FLAT list of the developer's raw app screenshots \u2014 each an inline base64 PNG, { "ref" }, { "url" }, or (local stdio only) { "path" }, and each with a \`name\` (its original filename). They are matched to the saved project's shots BY FILENAME (name === the shot's frameName), so the order you pass them does NOT matter and you never pre-sort. A shot with no matching file is reported as missing (it does not error the render). Prefer ref/url for real screenshots so the bytes never transit this conversation.`
839
+ );
840
+ var renderProjectShape = {
841
+ project,
842
+ screenshots: screenshotFileList,
843
+ locale,
844
+ preview,
845
+ output
846
+ };
481
847
  var saveLookShape = {
482
848
  project,
483
849
  look: z.record(z.string(), z.unknown()).describe(
@@ -501,6 +867,7 @@ var DESCRIBE_LOOK_CATALOG = {
501
867
  material: "real | clay",
502
868
  colorway: "orange | blue | silver | custom (with customColor hex)",
503
869
  finish: "0 (matte) \u2026 1 (glossy)",
870
+ clearcoat: "0 (none) \u2026 1 (glossy clear-coat lacquer over the body, on top of finish)",
504
871
  clayTone: "grey | white | charcoal | custom (with clayCustom hex)",
505
872
  flatScreen: "boolean",
506
873
  glare: "boolean",
@@ -510,28 +877,30 @@ var DESCRIBE_LOOK_CATALOG = {
510
877
  },
511
878
  background: {
512
879
  description: "The strip background.",
513
- defaults: { mode: "gradient", gradientFrom: "#1b1b2e", gradientTo: "#0a0a14", gradientDir: "vertical", shadow: true },
880
+ defaults: { mode: "gradient", gradientFrom: "#1b1b2e", gradientTo: "#0a0a14", gradientDir: "vertical", shadow: true, floorReflection: false },
514
881
  fields: {
515
882
  mode: "gradient | perPanel",
516
- panelColors: 'per-panel hex colours (mode "perPanel"), one entry per slot in order'
883
+ panelColors: 'per-panel hex colours (mode "perPanel"), one entry per slot in order',
884
+ floorReflection: "boolean \u2014 a flipped, faded floor reflection under each phone"
517
885
  }
518
886
  },
519
887
  captions: {
520
- description: "One entry PER PANEL in slot order (null = no caption). Styling is part of the look; `text`/`subtitle` are per-RENDER input and are never stored (zero-custody of content).",
521
- defaults: DEFAULT_CAPTION_STYLE,
888
+ description: "One entry PER PANEL in slot order (null = no caption). An entry is EITHER a single caption object OR an array of caption LAYERS stacked in order on that panel (e.g. a headline layer + a subline layer). Styling is part of the look; `text` (and a single caption\u2019s `subtitle`) are per-RENDER input and are never stored (zero-custody of content). Captions AUTO-LAYOUT above the device by default so they never collide with the phone.",
889
+ defaults: DEFAULT_CAPTION_STYLE2,
522
890
  fonts: CAPTION_FONT_IDS,
523
891
  fields: {
524
892
  sizePt: "font size in iOS points \u2014 preset-independent",
525
- anchor: "{ x, y } normalized 0\u20131 position on the panel",
893
+ anchor: "{ x, y } normalized 0\u20131 position on the panel \u2014 OMIT to auto-place above the device",
526
894
  maxWidth: "wrap width as a 0\u20131 fraction of the panel width",
527
- text: "the headline (per-render input)",
528
- subtitle: "optional subtitle (per-render input)"
895
+ text: "the headline / this layer\u2019s words (per-render input)",
896
+ subtitle: "single-caption shape only: an optional second line (per-render input). In the ARRAY shape, add another layer instead."
529
897
  }
530
898
  }
531
899
  },
532
900
  panelPresets: PANEL_PRESET_IDS.map((id) => ({ id, ...PANEL_DIMENSIONS[id], label: PANEL_LABELS[id] })),
533
901
  notes: [
534
902
  "Pass `style` to render_strip/emit_bundle for structured styling, or save a composed look with save_look and render with useSavedLook.",
903
+ 'style.shotLook is ONE device for the whole strip. For a DIFFERENT device per panel (mixed-device or continuous-scene strips), pass a `look` instead \u2014 a look has shots[], one entry per panel in slot order, each with its own `look` (same fields as shotLook). e.g. look:{ shots:[ {panelId:"panel-1",look:{angle:"left"}}, {panelId:"panel-2",look:{colorway:"orange"}} ] }.',
535
904
  "save_look bumps the look version; pinning a version is done in Storeframe Studio (the Look bar), and pinned projects render their pinned version by default."
536
905
  ]
537
906
  };
@@ -542,6 +911,13 @@ var requestScreenshotUploadShape = {
542
911
  // `{ ref }` bare (no `name`). Optional; a shorter/absent array just leaves those slots nameless.
543
912
  names: z.array(z.string().max(200)).optional().describe(
544
913
  'Original filenames, one per slot in the same order (e.g. ["03_statistics.png", ...]). Each is encoded into that slot\'s ref, so the saved project re-loads by real filename even without re-passing `name` at render time. Optional.'
914
+ ),
915
+ // plan-21 Phase 5 (D6): an ORGANIZATIONAL tag only — which locale this batch of uploads is
916
+ // for. Echoed back in the response so an agent uploading per-locale batches can keep its
917
+ // refs sorted; it changes nothing about the refs themselves (any ref works in any locale
918
+ // slot of a { locales: … } entry).
919
+ locale: z.string().optional().describe(
920
+ 'Optional tag: which App Store locale these screenshots are for (e.g. "de-DE") when uploading one batch per locale. Echoed back for your bookkeeping only \u2014 the refs are locale-agnostic; place each under the matching key of a { "locales": \u2026 } screenshot entry.'
545
921
  )
546
922
  };
547
923
 
@@ -558,6 +934,19 @@ function resolveOutputMode(requested, totalBytes) {
558
934
  if (requested === "inline" || requested === "urls") return requested;
559
935
  return totalBytes > AUTO_INLINE_THRESHOLD_BYTES ? "urls" : "inline";
560
936
  }
937
+ function dedupeLocales(list) {
938
+ if (!Array.isArray(list)) return [];
939
+ const seen = /* @__PURE__ */ new Set();
940
+ const out = [];
941
+ for (const raw of list) {
942
+ const locale2 = typeof raw === "string" ? raw.trim() : "";
943
+ if (locale2 && !seen.has(locale2)) {
944
+ seen.add(locale2);
945
+ out.push(locale2);
946
+ }
947
+ }
948
+ return out;
949
+ }
561
950
  async function resolveLook(deps2, args) {
562
951
  if (args.style != null) return { look: null, style: args.style };
563
952
  if (args.look != null) return { look: args.look };
@@ -629,9 +1018,16 @@ async function handleRenderStrip(deps2, args) {
629
1018
  if (args.createProject && args.preview) {
630
1019
  return errorResult("createProject cannot be combined with preview:true \u2014 drop preview to persist a full render (or iterate with preview, then render once without it).");
631
1020
  }
1021
+ const screenshotLocales = collectVariantLocales(args.screenshots.flat());
1022
+ let entries;
1023
+ try {
1024
+ entries = resolveLocaleVariantSlots(args.screenshots, locale2);
1025
+ } catch (err) {
1026
+ return errorResult(err.message);
1027
+ }
632
1028
  let screenshots2;
633
1029
  try {
634
- screenshots2 = await resolveScreenshots(args.screenshots, deps2.userId, deps2.allowLocalScreenshots ?? false);
1030
+ screenshots2 = await resolveScreenshots(entries, deps2.userId, deps2.allowLocalScreenshots ?? false);
635
1031
  } catch (err) {
636
1032
  return errorResult(err.message);
637
1033
  }
@@ -644,7 +1040,9 @@ async function handleRenderStrip(deps2, args) {
644
1040
  locale: locale2,
645
1041
  buildRecord: args.createProject === true,
646
1042
  // Phase 5A: only compute names when we're building a record (saving a project).
647
- ...args.createProject ? { screenshotNames: resolveScreenshotNames(args.screenshots) } : {}
1043
+ // Per-locale entries: names come from the locale-resolved entries — the record describes
1044
+ // exactly the files this render used.
1045
+ ...args.createProject ? { screenshotNames: resolveScreenshotNames(entries) } : {}
648
1046
  });
649
1047
  if (!result.ok || !result.panels) {
650
1048
  return errorResult(`render failed: ${result.error ?? "unknown error"}`);
@@ -672,6 +1070,9 @@ async function handleRenderStrip(deps2, args) {
672
1070
  renderMs: result.ms,
673
1071
  output: outputMode,
674
1072
  locale: locale2,
1073
+ // Only when the payload actually carried per-locale entries — a legacy payload's
1074
+ // response stays byte-identical (D6).
1075
+ ...screenshotLocales.length > 0 ? { screenshotLocales } : {},
675
1076
  ...note ? { note } : {},
676
1077
  ...persisted ? { projectId: persisted.projectId, openUrl: persisted.openUrl } : {},
677
1078
  ...persistNote ? { projectNote: persistNote } : {},
@@ -690,8 +1091,17 @@ async function handleEmitBundle(deps2, args) {
690
1091
  if (!isValidBundleId(args.bundleId)) {
691
1092
  return errorResult(`invalid bundleId "${args.bundleId}" \u2014 expected reverse-DNS, e.g. com.acme.app`);
692
1093
  }
693
- const locale2 = args.locale?.trim() || "en-US";
694
1094
  const projectName2 = args.projectName?.trim() || "storeframe";
1095
+ const requestedLocales = dedupeLocales(args.locales);
1096
+ if (requestedLocales.length >= 2) {
1097
+ if (args.createProject) {
1098
+ return errorResult(
1099
+ "createProject is not supported with the multi-locale `locales` form \u2014 save the editable project separately with save_project (a project record is structure + look, locale-independent), or emit a single-locale bundle with `locale`."
1100
+ );
1101
+ }
1102
+ return emitMultiLocaleBundle(deps2, args, requestedLocales, projectName2);
1103
+ }
1104
+ const locale2 = requestedLocales[0] ?? (args.locale?.trim() || "en-US");
695
1105
  if (args.createProject && args.panels && args.panels.length > 0) {
696
1106
  return errorResult("createProject is not supported on the compose-only `panels` path (nothing is re-styled here) \u2014 use render_strip({ createProject: true }) or save_project on the render path instead.");
697
1107
  }
@@ -703,9 +1113,15 @@ async function handleEmitBundle(deps2, args) {
703
1113
  if (args.panels && args.panels.length > 0) {
704
1114
  const dims = panelDimensionsFor(args.panelPresetId ?? "r69");
705
1115
  panelLabel = panelLabelFor(args.panelPresetId ?? "r69", dims.width, dims.height);
1116
+ let panelEntries;
1117
+ try {
1118
+ panelEntries = args.panels.map((p) => resolveLocaleVariant(p, locale2));
1119
+ } catch (err) {
1120
+ return errorResult(err.message);
1121
+ }
706
1122
  let bytesList;
707
1123
  try {
708
- bytesList = await Promise.all(args.panels.map((p) => fetchUploadedRef(p.ref, deps2.userId)));
1124
+ bytesList = await Promise.all(panelEntries.map((p) => fetchUploadedRef(p.ref, deps2.userId)));
709
1125
  } catch (err) {
710
1126
  return errorResult(`failed to fetch panel ref: ${err.message}`);
711
1127
  }
@@ -715,9 +1131,15 @@ async function handleEmitBundle(deps2, args) {
715
1131
  if (!args.screenshots || args.screenshots.length === 0) {
716
1132
  return errorResult("emit_bundle needs either `screenshots` or pre-rendered `panels` refs.");
717
1133
  }
1134
+ let entries;
1135
+ try {
1136
+ entries = resolveLocaleVariantSlots(args.screenshots, locale2);
1137
+ } catch (err) {
1138
+ return errorResult(err.message);
1139
+ }
718
1140
  let screenshots2;
719
1141
  try {
720
- screenshots2 = await resolveScreenshots(args.screenshots, deps2.userId, deps2.allowLocalScreenshots ?? false);
1142
+ screenshots2 = await resolveScreenshots(entries, deps2.userId, deps2.allowLocalScreenshots ?? false);
721
1143
  } catch (err) {
722
1144
  return errorResult(err.message);
723
1145
  }
@@ -730,7 +1152,7 @@ async function handleEmitBundle(deps2, args) {
730
1152
  style: resolved.style,
731
1153
  locale: locale2,
732
1154
  buildRecord: args.createProject === true,
733
- ...args.createProject && args.screenshots ? { screenshotNames: resolveScreenshotNames(args.screenshots) } : {}
1155
+ ...args.createProject ? { screenshotNames: resolveScreenshotNames(entries) } : {}
734
1156
  });
735
1157
  if (!result.ok || !result.panels || !result.panelPresetId) {
736
1158
  return errorResult(`render failed: ${result.error ?? "unknown error"}`);
@@ -740,6 +1162,11 @@ async function handleEmitBundle(deps2, args) {
740
1162
  previewPng = result.stripBase64 ? Buffer.from(result.stripBase64, "base64") : null;
741
1163
  projectRecord = result.projectRecord;
742
1164
  }
1165
+ const declaredLocales = collectVariantLocales([...(args.screenshots ?? []).flat(), ...args.panels ?? []]);
1166
+ if (declaredLocales.length > 0) {
1167
+ const hint = `per-locale screenshots detected (${declaredLocales.join(", ")}) \u2014 this call bundled only ${locale2}; pass locales: [...] to emit ONE multi-locale bundle.`;
1168
+ note = note ? `${note} ${hint}` : hint;
1169
+ }
743
1170
  const zip = buildFastlaneBundleZip(slots, { bundleId: args.bundleId, locale: locale2, panelLabel });
744
1171
  const filename = bundleZipFilename(projectName2);
745
1172
  let shareLink;
@@ -790,6 +1217,116 @@ async function handleEmitBundle(deps2, args) {
790
1217
  ...zipOut
791
1218
  });
792
1219
  }
1220
+ async function emitMultiLocaleBundle(deps2, args, locales, projectName2) {
1221
+ let localeSlots;
1222
+ let panelLabel;
1223
+ let previewPng = null;
1224
+ let note;
1225
+ if (args.panels && args.panels.length > 0) {
1226
+ const dims = panelDimensionsFor(args.panelPresetId ?? "r69");
1227
+ panelLabel = panelLabelFor(args.panelPresetId ?? "r69", dims.width, dims.height);
1228
+ const refCache = /* @__PURE__ */ new Map();
1229
+ const fetchRef = (ref) => {
1230
+ let pending = refCache.get(ref);
1231
+ if (!pending) {
1232
+ pending = fetchUploadedRef(ref, deps2.userId);
1233
+ refCache.set(ref, pending);
1234
+ }
1235
+ return pending;
1236
+ };
1237
+ localeSlots = [];
1238
+ try {
1239
+ for (const loc of locales) {
1240
+ const refs = args.panels.map((p) => resolveLocaleVariant(p, loc));
1241
+ const bytesList = await Promise.all(refs.map((p) => fetchRef(p.ref)));
1242
+ localeSlots.push({
1243
+ locale: loc,
1244
+ slots: bytesList.map((bytes, i) => ({ displayPosition: i + 1, bytes: Buffer.from(bytes) }))
1245
+ });
1246
+ }
1247
+ } catch (err) {
1248
+ return errorResult(`failed to fetch panel ref: ${err.message}`);
1249
+ }
1250
+ const firstPanel = localeSlots[0]?.slots[0];
1251
+ previewPng = firstPanel ? Buffer.from(firstPanel.bytes) : null;
1252
+ } else {
1253
+ if (!args.screenshots || args.screenshots.length === 0) {
1254
+ return errorResult("emit_bundle needs either `screenshots` or pre-rendered `panels` refs.");
1255
+ }
1256
+ const resolved = await resolveLook(deps2, args);
1257
+ note = resolved.note;
1258
+ localeSlots = [];
1259
+ panelLabel = "";
1260
+ for (const loc of locales) {
1261
+ let entries;
1262
+ try {
1263
+ entries = resolveLocaleVariantSlots(args.screenshots, loc);
1264
+ } catch (err) {
1265
+ return errorResult(err.message);
1266
+ }
1267
+ let screenshots2;
1268
+ try {
1269
+ screenshots2 = await resolveScreenshots(entries, deps2.userId, deps2.allowLocalScreenshots ?? false);
1270
+ } catch (err) {
1271
+ return errorResult(err.message);
1272
+ }
1273
+ const result = await deps2.renderer.render({
1274
+ screenshots: screenshots2,
1275
+ panelPresetId: args.panelPresetId,
1276
+ look: resolved.look,
1277
+ style: resolved.style,
1278
+ locale: loc
1279
+ });
1280
+ if (!result.ok || !result.panels || !result.panelPresetId) {
1281
+ return errorResult(`render failed for locale ${loc}: ${result.error ?? "unknown error"}`);
1282
+ }
1283
+ localeSlots.push({
1284
+ locale: loc,
1285
+ slots: result.panels.map((b64, i) => ({ displayPosition: i + 1, bytes: Buffer.from(b64, "base64") }))
1286
+ });
1287
+ if (!panelLabel) panelLabel = panelLabelFor(result.panelPresetId, result.panelWidth ?? 0, result.panelHeight ?? 0);
1288
+ if (!previewPng && result.stripBase64) previewPng = Buffer.from(result.stripBase64, "base64");
1289
+ }
1290
+ }
1291
+ const zip = buildMultiLocaleFastlaneBundleZip(localeSlots, { bundleId: args.bundleId, panelLabel });
1292
+ const filename = bundleZipFilename(projectName2);
1293
+ let shareLink;
1294
+ if (args.share) {
1295
+ if (!previewPng) return errorResult("cannot create share link: no preview image available");
1296
+ try {
1297
+ shareLink = await deps2.createShareLink({
1298
+ userId: deps2.userId,
1299
+ zip,
1300
+ previewPng,
1301
+ projectName: projectName2,
1302
+ panelCount: localeSlots[0]?.slots.length ?? 0,
1303
+ bundleId: args.bundleId,
1304
+ // The share row's locale is display metadata — name every locale in the bundle.
1305
+ locale: locales.join(", "),
1306
+ origin: deps2.studioOrigin
1307
+ });
1308
+ } catch (err) {
1309
+ return errorResult(`could not create share link: ${err.message}`);
1310
+ }
1311
+ }
1312
+ const outputMode = resolveOutputMode(deps2.forceOutput ?? args.output, zip.byteLength);
1313
+ const zipOut = outputMode === "urls" ? await storeAsset(uploadDirFor(deps2.userId), filename, zip, "application/zip").then((a) => ({
1314
+ zipUrl: a.url,
1315
+ zipRef: a.ref
1316
+ })) : { zipBase64: Buffer.from(zip).toString("base64") };
1317
+ return textResult({
1318
+ ok: true,
1319
+ filename,
1320
+ bundleId: args.bundleId,
1321
+ locales,
1322
+ panelCount: localeSlots[0]?.slots.length ?? 0,
1323
+ // per locale — total files = panelCount × locales.length
1324
+ output: outputMode,
1325
+ ...note ? { note } : {},
1326
+ ...shareLink ? { shareLink } : {},
1327
+ ...zipOut
1328
+ });
1329
+ }
793
1330
  async function handleSaveProject(deps2, args) {
794
1331
  const { look: look2, style: style2 } = await resolveLook(deps2, args);
795
1332
  const locale2 = args.locale?.trim() || "en-US";
@@ -800,7 +1337,13 @@ async function handleSaveProject(deps2, args) {
800
1337
  if (slotSizes.some((n) => n < 1)) {
801
1338
  return errorResult("every `screenshots` slot must contain at least one screenshot entry.");
802
1339
  }
803
- const screenshotNames = resolveScreenshotNames(args.screenshots).flat();
1340
+ let entries;
1341
+ try {
1342
+ entries = resolveLocaleVariantSlots(args.screenshots, locale2);
1343
+ } catch (err) {
1344
+ return errorResult(err.message);
1345
+ }
1346
+ const screenshotNames = resolveScreenshotNames(entries).flat();
804
1347
  let record;
805
1348
  try {
806
1349
  record = await deps2.renderer.buildRecord({
@@ -856,6 +1399,145 @@ async function handleReadLook(deps2, args) {
856
1399
  look: saved.look
857
1400
  });
858
1401
  }
1402
+ function projectFileFromRecord(record) {
1403
+ const inner = record?.project;
1404
+ return inner != null && typeof inner === "object" ? inner : record;
1405
+ }
1406
+ async function handleReadProject(deps2, args) {
1407
+ const project2 = await deps2.resolveProject(deps2.userId, args.project);
1408
+ if (!project2) {
1409
+ return textResult({
1410
+ saved: false,
1411
+ message: args.project ? "No such project on this account." : "No Storeframe project on this account yet \u2014 open Storeframe Studio and load a strip first."
1412
+ });
1413
+ }
1414
+ const rec = await deps2.readProjectRecord(project2.id);
1415
+ if (!rec) {
1416
+ return textResult({
1417
+ saved: false,
1418
+ project: { id: project2.id, name: project2.name },
1419
+ message: `Project "${project2.name}" has no saved record yet \u2014 open it in Storeframe Studio and save a strip first.`
1420
+ });
1421
+ }
1422
+ return textResult({
1423
+ saved: true,
1424
+ project: { id: project2.id, name: project2.name },
1425
+ updatedAt: rec.updatedAt,
1426
+ pinnedVersion: project2.pinnedVersion,
1427
+ // null = renders follow the latest look version
1428
+ project_file: projectFileFromRecord(rec.record),
1429
+ message: `Full project for "${project2.name}". project_file is the designer's current strip (frame order in \`panels\`, per-locale words in \`captionText\`, \`locales\`, styling). To detect changes, remember updatedAt and compare on your next read_project \u2014 a newer value means the designer edited it.`
1430
+ });
1431
+ }
1432
+ function frameNamesFromProjectFile(projectFile) {
1433
+ const shots = projectFile?.shots;
1434
+ if (!Array.isArray(shots)) return [];
1435
+ return shots.map((s) => s?.frameName).filter((n) => typeof n === "string" && n.length > 0);
1436
+ }
1437
+ function handoffRefsFromRecord(record) {
1438
+ const raw = record?.handoffRefs;
1439
+ if (!raw || typeof raw !== "object") return {};
1440
+ const out = {};
1441
+ for (const [name, ref] of Object.entries(raw)) {
1442
+ if (typeof ref === "string" && ref.length > 0) out[name] = ref;
1443
+ }
1444
+ return out;
1445
+ }
1446
+ async function handleRenderProject(deps2, args) {
1447
+ const project2 = await deps2.resolveProject(deps2.userId, args.project);
1448
+ if (!project2) {
1449
+ return errorResult(
1450
+ args.project ? "No such project on this account." : "No Storeframe project on this account yet \u2014 open Storeframe Studio and load a strip first."
1451
+ );
1452
+ }
1453
+ const rec = await deps2.readProjectRecord(project2.id);
1454
+ if (!rec) {
1455
+ return errorResult(`Project "${project2.name}" has no saved record yet \u2014 open it in Storeframe Studio and save a strip first.`);
1456
+ }
1457
+ const projectFile = projectFileFromRecord(rec.record);
1458
+ const nested = args.screenshots.map((e) => [e]);
1459
+ let resolvedBytes;
1460
+ try {
1461
+ resolvedBytes = await resolveScreenshots(nested, deps2.userId, deps2.allowLocalScreenshots ?? false);
1462
+ } catch (err) {
1463
+ return errorResult(err.message);
1464
+ }
1465
+ const names = resolveScreenshotNames(nested);
1466
+ const screensByName = {};
1467
+ let unnamed = 0;
1468
+ names.forEach((slot2, i) => {
1469
+ const name = slot2[0];
1470
+ if (name) screensByName[name] = resolvedBytes[i][0];
1471
+ else unnamed++;
1472
+ });
1473
+ const expected = frameNamesFromProjectFile(projectFile);
1474
+ const handoffRefs = handoffRefsFromRecord(rec.record);
1475
+ const handoffUsed = [];
1476
+ if (deps2.fetchHandoffBytes) {
1477
+ for (const frameName of expected) {
1478
+ if (screensByName[frameName]) continue;
1479
+ const ref = handoffRefs[frameName];
1480
+ if (!ref) continue;
1481
+ try {
1482
+ const bytes = await deps2.fetchHandoffBytes(ref);
1483
+ screensByName[frameName] = Buffer.from(bytes).toString("base64");
1484
+ handoffUsed.push(frameName);
1485
+ } catch {
1486
+ }
1487
+ }
1488
+ }
1489
+ if (expected.length > 0 && !expected.some((n) => screensByName[n])) {
1490
+ return errorResult(
1491
+ `None of the supplied screenshots matched this project's frames by filename. This project expects: ${expected.join(", ")}. Pass each raw screenshot with its original \`name\` (its filename), in any order \u2014 render_project matches by name.`
1492
+ );
1493
+ }
1494
+ const locale2 = args.locale?.trim() || void 0;
1495
+ const result = await deps2.renderer.renderProject({ projectFile, screensByName, locale: locale2, preview: args.preview });
1496
+ if (!result.ok || !result.panels) {
1497
+ return errorResult(`render failed: ${result.error ?? "unknown error"}`);
1498
+ }
1499
+ const shots = result.shots ?? [];
1500
+ const missing = shots.filter((s) => !s.matched).map((s) => s.frameName);
1501
+ const totalBytes = result.panels.reduce((sum, p) => sum + estimateDecodedBytes(p), 0);
1502
+ const outputMode = resolveOutputMode(deps2.forceOutput ?? args.output, totalBytes);
1503
+ const panels = outputMode === "urls" ? await uploadPanels(deps2.userId, result.panels) : result.panels;
1504
+ const notes = [];
1505
+ if (handoffUsed.length > 0) {
1506
+ notes.push(
1507
+ `${handoffUsed.length} shot(s) were resolved from the designer's uploaded screenshots (auto-handoff): ${handoffUsed.join(", ")}.`
1508
+ );
1509
+ }
1510
+ if (missing.length > 0) {
1511
+ notes.push(
1512
+ `${missing.length} shot(s) had no matching screenshot and rendered without a device: ${missing.join(", ")}. Supply those files (by filename) to complete the strip.`
1513
+ );
1514
+ }
1515
+ if (unnamed > 0) {
1516
+ notes.push(
1517
+ `${unnamed} supplied screenshot(s) had no filename (name) and could not be matched \u2014 pass each screenshot's original filename via \`name\`.`
1518
+ );
1519
+ }
1520
+ return textResult({
1521
+ ok: true,
1522
+ count: result.count,
1523
+ panelPresetId: result.panelPresetId,
1524
+ panelWidth: result.panelWidth,
1525
+ panelHeight: result.panelHeight,
1526
+ renderMs: result.ms,
1527
+ output: outputMode,
1528
+ locale: result.locale ?? locale2 ?? "en-US",
1529
+ project: { id: project2.id, name: project2.name },
1530
+ pinnedVersion: project2.pinnedVersion,
1531
+ // echoed for transparency; the render uses the record's saved look
1532
+ shots,
1533
+ // [{ frameName, matched }] — which of the project's shots got developer bytes
1534
+ ...handoffUsed.length > 0 ? { handoffUsed } : {},
1535
+ // shots resolved from the designer's uploads
1536
+ ...missing.length > 0 ? { missing } : {},
1537
+ ...notes.length > 0 ? { note: notes.join(" ") } : {},
1538
+ panels
1539
+ });
1540
+ }
859
1541
  async function handleSaveLook(deps2, args) {
860
1542
  const project2 = await deps2.resolveProject(deps2.userId, args.project);
861
1543
  if (!project2) {
@@ -888,10 +1570,12 @@ async function handleRequestScreenshotUpload(deps2, args) {
888
1570
  } catch (err) {
889
1571
  return errorResult(err.message);
890
1572
  }
1573
+ const locale2 = args.locale?.trim() || void 0;
891
1574
  return textResult({
892
1575
  ok: true,
893
1576
  slots,
894
- instructions: 'PUT each screenshot\'s raw PNG bytes to its uploadUrl, e.g.: curl -T screenshot.png "<uploadUrl>" \u2014 then pass { "ref": "<the matching ref>" } as that screenshot\'s entry in render_strip/emit_bundle\'s `screenshots` array. If you passed `names`, that ref already carries its filename \u2014 no need to also set `name` on the screenshot entry. Uploads are not auto-purged yet \u2014 treat refs as short-lived and single-use.'
1577
+ ...locale2 ? { locale: locale2 } : {},
1578
+ instructions: 'PUT each screenshot\'s raw PNG bytes to its uploadUrl, e.g.: curl -T screenshot.png "<uploadUrl>" \u2014 then pass { "ref": "<the matching ref>" } as that screenshot\'s entry in render_strip/emit_bundle\'s `screenshots` array. If you passed `names`, that ref already carries its filename \u2014 no need to also set `name` on the screenshot entry. Uploads are not auto-purged yet \u2014 treat refs as short-lived and single-use.' + (locale2 ? ` These slots are tagged for ${locale2}: place each ref under that key of a per-locale screenshot entry, e.g. { "locales": { "${locale2}": { "ref": "<ref>" } } }.` : "")
895
1579
  });
896
1580
  }
897
1581
  var SERVER_INSTRUCTIONS = [
@@ -910,6 +1594,13 @@ var SERVER_INSTRUCTIONS = [
910
1594
  "3. emit_bundle({ screenshots|panels, bundleId }) \u2014 a fastlane deliver-ready zip. Pass a prior",
911
1595
  ' render_strip output:"urls" result as `panels` to package without re-rendering.',
912
1596
  "",
1597
+ "## Input hygiene \u2014 feed RAW app screenshots, never already-composed panels",
1598
+ "render_strip expects RAW in-app screen captures (no device frame, no caption). Do NOT feed it a",
1599
+ "previously-rendered Storeframe panel, an exported fastlane image, or any screenshot that already",
1600
+ "has a phone frame or caption baked in \u2014 it double-frames (a phone inside a phone). If a folder",
1601
+ "holds both raw captures and finished panels, use the raw ones. Rule of thumb: if it already",
1602
+ "looks like an App Store screenshot, it is the WRONG input.",
1603
+ "",
913
1604
  "## Big jobs: render, THEN bundle (do NOT render+zip in one call for 4+ panels)",
914
1605
  "A full-resolution render is seconds PER PANEL and runs one panel at a time, so a 5-panel",
915
1606
  "full-res render can take a couple of minutes \u2014 long enough that a single render+zip call",
@@ -920,32 +1611,63 @@ var SERVER_INSTRUCTIONS = [
920
1611
  "(near-instant, no re-render). Preview (preview:true) is always fast and safe for iteration \u2014",
921
1612
  "only the FINAL full-res pass is the slow one, so split that one.",
922
1613
  "",
1614
+ "## Per-locale screenshots \u2192 ONE multi-locale bundle",
1615
+ 'A screenshot entry can carry per-locale variants: { "locales": { "en-US": { "ref": "\u2026" },',
1616
+ '"de-DE": { "ref": "\u2026" } } }. render_strip picks the top-level `locale`\'s variant; a locale',
1617
+ "with no variant of its own falls back to the en-US one (ship German captions over English",
1618
+ 'screens now, upgrade the pixels later). emit_bundle({ locales: ["en-US","de-DE"], \u2026 }) emits',
1619
+ "ONE zip with a fastlane/screenshots/<locale>/ folder per locale \u2014 fastlane deliver uploads",
1620
+ "every locale in a single run. request_screenshot_upload({ count, names, locale }) tags a",
1621
+ "batch of upload slots with the locale it is for (echoed back; bookkeeping only).",
1622
+ "RECOMMENDED multi-locale flow \u2014 render per locale, then compose ONCE (this is also the only",
1623
+ "way to get per-locale CAPTION text into one bundle, since caption text is per-render input):",
1624
+ `1. per locale L: render_strip({ locale: L, output: "urls", style: { captions: [<L's words>] },`,
1625
+ ` screenshots: [<L's screens or { "locales": \u2026 } entries>] }) \u2192 per-panel refs for L.`,
1626
+ '2. emit_bundle({ locales: [...], bundleId, panels: [ { "locales": { "en-US": { "ref": p1en },',
1627
+ ' "de-DE": { "ref": p1de } } }, \u2026 ] }) \u2014 zips every locale with NO re-render, near-instant.',
1628
+ "The one-call form (emit_bundle with `screenshots` + `locales`) works but renders once PER",
1629
+ "locale with the SAME captions for every locale \u2014 and multiplies the slow full-res render by",
1630
+ "the locale count (see Big jobs above), so prefer the compose flow for real jobs.",
1631
+ "",
923
1632
  "## Styling \u2014 you can fully art-direct the strip via the `style` object",
924
1633
  "Call describe_look for the exact field catalog + defaults; this is the map of what to reach for.",
925
1634
  "Three groups (all optional \u2014 omit for a sensible dark default):",
926
1635
  "- style.shotLook (the device, same for every phone): angle (front|left|right), material",
927
1636
  " (real|clay), colorway (orange|blue|silver|custom+customColor), finish (0 matte\u20261 glossy),",
928
- " roll (-45\u202645\xB0 tilt), phoneHeight (20\u2026100 % of panel), hOffset/vOffset to nudge position,",
929
- " glare, reflections, lighting, flatScreen.",
1637
+ " clearcoat (0\u20261 glossy clear-coat lacquer, on top of finish), roll (-45\u202645\xB0 tilt),",
1638
+ " phoneHeight (20\u2026100 % of panel), hOffset/vOffset to nudge position, glare, reflections,",
1639
+ " lighting, flatScreen.",
930
1640
  "- style.background (the strip): mode gradient|perPanel; gradientFrom/gradientTo (hex),",
931
- " gradientDir vertical|horizontal, shadow; or panelColors[] (one hex per slot) for perPanel.",
932
- "- style.captions[] (ONE entry per panel, in slot order; null = no caption): text (headline),",
933
- " subtitle, fontId (inter|manrope|poppins|fraunces|space-grotesk), sizePt, color (hex),",
934
- " align, anchor {x,y} (0\u20131 position), maxWidth (0\u20131 fraction of panel width).",
1641
+ " gradientDir vertical|horizontal, shadow, floorReflection; or panelColors[] (one hex per slot) for perPanel.",
1642
+ "- style.captions[] (ONE entry per panel, in slot order; null = no caption). An entry is EITHER a",
1643
+ " single caption object OR an array of layers stacked on that panel: [{text,\u2026},{text,\u2026}]. Fields:",
1644
+ " text (this layer\u2019s words), subtitle (single-caption shape only \u2014 in the array shape add another",
1645
+ " layer instead), fontId (inter|manrope|poppins|fraunces|space-grotesk), sizePt, color (hex),",
1646
+ " align, anchor {x,y} (0\u20131 position; OMIT to auto-place above the device), maxWidth (0\u20131 fraction).",
935
1647
  "panelPresetId picks the App Store size: r69 (6.9\u2033, default, 1290\xD72796) | r65 | r55 | ipad13 |",
936
1648
  "ipad129. Match it to the pixel size of your source screenshots (1320\xD72868 or 1290\xD72796 \u2192 r69).",
1649
+ "Per-panel variety (a DIFFERENT device per panel \u2014 mixed-device or continuous-scene strips):",
1650
+ "style.shotLook is ONE device for the WHOLE strip. To style each panel differently, pass a `look`",
1651
+ "instead of style \u2014 a look carries `shots[]`, ONE entry per panel in slot order, each with its own",
1652
+ '`look` (same fields as shotLook). e.g. look:{ shots:[ {panelId:"panel-1",look:{angle:"left"}},',
1653
+ '{panelId:"panel-2",look:{angle:"front",colorway:"orange"}} ] } tilts panel 1 and recolours panel 2',
1654
+ "in one render_strip call. This is the exact shape read_look/save_look use; describe_look details it.",
1655
+ "Continuous scene: panels have no hard divider \u2014 a phone pushed past its panel edge with a large",
1656
+ "hOffset overflows into the NEIGHBOURING panel, so one device (or a multi-phone fan) can span",
1657
+ "several panels and the strip reads as one swipe-through image. Deliberate \u2014 preview to place it.",
937
1658
  "",
938
- "## Caption guardrails (do this or captions crowd \u2014 the #1 first-render mistake)",
939
- 'Defaults are fontId "inter", sizePt 44, anchor {x:0.5, y:0.06} (near the top), maxWidth 0.86.',
940
- "At 44pt a headline longer than ~15\u201318 characters WRAPS to two lines, and with a subtitle the",
941
- "two then collide at the top of the panel. To keep it clean:",
942
- "- Keep headlines SHORT (aim \u2264 ~18 chars) so they stay one line; or drop sizePt to ~36\u201340 if",
943
- " they must be longer; or raise maxWidth toward 1.0 to reduce wrapping.",
944
- "- If you use a subtitle, give the block room: lower the phone a little (shotLook.phoneHeight",
945
- " ~64\u201368) and/or set the caption anchor slightly higher (anchor.y ~0.04). Preview and LOOK at",
946
- " the result before finalizing \u2014 a crowded headline+subtitle is the most common bad first pass.",
947
- '- Punchy, benefit-led headlines read best ("Track every throw", "See your stats"), one idea',
948
- " per panel. A consistent fontId + color across all panels makes the strip feel designed.",
1659
+ "## Caption guardrails (do this or captions crowd)",
1660
+ 'Defaults are fontId "inter", sizePt 44, maxWidth 0.86, and NO anchor (auto). By default a caption',
1661
+ "AUTO-LAYOUTS in a band above the device and the phone is pushed down so the two never collide \u2014",
1662
+ "even a headline that wraps to two lines, or several stacked layers, clears the phone automatically.",
1663
+ "So you rarely need to set anchor at all. To keep it reading well:",
1664
+ '- Punchy, benefit-led headlines read best ("Track every throw", "See your stats"), one idea per',
1665
+ " panel. A consistent fontId + color across all panels makes the strip feel designed.",
1666
+ "- Want a headline + a smaller subline? Pass an ARRAY of layers for that panel \u2014 the first larger,",
1667
+ " the second smaller (lower sizePt) \u2014 instead of one caption; they stack in order in the band.",
1668
+ "- Keep headlines fairly short; or drop sizePt to ~36\u201340, or raise maxWidth toward 1.0, to reduce",
1669
+ " wrapping if a line looks too tight. Setting anchor {x,y} OVERRIDES auto-layout \u2014 the caption is",
1670
+ " then pinned exactly there (it will not dodge the phone), so only set it when you want that.",
949
1671
  "",
950
1672
  "## Two kinds of state, kept separate on purpose",
951
1673
  "- STYLING (the look \u2014 device, background, caption styling) is reusable: save_look persists it",
@@ -988,6 +1710,27 @@ var SERVER_INSTRUCTIONS = [
988
1710
  "Skip the names and the record falls back to positional filenames (screen-1.png, screen-2.png\u2026)",
989
1711
  "the user's folder can't match \u2014 the single worst create-project failure mode. Pass the real filenames.",
990
1712
  "",
1713
+ '## "Any updates?" \u2014 read back what a designer changed (read_project)',
1714
+ "read_look returns only STYLING. To see what a designer edited in the web app \u2014 frame ORDER, the",
1715
+ "per-locale caption WORDS, the locale list, structural changes \u2014 call read_project. It returns the",
1716
+ "full project as `project_file` (an opaque ProjectFile: `panels` in the designer's order,",
1717
+ "`captionText` keyed by locale then panel, `locales`, plus the styling) together with `updatedAt`.",
1718
+ 'To answer a recurring "any updates?": remember the `updatedAt` you last saw and compare on the next',
1719
+ "read_project \u2014 a newer value means the designer changed something; re-read project_file to see what.",
1720
+ "read_project is read-only and never returns screenshots (zero-custody). To reproduce the designer's",
1721
+ "exact strip, feed the project to render_project with YOUR OWN raw screenshots (matched by filename).",
1722
+ "",
1723
+ "## Reproduce a saved project (render_project)",
1724
+ "render_project({ project?, screenshots }) re-renders a SAVED project with YOUR OWN raw screenshots \u2014",
1725
+ "the headless twin of opening the project in the web app and re-loading the raws. The PROJECT owns the",
1726
+ "structure (frame order, per-locale captions, styling); you just supply the pixels. Pass `screenshots`",
1727
+ "as a FLAT list of entries (base64 / { ref } / { url }), EACH with its original filename as `name` \u2014",
1728
+ "they are matched to the project's shots BY filename, so the ORDER you pass them in does not matter and",
1729
+ "you must NOT pre-sort. Pass `locale` to render that locale's caption words (the project already holds",
1730
+ "them). A shot with no matching file renders without its device and is listed in the result's `shots`",
1731
+ "/`missing` report \u2014 supply that filename to complete the strip. Typical flow: read_project to see what",
1732
+ "the designer changed, then render_project passing your raws by their real filenames.",
1733
+ "",
991
1734
  "## Refine visually, then re-render headlessly",
992
1735
  "A human can refine the look in Storeframe Studio (https://storeframe-studio.vercel.app \u2014 sign",
993
1736
  "in as the SAME account) and Save look; read_look then returns exactly that, so an agent",
@@ -999,7 +1742,7 @@ function registerTools(server2, deps2) {
999
1742
  "render_strip",
1000
1743
  {
1001
1744
  title: "Render App Store strip",
1002
- description: "Turn app screenshots into styled 3D device mockups laid out as an App Store screenshot strip, and return the finished per-panel PNGs. Optionally style with a saved look, render a cheap low-res `preview`, and get results `inline` or as `urls` for large payloads. For real, full-resolution screenshots, call request_screenshot_upload first \u2014 inline base64 is for small payloads only. No store credential is involved \u2014 this only renders images.",
1745
+ description: 'Turn app screenshots into styled 3D device mockups laid out as an App Store screenshot strip, and return the finished per-panel PNGs. Optionally style with a saved look, render a cheap low-res `preview`, and get results `inline` or as `urls` for large payloads. Screenshots can vary per App Store locale via { "locales": \u2026 } entries \u2014 `locale` picks which variant renders (missing variants fall back to en-US). For real, full-resolution screenshots, call request_screenshot_upload first \u2014 inline base64 is for small payloads only. No store credential is involved \u2014 this only renders images.',
1003
1746
  inputSchema: renderStripShape
1004
1747
  },
1005
1748
  (args) => handleRenderStrip(deps2, args)
@@ -1008,7 +1751,7 @@ function registerTools(server2, deps2) {
1008
1751
  "emit_bundle",
1009
1752
  {
1010
1753
  title: "Emit fastlane bundle",
1011
- description: 'Render the strip (or package pre-rendered `panels` refs from a prior render_strip `output: "urls"` call, without re-rendering), then package it as a `fastlane deliver`-ready zip (screenshots + a pre-filled, never-submitting Deliverfile + a README). Returns the zip `inline` or as a `url` for large payloads, and can also mint a 14-day share link to a landing page. YOU upload it with your own fastlane \u2014 this server never touches a store credential and never submits for review.',
1754
+ description: 'Render the strip (or package pre-rendered `panels` refs from a prior render_strip `output: "urls"` call, without re-rendering), then package it as a `fastlane deliver`-ready zip (screenshots + a pre-filled, never-submitting Deliverfile + a README). Pass `locales: [...]` for ONE multi-locale bundle \u2014 a fastlane/screenshots/<locale>/ folder per locale, best combined with per-locale { "locales": \u2026 } screenshot/panel entries. Returns the zip `inline` or as a `url` for large payloads, and can also mint a 14-day share link to a landing page. YOU upload it with your own fastlane \u2014 this server never touches a store credential and never submits for review.',
1012
1755
  inputSchema: emitBundleShape
1013
1756
  },
1014
1757
  (args) => handleEmitBundle(deps2, args)
@@ -1031,6 +1774,24 @@ function registerTools(server2, deps2) {
1031
1774
  },
1032
1775
  (args) => handleReadLook(deps2, args)
1033
1776
  );
1777
+ server2.registerTool(
1778
+ "read_project",
1779
+ {
1780
+ title: "Read full project",
1781
+ description: "Return a project's FULL current state \u2014 the designer's frame order (panels), per-locale caption words (captionText), locale list, and styling \u2014 as an opaque ProjectFile, NOT just the look. Use it to answer \"what did the designer change?\": remember the returned `updatedAt` and re-read later; a newer value means the strip was edited. Read-only, no store credential. To re-render this project with your own screenshots, pass it to render_project.",
1782
+ inputSchema: readProjectShape
1783
+ },
1784
+ (args) => handleReadProject(deps2, args)
1785
+ );
1786
+ server2.registerTool(
1787
+ "render_project",
1788
+ {
1789
+ title: "Render a saved project",
1790
+ description: "Re-render a SAVED Storeframe project with your OWN raw screenshots \u2014 the headless twin of opening the project in the web app and re-loading the raws. Pass the developer's screenshots as a FLAT list (each with its original filename as `name`); they are matched to the project's shots BY filename, so ORDER does not matter and you never pre-sort. The project supplies the structure \u2014 frame order, per-locale caption words (pick one with `locale`), styling \u2014 so the result reproduces the designer's exact strip. Shots with no matching file are reported as missing (not an error). Returns per-panel PNGs (`inline` or `urls`). No store credential.",
1791
+ inputSchema: renderProjectShape
1792
+ },
1793
+ (args) => handleRenderProject(deps2, args)
1794
+ );
1034
1795
  server2.registerTool(
1035
1796
  "save_look",
1036
1797
  {
@@ -1053,7 +1814,7 @@ function registerTools(server2, deps2) {
1053
1814
  "request_screenshot_upload",
1054
1815
  {
1055
1816
  title: "Request screenshot upload slots",
1056
- description: "Mint signed upload URLs so real screenshots' bytes never transit this conversation's context \u2014 PUT bytes directly to the returned URLs, then pass the returned `ref`s into render_strip/emit_bundle. Use this instead of inline base64 for real (full-resolution) screenshots.",
1817
+ description: "Mint signed upload URLs so real screenshots' bytes never transit this conversation's context \u2014 PUT bytes directly to the returned URLs, then pass the returned `ref`s into render_strip/emit_bundle. Use this instead of inline base64 for real (full-resolution) screenshots. Uploading one batch per App Store locale? Tag each batch with `locale` (echoed back for your bookkeeping).",
1057
1818
  inputSchema: requestScreenshotUploadShape
1058
1819
  },
1059
1820
  (args) => handleRequestScreenshotUpload(deps2, args)
@@ -1079,119 +1840,13 @@ import { chromium } from "playwright";
1079
1840
  import { createServer as createServer2 } from "vite";
1080
1841
  import { fileURLToPath as fileURLToPath2 } from "node:url";
1081
1842
 
1082
- // ../mockup-engine/appstore.ts
1083
- var DEFAULT_LOOK = {
1084
- angle: "front",
1085
- cameraPos: null,
1086
- roll: "0",
1087
- phoneHeight: "72",
1088
- hOffset: "0",
1089
- vOffset: "0",
1090
- material: "real",
1091
- colorway: "silver",
1092
- customColor: "F5F5F5",
1093
- finish: "0",
1094
- clayTone: "grey",
1095
- clayCustom: "B0B0B0",
1096
- flatScreen: false,
1097
- glare: false,
1098
- lighting: true,
1099
- reflections: false
1100
- };
1101
- var DEFAULT_CAPTION_STYLE2 = {
1102
- fontId: "inter",
1103
- sizePt: 44,
1104
- color: "FFFFFF",
1105
- align: "center",
1106
- anchor: { x: 0.5, y: 0.06 },
1107
- maxWidth: 0.86
1108
- };
1109
- var DEFAULT_LOCALE = "en-US";
1110
- function normalizeCaptionStyles(raw) {
1111
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
1112
- const out = {};
1113
- for (const [panelId, entry] of Object.entries(raw)) {
1114
- if (!entry || typeof entry !== "object") continue;
1115
- const e = entry;
1116
- out[panelId] = {
1117
- ...DEFAULT_CAPTION_STYLE2,
1118
- ...e,
1119
- anchor: { ...DEFAULT_CAPTION_STYLE2.anchor, ...e.anchor && typeof e.anchor === "object" ? e.anchor : {} }
1120
- };
1121
- }
1122
- return out;
1123
- }
1124
- function normalizeFlatCaptionMap(raw) {
1125
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
1126
- const out = {};
1127
- for (const [panelId, entry] of Object.entries(raw)) {
1128
- if (!entry || typeof entry !== "object") continue;
1129
- const e = entry;
1130
- if (typeof e.headline !== "string") continue;
1131
- out[panelId] = { headline: e.headline, ...typeof e.subtitle === "string" ? { subtitle: e.subtitle } : {} };
1132
- }
1133
- return out;
1134
- }
1135
- function normalizeCaptionText(raw) {
1136
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
1137
- const entries = Object.entries(raw);
1138
- const looksFlat = entries.some(
1139
- ([, value]) => !!value && typeof value === "object" && typeof value.headline === "string"
1140
- );
1141
- if (looksFlat) return { [DEFAULT_LOCALE]: normalizeFlatCaptionMap(raw) };
1142
- const out = {};
1143
- for (const [locale2, inner] of entries) out[locale2] = normalizeFlatCaptionMap(inner);
1144
- return out;
1145
- }
1146
- function shotsInPanelOrder(shots, panels) {
1147
- return panels.flatMap((panel) => shots.filter((s) => s.panelId === panel.id));
1148
- }
1149
- var PROJECT_SCHEMA_VERSION = 2;
1150
- function serializeProject(state) {
1151
- const ordered = shotsInPanelOrder(state.shots, state.panels);
1152
- const shots = ordered.map((s) => ({
1153
- id: s.id,
1154
- panelId: s.panelId,
1155
- frameNodeId: s.frameNodeId,
1156
- frameName: s.frameName,
1157
- look: s.look,
1158
- inputAnchor: s.inputAnchor,
1159
- // v2 provenance — persisted so drift survives reload
1160
- outputAnchor: s.outputAnchor
1161
- }));
1162
- return {
1163
- schema: PROJECT_SCHEMA_VERSION,
1164
- shots,
1165
- panels: state.panels,
1166
- selectedShotId: state.selectedShotId,
1167
- // Caption TEXT is content (C1): emitted here on the project record, deliberately NOT
1168
- // via extractStripStyle — so it can never leak into serializeLook's styling blob.
1169
- // normalizeCaptionText upgrades the flat v1 input to the locale-nested stored shape
1170
- // (flat → { [DEFAULT_LOCALE]: … }); a Phase-3 nested input passes through unchanged.
1171
- captionText: normalizeCaptionText(state.captionText),
1172
- ...extractStripStyle(state)
1173
- };
1174
- }
1175
- function extractStripStyle(state) {
1176
- return {
1177
- panelPresetId: state.panelPresetId,
1178
- bgMode: state.bgMode,
1179
- gradientFrom: state.gradientFrom,
1180
- gradientTo: state.gradientTo,
1181
- gradientDir: state.gradientDir,
1182
- panelColors: state.panelColors,
1183
- shadow: state.shadow,
1184
- captionStyles: state.captionStyles ?? {}
1185
- };
1186
- }
1187
-
1188
1843
  // ../mockup-engine/stripFromSlots.ts
1189
1844
  var FALLBACK_GRADIENT_FROM = "#1b1b2e";
1190
1845
  var FALLBACK_GRADIENT_TO = "#0a0a14";
1191
1846
  function staggeredHOffset(k) {
1192
1847
  return String(Math.max(-100, Math.min(100, k * 35)));
1193
1848
  }
1194
- var NUMERIC_STRING_FIELDS = ["roll", "phoneHeight", "hOffset", "vOffset", "finish"];
1849
+ var NUMERIC_STRING_FIELDS = ["roll", "phoneHeight", "hOffset", "vOffset", "finish", "clearcoat"];
1195
1850
  function coerceShotLook(raw) {
1196
1851
  if (!raw || typeof raw !== "object") return {};
1197
1852
  const out = { ...raw };
@@ -1200,6 +1855,14 @@ function coerceShotLook(raw) {
1200
1855
  }
1201
1856
  return out;
1202
1857
  }
1858
+ function coerceCaptionLayer(el) {
1859
+ const { subtitle: _subtitle, sizePt, maxWidth, ...rest } = el;
1860
+ return {
1861
+ ...rest,
1862
+ ...sizePt !== void 0 ? { sizePt: Number(sizePt) } : {},
1863
+ ...maxWidth !== void 0 ? { maxWidth: Number(maxWidth) } : {}
1864
+ };
1865
+ }
1203
1866
  function remapByOrdinal(record, sourceOrder, targetIds) {
1204
1867
  const out = {};
1205
1868
  if (!record) return out;
@@ -1212,7 +1875,7 @@ function remapByOrdinal(record, sourceOrder, targetIds) {
1212
1875
  function stripFromSlots(input) {
1213
1876
  const slotSizes = input.slotSizes.filter((n) => Number.isInteger(n) && n > 0);
1214
1877
  const panelIds = slotSizes.map((_, i) => `panel-${i + 1}`);
1215
- const captionText = {};
1878
+ let captionLayers = {};
1216
1879
  const opaque = !input.style && input.look && typeof input.look === "object" ? input.look : null;
1217
1880
  const style2 = input.style ?? null;
1218
1881
  const opaqueShots = opaque && Array.isArray(opaque.shots) ? opaque.shots : [];
@@ -1229,11 +1892,13 @@ function stripFromSlots(input) {
1229
1892
  });
1230
1893
  let panelColors = {};
1231
1894
  let captionStyles = {};
1895
+ let panelBackgrounds = {};
1232
1896
  let bgMode = "gradient";
1233
1897
  let gradientFrom = FALLBACK_GRADIENT_FROM;
1234
1898
  let gradientTo = FALLBACK_GRADIENT_TO;
1235
1899
  let gradientDir = "vertical";
1236
1900
  let shadow = true;
1901
+ let floorReflection = false;
1237
1902
  if (style2) {
1238
1903
  const bg = style2.background ?? {};
1239
1904
  bgMode = bg.mode === "perPanel" ? "perPanel" : "gradient";
@@ -1241,6 +1906,7 @@ function stripFromSlots(input) {
1241
1906
  if (typeof bg.gradientTo === "string") gradientTo = bg.gradientTo;
1242
1907
  if (bg.gradientDir === "horizontal") gradientDir = "horizontal";
1243
1908
  if (bg.shadow === false) shadow = false;
1909
+ if (bg.floorReflection === true) floorReflection = true;
1244
1910
  if (Array.isArray(bg.panelColors)) {
1245
1911
  bg.panelColors.forEach((color, i) => {
1246
1912
  if (typeof color === "string" && color && panelIds[i] !== void 0) panelColors[panelIds[i]] = color;
@@ -1248,22 +1914,28 @@ function stripFromSlots(input) {
1248
1914
  }
1249
1915
  if (Array.isArray(style2.captions)) {
1250
1916
  const rawStyles = {};
1917
+ const inner = {};
1251
1918
  style2.captions.forEach((entry, i) => {
1252
- if (!entry || typeof entry !== "object" || panelIds[i] === void 0) return;
1253
- const { text, subtitle, sizePt, maxWidth, ...rest } = entry;
1254
- rawStyles[panelIds[i]] = {
1255
- ...rest,
1256
- ...sizePt !== void 0 ? { sizePt: Number(sizePt) } : {},
1257
- ...maxWidth !== void 0 ? { maxWidth: Number(maxWidth) } : {}
1258
- };
1259
- if (typeof text === "string" && text.trim()) {
1260
- captionText[panelIds[i]] = {
1261
- headline: text,
1262
- ...typeof subtitle === "string" && subtitle ? { subtitle } : {}
1919
+ const panelId = panelIds[i];
1920
+ if (panelId === void 0 || entry === null || entry === void 0) return;
1921
+ if (Array.isArray(entry)) {
1922
+ const layers = entry.filter((el) => !!el && typeof el === "object" && typeof el.text === "string" && el.text.trim() !== "").map(coerceCaptionLayer);
1923
+ if (layers.length > 0) inner[panelId] = layers;
1924
+ } else if (typeof entry === "object") {
1925
+ const { text, subtitle, sizePt, maxWidth, ...rest } = entry;
1926
+ rawStyles[panelId] = {
1927
+ ...rest,
1928
+ ...sizePt !== void 0 ? { sizePt: Number(sizePt) } : {},
1929
+ ...maxWidth !== void 0 ? { maxWidth: Number(maxWidth) } : {}
1263
1930
  };
1931
+ if (typeof text === "string" && text.trim()) {
1932
+ inner[panelId] = { headline: text, ...typeof subtitle === "string" && subtitle ? { subtitle } : {} };
1933
+ }
1264
1934
  }
1265
1935
  });
1266
- captionStyles = normalizeCaptionStyles(rawStyles);
1936
+ const localeLayers = normalizeCaptionText({ [DEFAULT_LOCALE]: inner }, normalizeCaptionStyles(rawStyles));
1937
+ captionStyles = captionStylesFromLayers(localeLayers);
1938
+ captionLayers = resolveCaptionText(localeLayers);
1267
1939
  }
1268
1940
  } else if (opaque) {
1269
1941
  bgMode = opaque.bgMode === "perPanel" ? "perPanel" : "gradient";
@@ -1271,19 +1943,33 @@ function stripFromSlots(input) {
1271
1943
  if (typeof opaque.gradientTo === "string") gradientTo = opaque.gradientTo;
1272
1944
  if (opaque.gradientDir === "horizontal") gradientDir = "horizontal";
1273
1945
  if (opaque.shadow === false) shadow = false;
1946
+ if (opaque.floorReflection === true) floorReflection = true;
1274
1947
  const sourceOrder = [];
1275
1948
  for (const s of opaqueShots) {
1276
1949
  const id = typeof s?.panelId === "string" ? s.panelId : null;
1277
1950
  if (id && !sourceOrder.includes(id)) sourceOrder.push(id);
1278
1951
  }
1279
1952
  panelColors = remapByOrdinal(opaque.panelColors, sourceOrder, panelIds);
1280
- captionStyles = remapByOrdinal(normalizeCaptionStyles(opaque.captionStyles), sourceOrder, panelIds);
1953
+ captionStyles = remapByOrdinal(normalizeCaptionLayerStyles(opaque.captionStyles), sourceOrder, panelIds);
1954
+ panelBackgrounds = remapByOrdinal(normalizePanelBackgrounds(opaque.panelBackgrounds), sourceOrder, panelIds);
1281
1955
  }
1282
1956
  const panelPresetId2 = input.panelPresetId ?? (typeof opaque?.panelPresetId === "string" ? opaque.panelPresetId : void 0) ?? "r69";
1283
1957
  return {
1284
- look: { panelPresetId: panelPresetId2, bgMode, gradientFrom, gradientTo, gradientDir, panelColors, shadow, captionStyles, shots },
1958
+ look: {
1959
+ panelPresetId: panelPresetId2,
1960
+ bgMode,
1961
+ gradientFrom,
1962
+ gradientTo,
1963
+ gradientDir,
1964
+ panelColors,
1965
+ shadow,
1966
+ floorReflection,
1967
+ panelBackgrounds,
1968
+ captionStyles,
1969
+ shots
1970
+ },
1285
1971
  panelIds,
1286
- captionText
1972
+ captionText: captionLayers
1287
1973
  };
1288
1974
  }
1289
1975
 
@@ -1330,6 +2016,7 @@ function recordFromStrip(input) {
1330
2016
  gradientDir: look2.gradientDir,
1331
2017
  panelColors: look2.panelColors,
1332
2018
  shadow: look2.shadow,
2019
+ panelBackgrounds: look2.panelBackgrounds,
1333
2020
  captionStyles: look2.captionStyles,
1334
2021
  // Nest this render's flat caption text under its locale (default en-US) so the stored blob
1335
2022
  // is locale-correct; serializeProject's normalizeCaptionText passes a nested map through.
@@ -1469,6 +2156,17 @@ var StripRenderer = class {
1469
2156
  }
1470
2157
  return result;
1471
2158
  }
2159
+ // render_project: same page + boot handshake as render(); only the harness entry differs
2160
+ // (window.renderProject restores the saved ProjectFile and joins bytes by frameName).
2161
+ async renderProject(input) {
2162
+ await this.boot();
2163
+ if (!this.page) return { ok: false, error: "renderer failed to boot" };
2164
+ const result = await this.page.evaluate((inp) => window.renderProject(inp), input);
2165
+ if (this.sawContextLost) {
2166
+ console.warn("[renderer] CONTEXT_LOST observed since boot");
2167
+ }
2168
+ return result;
2169
+ }
1472
2170
  async close() {
1473
2171
  await this.page?.close().catch(() => {
1474
2172
  });
@@ -1490,6 +2188,57 @@ var StripRenderer = class {
1490
2188
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
1491
2189
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
1492
2190
  import { dirname } from "node:path";
2191
+
2192
+ // package.json
2193
+ var package_default = {
2194
+ name: "storeframe-mcp",
2195
+ version: "0.2.0",
2196
+ private: false,
2197
+ type: "module",
2198
+ description: "The bundle-emitting MCP server over the @engine/@sync spine. Exposes render_strip, emit_bundle, read_look to agents (Claude Code / Cursor / CI) over Streamable HTTP, auth'd with personal Storeframe API tokens. Zero-custody: no code path accepts, stores, or forwards a store-signing credential \u2014 upload stays the user's own fastlane. Renders via headless Playwright Chromium, the engine's native non-browser habitat (same path as mockup-mcp). Also ships as a free LOCAL stdio server (`npx storeframe-mcp`) \u2014 same render, on your own machine, no account needed.",
2199
+ license: "UNLICENSED",
2200
+ bin: {
2201
+ "storeframe-mcp": "./dist/local.js"
2202
+ },
2203
+ files: [
2204
+ "dist",
2205
+ "harness-dist",
2206
+ "README.md"
2207
+ ],
2208
+ engines: {
2209
+ node: ">=20.12"
2210
+ },
2211
+ scripts: {
2212
+ dev: "tsx src/server.ts",
2213
+ start: "tsx src/server.ts",
2214
+ "build:harness": "vite build --config vite.harness.config.mjs",
2215
+ "build:node": "node build.mjs",
2216
+ build: "npm run build:harness && npm run build:node",
2217
+ prepublishOnly: "npm run build",
2218
+ typecheck: "tsc --noEmit"
2219
+ },
2220
+ dependencies: {
2221
+ "@modelcontextprotocol/sdk": "^1.29.0",
2222
+ express: "^5.2.1",
2223
+ fflate: "^0.8.3",
2224
+ playwright: "1.61.1",
2225
+ vite: "^5.4.0",
2226
+ zod: "^3.25.76"
2227
+ },
2228
+ devDependencies: {
2229
+ "@types/express": "^5.0.6",
2230
+ esbuild: "^0.28.1",
2231
+ pixelmatch: "^7.2.0",
2232
+ pngjs: "^7.0.0",
2233
+ tsx: "^4.23.0",
2234
+ typescript: "^5.6.0"
2235
+ }
2236
+ };
2237
+
2238
+ // src/version.ts
2239
+ var VERSION = package_default.version;
2240
+
2241
+ // src/hostedBridge.ts
1493
2242
  var DEFAULT_HOSTED_MCP_URL = "https://storeframe-mcp.fly.dev/mcp";
1494
2243
  function hostedMcpUrl() {
1495
2244
  return (process.env.STOREFRAME_MCP_URL || DEFAULT_HOSTED_MCP_URL).replace(/\/+$/, "");
@@ -1527,7 +2276,7 @@ async function callSaveProject(client, ctx, extra) {
1527
2276
  return { id: res.projectId };
1528
2277
  }
1529
2278
  async function connectHostedBridge(token2) {
1530
- const client = new Client({ name: "storeframe-mcp-local-bridge", version: "0.1.0" }, { capabilities: {} });
2279
+ const client = new Client({ name: "storeframe-mcp-local-bridge", version: VERSION }, { capabilities: {} });
1531
2280
  const transport = new StreamableHTTPClientTransport(new URL(hostedMcpUrl()), {
1532
2281
  requestInit: { headers: { Authorization: `Bearer ${token2}` } }
1533
2282
  });
@@ -1555,6 +2304,14 @@ async function connectHostedBridge(token2) {
1555
2304
  // known gap — see the module comment above.
1556
2305
  };
1557
2306
  },
2307
+ async readProjectRecord(projectId) {
2308
+ const res = await callRemoteTool(client, "read_project", { project: projectId });
2309
+ if (!res.saved) return null;
2310
+ return {
2311
+ record: { project: res.project_file },
2312
+ updatedAt: typeof res.updatedAt === "string" ? res.updatedAt : null
2313
+ };
2314
+ },
1558
2315
  async saveLook(projectId, look2, sourceName) {
1559
2316
  const res = await callRemoteTool(client, "save_look", {
1560
2317
  project: projectId,
@@ -1596,6 +2353,7 @@ function localToolDeps(token2) {
1596
2353
  ...base,
1597
2354
  resolveProject: async () => null,
1598
2355
  readLook: async () => null,
2356
+ readProjectRecord: async () => null,
1599
2357
  saveLook: async () => {
1600
2358
  throw new Error(`Saving a look is ${SIGN_IN}`);
1601
2359
  },
@@ -1627,6 +2385,7 @@ function localToolDeps(token2) {
1627
2385
  ...base,
1628
2386
  resolveProject: async (userId, projectId) => (await bridge()).resolveProject(userId, projectId),
1629
2387
  readLook: async (projectId) => (await bridge()).readLook(projectId),
2388
+ readProjectRecord: async (projectId) => (await bridge()).readProjectRecord(projectId),
1630
2389
  saveLook: async (projectId, look2, sourceName) => (await bridge()).saveLook(projectId, look2, sourceName),
1631
2390
  createProject: async (userId, name, record, ctx) => (await bridge()).createProject(userId, name, record, ctx),
1632
2391
  updateProjectRecord: async (projectId, record, ctx) => (await bridge()).updateProjectRecord(projectId, record, ctx),
@@ -1657,7 +2416,7 @@ Rendering runs on THIS machine \u2014 free, no timeout ceiling. A hosted token i
1657
2416
  ## Local mode (this connection)
1658
2417
  Rendering runs on THIS machine \u2014 free, no timeout ceiling. No hosted token is configured, so save_project / read_look / save_look / share links cleanly no-op ("sign in") \u2014 pass local files as { "path": "..." } screenshot entries and render_strip / emit_bundle work fully.`;
1659
2418
  var server = new McpServer(
1660
- { name: "storeframe-mcp-local", version: "0.1.0" },
2419
+ { name: "storeframe-mcp-local", version: VERSION },
1661
2420
  { instructions: localInstructions }
1662
2421
  );
1663
2422
  registerTools(server, { userId: "local", ...deps });