shotops-mcp 0.9.7 → 0.9.9

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
@@ -24,7 +24,7 @@
24
24
  }
25
25
  await visit('')
26
26
  return hash.digest('hex')
27
- })(sourceDirectory) !== "1b56a11440edcc23083bfa4a283fe2d972acfb3a17820247bc951bcd5cc3810d") {
27
+ })(sourceDirectory) !== "726565c5a58fb0cf9d366865195f22440699f6a55c8789edcd92577fc682ebbf") {
28
28
  console.error('shotops-mcp: dist/local.js is stale; run npm run build in shotops-mcp.');
29
29
  }
30
30
  } catch { /* An unreadable checkout must not break an otherwise working CLI. */ }
@@ -44,6 +44,27 @@ var __export = (target, all) => {
44
44
  __defProp(target, name, { get: all[name], enumerable: true });
45
45
  };
46
46
 
47
+ // ../mockup-engine/appstore/presets.ts
48
+ function presetById(id) {
49
+ return PANEL_PRESETS.find((p) => p.id === id) ?? PANEL_PRESETS[0];
50
+ }
51
+ var PANEL_PRESETS;
52
+ var init_presets = __esm({
53
+ "../mockup-engine/appstore/presets.ts"() {
54
+ "use strict";
55
+ PANEL_PRESETS = [
56
+ { id: "r69", label: "6.9\u2033 iPhone \xB7 1320\xD72868", width: 1320, height: 2868 },
57
+ { id: "r65", label: "6.5\u2033 iPhone \xB7 1242\xD72688", width: 1242, height: 2688 },
58
+ { id: "r55", label: "5.5\u2033 iPhone \xB7 1242\xD72208", width: 1242, height: 2208 },
59
+ // Pixel 10 Pro — a tall marketing canvas matching the 6.9″ iPhone's dimensions so
60
+ // the strip layout + caption scale are identical; only the 3D body differs.
61
+ { id: "pixel69", label: "Pixel 10 Pro \xB7 1320\xD72868", width: 1320, height: 2868 },
62
+ { id: "ipad13", label: "13\u2033 iPad \xB7 2064\xD72752", width: 2064, height: 2752 },
63
+ { id: "ipad129", label: "12.9\u2033 iPad \xB7 2048\xD72732", width: 2048, height: 2732 }
64
+ ];
65
+ }
66
+ });
67
+
47
68
  // ../mockup-engine/appstore/devices.ts
48
69
  function deviceById(id) {
49
70
  return DEVICES.find((d) => d.id === id);
@@ -70,9 +91,23 @@ function deriveOutputs(explicit, panelPresetId2) {
70
91
  }
71
92
  return [DEFAULT_OUTPUT_DEVICE_ID];
72
93
  }
94
+ function activeDeviceId(outputs, panelPresetId2) {
95
+ const match = outputs.find((id) => deviceById(id)?.panelPresetId === panelPresetId2);
96
+ return match ?? outputs[0];
97
+ }
98
+ function projectOutputAxis(projectFile) {
99
+ const file = projectFile ?? {};
100
+ const explicit = Array.isArray(file.outputs) ? file.outputs.filter((id) => typeof id === "string") : void 0;
101
+ const outputs = deriveOutputs(explicit, typeof file.panelPresetId === "string" ? file.panelPresetId : void 0);
102
+ const panelPresetId2 = presetById(typeof file.panelPresetId === "string" ? file.panelPresetId : "").id;
103
+ return { outputs, panelPresetId: panelPresetId2, activeDeviceId: activeDeviceId(outputs, panelPresetId2) };
104
+ }
73
105
  function screenFamilyForDevice(deviceId) {
74
106
  return deviceById(deviceId)?.screenFamily ?? BASE_SCREEN_FAMILY;
75
107
  }
108
+ function screenFamilyForPreset(panelPresetId2) {
109
+ return DEVICES.find((d) => d.panelPresetId === panelPresetId2)?.screenFamily ?? BASE_SCREEN_FAMILY;
110
+ }
76
111
  function outputScreenFamilies(outputs) {
77
112
  const seen = /* @__PURE__ */ new Set();
78
113
  const out = [];
@@ -89,6 +124,7 @@ var IPHONE_MODEL_ID, PIXEL_MODEL_ID, DEVICES, DEFAULT_OUTPUT_DEVICE_ID, BASE_SCR
89
124
  var init_devices = __esm({
90
125
  "../mockup-engine/appstore/devices.ts"() {
91
126
  "use strict";
127
+ init_presets();
92
128
  IPHONE_MODEL_ID = "iphone-17-pro";
93
129
  PIXEL_MODEL_ID = "pixel-10-pro";
94
130
  DEVICES = [
@@ -104,10 +140,15 @@ var init_devices = __esm({
104
140
  });
105
141
 
106
142
  // ../mockup-engine/appstore/catalog/caption.ts
107
- var CAPTION_STYLE_BOUNDS;
143
+ var CAPTION_STYLE_DEFAULTS, CAPTION_STYLE_BOUNDS;
108
144
  var init_caption = __esm({
109
145
  "../mockup-engine/appstore/catalog/caption.ts"() {
110
146
  "use strict";
147
+ CAPTION_STYLE_DEFAULTS = {
148
+ lineHeight: 1.2,
149
+ bandInset: 0.06,
150
+ bandCenter: 0.5
151
+ };
111
152
  CAPTION_STYLE_BOUNDS = {
112
153
  sizePt: { min: 20, max: 120 },
113
154
  maxWidth: { min: 0.1, max: 1 },
@@ -120,7 +161,10 @@ var init_caption = __esm({
120
161
  });
121
162
 
122
163
  // ../mockup-engine/appstore/catalog/defaults.ts
123
- var DEFAULT_LOOK, SHOT_LOOK_DOMAIN, SHOT_LOOK_RANGES, DEFAULT_CAPTION_ANCHOR, DEFAULT_CAPTION_STYLE;
164
+ function deviceSizeFromLegacyPhoneHeight(phoneHeight) {
165
+ return Math.round(phoneHeight / DEVICE_SIZE_BASE_FRACTION * 100) / 100;
166
+ }
167
+ var DEFAULT_LOOK, SHOT_LOOK_DOMAIN, SHOT_LOOK_RANGES, DEVICE_SIZE_BASE_FRACTION, DEFAULT_CAPTION_ANCHOR, DEFAULT_CAPTION_STYLE;
124
168
  var init_defaults = __esm({
125
169
  "../mockup-engine/appstore/catalog/defaults.ts"() {
126
170
  "use strict";
@@ -138,19 +182,24 @@ var init_defaults = __esm({
138
182
  // pushPhonesBelowCaptionBands() solved per-render before e0f587c ("Stop captions moving
139
183
  // devices") deleted it: captions must never move a device, so the clearance has to be baked
140
184
  // into the default instead.
141
- // deviceTop(v) = (1 - phoneHeight)/2 + (v/100)/2 [layoutStrip, +y = down]
185
+ // deviceTop(v) = (1 - deviceHeightFraction(deviceSize))/2 + (v/100)/2 [layoutStrip, +y = down]
142
186
  // bandBottom(n) = CAPTION_BAND_TOP + n·LINE_HEIGHT·sizePt/PANEL_LOGICAL_HEIGHT_PT
143
- // At 82% height and a 32pt caption, deviceTop = 18.0% and the device sits FLUSH to the bottom
187
+ // At deviceSize 100 (82% of panel height) and a 32pt caption, deviceTop = 18.0% and the device sits FLUSH to the bottom
144
188
  // edge (18.0 + 82 = 100.0). A full 2-line headline's ink ends at 13.6%, so the gap is 4.4%;
145
189
  // a 3-line headline still clears by 0.3%. Four lines overlap — that is what Bleed is for, and
146
190
  // it buys the room by running the device 10% off the bottom edge. Re-derive via
147
- // catalog/layouts.ts if sizePt or phoneHeight changes; its tests are the guard.
148
- phoneHeight: "82",
191
+ // catalog/layouts.ts if sizePt or deviceSize changes; its tests are the guard.
192
+ deviceSize: "100",
149
193
  hOffset: "0",
150
194
  vOffset: "18",
151
195
  material: "real",
152
- colorway: "silver",
153
- customColor: "F5F5F5",
196
+ // Deep Blue on the iPhone, Moonstone on the Pixel (which binds no `blue`, so it falls back to its
197
+ // first finish — deviceFinishes.ts). A product call, not a derived one: the blue body is the one
198
+ // that reads as a device against the widest range of strip backgrounds, where Silver disappeared
199
+ // into a light one. `customColor` starts at the same hex so opening the custom well doesn't jump
200
+ // the phone to a different colour before anyone has picked anything.
201
+ colorway: "blue",
202
+ customColor: "32374A",
154
203
  finish: "0",
155
204
  clearcoat: "0",
156
205
  clayTone: "grey",
@@ -164,7 +213,7 @@ var init_defaults = __esm({
164
213
  angle: { valueKind: "angle", required: true, defaultSource: "default-look", defaultValue: DEFAULT_LOOK.angle, persistence: "always" },
165
214
  cameraPos: { valueKind: "camera-position", required: true, defaultSource: "default-look", defaultValue: DEFAULT_LOOK.cameraPos, persistence: "always" },
166
215
  roll: { valueKind: "numeric-string", required: true, defaultSource: "default-look", defaultValue: DEFAULT_LOOK.roll, persistence: "always" },
167
- phoneHeight: { valueKind: "numeric-string", required: true, defaultSource: "default-look", defaultValue: DEFAULT_LOOK.phoneHeight, persistence: "always" },
216
+ deviceSize: { valueKind: "numeric-string", required: true, defaultSource: "default-look", defaultValue: DEFAULT_LOOK.deviceSize, persistence: "always" },
168
217
  hOffset: { valueKind: "numeric-string", required: true, defaultSource: "default-look", defaultValue: DEFAULT_LOOK.hOffset, persistence: "always" },
169
218
  vOffset: { valueKind: "numeric-string", required: true, defaultSource: "default-look", defaultValue: DEFAULT_LOOK.vOffset, persistence: "always" },
170
219
  material: { valueKind: "material", required: true, defaultSource: "default-look", defaultValue: DEFAULT_LOOK.material, persistence: "always" },
@@ -176,21 +225,27 @@ var init_defaults = __esm({
176
225
  clayCustom: { valueKind: "string", required: true, defaultSource: "default-look", defaultValue: DEFAULT_LOOK.clayCustom, persistence: "always" },
177
226
  flatScreen: { valueKind: "boolean", required: true, defaultSource: "default-look", defaultValue: DEFAULT_LOOK.flatScreen, persistence: "always" },
178
227
  clipToFrame: { valueKind: "boolean", required: false, defaultSource: "explicit", defaultValue: false, persistence: "omit-default" },
228
+ deviceFrame: { valueKind: "boolean", required: false, defaultSource: "explicit", defaultValue: true, persistence: "omit-default" },
229
+ screenCornerRadius: { valueKind: "numeric-string", required: false, defaultSource: "explicit", defaultValue: "12", persistence: "omit-default" },
179
230
  glare: { valueKind: "boolean", required: true, defaultSource: "default-look", defaultValue: DEFAULT_LOOK.glare, persistence: "always" },
180
231
  lighting: { valueKind: "boolean", required: true, defaultSource: "default-look", defaultValue: DEFAULT_LOOK.lighting, persistence: "always" },
181
232
  reflections: { valueKind: "boolean", required: true, defaultSource: "default-look", defaultValue: DEFAULT_LOOK.reflections, persistence: "always" }
182
233
  };
183
234
  SHOT_LOOK_RANGES = {
184
235
  roll: { min: -45, max: 45 },
185
- // 20–200, which is what the clamp and both sliders have always done. Over 100% is a legitimate,
186
- // shipped composition — the device is TALLER than the panel and bleeds off an edge, which is
187
- // exactly what the Bleed layout template trades headline room for.
188
- phoneHeight: { min: 20, max: 200 },
236
+ // Percent of the default device size (see DEVICE_SIZE_BASE_FRACTION): 25–240 is 20–200% of the
237
+ // panel's height. Past ~122 the device is TALLER than the panel and bleeds off an edge — a
238
+ // legitimate, shipped composition, and exactly what the Bleed layout template trades headline
239
+ // room for.
240
+ deviceSize: { min: 25, max: 240 },
189
241
  hOffset: { min: -100, max: 100 },
190
242
  vOffset: { min: -200, max: 200 },
191
243
  finish: { min: 0, max: 1 },
192
- clearcoat: { min: 0, max: 1 }
244
+ clearcoat: { min: 0, max: 1 },
245
+ // Percent of the flat card's WIDTH (#907); 50 turns the short edges into semicircles.
246
+ screenCornerRadius: { min: 0, max: 50 }
193
247
  };
248
+ DEVICE_SIZE_BASE_FRACTION = 0.82;
194
249
  DEFAULT_CAPTION_ANCHOR = { x: 0.5, y: 0.06 };
195
250
  DEFAULT_CAPTION_STYLE = {
196
251
  fontId: "inter",
@@ -362,6 +417,9 @@ function variantKey(variant) {
362
417
  if (variant.locale !== void 0) parts.push(`locale=${variant.locale}`);
363
418
  return parts.join("&");
364
419
  }
420
+ function isBaseVariant(variant) {
421
+ return variantKey(variant) === "";
422
+ }
365
423
  function variantAxes(variant) {
366
424
  const out = [];
367
425
  if (variant.family !== void 0) out.push("family");
@@ -540,9 +598,9 @@ function sha256Hex(text3) {
540
598
  const words = [h0, h1, h2, h3, h4, h5, h6, h7];
541
599
  let out = "";
542
600
  for (let i = 0; i < words.length; i += 1) {
543
- let hex = (words[i] >>> 0).toString(16);
544
- while (hex.length < 8) hex = `0${hex}`;
545
- out += hex;
601
+ let hex2 = (words[i] >>> 0).toString(16);
602
+ while (hex2.length < 8) hex2 = `0${hex2}`;
603
+ out += hex2;
546
604
  }
547
605
  return out;
548
606
  }
@@ -1855,7 +1913,7 @@ var init_resultContract = __esm({
1855
1913
  { code: "render_failed", test: /^render failed|^renderer returned / },
1856
1914
  { code: "payload_too_large", test: /inline-response ceiling|is too large/ },
1857
1915
  { code: "storage_failed", test: /could not store the rendered panels|could not create share link/ },
1858
- { code: "asset_not_found", test: /failed to read panel|no preview image available|does not belong to this account/ },
1916
+ { code: "asset_not_found", test: /failed to read panel|no preview image available|does not belong to this account|no screenshot was ever uploaded for ref/ },
1859
1917
  { code: "persistence_failed", test: /could not build the project record|no project record was produced/ },
1860
1918
  { code: "quota_exhausted", test: /not enough credits|credit balance|wallet is empty/ },
1861
1919
  // Input-shape complaints raised by a HANDLER rather than by the schema. Last, and each anchored
@@ -1945,7 +2003,7 @@ var init_refineIntent = __esm({
1945
2003
  init_defaults();
1946
2004
  init_fonts();
1947
2005
  MAX_REFINE_PROMPT_LENGTH = 1200;
1948
- AI_COLORWAY_IDS = ["orange", "blue", "silver", "green"];
2006
+ AI_COLORWAY_IDS = ["orange", "blue", "ice", "silver", "red", "black", "green"];
1949
2007
  AI_CLAY_TONES = ["grey", "white", "charcoal"];
1950
2008
  AI_SHOT_RANGES = {
1951
2009
  orbit: { min: -180, max: 180 },
@@ -1982,7 +2040,7 @@ var init_shotLookProjection = __esm({
1982
2040
  valueKind: "camera-position",
1983
2041
  validation: { wireKind: "nullable-camera-position" },
1984
2042
  schemaDescription: "Manual camera position override; null/omit = use the angle preset.",
1985
- catalogDescription: "{ x, y, z } rotates the camera around the device to refine the angle beyond the presets, or null to use the preset. It does NOT zoom or resize \u2014 device SIZE is set ONLY by phoneHeight (the export alpha-crops the device tight, so camera distance is discarded)."
2043
+ catalogDescription: "{ x, y, z } rotates the camera around the device to refine the angle beyond the presets, or null to use the preset. It does NOT zoom or resize \u2014 device SIZE is set ONLY by deviceSize (the export alpha-crops the device tight, so camera distance is discarded)."
1986
2044
  },
1987
2045
  roll: {
1988
2046
  exposure: "public",
@@ -1991,12 +2049,12 @@ var init_shotLookProjection = __esm({
1991
2049
  schemaDescription: `${rangeText("roll")}\xB0 clock-hand tilt. Default ${defaultValue("roll")}.`,
1992
2050
  catalogDescription: `${catalogRange("roll")} (degrees, clock-hand tilt; continuous, unlike angle)`
1993
2051
  },
1994
- phoneHeight: {
2052
+ deviceSize: {
1995
2053
  exposure: "public",
1996
2054
  valueKind: "numeric-string",
1997
2055
  validation: { wireKind: "bounded-numeric" },
1998
- schemaDescription: `Phone height, ${rangeText("phoneHeight")} (% of panel height; over 100 bleeds off an edge). Default ${defaultValue("phoneHeight")} (the Standard layout template; prefer \`style.layout\` over setting this by hand).`,
1999
- catalogDescription: `${catalogRange("phoneHeight")} (% of panel height) \u2014 the ONLY device-size control. Over 100 the device is taller than the panel and bleeds off an edge. Default ${defaultValue("phoneHeight")} (the Standard layout template; prefer style.layout over setting this by hand).`
2056
+ schemaDescription: `Device size, ${rangeText("deviceSize")} (percent of the default device size; about 122 fills the panel height and more bleeds off an edge). Default ${defaultValue("deviceSize")} (the Standard layout template; prefer \`style.layout\` over setting this by hand).`,
2057
+ catalogDescription: `${catalogRange("deviceSize")} (percent of the default device size, which is 82% of the panel height) \u2014 the ONLY device-size control. Past about 122 the device is taller than the panel and bleeds off an edge. Default ${defaultValue("deviceSize")} (the Standard layout template; prefer style.layout over setting this by hand).`
2000
2058
  },
2001
2059
  hOffset: {
2002
2060
  exposure: "public",
@@ -2022,9 +2080,9 @@ var init_shotLookProjection = __esm({
2022
2080
  colorway: {
2023
2081
  exposure: "public",
2024
2082
  valueKind: "colorway",
2025
- validation: { wireKind: "enum", values: ["orange", "blue", "silver", "green", "custom"] },
2026
- schemaDescription: `Body colour (material "real"). Default ${defaultValue("colorway")}. These are finish SLOTS \u2014 each device binds its own real finish (silver = iPhone Silver / Pixel Moonstone). "green" is Pixel-only (Jade); other devices fall back to their first finish.`,
2027
- catalogDescription: "orange | blue | silver | green | custom (with customColor hex) \u2014 finish SLOTS, each device binds its own real finish (silver = iPhone Silver / Pixel Moonstone). green is Pixel-only (Jade); other devices fall back to their first finish."
2083
+ validation: { wireKind: "enum", values: ["orange", "blue", "ice", "silver", "red", "black", "green", "custom"] },
2084
+ schemaDescription: `Body colour (material "real"). Default ${defaultValue("colorway")}. These are finish SLOTS \u2014 each device binds its own real finish. On iPhone: blue = Deep Blue, silver = Silver, orange = Cosmic Orange, ice = Glacier, red = Burgundy, black = Black. A slot a device does not bind falls back to its first finish \u2014 "green" is Pixel-only (Jade).`,
2085
+ catalogDescription: "orange | blue | ice | silver | red | black | green | custom (with customColor hex) \u2014 finish SLOTS, each device binds its own real finish. On iPhone: blue = Deep Blue, silver = Silver, orange = Cosmic Orange, ice = Glacier, red = Burgundy, black = Black. A slot a device does not bind falls back to its first finish; green is Pixel-only (Jade)."
2028
2086
  },
2029
2087
  customColor: {
2030
2088
  exposure: "public",
@@ -2095,6 +2153,20 @@ var init_shotLookProjection = __esm({
2095
2153
  validation: { wireKind: "boolean" },
2096
2154
  schemaDescription: `Clip this complete device composite to its owning frame. Default ${defaultValue("clipToFrame")} (overflow allowed).`,
2097
2155
  catalogDescription: "boolean \u2014 clip this device, its screen, shadow and reflection to its owning frame; default false"
2156
+ },
2157
+ deviceFrame: {
2158
+ exposure: "public",
2159
+ valueKind: "boolean",
2160
+ validation: { wireKind: "boolean" },
2161
+ schemaDescription: `Render the 3D device frame around the screen. Default ${defaultValue("deviceFrame")}. Set false for a flat rounded screenshot card with no device chrome \u2014 screenCornerRadius then sets its corner rounding, and roll still rotates the flat card exactly as it tilts a framed device.`,
2162
+ catalogDescription: `boolean \u2014 false renders a flat rounded screenshot card instead of the 3D device; screenCornerRadius then sets its corner rounding, and roll still rotates the flat card. Default true.`
2163
+ },
2164
+ screenCornerRadius: {
2165
+ exposure: "public",
2166
+ valueKind: "numeric-string",
2167
+ validation: { wireKind: "bounded-numeric" },
2168
+ schemaDescription: `${rangeText("screenCornerRadius")} (percent of the flat card's width). Default ${defaultValue("screenCornerRadius")}. Applies only when deviceFrame is false \u2014 ignored on a framed device, whose corners come from the device model.`,
2169
+ catalogDescription: `${catalogRange("screenCornerRadius")} (percent of the flat card's width) \u2014 applies only when deviceFrame is false; ignored on a framed device.`
2098
2170
  }
2099
2171
  };
2100
2172
  MCP_SHOT_LOOK_PUBLIC_ENTRIES = Object.entries(MCP_SHOT_LOOK_PROJECTION).filter((entry) => entry[1].exposure === "public");
@@ -2104,24 +2176,12 @@ var init_shotLookProjection = __esm({
2104
2176
  }
2105
2177
  });
2106
2178
 
2107
- // ../mockup-engine/appstore/presets.ts
2108
- function presetById(id) {
2109
- return PANEL_PRESETS.find((p) => p.id === id) ?? PANEL_PRESETS[0];
2110
- }
2111
- var PANEL_PRESETS;
2112
- var init_presets = __esm({
2113
- "../mockup-engine/appstore/presets.ts"() {
2179
+ // ../mockup-engine/outputDeviceIds.ts
2180
+ var OUTPUT_DEVICE_IDS;
2181
+ var init_outputDeviceIds = __esm({
2182
+ "../mockup-engine/outputDeviceIds.ts"() {
2114
2183
  "use strict";
2115
- PANEL_PRESETS = [
2116
- { id: "r69", label: "6.9\u2033 iPhone \xB7 1290\xD72796", width: 1290, height: 2796 },
2117
- { id: "r65", label: "6.5\u2033 iPhone \xB7 1242\xD72688", width: 1242, height: 2688 },
2118
- { id: "r55", label: "5.5\u2033 iPhone \xB7 1242\xD72208", width: 1242, height: 2208 },
2119
- // Pixel 10 Pro — a tall marketing canvas matching the 6.9″ iPhone's dimensions so
2120
- // the strip layout + caption scale are identical; only the 3D body differs.
2121
- { id: "pixel69", label: "Pixel 10 Pro \xB7 1290\xD72796", width: 1290, height: 2796 },
2122
- { id: "ipad13", label: "13\u2033 iPad \xB7 2064\xD72752", width: 2064, height: 2752 },
2123
- { id: "ipad129", label: "12.9\u2033 iPad \xB7 2048\xD72732", width: 2048, height: 2732 }
2124
- ];
2184
+ OUTPUT_DEVICE_IDS = ["iphone-6-9", "iphone-6-5"];
2125
2185
  }
2126
2186
  });
2127
2187
 
@@ -2157,20 +2217,33 @@ function panelLabelFor(presetId, width, height) {
2157
2217
  function panelDimensionsFor(presetId) {
2158
2218
  return PANEL_DIMENSIONS[presetId] ?? PANEL_DIMENSIONS.r69;
2159
2219
  }
2160
- var MCP_UNKNOWN_PANEL_PRESET_IDS, MCP_PANEL_PRESETS, PANEL_PRESET_IDS, RENDERABLE_PANEL_PRESET_IDS, OUTPUT_DEVICE_IDS, OUTPUT_DEVICES, NOT_COVERED_BY_THIS_CALL, SIZE_SUFFIX, PANEL_LABELS, PANEL_DIMENSIONS;
2220
+ var MCP_UNRENDERED_DEVICE_IDS, MCP_UNKNOWN_PANEL_PRESET_IDS, MCP_PANEL_PRESETS, PANEL_PRESET_IDS, RENDERABLE_PANEL_PRESET_IDS, OUTPUT_DEVICES, NOT_COVERED_BY_THIS_CALL, SIZE_SUFFIX, PANEL_LABELS, PANEL_DIMENSIONS;
2161
2221
  var init_panelCatalog = __esm({
2162
2222
  "src/render/panelCatalog.ts"() {
2163
2223
  "use strict";
2164
2224
  init_presets();
2165
- MCP_UNKNOWN_PANEL_PRESET_IDS = ["pixel69"];
2225
+ init_devices();
2226
+ init_outputDeviceIds();
2227
+ init_outputDeviceIds();
2228
+ MCP_UNRENDERED_DEVICE_IDS = ["pixel-10-pro"];
2229
+ MCP_UNKNOWN_PANEL_PRESET_IDS = MCP_UNRENDERED_DEVICE_IDS.map(
2230
+ (id) => deviceById(id)?.panelPresetId ?? id
2231
+ );
2166
2232
  MCP_PANEL_PRESETS = PANEL_PRESETS.filter((preset) => !MCP_UNKNOWN_PANEL_PRESET_IDS.includes(preset.id));
2167
2233
  PANEL_PRESET_IDS = MCP_PANEL_PRESETS.map((preset) => preset.id);
2168
2234
  RENDERABLE_PANEL_PRESET_IDS = ["r69", "r65", "r55"];
2169
- OUTPUT_DEVICE_IDS = ["iphone-6-9", "iphone-6-5"];
2170
- OUTPUT_DEVICES = [
2171
- { id: "iphone-6-9", label: "iPhone 17 Pro", panelPresetId: "r69", ascDeviceSet: "APP_IPHONE_67" },
2172
- { id: "iphone-6-5", label: "iPhone 17", panelPresetId: "r65", ascDeviceSet: "APP_IPHONE_65" }
2173
- ];
2235
+ OUTPUT_DEVICES = OUTPUT_DEVICE_IDS.map((id) => {
2236
+ const device = deviceById(id);
2237
+ if (!device || device.comingSoon) {
2238
+ throw new Error(`output device "${id}" is not a selectable device in the engine catalog`);
2239
+ }
2240
+ return {
2241
+ id,
2242
+ label: device.label,
2243
+ panelPresetId: device.panelPresetId,
2244
+ ascDeviceSet: device.ascDeviceSet
2245
+ };
2246
+ });
2174
2247
  NOT_COVERED_BY_THIS_CALL = "not covered by this bundle";
2175
2248
  SIZE_SUFFIX = / · \d+×\d+$/;
2176
2249
  PANEL_LABELS = Object.fromEntries(
@@ -2219,7 +2292,7 @@ var init_layouts = __esm({
2219
2292
  blurb: "Headline on top, whole device below it, nothing cropped. The safe default.",
2220
2293
  character: "safe",
2221
2294
  bleedEdge: "none",
2222
- shotLook: { phoneHeight: "82", hOffset: "0", vOffset: "18", angle: "front", cameraPos: null, roll: "0" },
2295
+ shotLook: { deviceSize: "100", hOffset: "0", vOffset: "18", angle: "front", cameraPos: null, roll: "0" },
2223
2296
  caption: { sizePt: 32, maxWidth: 0.86, reserveLines: 2, align: "center", bandCenter: 0.5 }
2224
2297
  },
2225
2298
  {
@@ -2228,7 +2301,7 @@ var init_layouts = __esm({
2228
2301
  blurb: "Room for a longer headline, device running off the bottom edge. What the market looks like.",
2229
2302
  character: "conventional",
2230
2303
  bleedEdge: "bottom",
2231
- shotLook: { phoneHeight: "82", hOffset: "0", vOffset: "38", angle: "front", cameraPos: null, roll: "0" },
2304
+ shotLook: { deviceSize: "100", hOffset: "0", vOffset: "38", angle: "front", cameraPos: null, roll: "0" },
2232
2305
  caption: { sizePt: 32, maxWidth: 0.86, reserveLines: 4, align: "center", bandCenter: 0.5 }
2233
2306
  },
2234
2307
  {
@@ -2237,7 +2310,7 @@ var init_layouts = __esm({
2237
2310
  blurb: "Device running off the TOP edge, headline underneath it. Inverts the usual reading order.",
2238
2311
  character: "expressive",
2239
2312
  bleedEdge: "top",
2240
- shotLook: { phoneHeight: "82", hOffset: "0", vOffset: "-38", angle: "front", cameraPos: null, roll: "0" },
2313
+ shotLook: { deviceSize: "100", hOffset: "0", vOffset: "-38", angle: "front", cameraPos: null, roll: "0" },
2241
2314
  // The only entry that moves the band. Its caption sits BELOW the device, which the engine
2242
2315
  // could not express at all until #218 gave placeCaptions a bottom-anchored band — before that
2243
2316
  // the only way down was pinning an anchor, which silently costs reflow, stacking and the
@@ -2256,7 +2329,7 @@ var init_layouts = __esm({
2256
2329
  blurb: "Device pushed off the RIGHT edge, headline in a column down the left. Reads as one continuous swipe.",
2257
2330
  character: "expressive",
2258
2331
  bleedEdge: "right",
2259
- shotLook: { phoneHeight: "82", hOffset: "46", vOffset: "18", angle: "front", cameraPos: null, roll: "0" },
2332
+ shotLook: { deviceSize: "100", hOffset: "46", vOffset: "18", angle: "front", cameraPos: null, roll: "0" },
2260
2333
  caption: { sizePt: 32, maxWidth: 0.61, reserveLines: 2, align: "left", bandCenter: 0.375 }
2261
2334
  },
2262
2335
  {
@@ -2265,7 +2338,7 @@ var init_layouts = __esm({
2265
2338
  blurb: "Device pushed off the LEFT edge, headline in a column down the right. The mirror of Lean right.",
2266
2339
  character: "expressive",
2267
2340
  bleedEdge: "left",
2268
- shotLook: { phoneHeight: "82", hOffset: "-46", vOffset: "18", angle: "front", cameraPos: null, roll: "0" },
2341
+ shotLook: { deviceSize: "100", hOffset: "-46", vOffset: "18", angle: "front", cameraPos: null, roll: "0" },
2269
2342
  caption: { sizePt: 32, maxWidth: 0.61, reserveLines: 2, align: "right", bandCenter: 0.625 }
2270
2343
  },
2271
2344
  {
@@ -2279,7 +2352,7 @@ var init_layouts = __esm({
2279
2352
  // too — which makes this template claim `bottom-right` and collide with Corner bleed, the only
2280
2353
  // other entry on that edge. The horizontal push is the whole idea here; the vertical stays out
2281
2354
  // of its way. Both halves are guarded (clearance, and the declared-edge check).
2282
- shotLook: { phoneHeight: "82", hOffset: "78", vOffset: "18", angle: "front", cameraPos: null, roll: "0" },
2355
+ shotLook: { deviceSize: "100", hOffset: "78", vOffset: "18", angle: "front", cameraPos: null, roll: "0" },
2283
2356
  caption: { sizePt: 32, maxWidth: 0.86, reserveLines: 2, align: "center", bandCenter: 0.5 }
2284
2357
  },
2285
2358
  {
@@ -2288,7 +2361,7 @@ var init_layouts = __esm({
2288
2361
  blurb: "Off the bottom AND the right at once, headline in a column down the left. The most opinionated panel here.",
2289
2362
  character: "expressive",
2290
2363
  bleedEdge: "bottom-right",
2291
- shotLook: { phoneHeight: "82", hOffset: "46", vOffset: "38", angle: "front", cameraPos: null, roll: "0" },
2364
+ shotLook: { deviceSize: "100", hOffset: "46", vOffset: "38", angle: "front", cameraPos: null, roll: "0" },
2292
2365
  caption: { sizePt: 32, maxWidth: 0.61, reserveLines: 4, align: "left", bandCenter: 0.375 }
2293
2366
  }
2294
2367
  ];
@@ -2302,7 +2375,7 @@ var init_layouts = __esm({
2302
2375
  align: "center",
2303
2376
  bandCenter: 0.5
2304
2377
  };
2305
- LAYOUT_POSE_FIELDS = ["phoneHeight", "hOffset", "vOffset"];
2378
+ LAYOUT_POSE_FIELDS = ["deviceSize", "hOffset", "vOffset"];
2306
2379
  LAYOUT_CAMERA_FIELDS = ["angle", "cameraPos", "roll"];
2307
2380
  LAYOUT_CAPTION_FIELDS = Array.from(
2308
2381
  new Set(LAYOUT_TEMPLATE_CATALOG.flatMap((t) => Object.keys(t.caption)))
@@ -2314,7 +2387,8 @@ var init_layouts = __esm({
2314
2387
  function layoutTemplateAgentSummary(template) {
2315
2388
  const reserveLines = template.caption.reserveLines;
2316
2389
  const capacity = typeof reserveLines === "number" ? ` Reserves ${reserveLines} headline lines (~${Math.round(reserveLines * 18 * (template.caption.maxWidth ?? 0.86) / 0.86)} characters).` : "";
2317
- return `${template.blurb}${capacity} Character: [${template.character}]. Bleeds: [${template.bleedEdge}].`;
2390
+ const headline = (template.caption.maxWidth ?? 0.86) < 0.86 || (template.caption.align ?? "center") !== "center" ? " Restyles the headline into a narrower column." : " Leaves the headline full-width and centred.";
2391
+ return `${template.blurb}${capacity}${headline} Character: [${template.character}]. Bleeds: [${template.bleedEdge}].`;
2318
2392
  }
2319
2393
  function layoutTemplateById(id) {
2320
2394
  return LAYOUT_TEMPLATES.find((t) => t.id === id);
@@ -2406,12 +2480,20 @@ var init_layoutTemplates = __esm({
2406
2480
  'A request that names a composition outright ("device running off the bottom edge", "headline under the phone") beats the character.',
2407
2481
  "The EDGE beats the character too, and is checked first: a request that says which way the phone should go has already chosen, whatever quality words sit around it.",
2408
2482
  "When several templates share the right character AND the right edge, take the one whose reserved headline lines fit the LONGEST caption in the set. Under-reserving truncates; over-reserving leaves a visible empty band.",
2409
- "A side bleed is chosen for the WHOLE set or not at all. Every panel leaning the same way reads as one swipe; mirrored neighbours (lean-right beside lean-left) overlap and read as a mistake."
2483
+ "A side bleed may be given to the whole set or to named frames, but never to a frame whose NEIGHBOUR leans the other way. Every panel leaning the same way reads as one swipe; mirrored neighbours (lean-right beside lean-left) overlap by most of a panel width and read as a mistake. Alternate a lean with `standard`, never with its mirror.",
2484
+ "A template sets the HEADLINE as well as the phone, so choosing one restyles the captions on every frame it reaches. When the user asked for a composition and asked NOT to touch the captions, take the template on the right edge whose headline treatment already matches theirs, and say in the interpretation what it does to the headline."
2410
2485
  ];
2411
2486
  valuesEqual = (a, b) => JSON.stringify(a) === JSON.stringify(b);
2412
2487
  }
2413
2488
  });
2414
2489
 
2490
+ // ../mockup-engine/hexColor.ts
2491
+ var init_hexColor = __esm({
2492
+ "../mockup-engine/hexColor.ts"() {
2493
+ "use strict";
2494
+ }
2495
+ });
2496
+
2415
2497
  // ../mockup-engine/gradient.ts
2416
2498
  function angleFromDirection(dir) {
2417
2499
  return dir === "horizontal" ? HORIZONTAL_ANGLE : VERTICAL_ANGLE;
@@ -2470,6 +2552,7 @@ var HORIZONTAL_ANGLE, VERTICAL_ANGLE, FALLBACK_GRADIENT_FROM, FALLBACK_GRADIENT_
2470
2552
  var init_gradient = __esm({
2471
2553
  "../mockup-engine/gradient.ts"() {
2472
2554
  "use strict";
2555
+ init_hexColor();
2473
2556
  HORIZONTAL_ANGLE = 90;
2474
2557
  VERTICAL_ANGLE = 180;
2475
2558
  FALLBACK_GRADIENT_FROM = "#1b1b2e";
@@ -2766,7 +2849,7 @@ var init_toolContract = __esm({
2766
2849
  "device, if the project targets several), so the Big-jobs timing warning applies unchanged.",
2767
2850
  "",
2768
2851
  "## Outputs \u2014 one project, several device SIZES",
2769
- "A project targets one or more DEVICES: iphone-6-9 (1290\xD72796) and/or iphone-6-5 (1242\xD72688).",
2852
+ "A project targets one or more DEVICES: iphone-6-9 (1320\xD72868) and/or iphone-6-5 (1242\xD72688).",
2770
2853
  "You almost never need to name them. render_project renders EVERY device the project targets \u2014",
2771
2854
  "the top-level panels are the previewed device, and an `outputs` array carries each device's own",
2772
2855
  "panels when there is more than one. emit_bundle does the same for the zip: with a `project` and",
@@ -2815,7 +2898,7 @@ var init_toolContract = __esm({
2815
2898
  "COMPOSE \u2014 every real App Store strip needs BOTH varied devices AND headlines, and that is ONE",
2816
2899
  "call: pass `look` (devices + background) and `style` (style.captions gives the words) together.",
2817
2900
  "A style.shotLook/style.background alongside a look is redundant \u2014 the look wins, with a note.",
2818
- "panelPresetId picks the App Store size: r69 (6.9\u2033, default, 1290\xD72796) | r65 | r55 \u2014 iPhone only.",
2901
+ "panelPresetId picks the App Store size: r69 (6.9\u2033, default, 1320\xD72868) | r65 | r55 \u2014 iPhone only.",
2819
2902
  "Match it to your source screenshots (1320\xD72868 or 1290\xD72796 \u2192 r69).",
2820
2903
  "Continuous scene: panels have no hard divider, so a phone pushed past its edge with a large",
2821
2904
  "hOffset overflows into the NEIGHBOURING panel and the strip reads as one swipe-through image.",
@@ -2901,6 +2984,21 @@ var init_toolContract = __esm({
2901
2984
  "Skip the names and the record falls back to positional filenames (screen-1.png, screen-2.png\u2026)",
2902
2985
  "the user's folder can't match \u2014 the single worst create-project failure mode. Pass the real filenames.",
2903
2986
  "",
2987
+ "## Link the project to the real app (search_app_listing)",
2988
+ "When the user says which app these screenshots are for, find it instead of typing its name:",
2989
+ 'search_app_listing({ term: "Duolingo" }) returns matching App Store apps with their exact name,',
2990
+ "seller, icon, bundle ID and numeric app ID, plus the App Store Connect languages and iPhone",
2991
+ "devices ShotOps would apply. Show the candidates and let the USER say which one is theirs \u2014 the",
2992
+ "icon and the seller are what prove it \u2014 then pass that result's `appId` (or its `storeUrl`, which",
2993
+ "also carries the storefront) as save_project({ appListing }). The created project then takes the",
2994
+ "app's name, bundle ID, language list and device set; anything you state yourself \u2014 `projectName`,",
2995
+ "`outputs`, `locale` \u2014 wins, and the app is still linked. Passing `appListing` with an existing",
2996
+ "`project` re-links that project and rewrites nothing else. read_project reports the linked app as",
2997
+ "`linkedApp`. Both are free reads over Apple's public listing data: no Apple or store credential is",
2998
+ "involved, and a public listing proves nothing about who owns the app \u2014 the bundle ID stays",
2999
+ "editable. Both need an account; an unsigned local server says so and the save still works without",
3000
+ "the reference.",
3001
+ "",
2904
3002
  '## "Any updates?" \u2014 read back what a designer changed (read_project)',
2905
3003
  "read_look returns only STYLING. To see what a designer edited in the web app \u2014 frame ORDER, the",
2906
3004
  "per-locale caption WORDS, the locale list, structural changes \u2014 call read_project. It returns the",
@@ -3027,7 +3125,7 @@ var init_designIntake = __esm({
3027
3125
  { id: "continuous", label: "One continuous scene across panels", blurb: "Large hOffset values let a phone straddle the seam so the strip reads as one swipe-through image. Preview it \u2014 placement is fiddly." },
3028
3126
  // #223 — the bound is read from the engine's range table, not re-typed: this blurb said
3029
3127
  // 20–100 while the render clamped to 200 and both sliders offered it.
3030
- { id: "bigger", label: "Make the phones larger or smaller", blurb: `phoneHeight, ${SHOT_LOOK_RANGES.phoneHeight.min}\u2013${SHOT_LOOK_RANGES.phoneHeight.max} (% of panel height; over 100 bleeds off an edge), default 82. It is the ONLY device-size control \u2014 cameraPos does not resize. Prefer style.layout over hand-setting it.` }
3128
+ { id: "bigger", label: "Make the phones larger or smaller", blurb: `deviceSize, ${SHOT_LOOK_RANGES.deviceSize.min}\u2013${SHOT_LOOK_RANGES.deviceSize.max} (percent of the default device size; about 122 fills the panel height and more bleeds off an edge), default ${DEFAULT_LOOK.deviceSize}. It is the ONLY device-size control \u2014 cameraPos does not resize. Prefer style.layout over hand-setting it.` }
3031
3129
  ],
3032
3130
  apply: `The layout answer is one field: style.layout: ${LAYOUT_TEMPLATE_IDS.map((id) => `"${id}"`).join(" | ")} (see describe_look \`layouts\`). It expands FIRST into ` + LAYOUT_DEVICE_PATHS + " + " + LAYOUT_CAPTION_PATHS + ', and any of those fields you pass explicitly still overrides it \u2014 so "bleed, but a bit lower" is { layout: "bleed", shotLook: { vOffset: "45" } }. Nothing stores the template id. Anything past "uniform" on the device half means passing a `look` alongside `style`. They compose in one render_strip call: the look supplies per-device styling + background, style.captions the words.'
3033
3131
  }
@@ -3086,7 +3184,7 @@ function clampCaptionBounds(input) {
3086
3184
  }
3087
3185
  function captionClampNote(clamps) {
3088
3186
  if (clamps.length === 0) return void 0;
3089
- const said = clamps.map((clamp) => `${clamp.field} ${clamp.submitted} \u2192 ${clamp.stored}`).join("; ");
3187
+ const said = clamps.map((clamp2) => `${clamp2.field} ${clamp2.submitted} \u2192 ${clamp2.stored}`).join("; ");
3090
3188
  return `Caption values outside the supported range were stored at the bound (${said}) \u2014 sizePt is ${captionBoundText("sizePt")} and maxWidth ${captionBoundText("maxWidth")}, the same range the ShotOps caption controls offer, so nothing here is unreachable in the app.`;
3091
3189
  }
3092
3190
  function rangeIssueMessage(field, raw) {
@@ -3137,11 +3235,12 @@ var init_lookSchemas = __esm({
3137
3235
  rangeText2 = (field) => `${SHOT_LOOK_RANGES[field].min}\u2013${SHOT_LOOK_RANGES[field].max}`;
3138
3236
  RANGE_UNITS = {
3139
3237
  roll: "degrees of clock-hand tilt",
3140
- phoneHeight: "percent of panel height",
3238
+ deviceSize: "percent of the default device size, 100 = default",
3141
3239
  hOffset: "horizontal position, 0 = centre",
3142
3240
  vOffset: "vertical position, 0 = centre, positive = lower",
3143
3241
  finish: "0 = matte \u2026 1 = glossy",
3144
- clearcoat: "0 = none \u2026 1 = full clear-coat gloss"
3242
+ clearcoat: "0 = none \u2026 1 = full clear-coat gloss",
3243
+ screenCornerRadius: "percent of the flat card width"
3145
3244
  };
3146
3245
  PLACEMENT_FIELDS = LAYOUT_POSE_FIELDS;
3147
3246
  layoutAlternative = () => ` For a ready-made composition pass \`style.layout\`: ${LAYOUT_TEMPLATE_IDS.map((id) => `"${id}"`).join(" | ")} \u2014 it sets ${LAYOUT_POSE_FIELDS.join("/")} together, resets camera pose + roll, and sets the headline region they were solved against), and any field you also pass explicitly still wins over it.`;
@@ -3264,7 +3363,7 @@ var init_lookSchemas = __esm({
3264
3363
  // write door — saveLookForProject in api/_lib/projectLookStore.ts — so this field is purely
3265
3364
  // informational on the wire.
3266
3365
  schema: z3.number().optional().describe("Look schema version. The server stamps it on every save (save_look); read_look returns it. Any input value is ignored \u2014 never set it yourself."),
3267
- panelPresetId: z3.string().optional().describe("Canvas size preset, e.g. r69 = 1290\xD72796. Default r69."),
3366
+ panelPresetId: z3.string().optional().describe("Canvas size preset, e.g. r69 = 1320\xD72868. Default r69."),
3268
3367
  bgMode: z3.enum(["gradient", "perPanel"]).optional().describe("One gradient across the strip, or a flat colour per panel. Default gradient."),
3269
3368
  // #460 decision 6 — UNLIKE render_strip's `style.background` above, the PERSISTED look keeps
3270
3369
  // both forms: `gradientStops`/`gradientAngle` are canonical (win when present), and
@@ -3543,8 +3642,8 @@ var init_describeLookCatalog = __esm({
3543
3642
  gradientFrom: "#3E3D42",
3544
3643
  gradientTo: "#1E1D22",
3545
3644
  shots: [
3546
- { panelId: "panel-1", look: { material: "real", colorway: "blue", angle: "left", roll: "-19", phoneHeight: "74" } },
3547
- { panelId: "panel-1", look: { material: "real", colorway: "orange", angle: "right", roll: "8", phoneHeight: "86" } }
3645
+ { panelId: "panel-1", look: { material: "real", colorway: "blue", angle: "left", roll: "-19", deviceSize: "90" } },
3646
+ { panelId: "panel-1", look: { material: "real", colorway: "orange", angle: "right", roll: "8", deviceSize: "105" } }
3548
3647
  ]
3549
3648
  }
3550
3649
  },
@@ -3570,7 +3669,7 @@ var init_describeLookCatalog = __esm({
3570
3669
  "style.layout PRECEDENCE: the template expands first, then any explicit shotLook composition field or caption layout field you also pass overrides it. Nothing stores the template id \u2014 save_project persists the expanded values and read_look returns them unchanged, so a layout is a starting point exactly as a preset is.",
3571
3670
  "style.shotLook is ONE shared style for every phone. To vary ANY devices \u2014 including two phones inside the SAME panel \u2014 pass a `look` instead. `look.shots[]` has one entry per screenshot/device in flattened panel order; repeat panelId for devices that share a panel. See the `look` example.",
3572
3671
  "COMPOSE (every real App Store strip): to style devices independently AND caption their panels, pass BOTH `look` (per-device styling via shots[] + background) and `style` (captions via style.captions) in ONE render_strip/emit_bundle call \u2014 they combine. The look supplies the styling; if you also pass style.shotLook/style.background alongside a look they are ignored (the look wins) and a note tells you.",
3573
- "Fidelity ceilings: `angle` is preset-only (front|left|right) and `roll` is continuous (\xB145\xB0), but there is NO free yaw \u2014 an arbitrary 3-axis source rotation can only be approximated. Device SIZE is set only by `phoneHeight` (there is no separate scale, and cameraPos does not resize). Fonts are the 5 in `style.captions.fonts` \u2014 pick the closest; an exact non-bundled typeface match is not possible.",
3672
+ "Fidelity ceilings: `angle` is preset-only (front|left|right) and `roll` is continuous (\xB145\xB0), but there is NO free yaw \u2014 an arbitrary 3-axis source rotation can only be approximated. Device SIZE is set only by `deviceSize` (there is no separate scale, and cameraPos does not resize). Fonts are the 5 in `style.captions.fonts` \u2014 pick the closest; an exact non-bundled typeface match is not possible.",
3574
3673
  "save_look appends a look version; hold_look sets which saved version agents render by default (or Hold one in ShotOps\u2019s Version history), and render_strip `version` renders a specific one."
3575
3674
  ]
3576
3675
  };
@@ -3579,7 +3678,7 @@ var init_describeLookCatalog = __esm({
3579
3678
 
3580
3679
  // src/contracts/outputSchemas.ts
3581
3680
  import { z as z4 } from "zod";
3582
- var projectSummary, storedAssetOutput, panelDeliveryOutput, panelOutput, lookHistoryAccess, noteField, creditsField, layoutFindingOutput, layoutAdvisoryOutput, contractField, disclosureField, operationStateOutputSchema, durableRenderFinalOutputSchema, durableBundleFinalOutputSchema, renderStripOutputSchema, optionalShape, operationStateShape, renderStripToolOutputSchema, emitBundleOutputSchema, emitBundleToolOutputSchema, saveProjectOutputSchema, layoutFieldDiff, layoutReportShape, layoutReportOutput, readLookOutputSchema, storedScreenshots, readProjectOutputSchema, agentOutcomeOutput, refineProjectOutputSchema, renderProjectOutputSchema, renderProjectToolOutputSchema, productionOperationOutputSchema, saveLookOutputSchema, holdLookOutputSchema, releaseLookOutputSchema, describeLookOutputSchema, accountCreditBalanceSchema, accountStatusOutputSchema, requestScreenshotUploadOutputSchema, importedScreenshotOutput, importScreenshotOutputSchema, deleteAssetsOutputSchema, advertisedWithFailure, refineProjectToolOutputSchema, saveProjectToolOutputSchema, readLookToolOutputSchema, readProjectToolOutputSchema, saveLookToolOutputSchema, holdLookToolOutputSchema, releaseLookToolOutputSchema, requestScreenshotUploadToolOutputSchema, importScreenshotToolOutputSchema, deleteAssetsToolOutputSchema;
3681
+ var projectSummary, storedAssetOutput, panelDeliveryOutput, panelOutput, lookHistoryAccess, noteField, creditsField, layoutFindingOutput, layoutAdvisoryOutput, contractField, disclosureField, operationStateOutputSchema, durableRenderFinalOutputSchema, durableBundleFinalOutputSchema, renderStripOutputSchema, optionalShape, operationStateShape, renderStripToolOutputSchema, emitBundleOutputSchema, emitBundleToolOutputSchema, appListingOutput, saveProjectOutputSchema, layoutFieldDiff, layoutReportShape, layoutReportOutput, readLookOutputSchema, storedScreenshots, searchAppListingOutputSchema, readProjectOutputSchema, agentOutcomeOutput, refineProjectOutputSchema, renderProjectOutputSchema, renderProjectToolOutputSchema, productionOperationOutputSchema, saveLookOutputSchema, holdLookOutputSchema, releaseLookOutputSchema, describeLookOutputSchema, accountCreditBalanceSchema, accountStatusOutputSchema, requestScreenshotUploadOutputSchema, importedScreenshotOutput, importScreenshotOutputSchema, deleteAssetsOutputSchema, advertisedWithFailure, refineProjectToolOutputSchema, saveProjectToolOutputSchema, readLookToolOutputSchema, readProjectToolOutputSchema, searchAppListingToolOutputSchema, saveLookToolOutputSchema, holdLookToolOutputSchema, releaseLookToolOutputSchema, requestScreenshotUploadToolOutputSchema, importScreenshotToolOutputSchema, deleteAssetsToolOutputSchema;
3583
3682
  var init_outputSchemas = __esm({
3584
3683
  "src/contracts/outputSchemas.ts"() {
3585
3684
  "use strict";
@@ -3758,6 +3857,20 @@ var init_outputSchemas = __esm({
3758
3857
  zipRef: z4.string().optional(),
3759
3858
  ok: z4.boolean()
3760
3859
  }).passthrough();
3860
+ appListingOutput = z4.object({
3861
+ appId: z4.string().describe("Apple\u2019s numeric app ID, as a string. Pass it as save_project\u2019s `appListing`."),
3862
+ bundleId: z4.string(),
3863
+ name: z4.string(),
3864
+ seller: z4.string(),
3865
+ storeUrl: z4.string().optional().describe("The listing\u2019s own App Store page. Carries the storefront, so it is the reference to reuse for a non-US app."),
3866
+ iconUrl: z4.string().optional().describe("The app icon on Apple\u2019s CDN. Shown as project chrome; never downloaded and never rendered into a strip."),
3867
+ locales: z4.array(z4.string()).describe("The App Store Connect locales this listing\u2019s languages map to, in catalog order."),
3868
+ baseLocale: z4.string().describe("The caption base the other locales inherit from. Derived \u2014 Apple never says which language is primary."),
3869
+ outputs: z4.array(z4.string()).describe("The ShotOps output devices this listing fills. iPhone only, and EMPTY for an iPad-only app."),
3870
+ supportsIpad: z4.boolean().describe("The listing supports iPad. Recorded, and applied to no output until iPad panels ship."),
3871
+ storefront: z4.string().describe("The two-letter storefront this was read from. It decides `baseLocale`."),
3872
+ droppedLanguageCodes: z4.array(z4.string()).optional().describe("Apple language codes this build maps no App Store Connect locale for. Omitted when nothing was dropped.")
3873
+ }).passthrough();
3761
3874
  saveProjectOutputSchema = z4.object({
3762
3875
  ok: z4.boolean(),
3763
3876
  // Direct authenticated saves return projectId. Unsigned local saves instead return an
@@ -3776,6 +3889,10 @@ var init_outputSchemas = __esm({
3776
3889
  // response stays byte-identical, and a declared-but-absent field never fails the SDK's
3777
3890
  // structuredContent validation (the #196 lesson — an output field is never made required).
3778
3891
  outputs: z4.array(z4.string()).optional(),
3892
+ // #1004 — the App Store app this save linked the project to, echoed so the caller can see the
3893
+ // identity it now carries without a second read. Emitted ONLY when the call passed an
3894
+ // `appListing` reference; a save that did not link one answers exactly as it did before.
3895
+ linkedApp: appListingOutput.optional(),
3779
3896
  // #224 — save_project used to DROP this entirely: it destructured `{ look, style }` out of
3780
3897
  // resolveLook and never forwarded the note, on the one tool where a wrong composition is
3781
3898
  // PERSISTED rather than merely rendered.
@@ -3835,13 +3952,21 @@ var init_outputSchemas = __esm({
3835
3952
  "One row per shot \xD7 target-family \xD7 locale. `resolvedAt` names the manifest cell used; `inherited` says which requested axes fell back. This is the authoritative coverage report."
3836
3953
  )
3837
3954
  }).passthrough().optional();
3955
+ searchAppListingOutputSchema = z4.object({
3956
+ ok: z4.boolean(),
3957
+ results: z4.array(appListingOutput),
3958
+ message: z4.string()
3959
+ }).passthrough();
3838
3960
  readProjectOutputSchema = z4.object({
3839
3961
  saved: z4.boolean(),
3840
3962
  project: projectSummary.optional(),
3841
3963
  message: z4.string().optional(),
3842
3964
  layout: layoutReportOutput,
3843
3965
  screenshots: storedScreenshots,
3844
- history: lookHistoryAccess
3966
+ history: lookHistoryAccess,
3967
+ // #1004 — the App Store app this project is linked to. Omitted entirely for a project that
3968
+ // carries none, which is every project created without one.
3969
+ linkedApp: appListingOutput.optional()
3845
3970
  }).passthrough();
3846
3971
  agentOutcomeOutput = z4.object({
3847
3972
  status: z4.enum([
@@ -4063,6 +4188,7 @@ var init_outputSchemas = __esm({
4063
4188
  saveProjectToolOutputSchema = advertisedWithFailure(saveProjectOutputSchema);
4064
4189
  readLookToolOutputSchema = advertisedWithFailure(readLookOutputSchema);
4065
4190
  readProjectToolOutputSchema = advertisedWithFailure(readProjectOutputSchema);
4191
+ searchAppListingToolOutputSchema = advertisedWithFailure(searchAppListingOutputSchema);
4066
4192
  saveLookToolOutputSchema = advertisedWithFailure(saveLookOutputSchema);
4067
4193
  holdLookToolOutputSchema = advertisedWithFailure(holdLookOutputSchema);
4068
4194
  releaseLookToolOutputSchema = advertisedWithFailure(releaseLookOutputSchema);
@@ -4074,7 +4200,7 @@ var init_outputSchemas = __esm({
4074
4200
 
4075
4201
  // src/contracts/schemas.ts
4076
4202
  import { z as z5 } from "zod";
4077
- var nameField, inlineBase64Entry, refEntry, urlEntry, pathEntry, singleScreenshotEntry, localeScreenshotEntry, screenshotEntry, screenshotsDescription, slot, flatScreenshots, groupedScreenshots, screenshots, screenshotsForSaveDescription, screenshotsForSave, screenshotsOptionalDescription, screenshotsOptional, panelRef, pathPanel, panelEntry, localePanelRef, panelEntryUnion, panelsDescription, panelRefs, output, outputDeviceArray, outputDeviceList, outputsDescription, outputsField, projectOutputsDescription, projectOutputsField, clip, preview, idempotencyKey, panelPresetId, useSavedLook, locale, localesField, project, version, createProjectFlag, projectName, sourceDir, waiverShape, waiversField, renderStripShape, emitBundleShape, saveProjectShape, readLookShape, readProjectShape, refineProjectFocus, refineProjectShape, projectRefEntry, projectUrlEntry, projectPathEntry, projectScreenshotEntry, screenshotFileListDescription, screenshotFileList, renderProjectShape, productionOperationShape, SAVE_LOOK_LOOK_DESCRIPTION, saveLookShape, heldVersion, holdLookShape, releaseLookShape, describeLookShape, accountStatusShape, requestScreenshotUploadShape, deleteAssetsShape, FILE_URL_DESCRIPTION, FILE_ID_DESCRIPTION, FILE_MIME_DESCRIPTION, FILE_NAME_DESCRIPTION, FILE_DESCRIPTION, chatGptFile, importSourceName, importUrlSource, importFileSource, importPathSource, importSourceEntry, importScreenshotsDescription, importScreenshotShape, advertisedChatGptFile, advertisedOutputsField, advertisedProjectOutputsField, renderStripAdvertisedShape, emitBundleAdvertisedShape, saveProjectAdvertisedShape, saveLookAdvertisedShape, renderProjectAdvertisedShape, importScreenshotAdvertisedShape, screenshotSlotGuide, screenshotFileGuide, panelGuide, chatGptFileGuide, importSourceGuide, outputDevicesGuide, TOOL_INPUT_GUIDES;
4203
+ var nameField, inlineBase64Entry, refEntry, urlEntry, pathEntry, singleScreenshotEntry, localeScreenshotEntry, screenshotEntry, screenshotsDescription, slot, flatScreenshots, groupedScreenshots, screenshots, screenshotsForSaveDescription, screenshotsForSave, screenshotsOptionalDescription, screenshotsOptional, panelRef, pathPanel, panelEntry, localePanelRef, panelEntryUnion, panelsDescription, panelRefs, output, outputDeviceArray, outputDeviceList, outputsDescription, outputsField, projectOutputsDescription, projectOutputsField, clip, preview, idempotencyKey, panelPresetId, useSavedLook, locale, localesField, project, version, createProjectFlag, projectName, sourceDir, waiverShape, waiversField, renderStripShape, emitBundleShape, appListingReference, saveProjectShape, searchAppListingShape, readLookShape, readProjectShape, refineProjectFocus, refineProjectShape, projectRefEntry, projectUrlEntry, projectPathEntry, projectScreenshotEntry, screenshotFileListDescription, screenshotFileList, renderProjectShape, productionOperationShape, SAVE_LOOK_LOOK_DESCRIPTION, saveLookShape, heldVersion, holdLookShape, releaseLookShape, describeLookShape, accountStatusShape, requestScreenshotUploadShape, deleteAssetsShape, FILE_URL_DESCRIPTION, FILE_ID_DESCRIPTION, FILE_MIME_DESCRIPTION, FILE_NAME_DESCRIPTION, FILE_DESCRIPTION, chatGptFile, importSourceName, importUrlSource, importFileSource, importPathSource, importSourceEntry, importScreenshotsDescription, importScreenshotShape, advertisedChatGptFile, advertisedOutputsField, advertisedProjectOutputsField, renderStripAdvertisedShape, emitBundleAdvertisedShape, saveProjectAdvertisedShape, saveLookAdvertisedShape, renderProjectAdvertisedShape, importScreenshotAdvertisedShape, screenshotSlotGuide, screenshotFileGuide, panelGuide, chatGptFileGuide, importSourceGuide, outputDevicesGuide, TOOL_INPUT_GUIDES;
4078
4204
  var init_schemas = __esm({
4079
4205
  "src/contracts/schemas.ts"() {
4080
4206
  "use strict";
@@ -4108,7 +4234,7 @@ var init_schemas = __esm({
4108
4234
  flatScreenshots = z5.array(screenshotEntry).min(1).max(10).describe("Preferred: one screenshot entry per App Store panel, in display order.");
4109
4235
  groupedScreenshots = z5.array(slot).min(1).max(10).describe("Advanced: one nested slot per panel; put several screenshot entries in a slot for a multi-phone panel.");
4110
4236
  screenshots = z5.union([flatScreenshots, groupedScreenshots]).describe(screenshotsDescription);
4111
- screenshotsForSaveDescription = `${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).`;
4237
+ screenshotsForSaveDescription = `${screenshotsDescription} NOTE: save_project does not render and never fetches image bytes. It uses each slot's SHAPE (phones per panel), each entry's \`name\` (source filename, for re-load matching) and each { "ref" }, per-locale ones included, which it records on that entry's own frame so the project reopens with that screenshot. A ref that is not an upload under this account refuses the whole save, naming it, and nothing is written. Pass the same entries you rendered with (esp. their \`name\`s).`;
4112
4238
  screenshotsForSave = screenshots.describe(screenshotsForSaveDescription);
4113
4239
  screenshotsOptionalDescription = `${screenshotsDescription} Required unless \`panels\` (pre-rendered refs) is given instead.`;
4114
4240
  screenshotsOptional = screenshots.optional().describe(screenshotsOptionalDescription);
@@ -4140,9 +4266,9 @@ var init_schemas = __esm({
4140
4266
  seen.add(id);
4141
4267
  });
4142
4268
  });
4143
- outputsDescription = "Target device outputs to render into one screenshot bundle. Supported now: iphone-6-9 (1290\xD72796 / APP_IPHONE_67) and iphone-6-5 (1242\xD72688 / APP_IPHONE_65). Each is rendered from the same Look and inputs; omit this field to keep the legacy single panelPresetId form unchanged. With a `project` and no list, the project's OWN saved outputs are used. Not supported with pre-rendered `panels` refs.";
4269
+ outputsDescription = "Target device outputs to render into one screenshot bundle. Supported now: iphone-6-9 (1320\xD72868 / APP_IPHONE_67) and iphone-6-5 (1242\xD72688 / APP_IPHONE_65). Each is rendered from the same Look and inputs; omit this field to keep the legacy single panelPresetId form unchanged. With a `project` and no list, the project's OWN saved outputs are used. Not supported with pre-rendered `panels` refs.";
4144
4270
  outputsField = outputDeviceList.optional().describe(outputsDescription);
4145
- projectOutputsDescription = "The target DEVICES this project is for \u2014 iphone-6-9 (1290\xD72796) and/or iphone-6-5 (1242\xD72688), in the order they should be offered. On save_project this is saved ON the project (the same Output list the web app shows) and the first entry becomes the previewed size unless `panelPresetId` says otherwise; on render_project it OVERRIDES the saved set for this call. Omit it and the project's saved outputs are used unchanged.";
4271
+ projectOutputsDescription = "The target DEVICES this project is for \u2014 iphone-6-9 (1320\xD72868) and/or iphone-6-5 (1242\xD72688), in the order they should be offered. On save_project this is saved ON the project (the same Output list the web app shows) and the first entry becomes the previewed size unless `panelPresetId` says otherwise; on render_project it OVERRIDES the saved set for this call. Omit it and the project's saved outputs are used unchanged.";
4146
4272
  projectOutputsField = outputDeviceList.optional().describe(projectOutputsDescription);
4147
4273
  clip = z5.enum(["strip", "panel"]).optional().describe(MCP_TOP_LEVEL_CLIP_PRECEDENCE);
4148
4274
  preview = z5.boolean().optional().describe(
@@ -4151,7 +4277,7 @@ var init_schemas = __esm({
4151
4277
  idempotencyKey = z5.string().min(1).max(200).optional().describe(
4152
4278
  "Stable retry key for paid Hosted production. Reuse it only for the identical request; a new key deliberately requests a new render. Omit it for deterministic fingerprint retry safety."
4153
4279
  );
4154
- panelPresetId = z5.enum(RENDERABLE_PANEL_PRESET_IDS).optional().describe("App Store screenshot size. Default r69 (6.9\u2033 iPhone, 1290\xD72796). iPhone sizes only \u2014 iPad is not rendered yet.");
4280
+ panelPresetId = z5.enum(RENDERABLE_PANEL_PRESET_IDS).optional().describe("App Store screenshot size. Default r69 (6.9\u2033 iPhone, 1320\xD72868). iPhone sizes only \u2014 iPad is not rendered yet.");
4155
4281
  useSavedLook = z5.boolean().optional().describe("If true, style the strip with the project's saved look (ignored when `look`/`style` is given).");
4156
4282
  locale = z5.string().optional().describe(
4157
4283
  '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).'
@@ -4228,6 +4354,9 @@ var init_schemas = __esm({
4228
4354
  waivers: waiversField,
4229
4355
  idempotencyKey
4230
4356
  };
4357
+ appListingReference = z5.string().trim().min(1).max(500).optional().describe(
4358
+ 'Link this project to its App Store listing: an App Store URL ("https://apps.apple.com/us/app/duolingo/id570060128"), a numeric app ID ("570060128"), or a bundle ID ("com.duolingo.DuolingoMobile"). The server looks the app up and the created project takes its name, bundle ID, languages and iPhone device set. Anything YOU state wins: an explicit `projectName`, `outputs` or `locale` is applied as given and the app is still linked. On an EXISTING `project` it re-links that project and rewrites nothing else \u2014 the language list, the frames and every caption stay exactly as the designer left them. Use the full store URL when the app is listed outside the US storefront; a bare ID is looked up in the US. Needs an account \u2014 an unsigned local server refuses it. Not the same field as `bundleId`, which only pre-fills a fastlane Deliverfile.'
4359
+ );
4231
4360
  saveProjectShape = {
4232
4361
  screenshots: screenshotsForSave,
4233
4362
  panelPresetId,
@@ -4241,8 +4370,17 @@ var init_schemas = __esm({
4241
4370
  version,
4242
4371
  locale,
4243
4372
  projectName,
4373
+ appListing: appListingReference,
4244
4374
  sourceDir
4245
4375
  };
4376
+ searchAppListingShape = {
4377
+ term: z5.string().trim().min(1).max(200).describe(
4378
+ "What to search the App Store for \u2014 the app name as the user said it (\"Duolingo\"), or the developer's name. Each result carries the app ID to pass as save_project's `appListing`."
4379
+ ),
4380
+ country: z5.string().regex(/^[A-Za-z]{2}$/).optional().describe(
4381
+ `The two-letter App Store storefront to search, e.g. "dk". Defaults to "us". An app released in one country only is invisible to a search of any other, so pass the user's own storefront when they say the app is not showing up.`
4382
+ )
4383
+ };
4246
4384
  readLookShape = { project };
4247
4385
  readProjectShape = { project };
4248
4386
  refineProjectFocus = z5.discriminatedUnion("kind", [
@@ -5195,6 +5333,7 @@ var init_toolRegistry = __esm({
5195
5333
  "save_project",
5196
5334
  "read_look",
5197
5335
  "read_project",
5336
+ "search_app_listing",
5198
5337
  "render_project",
5199
5338
  "refine_project",
5200
5339
  "save_look",
@@ -5213,6 +5352,7 @@ var init_toolRegistry = __esm({
5213
5352
  save_project: "mutate",
5214
5353
  read_look: "read",
5215
5354
  read_project: "read",
5355
+ search_app_listing: "read",
5216
5356
  render_project: "render",
5217
5357
  refine_project: "mutate",
5218
5358
  save_look: "mutate",
@@ -5330,11 +5470,11 @@ function previewUsage(deps, snapshot) {
5330
5470
  ...credits ? publicCreditBalance(credits) : {}
5331
5471
  };
5332
5472
  }
5333
- function statusMessage(deps, snapshot, step) {
5334
- if (step === "none") {
5473
+ function statusMessage(deps, snapshot, step2) {
5474
+ if (step2 === "none") {
5335
5475
  return "This connection can produce store-ready output: full-resolution render_strip and render_project, and every emit_bundle form." + (isLocal(deps) ? " Local rendering spends no cloud credits." : "");
5336
5476
  }
5337
- if (step === "sign_in" && !snapshot.authenticated && deps.anonymous) {
5477
+ if (step2 === "sign_in" && !snapshot.authenticated && deps.anonymous) {
5338
5478
  return `This connection is not signed in. It can import screenshots and render up to ${ANONYMOUS.attempts} preview strips of up to ${ANONYMOUS.maxPanels} panels each per ${Math.round(ANONYMOUS.windowSeconds / 86400)} days. ` + productionDenialMessage(snapshot.productionExport, deps);
5339
5479
  }
5340
5480
  return productionDenialMessage(snapshot.productionExport, deps);
@@ -5471,6 +5611,11 @@ function operationSupported(deps, tool, account) {
5471
5611
  return deps.deleteAssets ? true : "hosted_connection";
5472
5612
  case "refine_project":
5473
5613
  return deps.refineProject ? true : "account";
5614
+ // #1004 — Apple is reached through the control plane on both doors, and an unsigned caller has
5615
+ // no door there. Derived from the bound dep AND the account, so an anonymous hosted connection
5616
+ // is told 'account' rather than being promised a search its own security scheme refuses.
5617
+ case "search_app_listing":
5618
+ return deps.searchAppListing && account ? true : "account";
5474
5619
  case "save_project":
5475
5620
  return local || account ? true : "account";
5476
5621
  default:
@@ -5480,7 +5625,7 @@ function operationSupported(deps, tool, account) {
5480
5625
  async function handleAccountStatus(deps) {
5481
5626
  const snapshot = await readSnapshot(deps);
5482
5627
  const decision = snapshot.productionExport;
5483
- const step = accountNextStep(decision);
5628
+ const step2 = accountNextStep(decision);
5484
5629
  return textResult({
5485
5630
  ok: true,
5486
5631
  connection: isLocal(deps) ? "local" : "hosted",
@@ -5506,8 +5651,8 @@ async function handleAccountStatus(deps) {
5506
5651
  ...decision.allowed ? {} : { reason: decision.reason },
5507
5652
  ...decision.allowed || !decision.requiredPlan ? {} : { requiredPlan: decision.requiredPlan }
5508
5653
  },
5509
- nextStep: step,
5510
- message: statusMessage(deps, snapshot, step),
5654
+ nextStep: step2,
5655
+ message: statusMessage(deps, snapshot, step2),
5511
5656
  // #662 — the capability half: accepted inputs, delivery modes, retention classes, operation
5512
5657
  // support, the cost model and the closed failure catalog. Additive; every field above is
5513
5658
  // exactly what it was.
@@ -5528,6 +5673,108 @@ var init_toolsAccount = __esm({
5528
5673
  }
5529
5674
  });
5530
5675
 
5676
+ // ../mockup-engine/appStoreLocales.ts
5677
+ function appStoreLocale(code) {
5678
+ return BY_CODE[code];
5679
+ }
5680
+ var APP_STORE_LOCALE_CATALOG, APP_STORE_LOCALES, BY_CODE;
5681
+ var init_appStoreLocales = __esm({
5682
+ "../mockup-engine/appStoreLocales.ts"() {
5683
+ "use strict";
5684
+ APP_STORE_LOCALE_CATALOG = [
5685
+ { code: "ar-SA", name: "Arabic", script: "rtl", dir: "rtl" },
5686
+ { code: "bn-BD", name: "Bengali", script: "indic", dir: "ltr" },
5687
+ { code: "ca", name: "Catalan", script: "latin", dir: "ltr" },
5688
+ { code: "cs", name: "Czech", script: "latin", dir: "ltr" },
5689
+ { code: "da", name: "Danish", script: "latin", dir: "ltr" },
5690
+ { code: "de-DE", name: "German", script: "latin", dir: "ltr" },
5691
+ { code: "el", name: "Greek", script: "greek", dir: "ltr" },
5692
+ { code: "en-AU", name: "English (Australia)", script: "latin", dir: "ltr" },
5693
+ { code: "en-CA", name: "English (Canada)", script: "latin", dir: "ltr" },
5694
+ { code: "en-GB", name: "English (U.K.)", script: "latin", dir: "ltr" },
5695
+ { code: "en-US", name: "English (U.S.)", script: "latin", dir: "ltr" },
5696
+ { code: "es-ES", name: "Spanish (Spain)", script: "latin", dir: "ltr" },
5697
+ { code: "es-MX", name: "Spanish (Mexico)", script: "latin", dir: "ltr" },
5698
+ { code: "fi", name: "Finnish", script: "latin", dir: "ltr" },
5699
+ { code: "fr-CA", name: "French (Canada)", script: "latin", dir: "ltr" },
5700
+ { code: "fr-FR", name: "French", script: "latin", dir: "ltr" },
5701
+ { code: "gu-IN", name: "Gujarati", script: "indic", dir: "ltr" },
5702
+ { code: "he", name: "Hebrew", script: "rtl", dir: "rtl" },
5703
+ { code: "hi", name: "Hindi", script: "indic", dir: "ltr" },
5704
+ { code: "hr", name: "Croatian", script: "latin", dir: "ltr" },
5705
+ { code: "hu", name: "Hungarian", script: "latin", dir: "ltr" },
5706
+ { code: "id", name: "Indonesian", script: "latin", dir: "ltr" },
5707
+ { code: "it", name: "Italian", script: "latin", dir: "ltr" },
5708
+ { code: "ja", name: "Japanese", script: "cjk", dir: "ltr" },
5709
+ { code: "kn-IN", name: "Kannada", script: "indic", dir: "ltr" },
5710
+ { code: "ko", name: "Korean", script: "cjk", dir: "ltr" },
5711
+ { code: "ml-IN", name: "Malayalam", script: "indic", dir: "ltr" },
5712
+ { code: "mr-IN", name: "Marathi", script: "indic", dir: "ltr" },
5713
+ { code: "ms", name: "Malay", script: "latin", dir: "ltr" },
5714
+ { code: "nl-NL", name: "Dutch", script: "latin", dir: "ltr" },
5715
+ { code: "no", name: "Norwegian", script: "latin", dir: "ltr" },
5716
+ { code: "or-IN", name: "Odia", script: "indic", dir: "ltr" },
5717
+ { code: "pa-IN", name: "Punjabi", script: "indic", dir: "ltr" },
5718
+ { code: "pl", name: "Polish", script: "latin", dir: "ltr" },
5719
+ { code: "pt-BR", name: "Portuguese (Brazil)", script: "latin", dir: "ltr" },
5720
+ { code: "pt-PT", name: "Portuguese (Portugal)", script: "latin", dir: "ltr" },
5721
+ { code: "ro", name: "Romanian", script: "latin", dir: "ltr" },
5722
+ { code: "ru", name: "Russian", script: "cyrillic", dir: "ltr" },
5723
+ { code: "sk", name: "Slovak", script: "latin", dir: "ltr" },
5724
+ { code: "sl-SI", name: "Slovenian", script: "latin", dir: "ltr" },
5725
+ { code: "sv", name: "Swedish", script: "latin", dir: "ltr" },
5726
+ { code: "ta-IN", name: "Tamil", script: "indic", dir: "ltr" },
5727
+ { code: "te-IN", name: "Telugu", script: "indic", dir: "ltr" },
5728
+ { code: "th", name: "Thai", script: "thai", dir: "ltr" },
5729
+ { code: "tr", name: "Turkish", script: "latin", dir: "ltr" },
5730
+ { code: "uk", name: "Ukrainian", script: "cyrillic", dir: "ltr" },
5731
+ { code: "ur-PK", name: "Urdu", script: "rtl", dir: "rtl" },
5732
+ { code: "vi", name: "Vietnamese", script: "latin", dir: "ltr" },
5733
+ { code: "zh-Hans", name: "Chinese (Simplified)", script: "cjk", dir: "ltr" },
5734
+ { code: "zh-Hant", name: "Chinese (Traditional)", script: "cjk", dir: "ltr" }
5735
+ ];
5736
+ APP_STORE_LOCALES = APP_STORE_LOCALE_CATALOG.map((l) => l.code);
5737
+ BY_CODE = Object.fromEntries(
5738
+ APP_STORE_LOCALE_CATALOG.map((l) => [l.code, l])
5739
+ );
5740
+ }
5741
+ });
5742
+
5743
+ // ../mockup-engine/appstore/appListing.ts
5744
+ function readAppListing(envelope) {
5745
+ if (envelope == null || typeof envelope !== "object" || Array.isArray(envelope)) return null;
5746
+ const stored = envelope[APP_LISTING_ENVELOPE_KEY];
5747
+ if (stored == null || typeof stored !== "object" || Array.isArray(stored)) return null;
5748
+ const value = stored;
5749
+ for (const key of ["trackId", "bundleId", "trackName", "sellerName", "storefront", "fetchedAt"]) {
5750
+ if (typeof value[key] !== "string") return null;
5751
+ }
5752
+ if (!Array.isArray(value.languageCodesISO2A) || !Array.isArray(value.supportedDevices)) return null;
5753
+ if (value.applied == null || typeof value.applied !== "object" || Array.isArray(value.applied)) return null;
5754
+ const applied = value.applied;
5755
+ if (typeof applied.projectName !== "string" || typeof applied.bundleId !== "string") return null;
5756
+ if (typeof applied.baseLocale !== "string" || typeof applied.supportsIpad !== "boolean") return null;
5757
+ for (const key of ["locales", "outputs", "droppedLanguageCodes"]) {
5758
+ if (!Array.isArray(applied[key])) return null;
5759
+ }
5760
+ return stored;
5761
+ }
5762
+ function appListingRewrite(listing2) {
5763
+ return { [APP_LISTING_ENVELOPE_KEY]: listing2 ?? void 0 };
5764
+ }
5765
+ var CATALOG_ORDER, APP_LISTING_ENVELOPE_KEY;
5766
+ var init_appListing = __esm({
5767
+ "../mockup-engine/appstore/appListing.ts"() {
5768
+ "use strict";
5769
+ init_appStoreLocales();
5770
+ init_devices();
5771
+ CATALOG_ORDER = Object.fromEntries(
5772
+ APP_STORE_LOCALE_CATALOG.map((locale2, index) => [locale2.code, index])
5773
+ );
5774
+ APP_LISTING_ENVELOPE_KEY = "appListing";
5775
+ }
5776
+ });
5777
+
5531
5778
  // ../mockup-engine/fastlaneBundle.ts
5532
5779
  import { strToU8, zipSync } from "fflate";
5533
5780
  function render(template, vars) {
@@ -5850,8 +6097,17 @@ function canonicalPersistence(look2) {
5850
6097
  }
5851
6098
  return canonical;
5852
6099
  }
6100
+ function upgradeLegacyShotLook(input) {
6101
+ if (!input || typeof input !== "object" || Array.isArray(input) || !("phoneHeight" in input)) return input;
6102
+ const { phoneHeight, ...rest } = input;
6103
+ if (rest.deviceSize === void 0) {
6104
+ const parsed = typeof phoneHeight === "number" ? phoneHeight : Number.parseFloat(String(phoneHeight ?? ""));
6105
+ if (Number.isFinite(parsed)) rest.deviceSize = String(deviceSizeFromLegacyPhoneHeight(parsed));
6106
+ }
6107
+ return rest;
6108
+ }
5853
6109
  function normalizeShotLook(input) {
5854
- return canonicalPersistence({ ...DEFAULT_LOOK, ...input });
6110
+ return canonicalPersistence({ ...DEFAULT_LOOK, ...upgradeLegacyShotLook(input) });
5855
6111
  }
5856
6112
  function serializeShotLook(input) {
5857
6113
  return canonicalPersistence(normalizeShotLook(input));
@@ -5862,7 +6118,7 @@ function comparableShotLook(input) {
5862
6118
  function shotLookNumber(field, raw) {
5863
6119
  const { min, max } = SHOT_LOOK_RANGES[field];
5864
6120
  const parsed = typeof raw === "number" ? raw : Number.parseFloat(raw ?? "");
5865
- const value = Number.isFinite(parsed) ? parsed : Number.parseFloat(DEFAULT_LOOK[field]);
6121
+ const value = Number.isFinite(parsed) ? parsed : Number.parseFloat(String(SHOT_LOOK_DOMAIN[field].defaultValue));
5866
6122
  return Math.max(min, Math.min(max, value));
5867
6123
  }
5868
6124
  var init_shotLook = __esm({
@@ -5916,12 +6172,178 @@ var init_panel = __esm({
5916
6172
  }
5917
6173
  });
5918
6174
 
6175
+ // ../mockup-engine/storagePath.ts
6176
+ function isSafeStoragePath(path) {
6177
+ if (path.length === 0 || path.startsWith("/") || UNSAFE_CHARACTER.test(path)) return false;
6178
+ const body = path.endsWith("/") ? path.slice(0, -1) : path;
6179
+ return body.split("/").every((segment) => segment.length > 0 && segment !== "." && segment !== "..");
6180
+ }
6181
+ var UNSAFE_CHARACTER;
6182
+ var init_storagePath = __esm({
6183
+ "../mockup-engine/storagePath.ts"() {
6184
+ "use strict";
6185
+ UNSAFE_CHARACTER = /[%\\?#\u0000-\u001f\u007f]/;
6186
+ }
6187
+ });
6188
+
6189
+ // ../mockup-engine/appstore/fingerprint.ts
6190
+ function isScreenshotFingerprint(value) {
6191
+ return typeof value === "string" && SCREENSHOT_FINGERPRINT.test(value);
6192
+ }
6193
+ async function screenshotFingerprint(bytes) {
6194
+ const subtle = globalThis.crypto?.subtle;
6195
+ if (subtle) return hex(new Uint8Array(await subtle.digest("SHA-256", bytes)));
6196
+ return sha256(bytes);
6197
+ }
6198
+ function sha256(input) {
6199
+ const length = input.length;
6200
+ const padded = new Uint8Array(length + 72 >> 6 << 6);
6201
+ padded.set(input);
6202
+ padded[length] = 128;
6203
+ const bits = length * 8;
6204
+ const view = new DataView(padded.buffer);
6205
+ view.setUint32(padded.length - 8, Math.floor(bits / 4294967296));
6206
+ view.setUint32(padded.length - 4, bits >>> 0);
6207
+ const h = new Uint32Array([
6208
+ 1779033703,
6209
+ 3144134277,
6210
+ 1013904242,
6211
+ 2773480762,
6212
+ 1359893119,
6213
+ 2600822924,
6214
+ 528734635,
6215
+ 1541459225
6216
+ ]);
6217
+ const w = new Uint32Array(64);
6218
+ for (let block = 0; block < padded.length; block += 64) {
6219
+ for (let i = 0; i < 16; i++) w[i] = view.getUint32(block + i * 4);
6220
+ for (let i = 16; i < 64; i++) {
6221
+ const a2 = w[i - 15];
6222
+ const b2 = w[i - 2];
6223
+ const s0 = rotr(a2, 7) ^ rotr(a2, 18) ^ a2 >>> 3;
6224
+ const s1 = rotr(b2, 17) ^ rotr(b2, 19) ^ b2 >>> 10;
6225
+ w[i] = w[i - 16] + s0 + w[i - 7] + s1 >>> 0;
6226
+ }
6227
+ let [a, b, c, d, e, f, g, hh] = [h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7]];
6228
+ for (let i = 0; i < 64; i++) {
6229
+ const s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
6230
+ const choose = e & f ^ ~e & g;
6231
+ const t1 = hh + s1 + choose + K[i] + w[i] >>> 0;
6232
+ const s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
6233
+ const majority = a & b ^ a & c ^ b & c;
6234
+ const t2 = s0 + majority >>> 0;
6235
+ hh = g;
6236
+ g = f;
6237
+ f = e;
6238
+ e = d + t1 >>> 0;
6239
+ d = c;
6240
+ c = b;
6241
+ b = a;
6242
+ a = t1 + t2 >>> 0;
6243
+ }
6244
+ h[0] = h[0] + a >>> 0;
6245
+ h[1] = h[1] + b >>> 0;
6246
+ h[2] = h[2] + c >>> 0;
6247
+ h[3] = h[3] + d >>> 0;
6248
+ h[4] = h[4] + e >>> 0;
6249
+ h[5] = h[5] + f >>> 0;
6250
+ h[6] = h[6] + g >>> 0;
6251
+ h[7] = h[7] + hh >>> 0;
6252
+ }
6253
+ return hex(bigEndian(h));
6254
+ }
6255
+ function bigEndian(words) {
6256
+ const out = new Uint8Array(words.length * 4);
6257
+ const view = new DataView(out.buffer);
6258
+ for (let i = 0; i < words.length; i++) view.setUint32(i * 4, words[i]);
6259
+ return out;
6260
+ }
6261
+ var SCREENSHOT_FINGERPRINT, hex, K, rotr;
6262
+ var init_fingerprint = __esm({
6263
+ "../mockup-engine/appstore/fingerprint.ts"() {
6264
+ "use strict";
6265
+ SCREENSHOT_FINGERPRINT = /^[a-f0-9]{64}$/;
6266
+ hex = (bytes) => {
6267
+ let out = "";
6268
+ for (let i = 0; i < bytes.length; i++) out += bytes[i].toString(16).padStart(2, "0");
6269
+ return out;
6270
+ };
6271
+ K = new Uint32Array([
6272
+ 1116352408,
6273
+ 1899447441,
6274
+ 3049323471,
6275
+ 3921009573,
6276
+ 961987163,
6277
+ 1508970993,
6278
+ 2453635748,
6279
+ 2870763221,
6280
+ 3624381080,
6281
+ 310598401,
6282
+ 607225278,
6283
+ 1426881987,
6284
+ 1925078388,
6285
+ 2162078206,
6286
+ 2614888103,
6287
+ 3248222580,
6288
+ 3835390401,
6289
+ 4022224774,
6290
+ 264347078,
6291
+ 604807628,
6292
+ 770255983,
6293
+ 1249150122,
6294
+ 1555081692,
6295
+ 1996064986,
6296
+ 2554220882,
6297
+ 2821834349,
6298
+ 2952996808,
6299
+ 3210313671,
6300
+ 3336571891,
6301
+ 3584528711,
6302
+ 113926993,
6303
+ 338241895,
6304
+ 666307205,
6305
+ 773529912,
6306
+ 1294757372,
6307
+ 1396182291,
6308
+ 1695183700,
6309
+ 1986661051,
6310
+ 2177026350,
6311
+ 2456956037,
6312
+ 2730485921,
6313
+ 2820302411,
6314
+ 3259730800,
6315
+ 3345764771,
6316
+ 3516065817,
6317
+ 3600352804,
6318
+ 4094571909,
6319
+ 275423344,
6320
+ 430227734,
6321
+ 506948616,
6322
+ 659060556,
6323
+ 883997877,
6324
+ 958139571,
6325
+ 1322822218,
6326
+ 1537002063,
6327
+ 1747873779,
6328
+ 1955562222,
6329
+ 2024104815,
6330
+ 2227730452,
6331
+ 2361852424,
6332
+ 2428436474,
6333
+ 2756734187,
6334
+ 3204031479,
6335
+ 3329325298
6336
+ ]);
6337
+ rotr = (value, by) => value >>> by | value << 32 - by;
6338
+ }
6339
+ });
6340
+
5919
6341
  // ../mockup-engine/appstore/screenManifest.ts
5920
6342
  function isShotManifestEntry(entry) {
5921
6343
  return typeof entry.shotId === "string";
5922
6344
  }
5923
6345
  function manifestRefUploader(ref) {
5924
- if (ref.includes("..")) return null;
6346
+ if (!isSafeStoragePath(ref)) return null;
5925
6347
  const match = UPLOAD_REF_RE.exec(ref);
5926
6348
  if (!match || !UUID_RE.test(match[1])) return null;
5927
6349
  return match[1];
@@ -6019,7 +6441,7 @@ function parseList(raw, mode, baseLocale) {
6019
6441
  const shotId = e.shotId === void 0 || e.shotId === null ? void 0 : e.shotId;
6020
6442
  const slot2 = e.slot === void 0 || e.slot === null ? void 0 : e.slot;
6021
6443
  const identified = typeof shotId === "string" && shotId.length > 0 && slot2 === void 0 || typeof slot2 === "string" && slot2.length > 0 && shotId === void 0;
6022
- if (!identified || typeof e.ref !== "string" || e.ref.length === 0 || variant === null || hash !== void 0 && (typeof hash !== "string" || hash.length === 0)) {
6444
+ if (!identified || typeof e.ref !== "string" || e.ref.length === 0 || variant === null || hash !== void 0 && !isScreenshotFingerprint(hash)) {
6023
6445
  if (mode === "strict") return null;
6024
6446
  continue;
6025
6447
  }
@@ -6167,25 +6589,13 @@ function rebindManifestToShots(cells, shots) {
6167
6589
  }
6168
6590
  return { manifest, findings };
6169
6591
  }
6170
- function manifestFromNamedRefs(refs, shots) {
6171
- const idsByFrameName = shotIdsByFrameName(shots);
6172
- const out = [];
6173
- for (const [name, ref] of Object.entries(refs)) {
6174
- const canonical = canonicalizeManifestRef(ref);
6175
- const ids = idsByFrameName[name];
6176
- if (ids === void 0) out.push({ slot: name, variant: {}, ref: canonical });
6177
- else for (const id of ids) out.push({ shotId: id, variant: {}, ref: canonical });
6178
- }
6179
- return out;
6180
- }
6181
- function nonBaseCellEntries(manifest) {
6182
- return manifest.filter((e) => variantKey(e.variant) !== "");
6183
- }
6184
6592
  var UPLOAD_REF_RE, UUID_RE, TRUNCATED_NAMED_UPLOAD_REF, EMPTY;
6185
6593
  var init_screenManifest = __esm({
6186
6594
  "../mockup-engine/appstore/screenManifest.ts"() {
6187
6595
  "use strict";
6596
+ init_storagePath();
6188
6597
  init_variant();
6598
+ init_fingerprint();
6189
6599
  UPLOAD_REF_RE = /^uploads\/([^/]+)\/([^/]+(?:\/[^/]+)*)$/;
6190
6600
  UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
6191
6601
  TRUNCATED_NAMED_UPLOAD_REF = /^uploads\/[^/]+\/[^/]+\/\d+__[A-Za-z0-9_-]+$/;
@@ -6193,6 +6603,23 @@ var init_screenManifest = __esm({
6193
6603
  }
6194
6604
  });
6195
6605
 
6606
+ // ../mockup-engine/appstore/screenUploadPlan.ts
6607
+ function planScreenUploads(current, manifest, shots) {
6608
+ const byCell = resolveScreenManifest(manifest, shots).byShotCell;
6609
+ const toUpload = [];
6610
+ for (const c of current) {
6611
+ const stored = byCell[manifestShotCellKey(c.shotId, c.variant)];
6612
+ if (stored === void 0 || stored.hash !== c.hash) toUpload.push(c);
6613
+ }
6614
+ return { toUpload, kept: manifest };
6615
+ }
6616
+ var init_screenUploadPlan = __esm({
6617
+ "../mockup-engine/appstore/screenUploadPlan.ts"() {
6618
+ "use strict";
6619
+ init_screenManifest();
6620
+ }
6621
+ });
6622
+
6196
6623
  // ../mockup-engine/appstore/localeAxis.ts
6197
6624
  function deriveLocales(captionText, explicit, baseLocale = DEFAULT_LOCALE) {
6198
6625
  const dedupe2 = (list) => {
@@ -6252,9 +6679,20 @@ function normalizeLegacyLocales(captionText, locales) {
6252
6679
  }
6253
6680
  return { captionText: nextCaption, locales: locales?.map((c) => LEGACY_LOCALE_ALIASES[c] ?? c) };
6254
6681
  }
6255
- var LEGACY_LOCALE_ALIASES;
6256
- var init_localeAxis = __esm({
6257
- "../mockup-engine/appstore/localeAxis.ts"() {
6682
+ function projectLocaleAxis(projectFile) {
6683
+ const file = projectFile ?? {};
6684
+ const baseLocaleRaw = normalizeBaseLocale(file.baseLocale);
6685
+ const explicit = Array.isArray(file.locales) ? file.locales.filter((locale2) => typeof locale2 === "string" && locale2.length > 0) : void 0;
6686
+ const { captionText, locales } = normalizeLegacyLocales(
6687
+ normalizeCaptionText(file.captionText, file.captionStyles, baseLocaleRaw),
6688
+ explicit
6689
+ );
6690
+ const baseLocale = normalizeLegacyLocales({ [baseLocaleRaw]: {} }, [baseLocaleRaw]).locales?.[0] ?? baseLocaleRaw;
6691
+ return { captionText, locales: deriveLocales(captionText, locales, baseLocale), baseLocale };
6692
+ }
6693
+ var LEGACY_LOCALE_ALIASES;
6694
+ var init_localeAxis = __esm({
6695
+ "../mockup-engine/appstore/localeAxis.ts"() {
6258
6696
  "use strict";
6259
6697
  init_captionModel();
6260
6698
  LEGACY_LOCALE_ALIASES = {
@@ -6552,6 +6990,16 @@ var init_layout = __esm({
6552
6990
  }
6553
6991
  });
6554
6992
 
6993
+ // ../mockup-engine/appstore/framelessCard.ts
6994
+ var FRAMELESS_BLANK_ASPECT;
6995
+ var init_framelessCard = __esm({
6996
+ "../mockup-engine/appstore/framelessCard.ts"() {
6997
+ "use strict";
6998
+ init_shotLook();
6999
+ FRAMELESS_BLANK_ASPECT = 9 / 19.5;
7000
+ }
7001
+ });
7002
+
6555
7003
  // ../mockup-engine/appstore/serialize.ts
6556
7004
  function extractStripStyle(state, captionText, baseLocale = DEFAULT_LOCALE) {
6557
7005
  const gradient = stripGradientOf(state);
@@ -6821,20 +7269,15 @@ function restoreProject(json) {
6821
7269
  const panels = file.panels.map((p) => ({ id: p.id }));
6822
7270
  reseedIdCounters(shots.map((s) => s.id), panels.map((p) => p.id));
6823
7271
  const selectedShotId = file.selectedShotId && shots.some((s) => s.id === file.selectedShotId) ? file.selectedShotId : shots[0]?.id ?? null;
6824
- const baseLocaleRaw = normalizeBaseLocale(file.baseLocale);
6825
- const { captionText, locales: normalizedLocales } = normalizeLegacyLocales(
6826
- normalizeCaptionText(file.captionText, file.captionStyles, baseLocaleRaw),
6827
- file.locales
6828
- );
6829
- const baseLocale = normalizeLegacyLocales({ [baseLocaleRaw]: {} }, [baseLocaleRaw]).locales?.[0] ?? baseLocaleRaw;
6830
- const locales = deriveLocales(captionText, normalizedLocales, baseLocale);
7272
+ const { captionText, locales, baseLocale } = projectLocaleAxis(file);
7273
+ const output2 = projectOutputAxis(file);
6831
7274
  const captionSlotIds2 = normalizeCaptionSlotIds(file.captionSlotIds, captionText, panels.map((panel) => panel.id));
6832
7275
  const canonicalGradient = normalizeGradient(file.gradientStops, file.gradientAngle);
6833
7276
  return {
6834
7277
  shots: shotsInPanelOrder(shots, panels),
6835
7278
  panels,
6836
7279
  selectedShotId,
6837
- panelPresetId: presetById(file.panelPresetId).id,
7280
+ panelPresetId: output2.panelPresetId,
6838
7281
  bgMode: file.bgMode === "perPanel" ? "perPanel" : "gradient",
6839
7282
  // Canonical first, and spread so an old record carries neither key rather than two undefineds.
6840
7283
  // #556 — when it IS there, the mirror is DERIVED from it rather than copied off the record: a
@@ -6868,7 +7311,7 @@ function restoreProject(json) {
6868
7311
  ),
6869
7312
  // Stored device list trusted verbatim (unknown/comingSoon ids dropped); an old record with no
6870
7313
  // `outputs` migrates from its panelPresetId (deriveOutputs) so it loads with a coherent device.
6871
- outputs: deriveOutputs(file.outputs, file.panelPresetId),
7314
+ outputs: output2.outputs,
6872
7315
  // The record carries no per-locale bytes (rule 1) — the studio re-joins them from the byte-cache
6873
7316
  // by frameName after restore (Phase 3). {} = the pre-join shape.
6874
7317
  localeScreens: {},
@@ -6940,7 +7383,6 @@ function serializeProject(state) {
6940
7383
  var init_project = __esm({
6941
7384
  "../mockup-engine/appstore/project.ts"() {
6942
7385
  "use strict";
6943
- init_presets();
6944
7386
  init_devices();
6945
7387
  init_shotLook();
6946
7388
  init_panel();
@@ -7818,9 +8260,19 @@ function applicationOutcome(facts) {
7818
8260
  }
7819
8261
  function headlineForChanges(changed) {
7820
8262
  const first = changed[0] ?? "Updated this project.";
8263
+ const whole = changed.join(" \xB7 ");
8264
+ if (changed.length <= 1 || whole.length <= AGENT_OUTCOME_BOUNDS.headline) {
8265
+ return boundOutcomeText(changed.length > 0 ? whole : first, AGENT_OUTCOME_BOUNDS.headline);
8266
+ }
8267
+ let kept = changed.length - 1;
8268
+ while (kept > 1) {
8269
+ const rest2 = changed.length - kept;
8270
+ const candidate = `${changed.slice(0, kept).join(" \xB7 ")} (+${rest2} more change${rest2 === 1 ? "" : "s"})`;
8271
+ if (candidate.length <= AGENT_OUTCOME_BOUNDS.headline) return candidate;
8272
+ kept -= 1;
8273
+ }
7821
8274
  const rest = changed.length - 1;
7822
- const headline = rest > 0 ? `${first} (+${rest} more change${rest === 1 ? "" : "s"})` : first;
7823
- return boundOutcomeText(headline, AGENT_OUTCOME_BOUNDS.headline);
8275
+ return boundOutcomeText(`${first} (+${rest} more change${rest === 1 ? "" : "s"})`, AGENT_OUTCOME_BOUNDS.headline);
7824
8276
  }
7825
8277
  function failureOutcome(reason, headline) {
7826
8278
  return {
@@ -7970,7 +8422,7 @@ function lookFromAiShotPatch(current, patch, allowHOffset) {
7970
8422
  }
7971
8423
  if (patch.orbit !== void 0) next.cameraPos = posFromAzimuthDeg(normalizedAzimuth(patch.orbit));
7972
8424
  if (patch.roll !== void 0) next.roll = String(shotLookNumber("roll", patch.roll));
7973
- if (patch.phoneHeight !== void 0) next.phoneHeight = String(shotLookNumber("phoneHeight", patch.phoneHeight));
8425
+ if (patch.deviceSize !== void 0) next.deviceSize = String(shotLookNumber("deviceSize", patch.deviceSize));
7974
8426
  if (patch.hOffset !== void 0 && allowHOffset) next.hOffset = String(shotLookNumber("hOffset", patch.hOffset));
7975
8427
  if (patch.vOffset !== void 0) next.vOffset = String(shotLookNumber("vOffset", patch.vOffset));
7976
8428
  if (patch.material !== void 0) next.material = patch.material;
@@ -7992,6 +8444,13 @@ function lookFromAiShotPatch(current, patch, allowHOffset) {
7992
8444
  if (patch.reflections !== void 0) next.reflections = patch.reflections;
7993
8445
  if (patch.clipToFrame === true) next.clipToFrame = true;
7994
8446
  else if (patch.clipToFrame === false) delete next.clipToFrame;
8447
+ if (patch.deviceFrame === false) next.deviceFrame = false;
8448
+ else if (patch.deviceFrame === true) delete next.deviceFrame;
8449
+ if (patch.screenCornerRadius !== void 0) {
8450
+ const radius = String(shotLookNumber("screenCornerRadius", patch.screenCornerRadius));
8451
+ if (radius === SHOT_LOOK_DOMAIN.screenCornerRadius.defaultValue) delete next.screenCornerRadius;
8452
+ else next.screenCornerRadius = radius;
8453
+ }
7995
8454
  return JSON.stringify(next) === JSON.stringify(current) ? current : next;
7996
8455
  }
7997
8456
  function resolveCaptionNumber(input, current, range) {
@@ -8041,6 +8500,7 @@ var init_refinementValues = __esm({
8041
8500
  init_gradient();
8042
8501
  init_panel();
8043
8502
  init_refineIntent();
8503
+ init_defaults();
8044
8504
  init_shotLook();
8045
8505
  AI_SHOT_LOOK_PROJECTION = {
8046
8506
  angle: {
@@ -8060,10 +8520,10 @@ var init_refinementValues = __esm({
8060
8520
  reason: "numeric-string",
8061
8521
  project: (look2) => ({ roll: shotLookNumber("roll", look2.roll) })
8062
8522
  },
8063
- phoneHeight: {
8523
+ deviceSize: {
8064
8524
  disposition: "transformed",
8065
8525
  reason: "numeric-string",
8066
- project: (look2) => ({ phoneHeight: shotLookNumber("phoneHeight", look2.phoneHeight) })
8526
+ project: (look2) => ({ deviceSize: shotLookNumber("deviceSize", look2.deviceSize) })
8067
8527
  },
8068
8528
  hOffset: {
8069
8529
  disposition: "transformed",
@@ -8120,6 +8580,16 @@ var init_refinementValues = __esm({
8120
8580
  reason: "false-is-absence",
8121
8581
  project: (look2) => ({ clipToFrame: look2.clipToFrame === true })
8122
8582
  },
8583
+ deviceFrame: {
8584
+ disposition: "transformed",
8585
+ reason: "true-is-default",
8586
+ project: (look2) => ({ deviceFrame: look2.deviceFrame !== false })
8587
+ },
8588
+ screenCornerRadius: {
8589
+ disposition: "transformed",
8590
+ reason: "numeric-string",
8591
+ project: (look2) => ({ screenCornerRadius: shotLookNumber("screenCornerRadius", look2.screenCornerRadius) })
8592
+ },
8123
8593
  glare: {
8124
8594
  disposition: "direct",
8125
8595
  reason: "same-value",
@@ -8841,7 +9311,7 @@ var TEMPLATE_OWNED_SHOT_FIELDS;
8841
9311
  var init_templatePoseGuard = __esm({
8842
9312
  "../mockup-engine/appstore/templatePoseGuard.ts"() {
8843
9313
  "use strict";
8844
- TEMPLATE_OWNED_SHOT_FIELDS = ["orbit", "roll", "angle", "phoneHeight", "hOffset", "vOffset"];
9314
+ TEMPLATE_OWNED_SHOT_FIELDS = ["orbit", "roll", "angle", "deviceSize", "hOffset", "vOffset"];
8845
9315
  }
8846
9316
  });
8847
9317
 
@@ -9117,6 +9587,33 @@ function applyProjectRefinementWithAssets(state, patch, panelIds, assets = []) {
9117
9587
  ).patch;
9118
9588
  }
9119
9589
  }
9590
+ const layoutFrames = patch.frames?.length === positionalCount ? patch.frames : void 0;
9591
+ if (layoutFrames?.some((frame) => frame?.layout)) {
9592
+ const panelsByTemplate = {};
9593
+ const order = [];
9594
+ requestedPanelIds.forEach((panelId, index) => {
9595
+ const id = layoutFrames[index]?.layout;
9596
+ if (!id || !surviving.has(panelId)) return;
9597
+ if (!panelsByTemplate[id]) {
9598
+ panelsByTemplate[id] = [];
9599
+ order.push(id);
9600
+ }
9601
+ panelsByTemplate[id].push(panelId);
9602
+ });
9603
+ for (const id of order) {
9604
+ const panelIds2 = panelsByTemplate[id];
9605
+ next = applyLayout(next, id, panelIds2);
9606
+ if (layoutTemplateById(id)) {
9607
+ const shots = next.shots;
9608
+ patch = dropTemplatePoses(
9609
+ patch,
9610
+ new Set(panelIds2),
9611
+ requestedPanelIds,
9612
+ (deviceId) => shots.find((shot) => shot.id === deviceId)?.panelId
9613
+ ).patch;
9614
+ }
9615
+ }
9616
+ }
9120
9617
  if (patch.shots?.length === positionalCount) {
9121
9618
  const entries = new Map(requestedPanelIds.map((panelId, index) => [panelId, patch.shots[index]]));
9122
9619
  const shared = new Set(
@@ -9178,11 +9675,16 @@ function applyProjectRefinementWithAssets(state, patch, panelIds, assets = []) {
9178
9675
  ...stripBackground.floorReflection !== void 0 ? { floorReflection: stripBackground.floorReflection } : {}
9179
9676
  };
9180
9677
  }
9181
- if (frameEntries?.some((frame) => frame.background && Object.keys(frame.background).length > 0)) {
9678
+ if (frameEntries?.some((frame) => frame.clearBackground || frame.background && Object.keys(frame.background).length > 0)) {
9182
9679
  const inherited = panelBackgroundFromGradientPatch(stripGradientOf(next));
9183
9680
  const panelBackgrounds = { ...next.panelBackgrounds };
9184
9681
  let changed = false;
9185
9682
  requestedPanelIds.forEach((panelId, index) => {
9683
+ if (frameEntries[index]?.clearBackground && surviving.has(panelId)) {
9684
+ delete panelBackgrounds[panelId];
9685
+ changed = true;
9686
+ return;
9687
+ }
9186
9688
  const entry = frameEntries[index]?.background;
9187
9689
  if (!entry || Object.keys(entry).length === 0 || !surviving.has(panelId)) return;
9188
9690
  const gradient = gradientFromAiBackground(backgroundGradient(panelBackgrounds[panelId] ?? inherited), entry);
@@ -9193,7 +9695,11 @@ function applyProjectRefinementWithAssets(state, patch, panelIds, assets = []) {
9193
9695
  if (changed) next = { ...next, panelBackgrounds };
9194
9696
  }
9195
9697
  if (patch.outputs) {
9196
- const wanted = patch.outputs;
9698
+ const valid = patch.outputs.filter((id, index, all) => {
9699
+ const device = deviceById(id);
9700
+ return device && !device.comingSoon && all.indexOf(id) === index;
9701
+ });
9702
+ const wanted = valid.length > 0 ? valid : next.outputs;
9197
9703
  const outputCaptions = pruneOutputFamilies(next.outputCaptions, wanted);
9198
9704
  const outputScreens = next.outputScreens === void 0 ? void 0 : pruneOutputFamilies(next.outputScreens, wanted);
9199
9705
  const previewPresetId = next.previewPresetId === void 0 ? void 0 : wanted.some((id) => deviceById(id)?.panelPresetId === next.previewPresetId) ? next.previewPresetId : deviceById(wanted[0])?.panelPresetId ?? next.previewPresetId;
@@ -9259,73 +9765,6 @@ var init_refinement = __esm({
9259
9765
  }
9260
9766
  });
9261
9767
 
9262
- // ../mockup-engine/appStoreLocales.ts
9263
- function appStoreLocale(code) {
9264
- return BY_CODE[code];
9265
- }
9266
- var APP_STORE_LOCALE_CATALOG, APP_STORE_LOCALES, BY_CODE;
9267
- var init_appStoreLocales = __esm({
9268
- "../mockup-engine/appStoreLocales.ts"() {
9269
- "use strict";
9270
- APP_STORE_LOCALE_CATALOG = [
9271
- { code: "ar-SA", name: "Arabic", script: "rtl", dir: "rtl" },
9272
- { code: "bn-BD", name: "Bengali", script: "indic", dir: "ltr" },
9273
- { code: "ca", name: "Catalan", script: "latin", dir: "ltr" },
9274
- { code: "cs", name: "Czech", script: "latin", dir: "ltr" },
9275
- { code: "da", name: "Danish", script: "latin", dir: "ltr" },
9276
- { code: "de-DE", name: "German", script: "latin", dir: "ltr" },
9277
- { code: "el", name: "Greek", script: "greek", dir: "ltr" },
9278
- { code: "en-AU", name: "English (Australia)", script: "latin", dir: "ltr" },
9279
- { code: "en-CA", name: "English (Canada)", script: "latin", dir: "ltr" },
9280
- { code: "en-GB", name: "English (U.K.)", script: "latin", dir: "ltr" },
9281
- { code: "en-US", name: "English (U.S.)", script: "latin", dir: "ltr" },
9282
- { code: "es-ES", name: "Spanish (Spain)", script: "latin", dir: "ltr" },
9283
- { code: "es-MX", name: "Spanish (Mexico)", script: "latin", dir: "ltr" },
9284
- { code: "fi", name: "Finnish", script: "latin", dir: "ltr" },
9285
- { code: "fr-CA", name: "French (Canada)", script: "latin", dir: "ltr" },
9286
- { code: "fr-FR", name: "French", script: "latin", dir: "ltr" },
9287
- { code: "gu-IN", name: "Gujarati", script: "indic", dir: "ltr" },
9288
- { code: "he", name: "Hebrew", script: "rtl", dir: "rtl" },
9289
- { code: "hi", name: "Hindi", script: "indic", dir: "ltr" },
9290
- { code: "hr", name: "Croatian", script: "latin", dir: "ltr" },
9291
- { code: "hu", name: "Hungarian", script: "latin", dir: "ltr" },
9292
- { code: "id", name: "Indonesian", script: "latin", dir: "ltr" },
9293
- { code: "it", name: "Italian", script: "latin", dir: "ltr" },
9294
- { code: "ja", name: "Japanese", script: "cjk", dir: "ltr" },
9295
- { code: "kn-IN", name: "Kannada", script: "indic", dir: "ltr" },
9296
- { code: "ko", name: "Korean", script: "cjk", dir: "ltr" },
9297
- { code: "ml-IN", name: "Malayalam", script: "indic", dir: "ltr" },
9298
- { code: "mr-IN", name: "Marathi", script: "indic", dir: "ltr" },
9299
- { code: "ms", name: "Malay", script: "latin", dir: "ltr" },
9300
- { code: "nl-NL", name: "Dutch", script: "latin", dir: "ltr" },
9301
- { code: "no", name: "Norwegian", script: "latin", dir: "ltr" },
9302
- { code: "or-IN", name: "Odia", script: "indic", dir: "ltr" },
9303
- { code: "pa-IN", name: "Punjabi", script: "indic", dir: "ltr" },
9304
- { code: "pl", name: "Polish", script: "latin", dir: "ltr" },
9305
- { code: "pt-BR", name: "Portuguese (Brazil)", script: "latin", dir: "ltr" },
9306
- { code: "pt-PT", name: "Portuguese (Portugal)", script: "latin", dir: "ltr" },
9307
- { code: "ro", name: "Romanian", script: "latin", dir: "ltr" },
9308
- { code: "ru", name: "Russian", script: "cyrillic", dir: "ltr" },
9309
- { code: "sk", name: "Slovak", script: "latin", dir: "ltr" },
9310
- { code: "sl-SI", name: "Slovenian", script: "latin", dir: "ltr" },
9311
- { code: "sv", name: "Swedish", script: "latin", dir: "ltr" },
9312
- { code: "ta-IN", name: "Tamil", script: "indic", dir: "ltr" },
9313
- { code: "te-IN", name: "Telugu", script: "indic", dir: "ltr" },
9314
- { code: "th", name: "Thai", script: "thai", dir: "ltr" },
9315
- { code: "tr", name: "Turkish", script: "latin", dir: "ltr" },
9316
- { code: "uk", name: "Ukrainian", script: "cyrillic", dir: "ltr" },
9317
- { code: "ur-PK", name: "Urdu", script: "rtl", dir: "rtl" },
9318
- { code: "vi", name: "Vietnamese", script: "latin", dir: "ltr" },
9319
- { code: "zh-Hans", name: "Chinese (Simplified)", script: "cjk", dir: "ltr" },
9320
- { code: "zh-Hant", name: "Chinese (Traditional)", script: "cjk", dir: "ltr" }
9321
- ];
9322
- APP_STORE_LOCALES = APP_STORE_LOCALE_CATALOG.map((l) => l.code);
9323
- BY_CODE = Object.fromEntries(
9324
- APP_STORE_LOCALE_CATALOG.map((l) => [l.code, l])
9325
- );
9326
- }
9327
- });
9328
-
9329
9768
  // ../mockup-engine/appstore/agentTurnRepetition.ts
9330
9769
  function appliedOperationOf(operation) {
9331
9770
  if (!NON_REPEATABLE_OPS.has(operation.op)) return null;
@@ -9400,7 +9839,360 @@ var init_agentTurnRepetition = __esm({
9400
9839
  }
9401
9840
  });
9402
9841
 
9842
+ // ../mockup-engine/appstore/agentTurnAdjustments.ts
9843
+ function step(field, from, to) {
9844
+ if (field !== "orbit") return to - from;
9845
+ return ((to - from) % 360 + 540) % 360 - 180;
9846
+ }
9847
+ function pushesFurther(field, start, now, proposed) {
9848
+ if (typeof start !== "number" || typeof now !== "number" || typeof proposed !== "number") return false;
9849
+ const moved = step(field, start, now);
9850
+ const next = step(field, now, proposed);
9851
+ return moved !== 0 && next !== 0 && Math.sign(moved) === Math.sign(next);
9852
+ }
9853
+ function deviceLooks(state) {
9854
+ const looks = {};
9855
+ for (const shot of state.shots) looks[shot.id] = aiShotPatchFromLook(shot.look);
9856
+ return looks;
9857
+ }
9858
+ function deviceRepeats(entry, deviceId, start, now) {
9859
+ const from = start[deviceId];
9860
+ const at = now[deviceId];
9861
+ if (!from || !at) return [];
9862
+ return ADJUSTABLE_DEVICE_FIELDS.filter((field) => pushesFurther(field, from[field], at[field], entry[field]));
9863
+ }
9864
+ function withoutFields(entry, fields2) {
9865
+ const kept = { ...entry };
9866
+ for (const field of fields2) delete kept[field];
9867
+ return kept;
9868
+ }
9869
+ function baseLayers(state, panelId) {
9870
+ return state.captionText[state.baseLocale]?.[panelId];
9871
+ }
9872
+ function effectiveCaptionValue(layer, field) {
9873
+ const value = layer[field];
9874
+ if (typeof value === "number") return value;
9875
+ switch (field) {
9876
+ case "lineHeight":
9877
+ case "bandInset":
9878
+ case "bandCenter":
9879
+ return CAPTION_STYLE_DEFAULTS[field];
9880
+ case "reserveLines":
9881
+ return layer.text.split("\n").length;
9882
+ default:
9883
+ return void 0;
9884
+ }
9885
+ }
9886
+ function captionRepeats(style2, start, now) {
9887
+ if (!start || !now || start.length !== now.length || now.length === 0) return [];
9888
+ return ADJUSTABLE_CAPTION_FIELDS.filter((field) => style2[field] !== void 0 && now.some((layer, index) => {
9889
+ const applied = captionLayerFromAiPatch(layer, { [field]: style2[field] });
9890
+ if (applied === layer) return false;
9891
+ return pushesFurther(
9892
+ field,
9893
+ effectiveCaptionValue(start[index], field),
9894
+ effectiveCaptionValue(layer, field),
9895
+ effectiveCaptionValue(applied, field)
9896
+ );
9897
+ }));
9898
+ }
9899
+ function dropRepeatedAdjustments(patch, context) {
9900
+ const dropped = [];
9901
+ const start = deviceLooks(context.start);
9902
+ const now = deviceLooks(context.now);
9903
+ const repairing = (panelId) => panelId !== void 0 && (context.repairing?.has(panelId) ?? false);
9904
+ const panelOfDevice = (deviceId) => context.now.shots.find((shot) => shot.id === deviceId)?.panelId;
9905
+ let next = patch;
9906
+ if (patch.devices) {
9907
+ const devices = [];
9908
+ let changed = false;
9909
+ for (const entry of patch.devices) {
9910
+ const repeats = repairing(panelOfDevice(entry.deviceId)) ? [] : deviceRepeats(entry.patch, entry.deviceId, start, now);
9911
+ if (repeats.length === 0) {
9912
+ devices.push(entry);
9913
+ continue;
9914
+ }
9915
+ changed = true;
9916
+ for (const field of repeats) dropped.push({ target: entry.deviceId, field });
9917
+ const kept = withoutFields(entry.patch, repeats);
9918
+ if (Object.keys(kept).length > 0) devices.push({ ...entry, patch: kept });
9919
+ }
9920
+ if (changed) {
9921
+ const { devices: _replaced, ...rest } = next;
9922
+ void _replaced;
9923
+ next = devices.length > 0 ? { ...rest, devices } : rest;
9924
+ }
9925
+ }
9926
+ if (patch.shots && patch.shots.length === context.panelIds.length) {
9927
+ let changed = false;
9928
+ const shots = patch.shots.map((entry, index) => {
9929
+ const panelId = context.panelIds[index];
9930
+ if (repairing(panelId)) return entry;
9931
+ const repeats = [];
9932
+ for (const shot of context.now.shots) {
9933
+ if (shot.panelId !== panelId) continue;
9934
+ for (const field of deviceRepeats(entry, shot.id, start, now)) {
9935
+ if (repeats.indexOf(field) < 0) repeats.push(field);
9936
+ }
9937
+ }
9938
+ if (repeats.length === 0) return entry;
9939
+ changed = true;
9940
+ for (const field of repeats) dropped.push({ target: `${panelId}#devices`, field });
9941
+ return withoutFields(entry, repeats);
9942
+ });
9943
+ if (changed) next = { ...next, shots };
9944
+ }
9945
+ const frames = patch.frames && patch.frames.length === context.panelIds.length ? patch.frames : void 0;
9946
+ if (frames) {
9947
+ let changed = false;
9948
+ const kept = frames.map((entry, index) => {
9949
+ const style2 = entry.captionStyle;
9950
+ const panelId = context.panelIds[index];
9951
+ if (!style2 || repairing(panelId)) return entry;
9952
+ const repeats = captionRepeats(style2, baseLayers(context.start, panelId), baseLayers(context.now, panelId));
9953
+ if (repeats.length === 0) return entry;
9954
+ changed = true;
9955
+ for (const field of repeats) dropped.push({ target: `${panelId}#captions`, field });
9956
+ const captionStyle = withoutFields(style2, repeats);
9957
+ const { captionStyle: _replaced, ...rest } = entry;
9958
+ void _replaced;
9959
+ return Object.keys(captionStyle).length > 0 ? { ...rest, captionStyle } : rest;
9960
+ });
9961
+ if (changed) next = { ...next, frames: kept };
9962
+ }
9963
+ if (patch.captionStyle && !context.panelIds.some(repairing)) {
9964
+ const stripWide = patch.captionStyle;
9965
+ const repeats = [];
9966
+ context.panelIds.forEach((panelId, index) => {
9967
+ const own = frames?.[index]?.captionStyle ?? {};
9968
+ const reaching = withoutFields(stripWide, Object.keys(own));
9969
+ const found = captionRepeats(reaching, baseLayers(context.start, panelId), baseLayers(context.now, panelId));
9970
+ for (const field of found) {
9971
+ if (repeats.indexOf(field) < 0) repeats.push(field);
9972
+ }
9973
+ });
9974
+ if (repeats.length > 0) {
9975
+ for (const field of repeats) dropped.push({ target: "strip#captions", field });
9976
+ const captionStyle = withoutFields(stripWide, repeats);
9977
+ const { captionStyle: _replaced, ...rest } = next;
9978
+ void _replaced;
9979
+ next = Object.keys(captionStyle).length > 0 ? { ...rest, captionStyle } : rest;
9980
+ }
9981
+ }
9982
+ return { patch: dropped.length > 0 ? next : patch, dropped };
9983
+ }
9984
+ function carriesSomething(value) {
9985
+ if (value === void 0 || value === null) return false;
9986
+ if (Array.isArray(value)) return value.some(carriesSomething);
9987
+ if (typeof value === "object") return Object.keys(value).some((key) => carriesSomething(value[key]));
9988
+ return true;
9989
+ }
9990
+ function patchChangesSomething(patch) {
9991
+ return Object.keys(patch).some((key) => key !== "summary" && carriesSomething(patch[key]));
9992
+ }
9993
+ var ADJUSTABLE_DEVICE_FIELDS, ADJUSTABLE_CAPTION_FIELDS;
9994
+ var init_agentTurnAdjustments = __esm({
9995
+ "../mockup-engine/appstore/agentTurnAdjustments.ts"() {
9996
+ "use strict";
9997
+ init_refinementValues();
9998
+ init_caption();
9999
+ ADJUSTABLE_DEVICE_FIELDS = [
10000
+ "orbit",
10001
+ "roll",
10002
+ "deviceSize",
10003
+ "hOffset",
10004
+ "vOffset",
10005
+ "finish",
10006
+ "clearcoat",
10007
+ "screenCornerRadius"
10008
+ ];
10009
+ ADJUSTABLE_CAPTION_FIELDS = ["sizePt", "maxWidth", "lineHeight", "bandInset", "bandCenter", "reserveLines"];
10010
+ }
10011
+ });
10012
+
10013
+ // ../mockup-engine/appstore/agentTurnLanded.ts
10014
+ function sameDeviceValue(field, proposed, applied) {
10015
+ if (field !== "orbit") return near(proposed, applied);
10016
+ const wrapped = ((proposed + 180) % 360 + 360) % 360 - 180;
10017
+ return near(wrapped, applied, 0.5) || near(Math.abs(wrapped), 180, 0.5) && near(Math.abs(applied), 180, 0.5);
10018
+ }
10019
+ function deviceDifference(field, proposed, applied, sharedOffset) {
10020
+ if (sameDeviceValue(field, proposed, applied)) return null;
10021
+ if (field === "hOffset" && sharedOffset) return "shared_offset";
10022
+ const range = field === "orbit" ? void 0 : SHOT_LOOK_RANGES[field];
10023
+ if (range && (proposed < range.min || proposed > range.max) && near(clamp(proposed, range), applied)) return "held_at_limit";
10024
+ return "set_differently";
10025
+ }
10026
+ function deviceDifferences(entry, applied, sharedOffset, out) {
10027
+ for (const field of DEVICE_NUMBERS) {
10028
+ const proposed = entry[field];
10029
+ const landed = applied[field];
10030
+ if (typeof proposed !== "number" || typeof landed !== "number") continue;
10031
+ const difference = deviceDifference(field, proposed, landed, sharedOffset);
10032
+ if (difference) out.add(difference);
10033
+ }
10034
+ }
10035
+ function expectedCaptionValue(field, proposed, current) {
10036
+ const raw = typeof proposed === "number" ? proposed : current === void 0 ? void 0 : current * clamp(proposed.scale, AI_SCALE_RANGE);
10037
+ return raw !== void 0 && field === "reserveLines" ? Math.round(raw) : raw;
10038
+ }
10039
+ function captionDifferences(style2, before, after, alignedByPosition, out) {
10040
+ const stated = CAPTION_NUMBERS.filter((field) => style2[field] !== void 0);
10041
+ if (stated.length === 0) return;
10042
+ if (!after || after.length === 0) {
10043
+ out.add("set_differently");
10044
+ return;
10045
+ }
10046
+ for (const field of stated) {
10047
+ const proposed = style2[field];
10048
+ if (typeof proposed !== "number" && !alignedByPosition) continue;
10049
+ after.forEach((layer, index) => {
10050
+ const expected = expectedCaptionValue(field, proposed, typeof proposed === "number" ? void 0 : before?.[index]?.[field]);
10051
+ const landed = layer[field];
10052
+ if (expected === void 0) {
10053
+ out.add("set_differently");
10054
+ return;
10055
+ }
10056
+ if (typeof landed === "number" && near(landed, expected)) return;
10057
+ const range = CAPTION_STYLE_BOUNDS[field];
10058
+ const limited = field === "reserveLines" ? Math.round(clamp(expected, range)) : clamp(expected, range);
10059
+ out.add(typeof landed === "number" && !near(limited, expected) && near(landed, limited) ? "held_at_limit" : "set_differently");
10060
+ });
10061
+ }
10062
+ }
10063
+ function landedDifferences(before, after, patch, panelIds) {
10064
+ const out = /* @__PURE__ */ new Set();
10065
+ const lookOf = (shotId) => {
10066
+ const shot = after.shots.find((candidate) => candidate.id === shotId);
10067
+ return shot ? aiShotPatchFromLook(shot.look) : null;
10068
+ };
10069
+ for (const entry of patch.devices ?? []) {
10070
+ const applied = lookOf(entry.deviceId);
10071
+ if (applied) deviceDifferences(entry.patch, applied, false, out);
10072
+ }
10073
+ if (patch.shots && patch.shots.length === panelIds.length) {
10074
+ patch.shots.forEach((entry, index) => {
10075
+ const shots = after.shots.filter((shot) => shot.panelId === panelIds[index]);
10076
+ for (const shot of shots) deviceDifferences(entry, aiShotPatchFromLook(shot.look), shots.length > 1, out);
10077
+ });
10078
+ }
10079
+ const frames = patch.frames && patch.frames.length === panelIds.length ? patch.frames : void 0;
10080
+ if (patch.captionStyle || frames) {
10081
+ const beforeSlots = normalizeCaptionSlotIds(before.captionSlotIds, before.captionText, before.panels.map((panel) => panel.id));
10082
+ const afterSlots = normalizeCaptionSlotIds(after.captionSlotIds, after.captionText, after.panels.map((panel) => panel.id));
10083
+ panelIds.forEach((panelId, index) => {
10084
+ const style2 = { ...patch.captionStyle, ...frames?.[index]?.captionStyle };
10085
+ const aligned = (beforeSlots[panelId] ?? []).join("\0") === (afterSlots[panelId] ?? []).join("\0");
10086
+ captionDifferences(
10087
+ style2,
10088
+ before.captionText[before.baseLocale]?.[panelId],
10089
+ after.captionText[after.baseLocale]?.[panelId],
10090
+ aligned,
10091
+ out
10092
+ );
10093
+ });
10094
+ }
10095
+ return out;
10096
+ }
10097
+ var DEVICE_NUMBERS, CAPTION_NUMBERS, clamp, near;
10098
+ var init_agentTurnLanded = __esm({
10099
+ "../mockup-engine/appstore/agentTurnLanded.ts"() {
10100
+ "use strict";
10101
+ init_caption();
10102
+ init_defaults();
10103
+ init_frameTemplates();
10104
+ init_refineIntent();
10105
+ init_refinementValues();
10106
+ DEVICE_NUMBERS = ["orbit", "roll", "deviceSize", "hOffset", "vOffset", "finish", "clearcoat", "screenCornerRadius"];
10107
+ CAPTION_NUMBERS = ["sizePt", "maxWidth", "lineHeight", "bandInset", "bandCenter", "reserveLines"];
10108
+ clamp = (value, range) => Math.min(Math.max(value, range.min), range.max);
10109
+ near = (a, b, tolerance = 1e-9) => Math.abs(a - b) <= tolerance;
10110
+ }
10111
+ });
10112
+
9403
10113
  // ../mockup-engine/appstore/agentTurnCoordinator.ts
10114
+ function passEffect(working, application, patch, panelIds, notApplied, focus) {
10115
+ const landed = landedDifferences(working, application.state, patch, panelIds);
10116
+ if (notApplied.size === 0 && landed.size === 0) return null;
10117
+ const applied = [...describeRefinementEffect(working, application.state), ...describeAssetBindings(application.assetBindings)];
10118
+ const lines = [applied.length > 0 ? `${applied.join(" \xB7 ")}.` : "Nothing changed in this pass."];
10119
+ if (notApplied.size > 0) lines.push(`Not applied: ${Array.from(notApplied).map((reason) => NOT_APPLIED[reason]).join("; ")}.`);
10120
+ if (landed.size > 0) lines.push(`Not applied as proposed: ${Array.from(landed).map((reason) => LANDED_DIFFERENTLY[reason]).join("; ")}.`);
10121
+ return {
10122
+ summary: lines.join(" "),
10123
+ reachesOutsideFocus: changedOutsideFocus(working, application.state, focus)
10124
+ };
10125
+ }
10126
+ function emptiedPassEffect(notApplied) {
10127
+ return {
10128
+ summary: `Nothing changed in this pass. Not applied: ${Array.from(notApplied).map((reason) => NOT_APPLIED[reason]).join("; ")}.`,
10129
+ reachesOutsideFocus: false
10130
+ };
10131
+ }
10132
+ function changedOutsideFocus(before, after, focus) {
10133
+ if (focus.kind === "project") return false;
10134
+ const frameId = focus.frameId;
10135
+ const ownsFrame = focus.kind === "frame";
10136
+ const slotsOf = (state) => normalizeCaptionSlotIds(state.captionSlotIds, state.captionText, state.panels.map((panel) => panel.id));
10137
+ const beforeSlots = slotsOf(before);
10138
+ const focusedSlot = focus.kind === "caption" ? beforeSlots[frameId]?.[focus.captionIndex] : void 0;
10139
+ const shotsOutside = (state) => state.shots.filter((shot) => !(ownsFrame && shot.panelId === frameId)).map((shot) => focus.kind === "device" && shot.id === focus.deviceId ? { id: shot.id, panelId: shot.panelId } : { id: shot.id, panelId: shot.panelId, frameName: shot.frameName, frameNodeId: shot.frameNodeId, look: shot.look });
10140
+ const bytesOutside = (state) => state.shots.filter((shot) => !(ownsFrame && shot.panelId === frameId) && !(focus.kind === "device" && shot.id === focus.deviceId)).map((shot) => shot.bytes);
10141
+ const perFrame = (map) => {
10142
+ if (!map || !ownsFrame) return map;
10143
+ const { [frameId]: _owned, ...rest } = map;
10144
+ void _owned;
10145
+ return rest;
10146
+ };
10147
+ const outside = (state) => {
10148
+ const {
10149
+ selection: _selection,
10150
+ activeLocale: _activeLocale,
10151
+ previewPresetId: _preview,
10152
+ outputScreens: _outputScreens,
10153
+ localeScreens: _localeScreens,
10154
+ selectedShotId: _selectedShotId,
10155
+ shots: _shots,
10156
+ captionSlotIds: _rawSlots,
10157
+ captionText,
10158
+ outputCaptions,
10159
+ panelColors,
10160
+ panelBackgrounds,
10161
+ staleCaptionTranslations,
10162
+ frameTemplates,
10163
+ ...rest
10164
+ } = state;
10165
+ void [_selection, _activeLocale, _preview, _outputScreens, _localeScreens, _selectedShotId, _shots, _rawSlots];
10166
+ const slots = slotsOf(state);
10167
+ const slotAt = (panelId, index) => slots[panelId]?.[index] ?? `${panelId}#${index}`;
10168
+ const owned = (panelId, index) => ownsFrame ? panelId === frameId : focusedSlot !== void 0 && slotAt(panelId, index) === focusedSlot;
10169
+ const keyed = (panelId, layers) => layers.flatMap((layer, index) => owned(panelId, index) ? [] : [[slotAt(panelId, index), layer]]);
10170
+ const byFrame = (frames) => frames && Object.keys(frames).map((panelId) => [panelId, keyed(panelId, frames[panelId])]).filter(([, entries]) => entries.length > 0);
10171
+ const byLocale = (locales) => locales && Object.keys(locales).map((locale2) => [locale2, byFrame(locales[locale2])]);
10172
+ return JSON.stringify({
10173
+ rest,
10174
+ shots: shotsOutside(state),
10175
+ captionText: byLocale(captionText),
10176
+ outputCaptions: outputCaptions && Object.keys(outputCaptions).map((family) => [family, byLocale(outputCaptions[family])]),
10177
+ captionSlots: Object.keys(slots).map((panelId) => [panelId, slots[panelId].filter((_, index) => !owned(panelId, index))]).filter(([, ids]) => ids.length > 0),
10178
+ staleCaptionTranslations: staleCaptionTranslations && Object.keys(staleCaptionTranslations).map((locale2) => [
10179
+ locale2,
10180
+ staleCaptionTranslations[locale2].flatMap((key) => {
10181
+ const cut = key.lastIndexOf(":");
10182
+ const panelId = key.slice(0, cut);
10183
+ const index = Number(key.slice(cut + 1));
10184
+ return owned(panelId, index) ? [] : [`${panelId}:${slotAt(panelId, index)}`];
10185
+ }).sort()
10186
+ ]),
10187
+ panelColors: perFrame(panelColors),
10188
+ panelBackgrounds: perFrame(panelBackgrounds),
10189
+ frameTemplates: perFrame(frameTemplates)
10190
+ });
10191
+ };
10192
+ const beforeBytes = bytesOutside(before);
10193
+ const afterBytes = bytesOutside(after);
10194
+ return outside(before) !== outside(after) || beforeBytes.length !== afterBytes.length || beforeBytes.some((bytes, index) => bytes !== afterBytes[index]);
10195
+ }
9404
10196
  function phaseFor(cycle) {
9405
10197
  return cycle === 1 ? "applying" : cycle === 2 ? "inspecting" : "adjusting";
9406
10198
  }
@@ -9469,6 +10261,7 @@ async function runAgentTurn(input, ports) {
9469
10261
  const appliedOperations = [];
9470
10262
  const touchedCapabilities = /* @__PURE__ */ new Set();
9471
10263
  const templatedPanels = /* @__PURE__ */ new Set();
10264
+ let instructionSatisfied = false;
9472
10265
  let terminalOutcome = null;
9473
10266
  let invalid2 = false;
9474
10267
  let cancelled = false;
@@ -9521,10 +10314,55 @@ async function runAgentTurn(input, ports) {
9521
10314
  }
9522
10315
  interpretation ||= authorization.interpretation?.trim() || instruction;
9523
10316
  inspection = authorization.inspection?.trim() || "Inspected the current composed result.";
10317
+ const notApplied = /* @__PURE__ */ new Set();
10318
+ if (!authorization.failed && !authorization.terminalOutcome) {
10319
+ if (authorization.patch && cycle > 1) {
10320
+ const guarded = dropRepeatedOperations(authorization.patch, appliedOperations);
10321
+ if (guarded.dropped.length > 0) {
10322
+ authorization = { ...authorization, patch: guarded.patch };
10323
+ notApplied.add("repeated_step");
10324
+ }
10325
+ }
10326
+ if (authorization.patch && instructionSatisfied) {
10327
+ const context = { start: before, now: working, panelIds: authorization.panelIds };
10328
+ let guarded = dropRepeatedAdjustments(authorization.patch, context);
10329
+ if (guarded.dropped.length > 0 && layoutRepairable && renderedState) {
10330
+ const repairing = /* @__PURE__ */ new Set();
10331
+ for (const index of ports.layoutCollisionFrames?.(rendered) ?? []) {
10332
+ const panelId = renderedState.panels[index]?.id;
10333
+ if (panelId) repairing.add(panelId);
10334
+ }
10335
+ if (repairing.size > 0) guarded = dropRepeatedAdjustments(authorization.patch, { ...context, repairing });
10336
+ }
10337
+ if (guarded.dropped.length > 0) {
10338
+ authorization = {
10339
+ ...authorization,
10340
+ patch: patchChangesSomething(guarded.patch) ? guarded.patch : void 0
10341
+ };
10342
+ notApplied.add("repeated_adjustment");
10343
+ }
10344
+ }
10345
+ if (authorization.patch && templatedPanels.size > 0) {
10346
+ const shots = working.shots;
10347
+ const guarded = dropTemplatePoses(
10348
+ authorization.patch,
10349
+ templatedPanels,
10350
+ authorization.panelIds,
10351
+ (deviceId) => shots.find((shot) => shot.id === deviceId)?.panelId
10352
+ );
10353
+ if (guarded.dropped) {
10354
+ authorization = { ...authorization, patch: guarded.patch };
10355
+ notApplied.add("template_pose");
10356
+ }
10357
+ }
10358
+ }
10359
+ const planned = !authorization.failed && !authorization.terminalOutcome;
10360
+ const application = planned && authorization.patch ? applyProjectRefinementWithAssets(working, authorization.patch, authorization.panelIds, input.assets ?? []) : null;
10361
+ const effect = !planned ? null : application && authorization.patch ? passEffect(working, application, authorization.patch, authorization.panelIds, notApplied, input.focus) : notApplied.size > 0 && !authorization.patch ? emptiedPassEffect(notApplied) : null;
9524
10362
  const pass = {
9525
10363
  phase,
9526
10364
  generationId: authorization.generationId,
9527
- summary: authorization.failed ? FAILED_PASS_SUMMARY[phase] : inspection,
10365
+ summary: authorization.failed ? FAILED_PASS_SUMMARY[phase] : effect?.summary ?? inspection,
9528
10366
  usage: authorization.usage,
9529
10367
  createdAt: now()
9530
10368
  };
@@ -9545,29 +10383,11 @@ async function runAgentTurn(input, ports) {
9545
10383
  break;
9546
10384
  }
9547
10385
  remainingMismatch = authorization.remainingMismatch?.trim() || authorization.followUp?.trim() || null;
9548
- for (const change of authorization.broaderChanges ?? []) broaderChanges.add(change);
9549
- if (authorization.patch && cycle > 1) {
9550
- const guarded = dropRepeatedOperations(authorization.patch, appliedOperations);
9551
- if (guarded.dropped.length > 0) authorization = { ...authorization, patch: guarded.patch };
9552
- }
9553
- if (authorization.patch && templatedPanels.size > 0) {
9554
- const shots = working.shots;
9555
- const guarded = dropTemplatePoses(
9556
- authorization.patch,
9557
- templatedPanels,
9558
- authorization.panelIds,
9559
- (deviceId) => shots.find((shot) => shot.id === deviceId)?.panelId
9560
- );
9561
- if (guarded.dropped) authorization = { ...authorization, patch: guarded.patch };
10386
+ if (authorization.patch && (!effect || effect.reachesOutsideFocus)) {
10387
+ for (const change of authorization.broaderChanges ?? []) broaderChanges.add(change);
9562
10388
  }
9563
10389
  if (authorization.patch) {
9564
10390
  for (const id of capabilitiesForPatch(authorization.patch)) touchedCapabilities.add(id);
9565
- const application = applyProjectRefinementWithAssets(
9566
- working,
9567
- authorization.patch,
9568
- authorization.panelIds,
9569
- input.assets ?? []
9570
- );
9571
10391
  if (!application) {
9572
10392
  invalid2 = true;
9573
10393
  break;
@@ -9593,6 +10413,7 @@ async function runAgentTurn(input, ports) {
9593
10413
  assetBindings: workingAssetBindings
9594
10414
  };
9595
10415
  selected = checkpoint;
10416
+ if (authorization.complete === true) instructionSatisfied = true;
9596
10417
  if (checkpoint.changed.length > 0 && (!best || checkpoint.quality >= best.quality)) best = checkpoint;
9597
10418
  await emit({
9598
10419
  type: "draft_applied",
@@ -9719,7 +10540,7 @@ async function runAgentTurn(input, ports) {
9719
10540
  void input.requestedCycle;
9720
10541
  return result;
9721
10542
  }
9722
- var AGENT_TURN_PASS_CAP, AgentTurnAuthorizationError, EMPTY_USAGE, NO_CHANGE_SUMMARY, NEEDS_INPUT_SUMMARY, FAILED_PASS_SUMMARY;
10543
+ var AGENT_TURN_PASS_CAP, AgentTurnAuthorizationError, EMPTY_USAGE, NO_CHANGE_SUMMARY, NEEDS_INPUT_SUMMARY, FAILED_PASS_SUMMARY, NOT_APPLIED, LANDED_DIFFERENTLY;
9723
10544
  var init_agentTurnCoordinator = __esm({
9724
10545
  "../mockup-engine/appstore/agentTurnCoordinator.ts"() {
9725
10546
  "use strict";
@@ -9731,8 +10552,11 @@ var init_agentTurnCoordinator = __esm({
9731
10552
  init_appStoreLocales();
9732
10553
  init_agentAssetCustody();
9733
10554
  init_agentTurnRepetition();
10555
+ init_agentTurnAdjustments();
9734
10556
  init_templatePoseGuard();
9735
10557
  init_layoutTemplates();
10558
+ init_frameTemplates();
10559
+ init_agentTurnLanded();
9736
10560
  AGENT_TURN_PASS_CAP = 3;
9737
10561
  AgentTurnAuthorizationError = class extends Error {
9738
10562
  constructor(outcome) {
@@ -9756,6 +10580,16 @@ var init_agentTurnCoordinator = __esm({
9756
10580
  inspecting: "The inspection pass did not complete.",
9757
10581
  adjusting: "The correction pass did not complete."
9758
10582
  };
10583
+ NOT_APPLIED = {
10584
+ repeated_step: "a step this turn had already taken",
10585
+ repeated_adjustment: "a further change to what this turn had already adjusted",
10586
+ template_pose: "a new pose for a device its layout template had placed"
10587
+ };
10588
+ LANDED_DIFFERENTLY = {
10589
+ held_at_limit: "a value past its limit, which was held at the limit",
10590
+ shared_offset: "a sideways offset for each phone of a frame that holds several, which stays shared",
10591
+ set_differently: "a value the project took differently"
10592
+ };
9759
10593
  }
9760
10594
  });
9761
10595
 
@@ -9866,6 +10700,13 @@ var init_agentTurnConformance = __esm({
9866
10700
  summary: "Restyled 1 device",
9867
10701
  broaderChanges: [],
9868
10702
  remainingMismatch: null
10703
+ },
10704
+ receipt: {
10705
+ committed: true,
10706
+ status: "applied",
10707
+ generationIds: ["generation-1", "generation-2"],
10708
+ recordVersion: 8,
10709
+ usage: { inputTokens: 20, cacheReadTokens: 4, imageTokens: null, outputTokens: 10, totalTokens: 30 }
9869
10710
  }
9870
10711
  }
9871
10712
  };
@@ -9968,6 +10809,8 @@ var init_appstore = __esm({
9968
10809
  init_captionModel();
9969
10810
  init_variant();
9970
10811
  init_screenManifest();
10812
+ init_fingerprint();
10813
+ init_screenUploadPlan();
9971
10814
  init_productionInput();
9972
10815
  init_productionReadiness();
9973
10816
  init_localeAxis();
@@ -9977,6 +10820,7 @@ var init_appstore = __esm({
9977
10820
  init_localeVariants();
9978
10821
  init_shot();
9979
10822
  init_layout();
10823
+ init_framelessCard();
9980
10824
  init_serialize();
9981
10825
  init_project();
9982
10826
  init_look();
@@ -10283,6 +11127,70 @@ var init_lookResolution = __esm({
10283
11127
  }
10284
11128
  });
10285
11129
 
11130
+ // ../mockup-engine/appstore/recordEnvelope.ts
11131
+ function isEnvelope(value) {
11132
+ return value != null && typeof value === "object" && !Array.isArray(value);
11133
+ }
11134
+ function isBlankRecord(value) {
11135
+ return isEnvelope(value) && value.record === 0 && value.project === void 0;
11136
+ }
11137
+ function rebuildEnvelope(stored, next) {
11138
+ const out = isEnvelope(stored) ? { ...stored } : {};
11139
+ const { project: project2, manifest, ...explicit } = next;
11140
+ out.record = SAVED_RECORD_VERSION;
11141
+ out.project = project2;
11142
+ if ("manifest" in next) {
11143
+ const folded = manifest ? mergeManifest([], manifest) : [];
11144
+ if (folded.length > 0) out.handoffRefs = folded;
11145
+ else delete out.handoffRefs;
11146
+ }
11147
+ for (const [key, value] of Object.entries(explicit)) {
11148
+ if (value === void 0) delete out[key];
11149
+ else out[key] = value;
11150
+ }
11151
+ return out;
11152
+ }
11153
+ function custodyUnion(...manifests) {
11154
+ const seen = /* @__PURE__ */ new Set();
11155
+ const out = [];
11156
+ for (const manifest of manifests) {
11157
+ for (const entry of manifest) {
11158
+ if (seen.has(entry.ref)) continue;
11159
+ seen.add(entry.ref);
11160
+ out.push(entry);
11161
+ }
11162
+ }
11163
+ return out;
11164
+ }
11165
+ var SAVED_RECORD_VERSION, ENVELOPE_KEYS_A_SAVE_OWNS;
11166
+ var init_recordEnvelope = __esm({
11167
+ "../mockup-engine/appstore/recordEnvelope.ts"() {
11168
+ "use strict";
11169
+ init_screenManifest();
11170
+ SAVED_RECORD_VERSION = 2;
11171
+ ENVELOPE_KEYS_A_SAVE_OWNS = [
11172
+ "record",
11173
+ "project",
11174
+ "fastlaneBundleId",
11175
+ "fastlaneLocale",
11176
+ "lastBundleExportAt",
11177
+ "lastShareAt",
11178
+ "latestVersion",
11179
+ "heldVersion",
11180
+ "source",
11181
+ "baseline",
11182
+ "reach",
11183
+ "handoffRefs",
11184
+ // The App Store listing a project is linked to (#1001). OWNED, because owned is the only kind a
11185
+ // DETACH can delete — an unowned key could never be removed. That does not oblige a save to carry
11186
+ // it: the rule above already says an unnamed key survives. Naming it is the hazard here, so no
11187
+ // ordinary save path may mention `appListing` — only a deliberate attach (a value) or detach
11188
+ // (`undefined` through rebuildEnvelope, `null` through patchRecord).
11189
+ "appListing"
11190
+ ];
11191
+ }
11192
+ });
11193
+
10286
11194
  // ../api/_lib/projectRecord.ts
10287
11195
  function parseScreenPurgeRefs(value) {
10288
11196
  if (!Array.isArray(value)) {
@@ -10299,13 +11207,15 @@ function parseScreenPurgeRefs(value) {
10299
11207
  }
10300
11208
  return entries;
10301
11209
  }
10302
- var PROJECT_RECORD_MAX_BYTES;
11210
+ var PROJECT_RECORD_MAX_BYTES, WRITABLE_ENVELOPE_KEYS;
10303
11211
  var init_projectRecord = __esm({
10304
11212
  "../api/_lib/projectRecord.ts"() {
10305
11213
  "use strict";
10306
11214
  init_projectFileGuard();
11215
+ init_recordEnvelope();
10307
11216
  init_screenManifest();
10308
11217
  PROJECT_RECORD_MAX_BYTES = 2 * 1024 * 1024;
11218
+ WRITABLE_ENVELOPE_KEYS = new Set(ENVELOPE_KEYS_A_SAVE_OWNS);
10309
11219
  }
10310
11220
  });
10311
11221
 
@@ -10447,10 +11357,14 @@ var init_supabaseRest = __esm({
10447
11357
  });
10448
11358
 
10449
11359
  // ../api/_lib/shareStorage.ts
11360
+ function safePath(path) {
11361
+ if (!isSafeStoragePath(path)) throw new Error(`storage: refusing unsafe object path ${JSON.stringify(path)}`);
11362
+ return path;
11363
+ }
10450
11364
  async function createSignedUploadUrl(path, options = {}) {
10451
11365
  const headers = serviceHeaders({ "Content-Type": "application/json" });
10452
11366
  if (options.upsert) headers["x-upsert"] = "true";
10453
- const res = await fetch(`${storageBase()}/object/upload/sign/${BUCKET}/${path}`, {
11367
+ const res = await fetch(`${storageBase()}/object/upload/sign/${BUCKET}/${safePath(path)}`, {
10454
11368
  method: "POST",
10455
11369
  headers,
10456
11370
  body: JSON.stringify({})
@@ -10463,7 +11377,7 @@ async function createSignedUploadUrl(path, options = {}) {
10463
11377
  return { uploadUrl: `${storageBase()}${data.url}` };
10464
11378
  }
10465
11379
  async function createSignedDownloadUrl(path, expiresInSeconds, downloadFilename) {
10466
- const res = await fetch(`${storageBase()}/object/sign/${BUCKET}/${path}`, {
11380
+ const res = await fetch(`${storageBase()}/object/sign/${BUCKET}/${safePath(path)}`, {
10467
11381
  method: "POST",
10468
11382
  headers: serviceHeaders({ "Content-Type": "application/json" }),
10469
11383
  body: JSON.stringify({ expiresIn: expiresInSeconds })
@@ -10488,7 +11402,7 @@ async function putBytes(uploadUrl, bytes, contentType) {
10488
11402
  }
10489
11403
  }
10490
11404
  async function downloadObject(path) {
10491
- const res = await fetch(`${storageBase()}/object/${BUCKET}/${path}`, { headers: serviceHeaders() });
11405
+ const res = await fetch(`${storageBase()}/object/${BUCKET}/${safePath(path)}`, { headers: serviceHeaders() });
10492
11406
  if (!res.ok) {
10493
11407
  const body = (await res.text().catch(() => "")).slice(0, 200);
10494
11408
  if (res.status === 404 || /"statusCode"\s*:\s*"?404"?/.test(body) || /not_found/.test(body)) {
@@ -10499,7 +11413,7 @@ async function downloadObject(path) {
10499
11413
  return new Uint8Array(await res.arrayBuffer());
10500
11414
  }
10501
11415
  async function getObjectInfo(path) {
10502
- const res = await fetch(`${storageBase()}/object/info/${BUCKET}/${path}`, { headers: serviceHeaders() });
11416
+ const res = await fetch(`${storageBase()}/object/info/${BUCKET}/${safePath(path)}`, { headers: serviceHeaders() });
10503
11417
  if (!res.ok) {
10504
11418
  const body = (await res.text().catch(() => "")).slice(0, 200);
10505
11419
  if (res.status === 404 || /"statusCode"\s*:\s*"?404"?/.test(body) || /not_found/.test(body)) {
@@ -10518,6 +11432,7 @@ var BUCKET, StorageObjectNotFoundError;
10518
11432
  var init_shareStorage = __esm({
10519
11433
  "../api/_lib/shareStorage.ts"() {
10520
11434
  "use strict";
11435
+ init_storagePath();
10521
11436
  init_supabaseRest();
10522
11437
  BUCKET = "share-bundles";
10523
11438
  StorageObjectNotFoundError = class extends Error {
@@ -10872,7 +11787,7 @@ function uploadCustody(userId) {
10872
11787
  }
10873
11788
  function refBelongsToUser(ref, userId, now = Date.now()) {
10874
11789
  const prefix = `uploads/${userId}/`;
10875
- if (!ref.startsWith(prefix) || ref.includes("..")) return false;
11790
+ if (!ref.startsWith(prefix) || !isSafeStoragePath(ref)) return false;
10876
11791
  if (!userId.startsWith(ANONYMOUS_USER_PREFIX)) return true;
10877
11792
  const dir = ref.slice(prefix.length).split("/")[0];
10878
11793
  const expiry = ANONYMOUS_DIR_EXPIRY_RE.exec(dir);
@@ -10886,17 +11801,16 @@ async function fetchUploadedRef(ref, userId) {
10886
11801
  await adoptLegacyAssetOnAuthorizedRead(ref, userId).catch(() => void 0);
10887
11802
  return await downloadObject(ref);
10888
11803
  } catch (err) {
10889
- if (err instanceof StorageObjectNotFoundError) {
10890
- throw new Error(
10891
- `no screenshot was ever uploaded for ref "${ref}". request_screenshot_upload only MINTS an upload slot \u2014 the raw PNG bytes must still be PUT to its uploadUrl before rendering (curl -T screenshot.png "$UPLOAD_URL"). If that PUT failed or was skipped, mint a fresh slot and retry it; slots are single-use and short-lived. Alternatively pass the screenshot as { "url": "https://\u2026" } and this server will fetch it for you. ` + // #180: on ChatGPT mobile the PUT can NEVER succeed (the sandbox cannot resolve the
10892
- // storage host), so "retry it" is not always advice an agent can take. Name the door
10893
- // that has no upload step at all.
10894
- BROWSER_FIRST_ROUTE
10895
- );
10896
- }
11804
+ if (err instanceof StorageObjectNotFoundError) throw new Error(missingUploadMessage(ref));
10897
11805
  throw err;
10898
11806
  }
10899
11807
  }
11808
+ function missingUploadMessage(ref) {
11809
+ return `no screenshot was ever uploaded for ref "${ref}". request_screenshot_upload only MINTS an upload slot \u2014 the raw PNG bytes must still be PUT to its uploadUrl before rendering (curl -T screenshot.png "$UPLOAD_URL"). If that PUT failed or was skipped, mint a fresh slot and retry it; slots are single-use and short-lived. Alternatively pass the screenshot as { "url": "https://\u2026" } and this server will fetch it for you. ` + // #180: on ChatGPT mobile the PUT can NEVER succeed (the sandbox cannot resolve the
11810
+ // storage host), so "retry it" is not always advice an agent can take. Name the door
11811
+ // that has no upload step at all.
11812
+ BROWSER_FIRST_ROUTE;
11813
+ }
10900
11814
  async function fetchPanelRef(ref, userId) {
10901
11815
  if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(ref)) {
10902
11816
  return fetchUploadedRef(ref, userId);
@@ -11070,6 +11984,7 @@ var init_uploads = __esm({
11070
11984
  init_shareStorage();
11071
11985
  init_assetRegistry();
11072
11986
  init_supabaseRest();
11987
+ init_storagePath();
11073
11988
  init_retentionPolicy();
11074
11989
  init_toolContract();
11075
11990
  init_pngOptimizer();
@@ -11152,7 +12067,12 @@ async function readLocalPathAsBase64(path) {
11152
12067
  const { bytes } = await readLocalScreenshotFile(path);
11153
12068
  return (await optimizeScreenshotPng(bytes, `The file at "${path}"`)).toString("base64");
11154
12069
  }
11155
- async function resolvePanelBytes(entry, userId, allowLocalPath) {
12070
+ async function readOneUpload(readUploads, ref) {
12071
+ const bytes = (await readUploads([ref])).get(ref);
12072
+ if (!bytes) throw new Error(`the screenshot for ref "${ref}" could not be read.`);
12073
+ return bytes;
12074
+ }
12075
+ async function resolvePanelBytes(entry, userId, allowLocalPath, readUploads) {
11156
12076
  if ("path" in entry) {
11157
12077
  if (!allowLocalPath) {
11158
12078
  throw new Error(
@@ -11165,14 +12085,20 @@ async function resolvePanelBytes(entry, userId, allowLocalPath) {
11165
12085
  throw new Error(`could not read local panel at "${entry.path}": ${err.message}`);
11166
12086
  }
11167
12087
  }
12088
+ if (readUploads) {
12089
+ if (PANEL_ID_RE.test(entry.ref)) throw new HostedOnlyPanelError(entry.ref);
12090
+ return readOneUpload(readUploads, entry.ref);
12091
+ }
11168
12092
  return entry.ref.includes("/") ? fetchUploadedRef(entry.ref, userId) : fetchPanelRef(entry.ref, userId);
11169
12093
  }
11170
- async function resolveEntry(entry, userId, allowLocalPath) {
12094
+ async function resolveEntry(entry, userId, allowLocalPath, uploaded) {
11171
12095
  if (typeof entry === "string") {
11172
12096
  return (await optimizeScreenshotPng(Buffer.from(entry, "base64"), "Inline screenshot")).toString("base64");
11173
12097
  }
11174
12098
  if ("ref" in entry) {
11175
- const bytes = await fetchUploadedRef(entry.ref, userId);
12099
+ const read = uploaded?.get(entry.ref);
12100
+ if (uploaded && !read) throw new Error(`the screenshot for ref "${entry.ref}" could not be read.`);
12101
+ const bytes = read ?? await fetchUploadedRef(entry.ref, userId);
11176
12102
  return (await optimizeScreenshotPng(bytes, `Screenshot ref "${entry.ref}"`)).toString("base64");
11177
12103
  }
11178
12104
  if ("path" in entry) {
@@ -11188,10 +12114,13 @@ async function resolveEntry(entry, userId, allowLocalPath) {
11188
12114
  }
11189
12115
  return fetchUrlAsBase64(entry.url);
11190
12116
  }
11191
- async function resolveScreenshots(slots, userId, allowLocalPath = false) {
12117
+ async function resolveScreenshots(slots, userId, allowLocalPath = false, readUploads) {
11192
12118
  assertInlinePayloadSizeNested(slots);
11193
- return Promise.all(slots.map((slot2) => Promise.all(slot2.map((entry) => resolveEntry(entry, userId, allowLocalPath)))));
12119
+ const refs = Array.from(new Set(slots.flat().flatMap((entry) => entry && typeof entry === "object" && "ref" in entry && typeof entry.ref === "string" ? [entry.ref] : [])));
12120
+ const uploaded = readUploads && refs.length > 0 ? await readUploads(refs) : null;
12121
+ return Promise.all(slots.map((slot2) => Promise.all(slot2.map((entry) => resolveEntry(entry, userId, allowLocalPath, uploaded)))));
11194
12122
  }
12123
+ var PANEL_ID_RE, HostedOnlyPanelError;
11195
12124
  var init_screenshotInput = __esm({
11196
12125
  "src/render/screenshotInput.ts"() {
11197
12126
  "use strict";
@@ -11199,6 +12128,15 @@ var init_screenshotInput = __esm({
11199
12128
  init_uploads();
11200
12129
  init_pngOptimizer();
11201
12130
  init_toolContract();
12131
+ PANEL_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
12132
+ HostedOnlyPanelError = class extends Error {
12133
+ constructor(ref) {
12134
+ super(
12135
+ `Rendered panel ids are hosted-only \u2014 this local server cannot read panel "${ref}". Pass the rendered PNG as { "path": "\u2026" } instead.`
12136
+ );
12137
+ this.name = "HostedOnlyPanelError";
12138
+ }
12139
+ };
11202
12140
  }
11203
12141
  });
11204
12142
 
@@ -11208,22 +12146,96 @@ function screenNamesFromRecord(record8) {
11208
12146
  if (!Array.isArray(shots)) return [];
11209
12147
  return shots.map((s) => s?.frameName).filter((n) => typeof n === "string" && n.length > 0);
11210
12148
  }
11211
- function handoffRefsFromScreenshots(screenshots2, frameNames, userId) {
11212
- if (!Array.isArray(screenshots2) || screenshots2.length === 0) return {};
11213
- const wanted = new Set(frameNames);
11214
- const out = {};
11215
- for (const slot2 of normalizeScreenshotSlots(screenshots2)) {
11216
- for (const entry of slot2) {
11217
- if (!entry || typeof entry !== "object" || !("ref" in entry) || typeof entry.ref !== "string") continue;
11218
- const ref = canonicalizeUploadRef(entry.ref);
11219
- if (!refBelongsToUser(ref, userId)) continue;
11220
- const name = entryName(entry);
11221
- if (!name || !wanted.has(name)) continue;
11222
- out[name] = ref;
12149
+ function refOf(entry) {
12150
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null;
12151
+ const ref = entry.ref;
12152
+ return typeof ref === "string" && ref.length > 0 ? canonicalizeUploadRef(ref) : null;
12153
+ }
12154
+ function introducedScreenCells(screenshots2, projectFile) {
12155
+ const out = { manifest: [], refs: [], unaddressed: [] };
12156
+ if (!Array.isArray(screenshots2) || screenshots2.length === 0) return out;
12157
+ const entries = normalizeScreenshotSlots(screenshots2).flat();
12158
+ const shots = shotRefsFromProjectFile(projectFile);
12159
+ const { baseLocale } = projectLocaleAxis(projectFile);
12160
+ entries.forEach((entry, i) => {
12161
+ const shot = entries.length === shots.length ? shots[i] : void 0;
12162
+ const cells = [];
12163
+ if (isLocaleVariants(entry)) {
12164
+ const base = refOf(resolveLocaleVariant(entry, baseLocale, baseLocale));
12165
+ if (base) cells.push({ ref: base, variant: {} });
12166
+ for (const [locale2, variant] of Object.entries(entry.locales)) {
12167
+ const ref = locale2 === baseLocale ? null : refOf(variant);
12168
+ if (ref) cells.push({ ref, variant: canonicalVariant({ locale: locale2 }, baseLocale), locale: locale2 });
12169
+ }
12170
+ } else {
12171
+ const ref = refOf(entry);
12172
+ if (ref) cells.push({ ref, variant: {} });
11223
12173
  }
11224
- }
12174
+ for (const cell of cells) {
12175
+ const named = {
12176
+ ref: cell.ref,
12177
+ frame: i + 1,
12178
+ frameName: shot?.frameName ?? null,
12179
+ ...cell.locale ? { locale: cell.locale } : {}
12180
+ };
12181
+ out.refs.push(named);
12182
+ if (!shot) out.unaddressed.push(named);
12183
+ else out.manifest.push({ shotId: shot.id, variant: cell.variant, ref: cell.ref });
12184
+ }
12185
+ });
11225
12186
  return out;
11226
12187
  }
12188
+ function describeUnsavedRefs(refs) {
12189
+ return refs.map((entry) => {
12190
+ const where = [
12191
+ `frame ${entry.frame}`,
12192
+ entry.frameName ? `"${entry.frameName}"` : null,
12193
+ entry.locale ? `locale ${entry.locale}` : null
12194
+ ].filter(Boolean).join(", ");
12195
+ return `${entry.ref} (${where})`;
12196
+ }).join("; ");
12197
+ }
12198
+ function refusedRefs(kind, refs) {
12199
+ const list = describeUnsavedRefs(refs);
12200
+ const details = { frames: refs.map(({ ref: _ref, ...where }) => where) };
12201
+ switch (kind) {
12202
+ case "no_account":
12203
+ return {
12204
+ code: "authentication_required",
12205
+ details,
12206
+ error: `This local server has no ShotOps account, so it cannot save { "ref" } screenshots \u2014 a ref points at an upload inside an account: ${list}. Pass each screenshot as a local { "path": "\u2026" } PNG, or sign in (npx shotops-mcp login) and save again. Nothing was saved.`
12207
+ };
12208
+ case "account_unknown":
12209
+ return {
12210
+ code: "upstream_unavailable",
12211
+ details,
12212
+ error: `Could not confirm which ShotOps account this server\u2019s token signs in as, so these { "ref" } screenshots could not be checked: ${list}. Nothing was saved \u2014 try again in a moment.`
12213
+ };
12214
+ case "foreign":
12215
+ return {
12216
+ code: "asset_not_found",
12217
+ details,
12218
+ error: `These { "ref" } screenshots do not belong to this account: ${list}. A ref has to be an upload under the account this save writes to. Pass those screenshots again from a source this account owns \u2014 a ref from import_screenshot or request_screenshot_upload, or a local { "path": "\u2026" } on the local server. Nothing was saved.`
12219
+ };
12220
+ case "unaddressed":
12221
+ return {
12222
+ code: "internal_error",
12223
+ details,
12224
+ error: `These { "ref" } screenshots could not be matched to a frame of the project being saved: ${list}. Nothing was saved.`
12225
+ };
12226
+ }
12227
+ }
12228
+ async function unsavableRefRefusal(deps, introduced) {
12229
+ if (introduced.refs.length === 0) return null;
12230
+ if (introduced.unaddressed.length > 0) return refusedRefs("unaddressed", introduced.unaddressed);
12231
+ if (deps.createPendingProjectClaim) return refusedRefs("no_account", introduced.refs);
12232
+ let owner = deps.userId;
12233
+ if (deps.accountUserId) owner = await deps.accountUserId().catch(() => null);
12234
+ if (!owner) return refusedRefs("account_unknown", introduced.refs);
12235
+ const account = owner;
12236
+ const foreign = introduced.refs.filter((entry) => !refBelongsToUser(entry.ref, account));
12237
+ return foreign.length > 0 ? refusedRefs("foreign", foreign) : null;
12238
+ }
11227
12239
  function projectShotRefsFromRecord(record8) {
11228
12240
  return shotRefsFromProjectFile(record8?.project);
11229
12241
  }
@@ -11241,14 +12253,6 @@ function preservedCells(record8, nextProjectFile) {
11241
12253
  }
11242
12254
  return { manifest: rebound.manifest, findings };
11243
12255
  }
11244
- function screenCustodyUnion(...manifests) {
11245
- const seen = /* @__PURE__ */ new Set();
11246
- return manifests.flatMap((manifest) => manifest.filter((entry) => {
11247
- if (seen.has(entry.ref)) return false;
11248
- seen.add(entry.ref);
11249
- return true;
11250
- }));
11251
- }
11252
12256
  function screenCustodyFromRecord(record8) {
11253
12257
  return parseScreenPurgeRefs(record8?.screenPurgeRefs) ?? [];
11254
12258
  }
@@ -11256,44 +12260,40 @@ function allLiveScreenRefs(record8) {
11256
12260
  const parsed = parseScreenManifest(record8?.handoffRefs);
11257
12261
  return parsed ? allManifestEntries(parsed) : [];
11258
12262
  }
11259
- async function persistProject(deps, args, record8, claimPolicy = "none") {
12263
+ function projectSaveEnvelope(stored, projectFile, manifest, source, listing2) {
12264
+ const custody = custodyUnion(screenCustodyFromRecord(stored), allLiveScreenRefs(stored), manifest);
12265
+ const storedSource = stored != null && typeof stored === "object" && !Array.isArray(stored) ? stored.source : void 0;
12266
+ const storedKind = storedSource != null && typeof storedSource === "object" ? storedSource.kind : void 0;
12267
+ const mcpOrigin = storedKind === "agent" || storedKind === "local-path";
12268
+ const sourceForEnvelope = storedSource != null && !mcpOrigin ? storedSource : source ?? storedSource;
12269
+ return rebuildEnvelope(stored, {
12270
+ project: projectFile,
12271
+ manifest,
12272
+ source: sourceForEnvelope ?? void 0,
12273
+ reach: manifest.length > 0 ? "account" : void 0,
12274
+ screenPurgeRefs: custody.length > 0 ? custody : void 0,
12275
+ // #1004 — the App Store listing, and the ONE shape in which this door may name that key.
12276
+ // SPREAD, never a ternary yielding `undefined`: `rebuildEnvelope` deletes on `undefined`, so
12277
+ // `appListing: listing ?? undefined` would wipe a stored association on every ordinary save —
12278
+ // which is exactly the hazard `recordEnvelope.ts` warns about. A save with no reference names
12279
+ // no key at all, and the stored snapshot survives because nothing touched it.
12280
+ ...listing2 ? { ...appListingRewrite(listing2), fastlaneBundleId: listing2.applied.bundleId } : {}
12281
+ });
12282
+ }
12283
+ async function persistProject(deps, args, record8, claimPolicy = "none", listing2) {
11260
12284
  const names = screenNamesFromRecord(record8);
11261
12285
  const source = names.length === 0 ? void 0 : args.sourceDir ? { kind: "local-path", ref: { dir: args.sourceDir, names } } : { kind: "agent", ref: { names } };
11262
- const introducedRefs = handoffRefsFromScreenshots(args.screenshots, names, deps.userId);
12286
+ const introduced = introducedScreenCells(args.screenshots, record8);
12287
+ const refusal = await unsavableRefRefusal(deps, introduced);
12288
+ if (refusal) return refusal;
11263
12289
  let screenNote;
11264
- const wrapWith = (handoffRefs, existingRecord) => {
12290
+ const wrapWith = (existingRecord) => {
11265
12291
  const preserved = preservedCells(existingRecord, record8);
11266
12292
  if (preserved.findings.length > 0) {
11267
12293
  screenNote = preserved.findings.map((finding) => `The stored screenshot filed under "${finding.frameName}" was not carried over: that filename names ${finding.shotIds.length} devices (${finding.shotIds.join(", ")}), so no single device owns it. Its object is retained; give those devices distinct filenames, or pass their screenshots explicitly.`).join(" ");
11268
12294
  }
11269
- const manifest = mergeManifest(
11270
- preserved.manifest,
11271
- manifestFromNamedRefs(handoffRefs, shotRefsFromProjectFile(record8))
11272
- );
11273
- const custody = screenCustodyUnion(
11274
- screenCustodyFromRecord(existingRecord),
11275
- allLiveScreenRefs(existingRecord),
11276
- manifest
11277
- );
11278
- const stored = existingRecord != null && typeof existingRecord === "object" && !Array.isArray(existingRecord) ? { ...existingRecord } : {};
11279
- delete stored.record;
11280
- delete stored.project;
11281
- delete stored.handoffRefs;
11282
- delete stored.reach;
11283
- delete stored.screenPurgeRefs;
11284
- const storedSource = stored.source;
11285
- delete stored.source;
11286
- const storedKind = storedSource != null && typeof storedSource === "object" ? storedSource.kind : void 0;
11287
- const mcpOrigin = storedKind === "agent" || storedKind === "local-path";
11288
- const sourceForEnvelope = storedSource != null && !mcpOrigin ? storedSource : source ?? storedSource;
11289
- return {
11290
- ...stored,
11291
- record: SAVED_RECORD_VERSION,
11292
- project: record8,
11293
- ...sourceForEnvelope != null ? { source: sourceForEnvelope } : {},
11294
- ...manifest.length > 0 ? { handoffRefs: manifest, reach: "account" } : {},
11295
- ...custody.length > 0 ? { screenPurgeRefs: custody } : {}
11296
- };
12295
+ const manifest = mergeManifest(preserved.manifest, introduced.manifest);
12296
+ return projectSaveEnvelope(existingRecord, record8, manifest, source, listing2);
11297
12297
  };
11298
12298
  const ctx = {
11299
12299
  actorUserId: deps.userId,
@@ -11318,7 +12318,7 @@ async function persistProject(deps, args, record8, claimPolicy = "none") {
11318
12318
  };
11319
12319
  }
11320
12320
  const name = args.projectName?.trim() || "ShotOps render";
11321
- return await deps.createPendingProjectClaim(name, wrapWith(introducedRefs), ctx);
12321
+ return await deps.createPendingProjectClaim(name, wrapWith(), ctx);
11322
12322
  }
11323
12323
  let projectId;
11324
12324
  if (args.project) {
@@ -11334,7 +12334,7 @@ async function persistProject(deps, args, record8, claimPolicy = "none") {
11334
12334
  if (denial) return { error: denial };
11335
12335
  const existing = await deps.readProjectRecord(project2.id);
11336
12336
  if (!existing) return { error: `Project "${project2.name}" has no readable saved record.` };
11337
- await deps.updateProjectRecord(project2.id, wrapWith(introducedRefs, existing.record), existing.recordVersion ?? 1, ctx);
12337
+ await deps.updateProjectRecord(project2.id, wrapWith(existing.record), existing.recordVersion ?? 1, ctx);
11338
12338
  projectId = project2.id;
11339
12339
  } else {
11340
12340
  const name = args.projectName?.trim() || "ShotOps render";
@@ -11347,7 +12347,7 @@ async function persistProject(deps, args, record8, claimPolicy = "none") {
11347
12347
  const denial = authorizationMessage(decision);
11348
12348
  if (denial) return { error: denial };
11349
12349
  }
11350
- const created = await deps.createProject(deps.userId, name, wrapWith(introducedRefs), ctx);
12350
+ const created = await deps.createProject(deps.userId, name, wrapWith(), ctx);
11351
12351
  projectId = created.id;
11352
12352
  }
11353
12353
  return { projectId, openUrl: `${deps.studioOrigin}/?project=${projectId}`, ...screenNote ? { screenNote } : {} };
@@ -11359,7 +12359,6 @@ function projectFileFromRecord(record8) {
11359
12359
  const inner = record8?.project;
11360
12360
  return inner != null && typeof inner === "object" ? inner : record8;
11361
12361
  }
11362
- var SAVED_RECORD_VERSION;
11363
12362
  var init_projectPersistence = __esm({
11364
12363
  "src/project/projectPersistence.ts"() {
11365
12364
  "use strict";
@@ -11367,12 +12366,16 @@ var init_projectPersistence = __esm({
11367
12366
  init_projectRecord();
11368
12367
  init_uploadRef();
11369
12368
  init_screenManifest();
12369
+ init_recordEnvelope();
12370
+ init_appListing();
12371
+ init_localeAxis();
12372
+ init_localeVariants();
12373
+ init_variant();
11370
12374
  init_authorization();
11371
12375
  init_projectResolution();
11372
12376
  init_schemas();
11373
12377
  init_screenshotInput();
11374
12378
  init_uploads();
11375
- SAVED_RECORD_VERSION = 2;
11376
12379
  }
11377
12380
  });
11378
12381
 
@@ -11658,7 +12661,6 @@ var init_projectAccessStore = __esm({
11658
12661
  });
11659
12662
 
11660
12663
  // ../api/_lib/projectLookStore.ts
11661
- var FREE_EDITABLE_PROJECT_LIMIT;
11662
12664
  var init_projectLookStore = __esm({
11663
12665
  "../api/_lib/projectLookStore.ts"() {
11664
12666
  "use strict";
@@ -11666,11 +12668,17 @@ var init_projectLookStore = __esm({
11666
12668
  init_look();
11667
12669
  init_lookDivergence();
11668
12670
  init_projectFileGuard();
12671
+ init_shotLook();
11669
12672
  init_supabaseRest();
11670
12673
  init_capabilities();
11671
- init_monetizationPolicy();
11672
12674
  init_projectAccessStore();
11673
- FREE_EDITABLE_PROJECT_LIMIT = MONETIZATION_POLICY.plans.free.ownedProjects;
12675
+ }
12676
+ });
12677
+
12678
+ // ../mockup-engine/access/projectActions.ts
12679
+ var init_projectActions = __esm({
12680
+ "../mockup-engine/access/projectActions.ts"() {
12681
+ "use strict";
11674
12682
  }
11675
12683
  });
11676
12684
 
@@ -11678,6 +12686,8 @@ var init_projectLookStore = __esm({
11678
12686
  var init_policy = __esm({
11679
12687
  "../api/_lib/authorization/policy.ts"() {
11680
12688
  "use strict";
12689
+ init_projectActions();
12690
+ init_monetizationPolicy();
11681
12691
  }
11682
12692
  });
11683
12693
 
@@ -11697,6 +12707,7 @@ var init_production = __esm({
11697
12707
  init_entitlement();
11698
12708
  init_monetizationPolicy();
11699
12709
  init_projectLookStore();
12710
+ init_projectActions();
11700
12711
  init_authorizeCapability();
11701
12712
  init_capabilities();
11702
12713
  init_policy();
@@ -12407,7 +13418,7 @@ var init_package = __esm({
12407
13418
  "package.json"() {
12408
13419
  package_default = {
12409
13420
  name: "shotops-mcp",
12410
- version: "0.9.7",
13421
+ version: "0.9.9",
12411
13422
  private: false,
12412
13423
  type: "module",
12413
13424
  description: "The bundle-emitting MCP server over the @engine/@sync spine. Exposes ShotOps tools to ChatGPT, Claude Code, Cursor, and CI over Streamable HTTP with OAuth or personal API tokens. Hosted and MCP tool paths never accept, store, or forward a store-signing credential; the explicit local release CLI validates the user's existing key directly with Apple and records only its path. 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 shotops-mcp`) \u2014 same render, on your own machine, no account needed.",
@@ -12595,8 +13606,7 @@ function projectStructure(projectFile) {
12595
13606
  }
12596
13607
  function projectCopyLocales(projectFile, locales, family = BASE_SCREEN_FAMILY) {
12597
13608
  const file = projectFile;
12598
- const baseLocale = typeof file?.baseLocale === "string" && file.baseLocale ? file.baseLocale : "en-US";
12599
- const captionText = normalizeCaptionText(file?.captionText, file?.captionStyles, baseLocale);
13609
+ const { captionText, baseLocale } = projectLocaleAxis(projectFile);
12600
13610
  const outputCaptions = normalizeOutputCaptionText(file?.outputCaptions);
12601
13611
  const { panelIds } = projectStructure(projectFile);
12602
13612
  const out = [];
@@ -12613,8 +13623,7 @@ function projectCopyLocales(projectFile, locales, family = BASE_SCREEN_FAMILY) {
12613
13623
  }
12614
13624
  function projectCopyGaps(projectFile, locales, family = BASE_SCREEN_FAMILY) {
12615
13625
  const file = projectFile;
12616
- const baseLocale = typeof file?.baseLocale === "string" && file.baseLocale ? file.baseLocale : "en-US";
12617
- const captionText = normalizeCaptionText(file?.captionText, file?.captionStyles, baseLocale);
13626
+ const { captionText, baseLocale } = projectLocaleAxis(projectFile);
12618
13627
  const outputCaptions = normalizeOutputCaptionText(file?.outputCaptions);
12619
13628
  const base = resolveOutputCaptionText(captionText, outputCaptions, family, baseLocale, baseLocale);
12620
13629
  if (!Object.values(base).some((layers) => layers.some((layer) => layer.text.trim().length > 0))) return [];
@@ -12879,7 +13888,7 @@ async function renderStrip(deps, args, scope, signal) {
12879
13888
  if (durableHostedProductionAvailable(deps) && args.preview !== true) {
12880
13889
  let durableScreenshots;
12881
13890
  try {
12882
- durableScreenshots = await resolveScreenshots(entries, deps.userId, false);
13891
+ durableScreenshots = await resolveScreenshots(entries, deps.userId, false, deps.readUploadedRefs);
12883
13892
  } catch (err) {
12884
13893
  return errorResult(err.message);
12885
13894
  }
@@ -12932,7 +13941,7 @@ async function renderStrip(deps, args, scope, signal) {
12932
13941
  if (refused) return errorResult(refused.error);
12933
13942
  let screenshots2;
12934
13943
  try {
12935
- screenshots2 = await resolveScreenshots(entries, deps.userId, deps.allowLocalScreenshots ?? false);
13944
+ screenshots2 = await resolveScreenshots(entries, deps.userId, deps.allowLocalScreenshots ?? false, deps.readUploadedRefs);
12936
13945
  } catch (err) {
12937
13946
  return errorResult(err.message);
12938
13947
  }
@@ -13072,8 +14081,7 @@ function onePanelStyle(value, panelIndex2) {
13072
14081
  }
13073
14082
  function projectPanelCaptionLayers(projectFile, panelId, variant) {
13074
14083
  const file = record3(projectFile);
13075
- const baseLocale = projectBaseLocale(projectFile);
13076
- const captionText = normalizeCaptionText(file?.captionText, file?.captionStyles, baseLocale);
14084
+ const { captionText, baseLocale } = projectLocaleAxis(projectFile);
13077
14085
  const outputCaptions = normalizeOutputCaptionText(file?.outputCaptions);
13078
14086
  const resolved = resolveOutputCaptionText(
13079
14087
  captionText,
@@ -13087,6 +14095,7 @@ function projectPanelCaptionLayers(projectFile, panelId, variant) {
13087
14095
  function projectPanelRecipes(projectFile, screens, targets) {
13088
14096
  const structure = projectStructure(projectFile);
13089
14097
  const file = record3(projectFile);
14098
+ const recordPreset = projectOutputAxis(projectFile).panelPresetId;
13090
14099
  const shots = Array.isArray(file?.shots) ? file.shots : [];
13091
14100
  const slotSizes = structure.panelIds.map(
13092
14101
  (panelId) => shots.filter((shot) => record3(shot)?.panelId === panelId).length
@@ -13095,14 +14104,14 @@ function projectPanelRecipes(projectFile, screens, targets) {
13095
14104
  const variant = projectRenderVariant(projectFile, target.locale, target.panelPresetId);
13096
14105
  return [screens.resolve(variant).then((resolved) => {
13097
14106
  const byShot = new Map(resolved.screenManifest.map((entry) => [entry.shotId, entry.bytes]));
13098
- const dimensions = panelDimensionsFor(target.panelPresetId ?? panelPresetFromProjectFile(projectFile) ?? "r69");
14107
+ const dimensions = panelDimensionsFor(target.panelPresetId ?? recordPreset);
13099
14108
  return structure.panelIds.flatMap((panelId, panelIndex2) => {
13100
14109
  const screenshots2 = shots.filter((shot) => record3(shot)?.panelId === panelId).map((shot) => byShot.get(String(record3(shot)?.id))).filter((bytes) => typeof bytes === "string");
13101
14110
  return [{
13102
14111
  key: [target.output, target.locale, `panel-${String(panelIndex2 + 1).padStart(4, "0")}`].filter(Boolean).join("-"),
13103
14112
  panelIndex: panelIndex2,
13104
14113
  screenshots: screenshots2,
13105
- panelPresetId: target.panelPresetId ?? panelPresetFromProjectFile(projectFile),
14114
+ panelPresetId: target.panelPresetId ?? recordPreset,
13106
14115
  look: onePanelLook(projectFile, panelIndex2, slotSizes, panelId),
13107
14116
  captionLayers: projectPanelCaptionLayers(projectFile, panelId, variant),
13108
14117
  backgroundViewport: { panelIndex: panelIndex2, panelCount: structure.panelIds.length },
@@ -13166,10 +14175,6 @@ function frameNamesFromProjectFile(projectFile) {
13166
14175
  if (!Array.isArray(shots)) return [];
13167
14176
  return shots.map((s) => s?.frameName).filter((n) => typeof n === "string" && n.length > 0);
13168
14177
  }
13169
- function panelPresetFromProjectFile(projectFile) {
13170
- const preset = projectFile?.panelPresetId;
13171
- return typeof preset === "string" && preset.length > 0 ? preset : void 0;
13172
- }
13173
14178
  function outputDevicesFromProjectFile(projectFile) {
13174
14179
  const outputs = projectFile?.outputs;
13175
14180
  if (!Array.isArray(outputs)) return { devices: [], dropped: [] };
@@ -13180,7 +14185,7 @@ async function loadProjectRecord(deps, projectId) {
13180
14185
  const project2 = resolvedProject(resolution);
13181
14186
  if (!project2) return { error: describeMiss(resolution, projectId) };
13182
14187
  const rec = await deps.readProjectRecord(project2.id);
13183
- if (!rec) {
14188
+ if (!rec || isBlankRecord(rec.record)) {
13184
14189
  return {
13185
14190
  error: `Project "${project2.name}" has no saved record yet \u2014 open it in ShotOps and save a strip first.`
13186
14191
  };
@@ -13193,34 +14198,29 @@ async function loadProjectRecord(deps, projectId) {
13193
14198
  };
13194
14199
  }
13195
14200
  function projectBaseLocale(projectFile) {
13196
- const file = projectFile;
13197
- if (typeof file?.baseLocale === "string" && file.baseLocale) return file.baseLocale;
13198
- const first = Array.isArray(file?.locales) ? file.locales[0] : void 0;
13199
- return typeof first === "string" && first ? first : "en-US";
13200
- }
13201
- function projectLocales(projectFile) {
13202
- const file = projectFile;
13203
- const authored = Array.isArray(file?.locales) ? file.locales.filter((locale2) => typeof locale2 === "string" && locale2.length > 0) : [];
13204
- const captionLocales = file?.captionText && typeof file.captionText === "object" ? Object.keys(file.captionText) : [];
13205
- return Array.from(/* @__PURE__ */ new Set([projectBaseLocale(projectFile), ...authored, ...captionLocales]));
14201
+ return projectLocaleAxis(projectFile).baseLocale;
13206
14202
  }
13207
14203
  function projectRenderLocale(projectFile, requested) {
14204
+ const { locales, baseLocale } = projectLocaleAxis(projectFile);
13208
14205
  const candidate = requested?.trim();
13209
- return matchAuthoredLocale(projectLocales(projectFile), candidate) ?? candidate ?? projectBaseLocale(projectFile);
14206
+ return matchAuthoredLocale(locales, candidate) ?? candidate ?? baseLocale;
14207
+ }
14208
+ function activeRenderDevice(projectFile, renderable) {
14209
+ const active = projectOutputAxis(projectFile).activeDeviceId;
14210
+ return renderable.find((device) => device.id === active) ?? renderable[0];
13210
14211
  }
13211
14212
  function projectRenderVariant(projectFile, locale2, panelPresetId2) {
13212
- const file = projectFile;
13213
- const preset = panelPresetId2 ?? (typeof file?.panelPresetId === "string" ? file.panelPresetId : void 0);
13214
- const family = DEVICES.find((device) => device.panelPresetId === preset)?.screenFamily ?? BASE_SCREEN_FAMILY;
14213
+ const preset = panelPresetId2 ?? projectOutputAxis(projectFile).panelPresetId;
13215
14214
  const baseLocale = projectBaseLocale(projectFile);
13216
- return canonicalVariant({ family, locale: projectRenderLocale(projectFile, locale2) }, baseLocale);
14215
+ const resolved = projectRenderLocale(projectFile, locale2);
14216
+ return canonicalVariant({ family: screenFamilyForPreset(preset), locale: resolved }, baseLocale);
13217
14217
  }
13218
14218
  async function resolveProjectScreens(deps, loaded, supplied) {
13219
14219
  const baseLocale = projectBaseLocale(loaded.projectFile);
13220
14220
  const nested = supplied.map((e) => [e]);
13221
14221
  let resolvedBytes;
13222
14222
  try {
13223
- resolvedBytes = await resolveScreenshots(nested, deps.userId, deps.allowLocalScreenshots ?? false);
14223
+ resolvedBytes = await resolveScreenshots(nested, deps.userId, deps.allowLocalScreenshots ?? false, deps.readUploadedRefs);
13224
14224
  } catch (err) {
13225
14225
  return { error: err.message };
13226
14226
  }
@@ -13262,7 +14262,7 @@ async function resolveProjectScreens(deps, loaded, supplied) {
13262
14262
  "lenient",
13263
14263
  baseLocale
13264
14264
  );
13265
- const storedManifest = parsed ? allManifestEntries(parsed) : [];
14265
+ const storedManifest2 = parsed ? allManifestEntries(parsed) : [];
13266
14266
  const repairFindings = [];
13267
14267
  let canReadStored = Boolean(deps.fetchHandoffBytes || deps.readProjectScreens);
13268
14268
  const storedSources = [];
@@ -13292,7 +14292,7 @@ async function resolveProjectScreens(deps, loaded, supplied) {
13292
14292
  canReadStored = Boolean(deps.fetchHandoffBytes);
13293
14293
  }
13294
14294
  } else if (deps.fetchHandoffBytes) {
13295
- const resolvedManifest = resolveScreenManifest(storedManifest, shots);
14295
+ const resolvedManifest = resolveScreenManifest(storedManifest2, shots);
13296
14296
  repairFindings.push(...resolvedManifest.findings);
13297
14297
  for (const cell of resolvedManifest.cells) {
13298
14298
  let pending;
@@ -13375,7 +14375,7 @@ async function resolveProjectScreens(deps, loaded, supplied) {
13375
14375
  }
13376
14376
  return { screenManifest, missing };
13377
14377
  };
13378
- return { expected, unnamed, storedCount: storedManifest.length, canReadStored, resolve: resolve14, plan, repairFindings };
14378
+ return { expected, unnamed, storedCount: storedManifest2.length, canReadStored, resolve: resolve14, plan, repairFindings };
13379
14379
  }
13380
14380
  function noProjectScreensMessage(screens, suppliedCount, toolName) {
13381
14381
  let lead;
@@ -13393,7 +14393,7 @@ function noProjectScreensMessage(screens, suppliedCount, toolName) {
13393
14393
  return `${lead} This project expects: ${screens.expected.join(", ")}. Pass each raw screenshot with its original \`name\` and optional \`variant\`, in any order \u2014 ${toolName} matches by name + coordinate.` + route;
13394
14394
  }
13395
14395
  function coordinateLabel(variant) {
13396
- return !variant || variantKey(variant) === "" ? "base" : variantKey(variant);
14396
+ return !variant || isBaseVariant(variant) ? "base" : variantKey(variant);
13397
14397
  }
13398
14398
  function projectScreenResolutionNote(shots) {
13399
14399
  const groups = /* @__PURE__ */ new Map();
@@ -13473,8 +14473,8 @@ async function renderProject(deps, args, scope, attempt, signal) {
13473
14473
  const requested = resolveOutputDevices(args.outputs);
13474
14474
  const chosen = requested.devices.length > 0 ? requested : outputDevicesFromProjectFile(projectFile);
13475
14475
  const droppedNote = droppedOutputsNote(chosen.dropped, renderedSavedSet(chosen.devices));
13476
- const recordPreset = panelPresetFromProjectFile(projectFile);
13477
- const active = chosen.devices.find((d) => d.panelPresetId === recordPreset) ?? chosen.devices[0];
14476
+ const recordPreset = projectOutputAxis(projectFile).panelPresetId;
14477
+ const active = activeRenderDevice(projectFile, chosen.devices);
13478
14478
  const ordered = active ? [active, ...chosen.devices.filter((d) => d.id !== active.id)] : [];
13479
14479
  const mayOverridePreset = requested.devices.length > 0 || chosen.devices.length > 1;
13480
14480
  const requestedLocale = args.locale?.trim() || void 0;
@@ -13498,7 +14498,7 @@ async function renderProject(deps, args, scope, attempt, signal) {
13498
14498
  locales: [snapshotLocale],
13499
14499
  outputs: ordered.map((device) => device.id),
13500
14500
  unsupportedOutputs: chosen.dropped,
13501
- panelPresetId: active?.panelPresetId ?? recordPreset ?? null
14501
+ panelPresetId: active?.panelPresetId ?? recordPreset
13502
14502
  },
13503
14503
  baseLocale: projectBaseLocale(projectFile),
13504
14504
  gaps: repairGaps(screens.repairFindings)
@@ -13519,9 +14519,9 @@ async function renderProject(deps, args, scope, attempt, signal) {
13519
14519
  });
13520
14520
  };
13521
14521
  if (durableHostedProductionAvailable(deps) && args.preview !== true) {
13522
- const activePreset = active && mayOverridePreset ? active.panelPresetId : recordPreset ?? "r69";
14522
+ const activePreset = active && mayOverridePreset ? active.panelPresetId : recordPreset;
13523
14523
  const dimensions = panelDimensionsFor(activePreset);
13524
- const targets = ordered.length > 0 ? ordered.map((device) => ({ locale: snapshotLocale, panelPresetId: device.panelPresetId, output: device.id })) : [{ locale: snapshotLocale, ...recordPreset ? { panelPresetId: recordPreset } : {} }];
14524
+ const targets = ordered.length > 0 ? ordered.map((device) => ({ locale: snapshotLocale, panelPresetId: device.panelPresetId, output: device.id })) : [{ locale: snapshotLocale, panelPresetId: recordPreset }];
13525
14525
  const recipes = await projectPanelRecipes(projectFile, screens, targets);
13526
14526
  const panelCount = projectStructure(projectFile).panelIds.length || recipes.length;
13527
14527
  const durableResolved = await screens.resolve(activeVariant);
@@ -13768,6 +14768,7 @@ var init_toolsRender = __esm({
13768
14768
  init_productionRenderAccess();
13769
14769
  init_outputMode();
13770
14770
  init_appstore();
14771
+ init_recordEnvelope();
13771
14772
  init_screenshotInput();
13772
14773
  init_uploads();
13773
14774
  init_resultContract();
@@ -13847,6 +14848,83 @@ var init_refineProjectReceipt = __esm({
13847
14848
  }
13848
14849
  });
13849
14850
 
14851
+ // ../mockup-engine/recordFromStrip.ts
14852
+ function normalizeSourceName(raw) {
14853
+ const name = raw?.trim();
14854
+ if (!name) return null;
14855
+ return /\.(png|jpe?g|webp)$/i.test(name) ? name : `${name}.png`;
14856
+ }
14857
+ function frameNamesFor(shots, shotNames) {
14858
+ const seen = /* @__PURE__ */ new Map();
14859
+ return shots.map((_, i) => {
14860
+ const real = normalizeSourceName(shotNames?.[i]);
14861
+ if (!real) return `screen-${i + 1}.png`;
14862
+ const count = seen.get(real) ?? 0;
14863
+ seen.set(real, count + 1);
14864
+ if (count === 0) return real;
14865
+ const dot = real.lastIndexOf(".");
14866
+ return `${real.slice(0, dot)}-${count + 1}${real.slice(dot)}`;
14867
+ });
14868
+ }
14869
+ function recordFromStrip(input) {
14870
+ const { look: look2, panelIds, captionText, locale: locale2, shotNames, outputs, locales, baseLocale } = input;
14871
+ const panels = panelIds.map((id) => ({ id }));
14872
+ const frameNames = frameNamesFor(look2.shots, shotNames);
14873
+ const shots = look2.shots.map((s, i) => ({
14874
+ id: `shot-${i + 1}`,
14875
+ panelId: s.panelId,
14876
+ frameNodeId: frameNames[i],
14877
+ frameName: frameNames[i],
14878
+ bytes: null,
14879
+ look: s.look,
14880
+ thumb: null,
14881
+ thumbStale: true
14882
+ }));
14883
+ return serializeProject({
14884
+ shots,
14885
+ panels,
14886
+ selectedShotId: shots[0]?.id ?? null,
14887
+ panelPresetId: look2.panelPresetId,
14888
+ bgMode: look2.bgMode,
14889
+ // #556 — the CANONICAL gradient, forwarded alongside the mirror. `stripFromSlots` genuinely
14890
+ // resolves stops+angle (it runs every styling source through `stripGradientOf`), so omitting
14891
+ // them here was live data loss: a three-stop backdrop at 37° was persisted as its two-stop
14892
+ // mirror on the nearest axis, and the middle stops were gone from the record for good. The
14893
+ // sibling render path (`buildStrip.ts`, #463) always got this right.
14894
+ gradientStops: look2.gradientStops,
14895
+ gradientAngle: look2.gradientAngle,
14896
+ gradientFrom: look2.gradientFrom,
14897
+ gradientTo: look2.gradientTo,
14898
+ gradientDir: look2.gradientDir,
14899
+ panelColors: look2.panelColors,
14900
+ shadow: look2.shadow,
14901
+ // #564 — the same drop, one field away and not a mirror bug at all: `serialize.ts` turns an
14902
+ // absent `floorReflection` into `false`, so a render that showed a floor reflection saved a
14903
+ // project that did not have one (the `studio` design preset is one of the two that turn it on).
14904
+ floorReflection: look2.floorReflection,
14905
+ panelBackgrounds: look2.panelBackgrounds,
14906
+ captionStyles: look2.captionStyles,
14907
+ // #40 — the selected target devices. Passed straight to serializeProject, which runs it
14908
+ // through deriveOutputs: an undefined list migrates to the single device matching
14909
+ // `panelPresetId` (the pre-#40 record, unchanged), a supplied one is trusted verbatim minus
14910
+ // unknown/coming-soon ids.
14911
+ outputs,
14912
+ // #1001 — the selected languages, when the caller knows them. Omitted by every render path, and
14913
+ // `serializeProject` then derives the list exactly as it always has (caption keys ∪ the base).
14914
+ ...locales ? { locales } : {},
14915
+ ...baseLocale ? { baseLocale } : {},
14916
+ // Nest this render's flat caption text under its locale (default en-US) so the stored blob
14917
+ // is locale-correct; serializeProject's normalizeCaptionText passes a nested map through.
14918
+ captionText: captionText && Object.keys(captionText).length > 0 ? { [locale2 || DEFAULT_LOCALE]: captionText } : {}
14919
+ });
14920
+ }
14921
+ var init_recordFromStrip = __esm({
14922
+ "../mockup-engine/recordFromStrip.ts"() {
14923
+ "use strict";
14924
+ init_appstore();
14925
+ }
14926
+ });
14927
+
13850
14928
  // ../mockup-engine/stripFromSlots.ts
13851
14929
  function staggeredHOffset(k) {
13852
14930
  return String(Math.max(-100, Math.min(100, k * 35)));
@@ -13982,111 +15060,282 @@ function stripFromSlots(input) {
13982
15060
  }
13983
15061
  }
13984
15062
  });
13985
- const localeLayers = normalizeCaptionText({ [DEFAULT_LOCALE]: inner }, normalizeCaptionStyles(rawStyles));
13986
- captionStyles = captionStylesFromLayers(localeLayers);
13987
- captionLayers = resolveCaptionText(localeLayers);
15063
+ const localeLayers = normalizeCaptionText({ [DEFAULT_LOCALE]: inner }, normalizeCaptionStyles(rawStyles));
15064
+ captionStyles = captionStylesFromLayers(localeLayers);
15065
+ captionLayers = resolveCaptionText(localeLayers);
15066
+ }
15067
+ const panelPresetId2 = input.panelPresetId ?? (typeof opaque?.panelPresetId === "string" ? opaque.panelPresetId : void 0) ?? "r69";
15068
+ return {
15069
+ look: {
15070
+ panelPresetId: panelPresetId2,
15071
+ bgMode,
15072
+ gradientStops,
15073
+ gradientAngle,
15074
+ gradientFrom,
15075
+ gradientTo,
15076
+ gradientDir,
15077
+ panelColors,
15078
+ shadow,
15079
+ floorReflection,
15080
+ panelBackgrounds,
15081
+ captionStyles,
15082
+ shots
15083
+ },
15084
+ panelIds,
15085
+ captionText: captionLayers
15086
+ };
15087
+ }
15088
+ var NUMERIC_STRING_FIELDS;
15089
+ var init_stripFromSlots = __esm({
15090
+ "../mockup-engine/stripFromSlots.ts"() {
15091
+ "use strict";
15092
+ init_appstore();
15093
+ init_gradient();
15094
+ NUMERIC_STRING_FIELDS = ["roll", "deviceSize", "hOffset", "vOffset", "finish", "clearcoat"];
15095
+ }
15096
+ });
15097
+
15098
+ // ../mockup-engine/appstore/importScreens.ts
15099
+ var init_importScreens = __esm({
15100
+ "../mockup-engine/appstore/importScreens.ts"() {
15101
+ "use strict";
15102
+ init_defaults();
15103
+ init_recordFromStrip();
15104
+ init_stripFromSlots();
15105
+ init_projectFileGuard();
15106
+ init_recordEnvelope();
15107
+ init_screenManifest();
15108
+ }
15109
+ });
15110
+
15111
+ // ../mockup-engine/appstore/reach.ts
15112
+ function reachUploadsScreens(reach) {
15113
+ return reach === "account";
15114
+ }
15115
+ function readReach(value) {
15116
+ if (value === "device" || value === "account") return value;
15117
+ if (value === "shared") return "account";
15118
+ return null;
15119
+ }
15120
+ var init_reach = __esm({
15121
+ "../mockup-engine/appstore/reach.ts"() {
15122
+ "use strict";
15123
+ }
15124
+ });
15125
+
15126
+ // ../mockup-engine/ops/persistenceOps.ts
15127
+ function envelopeOf(record8) {
15128
+ return record8 != null && typeof record8 === "object" && !Array.isArray(record8) ? record8 : {};
15129
+ }
15130
+ function storedManifest(record8) {
15131
+ const parsed = parseScreenManifest(envelopeOf(record8).handoffRefs);
15132
+ return parsed ? allManifestEntries(parsed) : [];
15133
+ }
15134
+ function storedCustody(record8) {
15135
+ const parsed = parseScreenManifest(envelopeOf(record8).screenPurgeRefs);
15136
+ return parsed ? allManifestEntries(parsed) : [];
15137
+ }
15138
+ function writeReach(record8, override) {
15139
+ if (override) return override;
15140
+ return readReach(envelopeOf(record8).reach) ?? "account";
15141
+ }
15142
+ function planScreenSave(args) {
15143
+ const kept = storedManifest(args.record);
15144
+ const reach = writeReach(args.record, args.reach);
15145
+ if (!reachUploadsScreens(reach)) {
15146
+ return { toUpload: [], kept, alreadyDurable: [], nothingToUpload: true };
15147
+ }
15148
+ const { toUpload } = planScreenUploads(args.cells, kept, args.shots);
15149
+ const going = new Set(toUpload.map((cell) => manifestShotCellKey(cell.shotId, cell.variant)));
15150
+ const alreadyDurable = args.cells.filter(
15151
+ (cell) => !going.has(manifestShotCellKey(cell.shotId, cell.variant))
15152
+ );
15153
+ return {
15154
+ toUpload: toUpload.map(({ shotId, variant, hash }) => ({ shotId, variant, hash })),
15155
+ kept,
15156
+ alreadyDurable: alreadyDurable.map(({ shotId, variant, hash }) => ({ shotId, variant, hash })),
15157
+ nothingToUpload: toUpload.length === 0
15158
+ };
15159
+ }
15160
+ function reserveUploads(cells, slots) {
15161
+ const byCell = new Map(cells.map((cell) => [manifestShotCellKey(cell.shotId, cell.variant), cell]));
15162
+ const reserved = [];
15163
+ for (const slot2 of slots) {
15164
+ const cell = byCell.get(manifestShotCellKey(slot2.shotId, slot2.variant));
15165
+ if (!cell) continue;
15166
+ reserved.push({
15167
+ cell,
15168
+ slot: slot2,
15169
+ entry: { shotId: slot2.shotId, variant: slot2.variant, hash: cell.hash, ref: slot2.ref }
15170
+ });
13988
15171
  }
13989
- const panelPresetId2 = input.panelPresetId ?? (typeof opaque?.panelPresetId === "string" ? opaque.panelPresetId : void 0) ?? "r69";
15172
+ return reserved;
15173
+ }
15174
+ function recordScreenSave(args) {
15175
+ const reach = writeReach(args.record, args.reach);
15176
+ const manifest = reachUploadsScreens(reach) ? mergeManifest(args.kept, args.uploaded) : [];
15177
+ const custody = custodyUnion(storedCustody(args.record), args.heldCustody ?? [], args.kept, args.uploaded);
15178
+ const envelope = envelopeOf(args.record);
13990
15179
  return {
13991
- look: {
13992
- panelPresetId: panelPresetId2,
13993
- bgMode,
13994
- gradientStops,
13995
- gradientAngle,
13996
- gradientFrom,
13997
- gradientTo,
13998
- gradientDir,
13999
- panelColors,
14000
- shadow,
14001
- floorReflection,
14002
- panelBackgrounds,
14003
- captionStyles,
14004
- shots
14005
- },
14006
- panelIds,
14007
- captionText: captionLayers
15180
+ record: rebuildEnvelope(args.record, {
15181
+ project: envelope.project,
15182
+ manifest,
15183
+ reach,
15184
+ ...custody.length > 0 ? { screenPurgeRefs: custody } : {}
15185
+ }),
15186
+ manifest,
15187
+ custody,
15188
+ reach
14008
15189
  };
14009
15190
  }
14010
- var NUMERIC_STRING_FIELDS;
14011
- var init_stripFromSlots = __esm({
14012
- "../mockup-engine/stripFromSlots.ts"() {
15191
+ async function saveProjectScreens(request, transport) {
15192
+ const reach = writeReach(request.record, request.reach);
15193
+ const plan = planScreenSave({
15194
+ record: request.record,
15195
+ shots: request.shots,
15196
+ cells: request.cells,
15197
+ reach
15198
+ });
15199
+ const build = request.rebuild ?? ((rewrite) => recordScreenSave({
15200
+ record: request.record,
15201
+ kept: rewrite.manifest,
15202
+ uploaded: [],
15203
+ heldCustody: rewrite.custody,
15204
+ reach: rewrite.reach
15205
+ }).record);
15206
+ const overtaken = () => transport.overtaken?.() === true;
15207
+ const held = custodyUnion(storedCustody(request.record), request.heldCustody ?? []);
15208
+ const settled = (uploaded2, custody2, projectId2, stopped) => {
15209
+ const manifest = reachUploadsScreens(reach) ? mergeManifest(plan.kept, uploaded2) : [];
15210
+ return {
15211
+ projectId: projectId2,
15212
+ record: build({ manifest, custody: custody2, reach }),
15213
+ manifest,
15214
+ custody: custody2,
15215
+ uploaded: uploaded2,
15216
+ stopped
15217
+ };
15218
+ };
15219
+ let projectId = request.projectId;
15220
+ if (projectId === null) {
15221
+ if (!transport.insertRow) throw new Error("This project has no row yet and the transport cannot insert one");
15222
+ const custody2 = custodyUnion(held, plan.kept);
15223
+ const created = await transport.insertRow({ record: build({ manifest: plan.kept, custody: custody2, reach }) });
15224
+ projectId = created.projectId;
15225
+ if (overtaken()) return settled([], custody2, projectId, true);
15226
+ }
15227
+ if (plan.nothingToUpload) {
15228
+ return settled([], custodyUnion(held, plan.kept), projectId, false);
15229
+ }
15230
+ const slots = await transport.mintUploadSlots({ projectId, cells: plan.toUpload });
15231
+ if (overtaken()) return settled([], custodyUnion(held, plan.kept), projectId, true);
15232
+ const reserved = reserveUploads(request.cells, slots);
15233
+ const custody = custodyUnion(held, plan.kept, reserved.map(({ entry }) => entry));
15234
+ if (reserved.length === 0) return settled([], custody, projectId, false);
15235
+ await transport.saveRecord({ projectId, record: build({ manifest: plan.kept, custody, reach }) });
15236
+ if (overtaken()) return settled([], custody, projectId, true);
15237
+ const uploaded = [];
15238
+ for (const { entry, slot: slot2, cell } of reserved) {
15239
+ await transport.putBytes({ slot: slot2, bytes: cell.bytes });
15240
+ if (overtaken()) return settled([], custody, projectId, true);
15241
+ uploaded.push(entry);
15242
+ }
15243
+ const done = settled(uploaded, custody, projectId, false);
15244
+ await transport.saveRecord({ projectId, record: done.record });
15245
+ return done;
15246
+ }
15247
+ var init_persistenceOps = __esm({
15248
+ "../mockup-engine/ops/persistenceOps.ts"() {
14013
15249
  "use strict";
14014
- init_appstore();
14015
- init_gradient();
14016
- NUMERIC_STRING_FIELDS = ["roll", "phoneHeight", "hOffset", "vOffset", "finish", "clearcoat"];
15250
+ init_recordEnvelope();
15251
+ init_importScreens();
15252
+ init_screenManifest();
15253
+ init_screenUploadPlan();
15254
+ init_variant();
15255
+ init_reach();
15256
+ init_reach();
14017
15257
  }
14018
15258
  });
14019
15259
 
14020
- // ../mockup-engine/recordFromStrip.ts
14021
- function normalizeSourceName(raw) {
14022
- const name = raw?.trim();
14023
- if (!name) return null;
14024
- return /\.(png|jpe?g|webp)$/i.test(name) ? name : `${name}.png`;
14025
- }
14026
- function frameNamesFor(shots, shotNames) {
14027
- const seen = /* @__PURE__ */ new Map();
14028
- return shots.map((_, i) => {
14029
- const real = normalizeSourceName(shotNames?.[i]);
14030
- if (!real) return `screen-${i + 1}.png`;
14031
- const count = seen.get(real) ?? 0;
14032
- seen.set(real, count + 1);
14033
- if (count === 0) return real;
14034
- const dot = real.lastIndexOf(".");
14035
- return `${real.slice(0, dot)}-${count + 1}${real.slice(dot)}`;
15260
+ // ../mockup-engine/appstore/blankProject.ts
15261
+ var init_blankProject = __esm({
15262
+ "../mockup-engine/appstore/blankProject.ts"() {
15263
+ "use strict";
15264
+ init_recordFromStrip();
15265
+ init_stripFromSlots();
15266
+ init_recordEnvelope();
15267
+ }
15268
+ });
15269
+
15270
+ // ../mockup-engine/appstore/captionEdit.ts
15271
+ var init_captionEdit = __esm({
15272
+ "../mockup-engine/appstore/captionEdit.ts"() {
15273
+ "use strict";
15274
+ init_defaults();
15275
+ init_captionModel();
15276
+ init_recordTransforms();
15277
+ }
15278
+ });
15279
+
15280
+ // ../mockup-engine/captionTranslationPlan.ts
15281
+ var init_captionTranslationPlan = __esm({
15282
+ "../mockup-engine/captionTranslationPlan.ts"() {
15283
+ "use strict";
15284
+ }
15285
+ });
15286
+
15287
+ // ../mockup-engine/appstore/translationTable.ts
15288
+ var init_translationTable = __esm({
15289
+ "../mockup-engine/appstore/translationTable.ts"() {
15290
+ "use strict";
15291
+ init_appStoreLocales();
15292
+ init_captionEdit();
15293
+ init_captionModel();
15294
+ init_localeAxis();
15295
+ init_captionTranslationPlan();
15296
+ }
15297
+ });
15298
+
15299
+ // ../mockup-engine/ops/editOps.ts
15300
+ function buildRecord(input) {
15301
+ const { look: look2, panelIds, captionText } = stripFromSlots({
15302
+ slotSizes: input.slotSizes,
15303
+ panelPresetId: input.panelPresetId,
15304
+ look: input.look ?? void 0,
15305
+ style: input.style ?? void 0
14036
15306
  });
14037
- }
14038
- function recordFromStrip(input) {
14039
- const { look: look2, panelIds, captionText, locale: locale2, shotNames, outputs } = input;
14040
- const panels = panelIds.map((id) => ({ id }));
14041
- const frameNames = frameNamesFor(look2.shots, shotNames);
14042
- const shots = look2.shots.map((s, i) => ({
14043
- id: `shot-${i + 1}`,
14044
- panelId: s.panelId,
14045
- frameNodeId: frameNames[i],
14046
- frameName: frameNames[i],
14047
- bytes: null,
14048
- look: s.look,
14049
- thumb: null,
14050
- thumbStale: true
14051
- }));
14052
- return serializeProject({
14053
- shots,
14054
- panels,
14055
- selectedShotId: shots[0]?.id ?? null,
14056
- panelPresetId: look2.panelPresetId,
14057
- bgMode: look2.bgMode,
14058
- // #556 — the CANONICAL gradient, forwarded alongside the mirror. `stripFromSlots` genuinely
14059
- // resolves stops+angle (it runs every styling source through `stripGradientOf`), so omitting
14060
- // them here was live data loss: a three-stop backdrop at 37° was persisted as its two-stop
14061
- // mirror on the nearest axis, and the middle stops were gone from the record for good. The
14062
- // sibling render path (`buildStrip.ts`, #463) always got this right.
14063
- gradientStops: look2.gradientStops,
14064
- gradientAngle: look2.gradientAngle,
14065
- gradientFrom: look2.gradientFrom,
14066
- gradientTo: look2.gradientTo,
14067
- gradientDir: look2.gradientDir,
14068
- panelColors: look2.panelColors,
14069
- shadow: look2.shadow,
14070
- // #564 — the same drop, one field away and not a mirror bug at all: `serialize.ts` turns an
14071
- // absent `floorReflection` into `false`, so a render that showed a floor reflection saved a
14072
- // project that did not have one (the `studio` design preset is one of the two that turn it on).
14073
- floorReflection: look2.floorReflection,
14074
- panelBackgrounds: look2.panelBackgrounds,
14075
- captionStyles: look2.captionStyles,
14076
- // #40 — the selected target devices. Passed straight to serializeProject, which runs it
14077
- // through deriveOutputs: an undefined list migrates to the single device matching
14078
- // `panelPresetId` (the pre-#40 record, unchanged), a supplied one is trusted verbatim minus
14079
- // unknown/coming-soon ids.
14080
- outputs,
14081
- // Nest this render's flat caption text under its locale (default en-US) so the stored blob
14082
- // is locale-correct; serializeProject's normalizeCaptionText passes a nested map through.
14083
- captionText: captionText && Object.keys(captionText).length > 0 ? { [locale2 || DEFAULT_LOCALE]: captionText } : {}
15307
+ return recordFromStrip({
15308
+ look: look2,
15309
+ panelIds,
15310
+ captionText,
15311
+ locale: input.locale,
15312
+ shotNames: input.screenshotNames,
15313
+ outputs: input.outputs,
15314
+ ...input.locales ? { locales: input.locales } : {},
15315
+ ...input.baseLocale ? { baseLocale: input.baseLocale } : {}
14084
15316
  });
14085
15317
  }
14086
- var init_recordFromStrip = __esm({
14087
- "../mockup-engine/recordFromStrip.ts"() {
15318
+ var STRIP_PREVIEW;
15319
+ var init_editOps = __esm({
15320
+ "../mockup-engine/ops/editOps.ts"() {
14088
15321
  "use strict";
15322
+ init_gradient();
15323
+ init_variant();
15324
+ init_persistenceOps();
14089
15325
  init_appstore();
15326
+ init_appListing();
15327
+ init_blankProject();
15328
+ init_captionEdit();
15329
+ init_recordEnvelope();
15330
+ init_translationTable();
15331
+ init_recordFromStrip();
15332
+ init_stripFromSlots();
15333
+ STRIP_PREVIEW = {
15334
+ /** The band's aspect, width ÷ height. */
15335
+ bandRatio: 25 / 11,
15336
+ /** How many frames fill the band before it starts scrolling. */
15337
+ framesAcross: 5
15338
+ };
14090
15339
  }
14091
15340
  });
14092
15341
 
@@ -14158,8 +15407,7 @@ var init_renderer = __esm({
14158
15407
  "src/render/renderer.ts"() {
14159
15408
  "use strict";
14160
15409
  init_packagePaths();
14161
- init_stripFromSlots();
14162
- init_recordFromStrip();
15410
+ init_editOps();
14163
15411
  init_log();
14164
15412
  init_staticServer();
14165
15413
  init_browserInstall();
@@ -14245,23 +15493,20 @@ var init_renderer = __esm({
14245
15493
  // buildRecord: the SAME styling seam the render uses (stripFromSlots → recordFromStrip), run
14246
15494
  // in Node with NO browser. Both are pure, browser-free @engine functions and a record carries
14247
15495
  // no bytes, so save_project persists an editable project without a multi-second Chromium render
14248
- // — the fix for the Fly proxy timeout on that path. Kept byte-for-byte aligned with the
14249
- // harness's buildRecord branch (render.js): same stripFromSlots call, same recordFromStrip
14250
- // args — so a save_project record matches a render({buildRecord:true}) record exactly.
15496
+ // — the fix for the Fly proxy timeout on that path. It is the engine's `buildRecord` op, the one
15497
+ // the phone's bridge calls too, so a save_project record, a render({buildRecord:true}) record and
15498
+ // a record built on the phone are the same record rather than three copies kept aligned by hand.
14251
15499
  async buildRecord(input) {
14252
- const { look: look2, panelIds, captionText } = stripFromSlots({
15500
+ return buildRecord({
14253
15501
  slotSizes: input.slotSizes,
14254
15502
  panelPresetId: input.panelPresetId,
14255
15503
  look: input.look ?? void 0,
14256
- style: input.style ?? void 0
14257
- });
14258
- return recordFromStrip({
14259
- look: look2,
14260
- panelIds,
14261
- captionText,
15504
+ style: input.style ?? void 0,
14262
15505
  locale: input.locale,
14263
- shotNames: input.screenshotNames,
14264
- outputs: input.outputs
15506
+ screenshotNames: input.screenshotNames,
15507
+ outputs: input.outputs,
15508
+ locales: input.locales,
15509
+ baseLocale: input.baseLocale
14265
15510
  });
14266
15511
  }
14267
15512
  async render(input) {
@@ -14398,6 +15643,9 @@ function parseControlPlaneResponse(op, value) {
14398
15643
  case "list_projects":
14399
15644
  if (!Array.isArray(body.projects)) throw new Error("control plane response is missing projects");
14400
15645
  if (!object(body.ownedProjects)) throw new Error("control plane response is missing ownedProjects");
15646
+ for (const row2 of body.projects) {
15647
+ if (!object(object(row2)?.actions)) throw new Error("control plane response project row is missing actions");
15648
+ }
14401
15649
  break;
14402
15650
  case "read_look":
14403
15651
  if (!Object.prototype.hasOwnProperty.call(body, "look")) throw new Error("control plane response is missing look");
@@ -14422,6 +15670,10 @@ function parseControlPlaneResponse(op, value) {
14422
15670
  case "read_attachment_screens":
14423
15671
  if (!Array.isArray(body.screens)) throw new Error("control plane response is missing screens");
14424
15672
  break;
15673
+ case "read_upload_screens":
15674
+ if (!Array.isArray(body.screens)) throw new Error("control plane response is missing screens");
15675
+ if (body.missing !== void 0 && !Array.isArray(body.missing)) throw new Error("control plane response has malformed missing");
15676
+ break;
14425
15677
  case "refine_project":
14426
15678
  if (typeof body.ok !== "boolean") throw new Error("control plane response is missing refine_project result");
14427
15679
  if (body.ok) {
@@ -14462,6 +15714,16 @@ function parseControlPlaneResponse(op, value) {
14462
15714
  throw new Error("control plane response is missing clear_look result");
14463
15715
  }
14464
15716
  break;
15717
+ case "search_app_listing":
15718
+ if (typeof body.error !== "string" && !Array.isArray(body.results)) {
15719
+ throw new Error("control plane response is missing results");
15720
+ }
15721
+ break;
15722
+ case "resolve_app_listing":
15723
+ if (typeof body.error !== "string" && !Object.prototype.hasOwnProperty.call(body, "listing")) {
15724
+ throw new Error("control plane response is missing listing");
15725
+ }
15726
+ break;
14465
15727
  case "create_project":
14466
15728
  case "duplicate_project":
14467
15729
  requiredString(body.id, "id");
@@ -14618,8 +15880,25 @@ async function readAccountSnapshot(token2) {
14618
15880
  if (!body) return unavailable2("billing_unavailable");
14619
15881
  return snapshotFromEntitlementBody(body);
14620
15882
  }
15883
+ async function readAccountUserId(token2) {
15884
+ try {
15885
+ const res = await fetch(`${studioOrigin()}/api/billing-entitlement`, {
15886
+ headers: { Authorization: `Bearer ${token2}`, [SHOTOPS_CLIENT_HEADER]: `shotops-mcp/${VERSION}` }
15887
+ });
15888
+ if (!res.ok) return null;
15889
+ const body = await res.json().catch(() => null);
15890
+ return typeof body?.userId === "string" && body.userId.length > 0 ? body.userId : null;
15891
+ } catch {
15892
+ return null;
15893
+ }
15894
+ }
14621
15895
  function connectHostedBridge(token2) {
15896
+ let accountId = null;
14622
15897
  return {
15898
+ async accountUserId() {
15899
+ accountId ??= await readAccountUserId(token2);
15900
+ return accountId;
15901
+ },
14623
15902
  async productionOperation(action, operationId) {
14624
15903
  const response = await callOp(token2, "production_operation", { action, operationId });
14625
15904
  return response.operation;
@@ -14663,6 +15942,18 @@ function connectHostedBridge(token2) {
14663
15942
  const res = await callOp(token2, "read_attachment_screens", { project: projectId, refs: [...refs] });
14664
15943
  return res.screens;
14665
15944
  },
15945
+ async readUploadScreens(refs) {
15946
+ try {
15947
+ const res = await callOp(token2, "read_upload_screens", { refs: [...refs] });
15948
+ return { screens: res.screens, missing: res.missing ?? [] };
15949
+ } catch (err) {
15950
+ if (err instanceof ControlPlaneOperationError && err.code === "foreign_refs") {
15951
+ const named = err.refs.length > 0 ? err.refs : [...refs];
15952
+ throw new Error(`ref does not belong to this account: ${named.join(", ")}`);
15953
+ }
15954
+ throw err;
15955
+ }
15956
+ },
14666
15957
  async refineProject(input) {
14667
15958
  return callOp(token2, "refine_project", input);
14668
15959
  },
@@ -14698,6 +15989,8 @@ function connectHostedBridge(token2) {
14698
15989
  const res = await callOp(token2, "save_look", { project: projectId, look: look2, ...sourceName ? { sourceName } : {} });
14699
15990
  return { version: res.version };
14700
15991
  },
15992
+ searchAppListing: (term, country) => callOp(token2, "search_app_listing", { term, ...country ? { country } : {} }),
15993
+ resolveAppListing: (reference, country) => callOp(token2, "resolve_app_listing", { reference, ...country ? { country } : {} }),
14701
15994
  async createProject(_userId, name, record8) {
14702
15995
  const res = await callOp(token2, "create_project", { name, record: record8 });
14703
15996
  return { id: res.id, recordVersion: res.recordVersion };
@@ -14736,6 +16029,8 @@ var init_hostedBridge = __esm({
14736
16029
  code;
14737
16030
  capability;
14738
16031
  requiredPlan;
16032
+ /** The refs a `read_upload_screens` refusal named (#1014); empty on every other refusal. */
16033
+ refs;
14739
16034
  constructor(op, status, body) {
14740
16035
  const code = typeof body?.error === "string" ? body.error : null;
14741
16036
  super(`${op} failed (${status}${code ? ` ${code}` : ""})`);
@@ -14744,6 +16039,7 @@ var init_hostedBridge = __esm({
14744
16039
  this.code = code;
14745
16040
  this.capability = typeof body?.capability === "string" ? body.capability : null;
14746
16041
  this.requiredPlan = typeof body?.requiredPlan === "string" ? body.requiredPlan : null;
16042
+ this.refs = Array.isArray(body?.refs) ? body.refs.filter((ref) => typeof ref === "string") : [];
14747
16043
  }
14748
16044
  };
14749
16045
  RATE_LIMIT_RETRIES = 3;
@@ -14751,7 +16047,7 @@ var init_hostedBridge = __esm({
14751
16047
  }
14752
16048
  });
14753
16049
 
14754
- // ../mockup-engine/layoutFindings.ts
16050
+ // ../mockup-engine/layoutFindingSentence.ts
14755
16051
  function describeUnresolvedLayout(findings) {
14756
16052
  if (!findings || findings.length === 0) return null;
14757
16053
  const frames = Array.from(new Set(findings.flatMap((finding) => finding.frames))).sort((left, right) => left - right).map((index) => `Frame ${index + 1}`);
@@ -14762,9 +16058,50 @@ function describeUnresolvedLayout(findings) {
14762
16058
  }
14763
16059
  return `${findings.length} layout overlaps still remain${where}.`;
14764
16060
  }
14765
- var init_layoutFindings = __esm({
14766
- "../mockup-engine/layoutFindings.ts"() {
16061
+ var init_layoutFindingSentence = __esm({
16062
+ "../mockup-engine/layoutFindingSentence.ts"() {
16063
+ "use strict";
16064
+ }
16065
+ });
16066
+
16067
+ // ../mockup-engine/ops/agentTurnOps.ts
16068
+ function createAgentTurnOp(sources) {
16069
+ const assetTable = mintAgentAssets(sources);
16070
+ return {
16071
+ assetTable,
16072
+ run: (input, ports) => runAgentTurn({ ...input, assets: assetTable.descriptors }, {
16073
+ loadProject: ports.loadProject,
16074
+ renderComposed: ports.renderComposed,
16075
+ authorize: ports.authorize,
16076
+ emit: ports.emit,
16077
+ unresolvedLayout: (rendered) => describeUnresolvedLayout(ports.layoutFindings(rendered)),
16078
+ layoutCollisionFrames: (rendered) => ports.layoutFindings(rendered)?.flatMap((finding) => finding.frames) ?? null,
16079
+ commit: async ({ after, assetBindings, expectedVersion }) => {
16080
+ const current = ports.readCustody();
16081
+ const custody = assetBindings.length > 0 ? bindAgentAssets(
16082
+ assetTable,
16083
+ assetBindings,
16084
+ current.handoffRefs,
16085
+ after.shots.map((shot) => ({ id: shot.id, frameName: shot.frameName }))
16086
+ ) : null;
16087
+ return ports.commitProject({
16088
+ after,
16089
+ expectedVersion,
16090
+ custody: custody && {
16091
+ handoffRefs: custody.handoffRefs,
16092
+ screenPurgeRefs: foldDisplacedRefs(current.screenPurgeRefs, custody.displaced)
16093
+ }
16094
+ });
16095
+ }
16096
+ })
16097
+ };
16098
+ }
16099
+ var init_agentTurnOps = __esm({
16100
+ "../mockup-engine/ops/agentTurnOps.ts"() {
14767
16101
  "use strict";
16102
+ init_layoutFindingSentence();
16103
+ init_agentAssetCustody();
16104
+ init_agentTurnCoordinator();
14768
16105
  }
14769
16106
  });
14770
16107
 
@@ -14825,10 +16162,10 @@ var init_aiShotProjection = __esm({
14825
16162
  schema: z9.enum(["front", "left", "right"]).optional(),
14826
16163
  prompt: "angle: front | left | right \u2014 a camera PRESET. Setting it discards any orbit value on that frame, so send orbit OR angle, never both."
14827
16164
  },
14828
- phoneHeight: {
16165
+ deviceSize: {
14829
16166
  exposure: "included",
14830
- schema: z9.number().min(AI_SHOT_RANGES.phoneHeight.min).max(AI_SHOT_RANGES.phoneHeight.max).optional(),
14831
- prompt: `phoneHeight: ${AI_SHOT_RANGES.phoneHeight.min}\u2013${AI_SHOT_RANGES.phoneHeight.max} (% of panel height; over 100 bleeds off an edge)`
16167
+ schema: z9.number().min(AI_SHOT_RANGES.deviceSize.min).max(AI_SHOT_RANGES.deviceSize.max).optional(),
16168
+ prompt: `deviceSize: ${AI_SHOT_RANGES.deviceSize.min}\u2013${AI_SHOT_RANGES.deviceSize.max} (percent of the default device size: 100 is the default, about 122 fills the panel height, more bleeds off an edge)`
14832
16169
  },
14833
16170
  hOffset: {
14834
16171
  exposure: "included",
@@ -14899,6 +16236,16 @@ var init_aiShotProjection = __esm({
14899
16236
  exposure: "included",
14900
16237
  schema: z9.boolean().optional(),
14901
16238
  prompt: "flatScreen, glare, lighting, reflections, clipToFrame: true | false."
16239
+ },
16240
+ deviceFrame: {
16241
+ exposure: "included",
16242
+ schema: z9.boolean().optional(),
16243
+ prompt: 'deviceFrame: true | false, default true. False renders a flat rounded screenshot card instead of the 3D device \u2014 no device chrome. "remove the phone frame"/"make it borderless" means deviceFrame: false. roll still rotates the flat card the same way it tilts a framed device.'
16244
+ },
16245
+ screenCornerRadius: {
16246
+ exposure: "included",
16247
+ schema: z9.number().min(AI_SHOT_RANGES.screenCornerRadius.min).max(AI_SHOT_RANGES.screenCornerRadius.max).optional(),
16248
+ prompt: `screenCornerRadius: ${AI_SHOT_RANGES.screenCornerRadius.min}\u2013${AI_SHOT_RANGES.screenCornerRadius.max} (percent of the flat card's width). Applies only when deviceFrame is false.`
14902
16249
  }
14903
16250
  };
14904
16251
  API_AI_SHOT_INCLUDED_ENTRIES = Object.entries(API_AI_SHOT_PROJECTION).filter((entry) => entry[1].exposure === "included");
@@ -14979,7 +16326,11 @@ var init_refineProjectContracts = __esm({
14979
16326
  });
14980
16327
  framePatchSchema = z10.object({
14981
16328
  captionStyle: captionStylePatchSchema.optional(),
14982
- background: backgroundPatchSchema.optional()
16329
+ background: backgroundPatchSchema.optional(),
16330
+ // The per-frame half of the strip-wide `layout`, on the same two-scope footing as `captionStyle`
16331
+ // and `background` above (#266). Without it a composition asked for on SOME frames had to be
16332
+ // spent on ALL of them.
16333
+ layout: z10.enum(START_LAYOUT_IDS).optional()
14983
16334
  });
14984
16335
  currentCaptionStyleSchema = captionStylePatchSchema.extend({
14985
16336
  color: currentColorSchema.optional()
@@ -15418,15 +16769,13 @@ function recordCustody(existing) {
15418
16769
  };
15419
16770
  }
15420
16771
  function recordEnvelope(existing, state, custody) {
15421
- const stored = existing != null && typeof existing === "object" && !Array.isArray(existing) ? { ...existing } : { record: 2 };
15422
- return {
15423
- ...stored,
16772
+ return rebuildEnvelope(existing, {
15424
16773
  project: serializeProject(state),
15425
16774
  ...custody ? {
15426
- handoffRefs: custody.handoffRefs,
16775
+ manifest: custody.handoffRefs,
15427
16776
  ...custody.screenPurgeRefs.length > 0 ? { screenPurgeRefs: custody.screenPurgeRefs } : {}
15428
16777
  } : {}
15429
- };
16778
+ });
15430
16779
  }
15431
16780
  function aiCaptionStyleFromLayer(layer) {
15432
16781
  if (!layer) return {};
@@ -15748,13 +17097,14 @@ async function runMcpProjectAgentTurn(deps, input, runtime) {
15748
17097
  return { ...INVALID_ATTACHMENT };
15749
17098
  }
15750
17099
  }
15751
- const assetTable = mintAgentAssets(attachments.map((attachment) => ({
17100
+ const turnOp = createAgentTurnOp(attachments.map((attachment) => ({
15752
17101
  ref: attachment.ref,
15753
17102
  label: attachment.label,
15754
17103
  ...attachment.width !== void 0 ? { width: attachment.width } : {},
15755
17104
  ...attachment.height !== void 0 ? { height: attachment.height } : {},
15756
17105
  singleUse: false
15757
17106
  })));
17107
+ const assetTable = turnOp.assetTable;
15758
17108
  const turnId = randomUUID3();
15759
17109
  const turnKey = `turn_${turnId}`;
15760
17110
  const startedAt = input.startedAt ?? (/* @__PURE__ */ new Date()).toISOString();
@@ -15783,11 +17133,10 @@ async function runMcpProjectAgentTurn(deps, input, runtime) {
15783
17133
  assets: assetTable.descriptors,
15784
17134
  assetTable
15785
17135
  };
15786
- const result = await runAgentTurn({
17136
+ const result = await turnOp.run({
15787
17137
  turnId,
15788
17138
  instruction: input.instruction,
15789
17139
  focus: input.focus,
15790
- assets: assetTable.descriptors,
15791
17140
  requestedCycle: input.requestedCycle,
15792
17141
  startedAt,
15793
17142
  cancelled: input.cancelled
@@ -15819,7 +17168,7 @@ async function runMcpProjectAgentTurn(deps, input, runtime) {
15819
17168
  const resolved = await withBoundAttachments(stored, state, variant, boundAssets, assetTable, readAttachmentBytes);
15820
17169
  return renderedTurn(deps, state, resolved);
15821
17170
  },
15822
- unresolvedLayout: (rendered) => describeUnresolvedLayout(rendered.layoutFindings),
17171
+ layoutFindings: (rendered) => rendered.layoutFindings,
15823
17172
  authorize: async ({ cycle, state, rendered, previous }) => {
15824
17173
  const request = requestForPass(context, { cycle, state, rendered, previous });
15825
17174
  const authorization = await authorizePass(
@@ -15834,21 +17183,12 @@ async function runMcpProjectAgentTurn(deps, input, runtime) {
15834
17183
  const { patch: _refused, ...rest } = authorization;
15835
17184
  return { ...rest, terminalOutcome: failureOutcome("invalid_plan", handles.failure.message) };
15836
17185
  },
15837
- commit: async ({ after, assetBindings, expectedVersion }) => {
15838
- const custody = recordCustody(storedRecord);
15839
- const bound = assetBindings.length > 0 ? bindAgentAssets(
15840
- assetTable,
15841
- assetBindings,
15842
- custody.handoffRefs,
15843
- after.shots.map((shot) => ({ id: shot.id, frameName: shot.frameName }))
15844
- ) : null;
17186
+ readCustody: () => recordCustody(storedRecord),
17187
+ commitProject: async ({ after, expectedVersion, custody }) => {
15845
17188
  const write = await runtime.writeProjectRecord(
15846
17189
  project2.id,
15847
17190
  expectedVersion,
15848
- recordEnvelope(storedRecord, after, bound ? {
15849
- handoffRefs: bound.handoffRefs,
15850
- screenPurgeRefs: foldDisplacedRefs(custody.screenPurgeRefs, bound.displaced)
15851
- } : void 0),
17191
+ recordEnvelope(storedRecord, after, custody ?? void 0),
15852
17192
  { actorUserId: deps.userId }
15853
17193
  );
15854
17194
  if (write.ok) return { ok: true, data: { projectId: project2.id, recordVersion: write.recordVersion } };
@@ -15921,10 +17261,11 @@ var init_agentTurnAdapter = __esm({
15921
17261
  "src/project/agentTurnAdapter.ts"() {
15922
17262
  "use strict";
15923
17263
  init_appstore();
17264
+ init_agentTurnOps();
17265
+ init_recordEnvelope();
15924
17266
  init_agentOutcome();
15925
17267
  init_projectRecord();
15926
17268
  init_gradient();
15927
- init_layoutFindings();
15928
17269
  init_capabilities();
15929
17270
  init_invalidRequest();
15930
17271
  init_refineProjectContracts();
@@ -16084,8 +17425,8 @@ async function postClaim(body) {
16084
17425
  const parsed = await res.json().catch(() => null);
16085
17426
  return { status: res.status, body: parsed ?? {} };
16086
17427
  }
16087
- function claimFailure(step, status, code) {
16088
- const detail = status == null ? step : `${step} (${status}${typeof code === "string" && code ? ` ${code}` : ""})`;
17428
+ function claimFailure(step2, status, code) {
17429
+ const detail = status == null ? step2 : `${step2} (${status}${typeof code === "string" && code ? ` ${code}` : ""})`;
16089
17430
  return new Error(
16090
17431
  `Images delivered, but the editable project was not created: pending-claim ${detail}. The local render/export is still usable; retry save_project to create a fresh private claim link.`
16091
17432
  );
@@ -16180,6 +17521,16 @@ var init_projectClaimClient = __esm({
16180
17521
 
16181
17522
  // src/runtime/localDeps.ts
16182
17523
  import { readFile as readFile4 } from "node:fs/promises";
17524
+ function refsNeedAccount(refs) {
17525
+ throw new Error(
17526
+ `A { "ref" } screenshot needs a signed-in ShotOps account, and this local server has none (${refs.join(", ")}). Pass each screenshot as a local { "path": "\u2026" } PNG, or sign in (npx shotops-mcp login) and try again.`
17527
+ );
17528
+ }
17529
+ async function readUploadsThroughBridge(bridge, refs) {
17530
+ const { screens, missing } = await bridge.readUploadScreens(refs);
17531
+ if (missing.length > 0) throw new Error(missingUploadMessage(missing[0]));
17532
+ return new Map(await Promise.all(screens.map(async ({ ref, url }) => [ref, new Uint8Array(await downloadPng(url, `Screenshot ref "${ref}"`))])));
17533
+ }
16183
17534
  function localToolDeps(token2, options = {}) {
16184
17535
  const base = {
16185
17536
  // #889 — the first render on a machine with no Playwright Chromium downloads it once, here only.
@@ -16238,6 +17589,7 @@ function localToolDeps(token2, options = {}) {
16238
17589
  throw new Error(`Saving an editable project is ${SIGN_IN}`);
16239
17590
  },
16240
17591
  createPendingProjectClaim: (name, record8, ctx) => createAnonymousProjectClaim(name, record8, ctx?.screenshots),
17592
+ readUploadedRefs: async (refs) => refsNeedAccount(refs),
16241
17593
  updateProjectRecord: async () => {
16242
17594
  throw new Error(`Updating a saved project is ${SIGN_IN}`);
16243
17595
  },
@@ -16259,10 +17611,22 @@ function localToolDeps(token2, options = {}) {
16259
17611
  // Read FRESH on every production request and never cached: that is what makes a revoked,
16260
17612
  // expired or downgraded token take effect on the next call rather than at the next restart.
16261
17613
  readAccountSnapshot: () => bridge.readAccountSnapshot(),
17614
+ // #1014 — `userId` is 'local' on this tier, so the account a `{ ref }` must belong to comes
17615
+ // from the token. Without this every ref a save was handed failed the ownership test and was
17616
+ // dropped from the project it reported saving.
17617
+ accountUserId: () => bridge.accountUserId(),
17618
+ // #1014 — the same placeholder `userId` made every render path refuse the caller's own refs as
17619
+ // "not this account's". The control plane checks them against the token's account instead.
17620
+ readUploadedRefs: (refs) => readUploadsThroughBridge(bridge, refs),
16262
17621
  productionOperation: {
16263
17622
  execute: (_userId, action, operationId) => bridge.productionOperation(action, operationId)
16264
17623
  },
16265
17624
  saveLook: (projectId, look2, sourceName) => bridge.saveLook(projectId, look2, sourceName),
17625
+ // #1004 — the App Store listing doors, through the bridge and nowhere else. This tier runs in
17626
+ // the USER's own process, so a direct Apple client here would be a second copy of the locale
17627
+ // and device tables on their machine; the control plane keeps exactly one.
17628
+ searchAppListing: (_userId, term, country) => bridgedListingCall(() => bridge.searchAppListing(term, country)),
17629
+ resolveAppListing: (_userId, reference, country) => bridgedListingCall(() => bridge.resolveAppListing(reference, country)),
16266
17630
  // `ctx` is NOT forwarded as tool args — it existed so the REMOTE save_project tool could
16267
17631
  // rebuild the record, and the control plane is a dumb writer, so the record built HERE is the
16268
17632
  // one that gets stored. It is still READ, for one thing only: the local file paths whose
@@ -16290,83 +17654,84 @@ function localToolDeps(token2, options = {}) {
16290
17654
  bridged.refineProject = (userId, input) => runLocalRefineTurn({ ...bridged, userId }, bridge, input, options.emit ? { emit: options.emit } : void 0);
16291
17655
  return bridged;
16292
17656
  }
17657
+ async function bridgedListingCall(call) {
17658
+ try {
17659
+ return await call();
17660
+ } catch (err) {
17661
+ const message = err instanceof Error ? err.message : String(err);
17662
+ const throttled = err instanceof ControlPlaneOperationError && err.status === 429 || /refusing new requests/i.test(message);
17663
+ return { error: throttled ? "throttled" : "unavailable", message };
17664
+ }
17665
+ }
16293
17666
  async function pushScreens(bridge, projectId, record8, recordVersion, screenshots2) {
16294
17667
  const local = localScreenPaths(screenshots2);
16295
17668
  if (local.length === 0) return;
16296
17669
  try {
16297
- const slots = await bridge.mintScreenUploads(
16298
- projectId,
16299
- local.map((s) => s.name)
16300
- );
16301
- const uploadable = await Promise.all(slots.map(async (slot2) => {
16302
- const source = local.find((s) => s.name === slot2.name);
16303
- if (!source) return null;
16304
- return { slot: slot2, bytes: await readFile4(source.path) };
16305
- }));
16306
- const prepared = uploadable.filter((item) => item !== null);
16307
- if (prepared.length === 0) return;
16308
17670
  const envelope = record8 && typeof record8 === "object" ? record8 : {};
16309
- const parsedLive = parseScreenManifest(envelope.handoffRefs);
16310
- const existingLive = parsedLive ? allManifestEntries(parsedLive) : [];
16311
- const existingCustody = parseScreenPurgeRefs(envelope.screenPurgeRefs) ?? [];
16312
17671
  const cloudShots = shotRefsFromProjectFile(envelope.project);
16313
- const mintedRefs = {};
16314
- for (const { slot: slot2 } of prepared) {
16315
- if (slot2.name) mintedRefs[slot2.name] = slot2.ref;
16316
- }
16317
- const minted = manifestFromNamedRefs(mintedRefs, cloudShots);
16318
- const custody = custodyUnion(existingCustody, existingLive, minted);
16319
- const registered = await bridge.updateProjectRecord(projectId, {
16320
- ...envelope,
16321
- screenPurgeRefs: custody
16322
- }, recordVersion);
16323
- const registeredVersion = registered?.recordVersion ?? recordVersion + 1;
16324
- const outcomes = await Promise.allSettled(
16325
- prepared.map(async ({ slot: slot2, bytes }) => {
16326
- const res = await fetch(slot2.uploadUrl, {
16327
- method: "PUT",
16328
- headers: { "content-type": "image/png" },
16329
- body: new Uint8Array(bytes)
16330
- });
16331
- if (!res.ok) throw new Error(`upload ${slot2.name} -> ${res.status}`);
16332
- return slot2;
16333
- })
16334
- );
16335
- const successfulRefs = {};
16336
- for (const outcome of outcomes) {
16337
- if (outcome.status === "fulfilled" && outcome.value.name) {
16338
- successfulRefs[outcome.value.name] = outcome.value.ref;
17672
+ const idsByFrameName = shotIdsByFrameName(cloudShots);
17673
+ const cells = [];
17674
+ const unaddressed = [];
17675
+ for (const source of local) {
17676
+ const ids = idsByFrameName[source.name];
17677
+ if (ids === void 0) {
17678
+ unaddressed.push(source.name);
17679
+ continue;
16339
17680
  }
17681
+ const bytes = await readFile4(source.path);
17682
+ const hash = await screenshotFingerprint(bytes);
17683
+ for (const id of ids) cells.push({ shotId: id, variant: {}, hash, bytes: new Uint8Array(bytes) });
16340
17684
  }
16341
- if (Object.keys(successfulRefs).length === 0) {
16342
- const failed2 = outcomes.find((outcome) => outcome.status === "rejected");
16343
- throw failed2?.reason ?? new Error("no screenshot upload completed");
17685
+ if (unaddressed.length > 0) {
17686
+ logEvent("warn", "screen_upload_unaddressed", { names: unaddressed.join(", ") });
16344
17687
  }
16345
- const preservedEntries = nonBaseCellEntries(existingLive);
16346
- const preservedKeep = new Set(
16347
- resolveScreenManifest(preservedEntries, cloudShots).cells.map((cell) => cell.entry)
17688
+ if (cells.length === 0) return;
17689
+ let version2 = recordVersion;
17690
+ await saveProjectScreens(
17691
+ {
17692
+ projectId,
17693
+ record: envelope,
17694
+ shots: cloudShots,
17695
+ cells,
17696
+ // #178: refs and `reach` travel together. Studio only fetches bytes from refs when the
17697
+ // record says the screens reach the account, so writing refs without it leaves the screens
17698
+ // unreachable and the "Screens missing" banner firing anyway.
17699
+ reach: "account",
17700
+ rebuild: ({ manifest, custody }) => ({
17701
+ ...envelope,
17702
+ ...manifest.length > 0 ? { handoffRefs: manifest, reach: "account" } : {},
17703
+ screenPurgeRefs: custody
17704
+ })
17705
+ },
17706
+ {
17707
+ async saveRecord({ record: next }) {
17708
+ const written = await bridge.updateProjectRecord(projectId, next, version2);
17709
+ version2 = written?.recordVersion ?? version2 + 1;
17710
+ },
17711
+ async mintUploadSlots({ cells: wanted }) {
17712
+ const nameById = {};
17713
+ for (const shot of cloudShots) nameById[shot.id] = shot.frameName;
17714
+ const names = wanted.map((cell) => nameById[cell.shotId]).filter((name) => !!name);
17715
+ const slots = await bridge.mintScreenUploads(projectId, names);
17716
+ return wanted.flatMap((cell) => {
17717
+ const slot2 = slots.find((minted) => minted.name === nameById[cell.shotId]);
17718
+ return slot2 ? [{ shotId: cell.shotId, variant: cell.variant, ref: slot2.ref, uploadUrl: slot2.uploadUrl }] : [];
17719
+ });
17720
+ },
17721
+ async putBytes({ slot: slot2, bytes }) {
17722
+ const res = await fetch(slot2.uploadUrl, {
17723
+ method: "PUT",
17724
+ headers: { "content-type": "image/png" },
17725
+ body: new Uint8Array(bytes)
17726
+ });
17727
+ if (!res.ok) throw new Error(`upload ${slot2.ref} -> ${res.status}`);
17728
+ }
17729
+ }
16348
17730
  );
16349
- const preserved = preservedEntries.filter((entry) => preservedKeep.has(entry));
16350
- await bridge.updateProjectRecord(projectId, {
16351
- ...envelope,
16352
- handoffRefs: mergeManifest(preserved, manifestFromNamedRefs(successfulRefs, cloudShots)),
16353
- screenPurgeRefs: custody,
16354
- reach: "account"
16355
- }, registeredVersion);
16356
- const failed = outcomes.find((outcome) => outcome.status === "rejected");
16357
- if (failed) throw failed.reason;
16358
17731
  } catch (err) {
16359
17732
  logEvent("error", "screen_upload_failed", { message: err.message });
16360
17733
  }
16361
17734
  }
16362
- function custodyUnion(...manifests) {
16363
- const seen = /* @__PURE__ */ new Set();
16364
- return manifests.flatMap((manifest) => manifest.filter((entry) => {
16365
- if (seen.has(entry.ref)) return false;
16366
- seen.add(entry.ref);
16367
- return true;
16368
- }));
16369
- }
16370
17735
  var SIGN_IN, SHARE_LINK_NOT_BRIDGED;
16371
17736
  var init_localDeps = __esm({
16372
17737
  "src/runtime/localDeps.ts"() {
@@ -16379,8 +17744,10 @@ var init_localDeps = __esm({
16379
17744
  init_projectClaimClient();
16380
17745
  init_log();
16381
17746
  init_capabilities();
16382
- init_projectRecord();
16383
17747
  init_screenManifest();
17748
+ init_fingerprint();
17749
+ init_persistenceOps();
17750
+ init_uploads();
16384
17751
  SIGN_IN = "not available in local (free) mode \u2014 connect the hosted ShotOps MCP and sign in for saved looks, editable projects, and share links.";
16385
17752
  SHARE_LINK_NOT_BRIDGED = `Creating a share link is ${SIGN_IN} (bridging this needs a hosted rendering step, which local mode deliberately avoids \u2014 connect directly to the hosted MCP for share links.)`;
16386
17753
  }
@@ -16496,7 +17863,7 @@ function canonicalJson(value) {
16496
17863
  return `${JSON.stringify(stableValue(value), null, 2)}
16497
17864
  `;
16498
17865
  }
16499
- function sha256(value) {
17866
+ function sha2562(value) {
16500
17867
  return createHash5("sha256").update(value).digest("hex");
16501
17868
  }
16502
17869
  async function sha256File(path) {
@@ -17238,7 +18605,7 @@ function desiredReleasePayload(manifest) {
17238
18605
  return payload;
17239
18606
  }
17240
18607
  function computeDesiredId(payload) {
17241
- return sha256(canonicalJson(payload));
18608
+ return sha2562(canonicalJson(payload));
17242
18609
  }
17243
18610
  function parseTextValue(value, label) {
17244
18611
  const item = record4(value, label);
@@ -18519,8 +19886,8 @@ async function assembleDesiredRelease(input) {
18519
19886
  };
18520
19887
  const recordBytes = Buffer.from(canonicalString(project2.record), "utf8");
18521
19888
  const projectBlob = {
18522
- path: seal(sha256(recordBytes), { bytes: recordBytes }),
18523
- sha256: sha256(recordBytes),
19889
+ path: seal(sha2562(recordBytes), { bytes: recordBytes }),
19890
+ sha256: sha2562(recordBytes),
18524
19891
  bytes: recordBytes.byteLength,
18525
19892
  mediaType: "application/json"
18526
19893
  };
@@ -18567,7 +19934,7 @@ async function assembleDesiredRelease(input) {
18567
19934
  throw new Error(`renderer returned a non-PNG panel for ${output2.id}/${locale2} at position ${index + 1}`);
18568
19935
  }
18569
19936
  const position = index + 1;
18570
- const digest4 = sha256(bytes);
19937
+ const digest4 = sha2562(bytes);
18571
19938
  artifacts.push({
18572
19939
  role: "rendered_screenshot",
18573
19940
  custody: "sealed",
@@ -18780,7 +20147,7 @@ function screenshotSetProjection(set) {
18780
20147
  return set ? { id: set.id, deviceSet: set.deviceSet, items: mediaProjection(set.items) } : null;
18781
20148
  }
18782
20149
  function screenshotSetToken(set) {
18783
- return sha256(canonicalJson(screenshotSetProjection(set)));
20150
+ return sha2562(canonicalJson(screenshotSetProjection(set)));
18784
20151
  }
18785
20152
  function previewTypeFor(deviceSet) {
18786
20153
  return deviceSet.replace(/^APP_/, "");
@@ -18836,7 +20203,7 @@ function projectRemoteResource(remote, scope) {
18836
20203
  }
18837
20204
  }
18838
20205
  function resourceToken(remote, scope) {
18839
- return sha256(canonicalJson(projectRemoteResource(remote, scope)));
20206
+ return sha2562(canonicalJson(projectRemoteResource(remote, scope)));
18840
20207
  }
18841
20208
  function scopeKey(scope) {
18842
20209
  return scope.key === null ? scope.resource : `${scope.resource}:${scope.key}`;
@@ -18919,10 +20286,10 @@ var init_releaseDrift = __esm({
18919
20286
 
18920
20287
  // src/release/releaseOperations.ts
18921
20288
  function operationValueHash(value) {
18922
- return sha256(canonicalJson(value));
20289
+ return sha2562(canonicalJson(value));
18923
20290
  }
18924
20291
  function computeOperationId(input) {
18925
- return sha256(canonicalJson({
20292
+ return sha2562(canonicalJson({
18926
20293
  kind: input.kind,
18927
20294
  target: input.target,
18928
20295
  precondition: input.precondition,
@@ -19537,7 +20904,7 @@ function assertSha(value, label) {
19537
20904
  if (typeof value !== "string" || !SHA2562.test(value)) throw new Error(`${label} is not a SHA-256 digest`);
19538
20905
  }
19539
20906
  function valueHash(value) {
19540
- return sha256(canonicalJson(value));
20907
+ return sha2562(canonicalJson(value));
19541
20908
  }
19542
20909
  function assertExactReleasePlan(value) {
19543
20910
  if (!isRecord5(value) || value.kind !== "shotops.release-plan") {
@@ -19636,7 +21003,7 @@ async function readScreenshot(root, reference) {
19636
21003
  if (!bytes.subarray(0, PNG_SIGNATURE3.length).equals(PNG_SIGNATURE3)) {
19637
21004
  throw new Error(`release screenshot is not a PNG: ${reference.source}`);
19638
21005
  }
19639
- if (sha256(bytes) !== reference.sha256) throw new Error(`release screenshot content changed: ${reference.source}`);
21006
+ if (sha2562(bytes) !== reference.sha256) throw new Error(`release screenshot content changed: ${reference.source}`);
19640
21007
  return bytes;
19641
21008
  }
19642
21009
  async function atomicPrivateWrite(path, bytes) {
@@ -19748,7 +21115,7 @@ async function loadArtifact(path, now) {
19748
21115
  throw new Error("release review artifact screenshot manifest does not match the plan");
19749
21116
  }
19750
21117
  const bytes = entries[raw.path];
19751
- if (!bytes || bytes.byteLength !== raw.bytes || bytes.byteLength !== expected.get(raw.sha256).bytes || !Buffer.from(bytes.subarray(0, PNG_SIGNATURE3.length)).equals(PNG_SIGNATURE3) || sha256(bytes) !== raw.sha256) {
21118
+ if (!bytes || bytes.byteLength !== raw.bytes || bytes.byteLength !== expected.get(raw.sha256).bytes || !Buffer.from(bytes.subarray(0, PNG_SIGNATURE3.length)).equals(PNG_SIGNATURE3) || sha2562(bytes) !== raw.sha256) {
19752
21119
  throw new Error("release review artifact screenshot content does not match the plan");
19753
21120
  }
19754
21121
  screenshots2.set(raw.sha256, bytes);
@@ -19854,7 +21221,7 @@ function assertRecord(record8) {
19854
21221
  assertDetail(record8.detail);
19855
21222
  }
19856
21223
  function chain(previous, payload) {
19857
- return sha256(`${previous}\0${canonicalJson(payload)}`);
21224
+ return sha2562(`${previous}\0${canonicalJson(payload)}`);
19858
21225
  }
19859
21226
  function journalFileName(attemptId) {
19860
21227
  return `attempt-${attemptId}.ndjson`;
@@ -20129,7 +21496,7 @@ async function writePartialReceipt(path, receipt) {
20129
21496
  await rename4(temporary, path);
20130
21497
  }
20131
21498
  function receiptDigest(payload) {
20132
- return sha256(canonicalJson(payload));
21499
+ return sha2562(canonicalJson(payload));
20133
21500
  }
20134
21501
  var RELEASE_JOURNAL_SCHEMA, RELEASE_JOURNAL_KIND, MAX_DETAIL_TEXT, SHA2563, RELEASE_JOURNAL_STAGES, RELEASE_JOURNAL_PHASES, RELEASE_JOURNAL_STATUSES, ReleaseJournalError, SAFE_ID, JOURNAL_FILE, ATTEMPT_DIRECTORY_NAME;
20135
21502
  var init_releaseJournal = __esm({
@@ -20983,7 +22350,7 @@ async function inspectLocalEvidence(input) {
20983
22350
  let workspace = null;
20984
22351
  try {
20985
22352
  workspace = await verifyReleaseWorkspace(workspacePath);
20986
- if (workspace.manifest.desiredId !== plan.desired.desiredId || sha256(workspace.text) !== plan.desired.manifestSha256) {
22353
+ if (workspace.manifest.desiredId !== plan.desired.desiredId || sha2562(workspace.text) !== plan.desired.manifestSha256) {
20987
22354
  diagnostics.push("unsupported_evidence");
20988
22355
  }
20989
22356
  } catch {
@@ -21283,7 +22650,7 @@ async function inspectApple(evidence, deps, diagnostics) {
21283
22650
  diagnostics.push("credentials_missing");
21284
22651
  return { editable: false, url: null, context: null };
21285
22652
  }
21286
- if (sha256(setup.issuerId) !== evidence.plan.owner.issuerIdSha256 || sha256(setup.keyId) !== evidence.plan.owner.keyIdSha256) {
22653
+ if (sha2562(setup.issuerId) !== evidence.plan.owner.issuerIdSha256 || sha2562(setup.keyId) !== evidence.plan.owner.keyIdSha256) {
21287
22654
  diagnostics.push("credentials_missing");
21288
22655
  return { editable: false, url: null, context: null };
21289
22656
  }
@@ -22198,7 +23565,7 @@ async function runUpload(request, client, deps, ids, record8) {
22198
23565
  } catch {
22199
23566
  return { state: "not-applied", reason: "validation_failed" };
22200
23567
  }
22201
- if (bytes.byteLength !== request.payload.bytes || sha256(bytes) !== request.payload.sha256) {
23568
+ if (bytes.byteLength !== request.payload.bytes || sha2562(bytes) !== request.payload.sha256) {
22202
23569
  return { state: "not-applied", reason: "validation_failed" };
22203
23570
  }
22204
23571
  let relationshipId;
@@ -23087,7 +24454,7 @@ async function sealBackup(destination, manifest, blobs) {
23087
24454
  await writeFile6(file, bytes, { mode: 384 });
23088
24455
  const written = await stat5(file);
23089
24456
  if (written.size !== bytes.byteLength) fail4("a recovery backup image changed size while it was being written");
23090
- if (await sha256File(file) !== sha256(bytes)) fail4("a recovery backup image changed content while it was being written");
24457
+ if (await sha256File(file) !== sha2562(bytes)) fail4("a recovery backup image changed content while it was being written");
23091
24458
  await fsyncPath2(file, false);
23092
24459
  }
23093
24460
  const manifestPath = join15(temporary, BACKUP_FILENAME);
@@ -23227,7 +24594,7 @@ async function readSealedImages(workspace, manifest, selected) {
23227
24594
  if (!info?.isFile() || info.isSymbolicLink()) fail4(`the sealed image for ${set.key} is missing or unsafe`);
23228
24595
  if (info.size !== payload.bytes) fail4(`the sealed image for ${set.key} changed size since the plan was approved`);
23229
24596
  const bytes = await readFile12(file);
23230
- if (sha256(bytes) !== payload.sha256) fail4(`the sealed image for ${set.key} changed since the plan was approved`);
24597
+ if (sha2562(bytes) !== payload.sha256) fail4(`the sealed image for ${set.key} changed since the plan was approved`);
23231
24598
  const md5 = md5Hex(bytes);
23232
24599
  if (md5 !== payload.md5) fail4(`the sealed image for ${set.key} does not match the checksum the plan approved`);
23233
24600
  sealed.set(payload.sha256, { bytes, md5 });
@@ -23307,7 +24674,7 @@ async function captureBackup(client, plan, manifest, bound, destination, capture
23307
24674
  fail4(`App Store Connect will not release the bytes of ${image.fileName} in ${item.selected.target.locale}/${item.selected.target.deviceSet}; nothing was deleted`);
23308
24675
  }
23309
24676
  const bytes = await client.download(image.asset);
23310
- const digest4 = sha256(bytes);
24677
+ const digest4 = sha2562(bytes);
23311
24678
  const md5 = md5Hex(bytes);
23312
24679
  if (image.sourceFileChecksum && image.sourceFileChecksum !== md5) {
23313
24680
  fail4(`the bytes App Store Connect returned for ${image.fileName} do not match its own checksum; nothing was deleted`);
@@ -23704,7 +25071,7 @@ function assetsFingerprint(assets) {
23704
25071
  });
23705
25072
  }
23706
25073
  function valueHash2(value) {
23707
- return sha256(canonicalJson(value));
25074
+ return sha2562(canonicalJson(value));
23708
25075
  }
23709
25076
  function payloadOf(asset, role) {
23710
25077
  return { role, source: asset.source, sha256: asset.sha256, md5: asset.md5, bytes: asset.bytes };
@@ -24317,7 +25684,7 @@ async function createReleasePlan(input, deps = {}) {
24317
25684
  const approvalExpiresAt = new Date(now.getTime() + APPROVAL_TTL_MS).toISOString();
24318
25685
  const policySha256 = valueHash2(RELEASE_POLICY);
24319
25686
  const remoteSnapshotSha256 = valueHash2(remote);
24320
- const manifestSha256 = sha256(loaded.text);
25687
+ const manifestSha256 = sha2562(loaded.text);
24321
25688
  const commitment = mintCommitmentKey(deps.randomSource);
24322
25689
  const payload = {
24323
25690
  schema: PLAN_SCHEMA2,
@@ -24339,7 +25706,7 @@ async function createReleasePlan(input, deps = {}) {
24339
25706
  buildSha256: loaded.manifest.build?.sha256 ?? null,
24340
25707
  scope: loaded.manifest.scope
24341
25708
  },
24342
- owner: { issuerIdSha256: sha256(setup.issuerId), keyIdSha256: sha256(setup.keyId) },
25709
+ owner: { issuerIdSha256: sha2562(setup.issuerId), keyIdSha256: sha2562(setup.keyId) },
24343
25710
  target: {
24344
25711
  appId: remote.app.id,
24345
25712
  appInfoId: remote.appInfo.id,
@@ -25036,7 +26403,7 @@ async function runPreflight(input, deps, recovery) {
25036
26403
  if (plan.desired.desiredId !== workspace.manifest.desiredId) {
25037
26404
  refuse2("plan_mismatch", "this plan was built from a different desired release than the workspace holds");
25038
26405
  }
25039
- if (plan.desired.manifestSha256 !== sha256(workspace.text)) {
26406
+ if (plan.desired.manifestSha256 !== sha2562(workspace.text)) {
25040
26407
  refuse2("plan_mismatch", "the sealed desired release changed after this plan was built; re-plan before committing");
25041
26408
  }
25042
26409
  if (!EDITABLE_VERSION_STATES.has(plan.target.appStoreState)) {
@@ -25052,7 +26419,7 @@ async function runPreflight(input, deps, recovery) {
25052
26419
  } catch (error) {
25053
26420
  refuse2("setup_unavailable", error.message);
25054
26421
  }
25055
- if (sha256(setup.issuerId) !== plan.owner.issuerIdSha256 || sha256(setup.keyId) !== plan.owner.keyIdSha256) {
26422
+ if (sha2562(setup.issuerId) !== plan.owner.issuerIdSha256 || sha2562(setup.keyId) !== plan.owner.keyIdSha256) {
25056
26423
  refuse2("owner_mismatch", "this machine's App Store Connect setup is not the one this plan was built with");
25057
26424
  }
25058
26425
  let appleToken;
@@ -25255,7 +26622,7 @@ async function runStages(preflight, attemptId, journal, signal, deps, now) {
25255
26622
  function readSealedPayload(workspacePath) {
25256
26623
  return async (reference) => {
25257
26624
  const bytes = await readFile13(join17(workspacePath, ...reference.source.split("/")));
25258
- if (bytes.byteLength !== reference.bytes || sha256(bytes) !== reference.sha256) {
26625
+ if (bytes.byteLength !== reference.bytes || sha2562(bytes) !== reference.sha256) {
25259
26626
  throw new Error("a sealed payload changed since this plan was approved");
25260
26627
  }
25261
26628
  return bytes;
@@ -25861,23 +27228,20 @@ function parsePrepareArgs(argv2) {
25861
27228
  };
25862
27229
  }
25863
27230
  function authoredLocales(projectFile) {
25864
- const file = projectFile;
25865
- const locales = Array.isArray(file.locales) ? file.locales.filter((locale2) => typeof locale2 === "string" && locale2.trim().length > 0) : [];
25866
- const fallback = typeof file.baseLocale === "string" && file.baseLocale.trim() ? file.baseLocale.trim() : "en-US";
25867
- const result = Array.from(new Set((locales.length ? locales : [fallback]).map((locale2) => locale2.trim())));
25868
- for (const locale2 of result) {
27231
+ const locales = projectLocaleAxis(projectFile).locales;
27232
+ for (const locale2 of locales) {
25869
27233
  if (!/^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$/.test(locale2)) throw new Error(`project contains an unsafe locale identifier: ${locale2}`);
25870
27234
  }
25871
- return result;
27235
+ return locales;
25872
27236
  }
25873
27237
  function selectedOutputs(projectFile) {
25874
27238
  const resolved = outputDevicesFromProjectFile(projectFile);
25875
27239
  const file = projectFile;
25876
27240
  const declared = Array.isArray(file.outputs) ? file.outputs.filter((id) => typeof id === "string" && id.length > 0) : [];
25877
27241
  if (declared.length > 0) return { outputs: [...resolved.devices], unsupported: resolved.dropped };
25878
- const preset = typeof file.panelPresetId === "string" ? file.panelPresetId : void 0;
27242
+ const preset = projectOutputAxis(projectFile).panelPresetId;
25879
27243
  const fallback = OUTPUT_DEVICES.find((output2) => output2.panelPresetId === preset);
25880
- if (!fallback) throw new Error(`project output is unavailable or unsupported (panel preset ${preset ?? "missing"})`);
27244
+ if (!fallback) throw new Error(`project output is unavailable or unsupported (panel preset ${preset})`);
25881
27245
  return { outputs: [fallback], unsupported: [] };
25882
27246
  }
25883
27247
  function localReleaseDoor(token2) {
@@ -26028,6 +27392,7 @@ var RELEASE_PREPARE_USAGE, RELEASE_GC_USAGE, LOCAL_ACTOR;
26028
27392
  var init_releasePrepare = __esm({
26029
27393
  "src/release/releasePrepare.ts"() {
26030
27394
  "use strict";
27395
+ init_appstore();
26031
27396
  init_env();
26032
27397
  init_productionRenderAccess();
26033
27398
  init_projectResolution();
@@ -26278,7 +27643,7 @@ function pageCss() {
26278
27643
  *{box-sizing:border-box}html{background:var(--paper);color:var(--ink);font-family:ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:16px;line-height:1.5}body{margin:0}main{max-width:1240px;margin:0 auto;padding:44px 32px 96px}
26279
27644
  .eyebrow,.section-heading>p,.integrity-lead>p{margin:0 0 8px;text-transform:uppercase;letter-spacing:.14em;font-size:.72rem;font-weight:750;color:var(--green)}h1,h2{font-family:Georgia,"Times New Roman",serif;font-weight:500;letter-spacing:-.025em}h1{font-size:clamp(2.7rem,7vw,6.4rem);line-height:.9;margin:20px 0 24px;max-width:900px}h2{font-size:2rem;line-height:1.05;margin:0}h3{font-size:1rem;margin:0}.masthead{display:grid;grid-template-columns:minmax(0,1.6fr) minmax(260px,.55fr);gap:56px;padding:10px 0 42px;border-bottom:1px solid var(--ink)}.masthead-copy>p:last-child{max-width:700px;color:var(--muted);font-size:1.08rem}.identity{align-self:end;border-left:4px solid var(--green);padding:4px 0 4px 20px}.identity dl{margin:0}.identity div{padding:8px 0;border-bottom:1px solid var(--line)}dt{text-transform:uppercase;letter-spacing:.08em;font-size:.67rem;font-weight:750;color:var(--muted)}dd{margin:2px 0 0;font-weight:650;overflow-wrap:anywhere}.warnings{position:sticky;top:0;z-index:5;display:grid;gap:1px;margin:0;background:var(--ink);box-shadow:0 6px 18px #161b1d22}.warning{display:flex;gap:20px;align-items:baseline;padding:14px 32px;color:white}.warning strong{min-width:150px;text-transform:uppercase;letter-spacing:.1em;font-size:.7rem}.warning--partial{background:var(--red)}.warning--build{background:var(--amber)}.warning--expired{background:var(--ink)}
26280
27645
  .integrity{display:grid;grid-template-columns:minmax(240px,.55fr) 1.45fr;margin:48px 0;background:var(--ink);color:var(--paper);border-radius:2px;overflow:hidden}.integrity-lead{padding:32px;border-right:1px solid #ffffff2a}.integrity-lead p{color:#9dd0b5}.integrity-lead span{display:block;color:#c9c4ba;margin-top:16px}.integrity dl{display:grid;grid-template-columns:1fr 1fr;margin:0}.integrity dl div{min-width:0;padding:18px 22px;border-right:1px solid #ffffff1c;border-bottom:1px solid #ffffff1c}.integrity dt{color:#aaa59c}.integrity code{font-size:.73rem;color:#fff;overflow-wrap:anywhere}.scope-card{display:grid;grid-template-columns:1.1fr 1fr 1fr 1fr;border:1px solid var(--line);background:var(--white);margin-bottom:64px}.scope-card>div{padding:24px;border-right:1px solid var(--line)}.scope-card>div:last-child{border:0}.scope-status span{display:inline-block;padding:4px 8px;background:var(--green);color:white;text-transform:uppercase;letter-spacing:.08em;font-size:.66rem;font-weight:750}.scope-card[data-scope="partial"] .scope-status span{background:var(--red)}.scope-status strong{display:block;margin-top:18px;font-family:Georgia,"Times New Roman",serif;font-size:1.5rem;line-height:1.1}.scope-card h3{text-transform:uppercase;letter-spacing:.08em;font-size:.7rem}.scope-card ul{list-style:none;margin:12px 0 0;padding:0}.scope-card li{padding:3px 0}.muted{color:var(--muted)}
26281
- .operation-section,.forbidden{display:grid;grid-template-columns:230px 1fr;gap:34px;margin:64px 0}.section-heading{position:sticky;top:82px;align-self:start}.section-heading>span{display:block;margin-top:12px;color:var(--muted);font-size:.85rem}.operation-list{display:grid;gap:14px}.operation{background:var(--white);border:1px solid var(--line)}.operation>header{display:grid;grid-template-columns:42px 1fr auto;gap:14px;padding:18px 20px;border-bottom:1px solid var(--line);align-items:start}.sequence{font-family:Georgia,"Times New Roman",serif;font-size:1.3rem;color:var(--muted)}.action{padding:4px 8px;border:1px solid currentColor;text-transform:uppercase;letter-spacing:.08em;font-size:.65rem;font-weight:800;color:var(--green)}.action--clear,.action--replace{color:var(--red)}.action--noop{color:var(--muted)}.action--attach,.action--upload{color:var(--amber)}.target{display:flex;flex-wrap:wrap;gap:6px 16px;margin:7px 0 0}.target div{display:flex;gap:5px}.target dt{font-size:.6rem}.target dd{font-size:.72rem;margin:0;font-weight:600}.diff{display:grid;grid-template-columns:1fr 1fr}.value{min-width:0;padding:18px 20px}.value:first-child{border-right:1px solid var(--line)}.value>span{display:block;text-transform:uppercase;letter-spacing:.09em;font-size:.62rem;font-weight:750;color:var(--muted);margin-bottom:7px}.value pre{margin:0;white-space:pre-wrap;overflow-wrap:anywhere;font:500 .82rem/1.5 ui-monospace,SFMono-Regular,Menlo,monospace}.value--after{background:#f8f5ef}.value--empty pre{color:var(--red);font-style:italic}.verdict{font-size:1.5rem}.gallery-groups{display:grid;gap:24px}.gallery .shot-grid{grid-template-columns:repeat(auto-fill,minmax(150px,190px));gap:12px;background:none}.gallery .shot-grid figure{border:1px solid var(--line)}.gallery-group h3{text-transform:uppercase;letter-spacing:.08em;font-size:.7rem;color:var(--muted);margin:0 0 10px}.fold{border:1px solid var(--line);background:var(--white)}.fold summary{cursor:pointer;padding:16px 20px;font-weight:650;list-style:none}.fold summary::-webkit-details-marker{display:none}.fold summary::before{content:'\u25B8 ';color:var(--muted)}.fold[open] summary::before{content:'\u25BE '}.fold-list{list-style:none;margin:0;padding:0 20px 14px;display:grid;gap:6px}.operation--folded{display:flex;gap:12px;border:0;background:none;font-size:.9rem}.operation--folded .sequence{font-size:.9rem}.operation>footer{display:flex;justify-content:space-between;gap:20px;padding:10px 20px;border-top:1px solid var(--line);color:var(--muted);font-size:.68rem}.operation>footer code{overflow:hidden;text-overflow:ellipsis}.reason{font-weight:700;color:var(--amber);white-space:nowrap}.shot-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:1px;background:var(--line)}.shot-grid figure{margin:0;background:var(--white);padding:14px}.shot-frame{aspect-ratio:1290/2796;background:var(--wash);display:flex;align-items:center;justify-content:center;overflow:hidden}.shot-frame img{display:block;width:100%;height:100%;object-fit:contain}.shot-grid figcaption{display:grid;gap:2px;margin-top:10px}.shot-grid figcaption span,.shot-grid figcaption small{font-size:.68rem;overflow-wrap:anywhere;color:var(--muted)}.empty-section>p{margin:0;padding:28px;background:var(--wash);color:var(--muted)}
27646
+ .operation-section,.forbidden{display:grid;grid-template-columns:230px 1fr;gap:34px;margin:64px 0}.section-heading{position:sticky;top:82px;align-self:start}.section-heading>span{display:block;margin-top:12px;color:var(--muted);font-size:.85rem}.operation-list{display:grid;gap:14px}.operation{background:var(--white);border:1px solid var(--line)}.operation>header{display:grid;grid-template-columns:42px 1fr auto;gap:14px;padding:18px 20px;border-bottom:1px solid var(--line);align-items:start}.sequence{font-family:Georgia,"Times New Roman",serif;font-size:1.3rem;color:var(--muted)}.action{padding:4px 8px;border:1px solid currentColor;text-transform:uppercase;letter-spacing:.08em;font-size:.65rem;font-weight:800;color:var(--green)}.action--clear,.action--replace{color:var(--red)}.action--noop{color:var(--muted)}.action--attach,.action--upload{color:var(--amber)}.target{display:flex;flex-wrap:wrap;gap:6px 16px;margin:7px 0 0}.target div{display:flex;gap:5px}.target dt{font-size:.6rem}.target dd{font-size:.72rem;margin:0;font-weight:600}.diff{display:grid;grid-template-columns:1fr 1fr}.value{min-width:0;padding:18px 20px}.value:first-child{border-right:1px solid var(--line)}.value>span{display:block;text-transform:uppercase;letter-spacing:.09em;font-size:.62rem;font-weight:750;color:var(--muted);margin-bottom:7px}.value pre{margin:0;white-space:pre-wrap;overflow-wrap:anywhere;font:500 .82rem/1.5 ui-monospace,SFMono-Regular,Menlo,monospace}.value--after{background:#f8f5ef}.value--empty pre{color:var(--red);font-style:italic}.verdict{font-size:1.5rem}.gallery-groups{display:grid;gap:24px}.gallery .shot-grid{grid-template-columns:repeat(auto-fill,minmax(150px,190px));gap:12px;background:none}.gallery .shot-grid figure{border:1px solid var(--line)}.gallery-group h3{text-transform:uppercase;letter-spacing:.08em;font-size:.7rem;color:var(--muted);margin:0 0 10px}.fold{border:1px solid var(--line);background:var(--white)}.fold summary{cursor:pointer;padding:16px 20px;font-weight:650;list-style:none}.fold summary::-webkit-details-marker{display:none}.fold summary::before{content:'\u25B8 ';color:var(--muted)}.fold[open] summary::before{content:'\u25BE '}.fold-list{list-style:none;margin:0;padding:0 20px 14px;display:grid;gap:6px}.operation--folded{display:flex;gap:12px;border:0;background:none;font-size:.9rem}.operation--folded .sequence{font-size:.9rem}.operation>footer{display:flex;justify-content:space-between;gap:20px;padding:10px 20px;border-top:1px solid var(--line);color:var(--muted);font-size:.68rem}.operation>footer code{overflow:hidden;text-overflow:ellipsis}.reason{font-weight:700;color:var(--amber);white-space:nowrap}.shot-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:1px;background:var(--line)}.shot-grid figure{margin:0;background:var(--white);padding:14px}.shot-frame{aspect-ratio:1320/2868;background:var(--wash);display:flex;align-items:center;justify-content:center;overflow:hidden}.shot-frame img{display:block;width:100%;height:100%;object-fit:contain}.shot-grid figcaption{display:grid;gap:2px;margin-top:10px}.shot-grid figcaption span,.shot-grid figcaption small{font-size:.68rem;overflow-wrap:anywhere;color:var(--muted)}.empty-section>p{margin:0;padding:28px;background:var(--wash);color:var(--muted)}
26282
27647
  .approval .approve-body{border:1px solid var(--line);background:var(--white);padding:26px}.approval[data-approval-state="approved"] .approve-body{border-left:4px solid var(--green)}.approval[data-approval-state="expired"] .approve-body{border-left:4px solid var(--red)}.approve-body>p{margin:0 0 16px}.approve-code{display:flex;flex-wrap:wrap;align-items:baseline;gap:12px;margin:0 0 18px;padding:14px 18px;background:var(--ink);color:var(--paper)}.approve-code span{text-transform:uppercase;letter-spacing:.1em;font-size:.66rem;font-weight:750;color:#9dd0b5}.approve-code code{font:700 1.25rem/1 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.08em}.approve-facts{display:grid;grid-template-columns:1fr 1fr;gap:12px 24px;margin:22px 0 0}.approve-facts code{font-size:.73rem;overflow-wrap:anywhere}.approve-note{margin:20px 0 0;color:var(--muted);font-size:.82rem}
26283
27648
  .forbidden{padding-top:54px;border-top:1px solid var(--ink)}.forbidden ul{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:1fr 1fr;gap:1px;background:var(--line)}.forbidden li{display:flex;gap:14px;padding:22px;background:var(--white)}.forbidden li>span{color:var(--red);font-size:1.4rem;line-height:1}.forbidden p{margin:5px 0 0;color:var(--muted);font-size:.82rem}.page-footer{display:flex;justify-content:space-between;gap:30px;margin-top:72px;padding-top:20px;border-top:1px solid var(--ink);color:var(--muted);font-size:.76rem}.page-footer code{overflow-wrap:anywhere}
26284
27649
  @media(max-width:900px){main{padding:28px 20px 70px}.masthead{grid-template-columns:1fr;gap:24px}.identity{align-self:auto}.warning{padding-inline:20px}.integrity{grid-template-columns:1fr}.integrity-lead{border-right:0;border-bottom:1px solid #ffffff2a}.scope-card{grid-template-columns:1fr 1fr}.scope-card>div:nth-child(2){border-right:0}.scope-card>div:nth-child(-n+2){border-bottom:1px solid var(--line)}.operation-section,.forbidden{grid-template-columns:1fr;gap:20px}.section-heading{position:static}.forbidden ul{grid-template-columns:1fr}}
@@ -26455,7 +27820,7 @@ function serializeReleaseReviewPayload(payload) {
26455
27820
  if (json.length > RELEASE_REVIEW_PAYLOAD_LIMITS.maxPayloadBytes) {
26456
27821
  throw new Error("this plan\u2019s review content is too large to upload for remote review");
26457
27822
  }
26458
- return { bytes: new TextEncoder().encode(json), sha256: sha256(json) };
27823
+ return { bytes: new TextEncoder().encode(json), sha256: sha2562(json) };
26459
27824
  }
26460
27825
  var init_releaseReviewPayload2 = __esm({
26461
27826
  "src/release/releaseReviewPayload.ts"() {
@@ -27423,6 +28788,66 @@ init_toolContract();
27423
28788
  init_toolResults();
27424
28789
  init_toolsAccount();
27425
28790
 
28791
+ // src/tools/toolsAppListing.ts
28792
+ init_toolResults();
28793
+
28794
+ // src/project/appListingLink.ts
28795
+ init_appListing();
28796
+ function appListingView(listing2) {
28797
+ const { applied } = listing2;
28798
+ return {
28799
+ appId: listing2.trackId,
28800
+ bundleId: listing2.bundleId,
28801
+ name: listing2.trackName,
28802
+ seller: listing2.sellerName,
28803
+ ...listing2.trackViewUrl ? { storeUrl: listing2.trackViewUrl } : {},
28804
+ ...applied.iconUrl ? { iconUrl: applied.iconUrl } : {},
28805
+ locales: applied.locales,
28806
+ baseLocale: applied.baseLocale,
28807
+ outputs: applied.outputs,
28808
+ supportsIpad: applied.supportsIpad,
28809
+ storefront: listing2.storefront,
28810
+ ...applied.droppedLanguageCodes.length > 0 ? { droppedLanguageCodes: applied.droppedLanguageCodes } : {}
28811
+ };
28812
+ }
28813
+ function linkedAppFromRecord(record8) {
28814
+ const listing2 = readAppListing(record8);
28815
+ return listing2 ? appListingView(listing2) : null;
28816
+ }
28817
+ function appListingFailureCode(kind) {
28818
+ const codes = {
28819
+ // Both are the caller's to fix with a different reference: one could not be parsed, the other
28820
+ // named no app. Neither is a ShotOps asset, so `asset_not_found` would point at the wrong door.
28821
+ invalid_reference: "invalid_input",
28822
+ not_found: "invalid_input",
28823
+ throttled: "rate_limited",
28824
+ unavailable: "upstream_unavailable"
28825
+ };
28826
+ return codes[kind];
28827
+ }
28828
+
28829
+ // src/tools/toolsAppListing.ts
28830
+ var APP_LISTING_SIGN_IN = "Searching the App Store needs a ShotOps account. Sign in at https://shotops.dev, or configure SHOTOPS_TOKEN on this server, and try again. You can still name the app yourself \u2014 pass `projectName` on save_project.";
28831
+ function failureKind(answer) {
28832
+ const error = answer?.error;
28833
+ return typeof error === "string" ? error : null;
28834
+ }
28835
+ async function handleSearchAppListing(deps, args) {
28836
+ if (!deps.searchAppListing) return errorResult(APP_LISTING_SIGN_IN, "authentication_required");
28837
+ const answer = await deps.searchAppListing(deps.userId, args.term, args.country);
28838
+ const kind = failureKind(answer);
28839
+ if (kind) {
28840
+ return errorResult(answer.message, appListingFailureCode(kind));
28841
+ }
28842
+ const results = answer.results.map(appListingView);
28843
+ const storefront = args.country?.toLowerCase() ?? "us";
28844
+ return textResult({
28845
+ ok: true,
28846
+ results,
28847
+ message: results.length === 0 ? `No App Store app matched "${args.term}" in the ${storefront} storefront. Try the exact name as it appears on the App Store, or another storefront with \`country\` \u2014 an app released in one country is invisible to a search of any other.` : `${results.length} App Store ${results.length === 1 ? "app" : "apps"} matched "${args.term}" in the ${storefront} storefront. Confirm with the user WHICH app is theirs \u2014 the icon and seller are what prove it \u2014 then pass that result\u2019s \`appId\` (or \`storeUrl\`) as \`appListing\` on save_project, and the project it creates takes the app\u2019s name, bundle ID, languages and iPhone devices. \`locales\` and \`outputs\` on each result are what the listing would apply; \`outputs\` is empty for an iPad-only app, because ShotOps renders iPhone panels only. This says nothing about who owns the app.`
28848
+ });
28849
+ }
28850
+
27426
28851
  // src/tools/toolsBundle.ts
27427
28852
  init_fastlaneBundle();
27428
28853
  init_appstore();
@@ -27442,6 +28867,10 @@ init_renderCredits();
27442
28867
  init_browserInstall();
27443
28868
  init_appstore();
27444
28869
  init_productionOperationStart();
28870
+ function panelReadFailure(err) {
28871
+ if (err instanceof HostedOnlyPanelError) return errorResult(`failed to read panel: ${err.message}`, "unsupported_input");
28872
+ return errorResult(`failed to read panel: ${err.message}`);
28873
+ }
27445
28874
  function dedupeLocales(list) {
27446
28875
  if (!Array.isArray(list)) return [];
27447
28876
  const seen = /* @__PURE__ */ new Set();
@@ -27456,10 +28885,7 @@ function dedupeLocales(list) {
27456
28885
  return out;
27457
28886
  }
27458
28887
  function projectBaseLocale2(projectFile) {
27459
- const file = projectFile;
27460
- if (typeof file?.baseLocale === "string" && file.baseLocale) return file.baseLocale;
27461
- const first = Array.isArray(file?.locales) ? file.locales[0] : void 0;
27462
- return typeof first === "string" && first ? first : void 0;
28888
+ return projectLocaleAxis(projectFile).baseLocale;
27463
28889
  }
27464
28890
  var PROJECT_SOURCE_CREATE_PROJECT_ERROR = "createProject is not supported when the screenshots come from a saved `project` \u2014 that project already exists. Drop createProject to bundle it, or pass `screenshots` to render (and save) a new one.";
27465
28891
  function joinNotes(...parts) {
@@ -27485,7 +28911,7 @@ async function directDurableBundlePanels(input) {
27485
28911
  input.slots,
27486
28912
  locale2
27487
28913
  );
27488
- const screenshots2 = await resolveScreenshots(entries, input.deps.userId, false);
28914
+ const screenshots2 = await resolveScreenshots(entries, input.deps.userId, false, input.deps.readUploadedRefs);
27489
28915
  const preset = output2?.panelPresetId ?? input.panelPresetId ?? "r69";
27490
28916
  const dimensions = panelDimensionsFor(preset);
27491
28917
  const slotSizes = screenshots2.map((slot2) => slot2.length);
@@ -27824,7 +29250,7 @@ async function emitBundle(deps, args, scope, attempt, signal) {
27824
29250
  }
27825
29251
  let screenshots2;
27826
29252
  try {
27827
- screenshots2 = await resolveScreenshots(entries, deps.userId, deps.allowLocalScreenshots ?? false);
29253
+ screenshots2 = await resolveScreenshots(entries, deps.userId, deps.allowLocalScreenshots ?? false, deps.readUploadedRefs);
27828
29254
  } catch (err) {
27829
29255
  return { ok: false, error: err.message };
27830
29256
  }
@@ -28039,9 +29465,9 @@ async function emitBundle(deps, args, scope, attempt, signal) {
28039
29465
  readinessReport = resolvedInput.report;
28040
29466
  let bytesList;
28041
29467
  try {
28042
- bytesList = await Promise.all(panelEntries.map((p) => resolvePanelBytes(p, deps.userId, deps.allowLocalScreenshots ?? false)));
29468
+ bytesList = await Promise.all(panelEntries.map((p) => resolvePanelBytes(p, deps.userId, deps.allowLocalScreenshots ?? false, deps.readUploadedRefs)));
28043
29469
  } catch (err) {
28044
- return errorResult(`failed to read panel: ${err.message}`);
29470
+ return panelReadFailure(err);
28045
29471
  }
28046
29472
  slots = bytesList.map((bytes, i) => ({ displayPosition: i + 1, bytes: Buffer.from(bytes) }));
28047
29473
  previewPng = bytesList[0] ? Buffer.from(bytesList[0]) : null;
@@ -28181,7 +29607,7 @@ async function emitBundle(deps, args, scope, attempt, signal) {
28181
29607
  if (refused) return errorResult(refused.error);
28182
29608
  let screenshots2;
28183
29609
  try {
28184
- screenshots2 = await resolveScreenshots(entries, deps.userId, deps.allowLocalScreenshots ?? false);
29610
+ screenshots2 = await resolveScreenshots(entries, deps.userId, deps.allowLocalScreenshots ?? false, deps.readUploadedRefs);
28185
29611
  } catch (err) {
28186
29612
  return errorResult(err.message);
28187
29613
  }
@@ -28521,7 +29947,7 @@ async function emitMultiLocaleBundle(deps, args, locales, projectName2, scope, p
28521
29947
  const key = "path" in p ? `path:${p.path}` : `ref:${p.ref}`;
28522
29948
  let pending = bytesCache.get(key);
28523
29949
  if (!pending) {
28524
- pending = resolvePanelBytes(p, deps.userId, deps.allowLocalScreenshots ?? false);
29950
+ pending = resolvePanelBytes(p, deps.userId, deps.allowLocalScreenshots ?? false, deps.readUploadedRefs);
28525
29951
  bytesCache.set(key, pending);
28526
29952
  }
28527
29953
  return pending;
@@ -28537,7 +29963,7 @@ async function emitMultiLocaleBundle(deps, args, locales, projectName2, scope, p
28537
29963
  });
28538
29964
  }
28539
29965
  } catch (err) {
28540
- return errorResult(`failed to read panel: ${err.message}`);
29966
+ return panelReadFailure(err);
28541
29967
  }
28542
29968
  const firstPanel = localeSlots[0]?.slots[0];
28543
29969
  previewPng = firstPanel ? Buffer.from(firstPanel.bytes) : null;
@@ -28559,7 +29985,7 @@ async function emitMultiLocaleBundle(deps, args, locales, projectName2, scope, p
28559
29985
  }
28560
29986
  let screenshots2;
28561
29987
  try {
28562
- screenshots2 = await resolveScreenshots(entries, deps.userId, deps.allowLocalScreenshots ?? false);
29988
+ screenshots2 = await resolveScreenshots(entries, deps.userId, deps.allowLocalScreenshots ?? false, deps.readUploadedRefs);
28563
29989
  } catch (err) {
28564
29990
  return errorResult(err.message);
28565
29991
  }
@@ -28807,7 +30233,7 @@ function readPose(look2) {
28807
30233
  const angle = look2.angle === "left" || look2.angle === "right" || look2.angle === "front" ? look2.angle : DEFAULT_LOOK.angle;
28808
30234
  const cameraPos = isRecord2(look2.cameraPos) && typeof look2.cameraPos.x === "number" && Number.isFinite(look2.cameraPos.x) && typeof look2.cameraPos.y === "number" && Number.isFinite(look2.cameraPos.y) && typeof look2.cameraPos.z === "number" && Number.isFinite(look2.cameraPos.z) ? { x: look2.cameraPos.x, y: look2.cameraPos.y, z: look2.cameraPos.z } : null;
28809
30235
  return {
28810
- phoneHeight: normalizePose(look2.phoneHeight, "phoneHeight"),
30236
+ deviceSize: normalizePose(look2.deviceSize, "deviceSize"),
28811
30237
  hOffset: normalizePose(look2.hOffset, "hOffset"),
28812
30238
  vOffset: normalizePose(look2.vOffset, "vOffset"),
28813
30239
  angle,
@@ -29118,12 +30544,28 @@ init_toolContract();
29118
30544
  init_capabilities();
29119
30545
  init_authorization();
29120
30546
  init_productionInput2();
30547
+ var SAVE_APP_LISTING_SIGN_IN = "Linking a project to its App Store listing needs a ShotOps account. Sign in at https://shotops.dev, or configure SHOTOPS_TOKEN on this server, and try again \u2014 or drop `appListing` and pass `projectName` to name the project yourself. Nothing was saved.";
29121
30548
  async function handleSaveProject(deps, args) {
29122
30549
  const looked = await resolveLook(deps, args);
29123
30550
  if (isLookFailure(looked)) return productionInputErrorResult(looked.failure);
29124
30551
  const { look: look2, style: style2, note: resolveNote } = looked;
29125
30552
  const baseNote = [resolveNote, composeConflictNote(look2, style2)].filter(Boolean).join(" ") || void 0;
29126
- const locale2 = args.locale?.trim() || "en-US";
30553
+ const creating = !args.project;
30554
+ let listing2 = null;
30555
+ if (args.appListing) {
30556
+ if (!deps.resolveAppListing) return errorResult(SAVE_APP_LISTING_SIGN_IN, "authentication_required");
30557
+ const answer = await deps.resolveAppListing(deps.userId, args.appListing);
30558
+ const kind = typeof answer.error === "string" ? answer.error : null;
30559
+ if (kind) return errorResult(answer.message, appListingFailureCode(kind));
30560
+ listing2 = answer.listing;
30561
+ if (!listing2) {
30562
+ return errorResult(
30563
+ `No App Store app matches "${args.appListing}". Search for it with search_app_listing and pass a result\u2019s \`appId\`, or drop \`appListing\` and name the project yourself. Nothing was saved.`,
30564
+ "invalid_input"
30565
+ );
30566
+ }
30567
+ }
30568
+ const locale2 = args.locale?.trim() || (creating && listing2 ? listing2.applied.baseLocale : "en-US");
29127
30569
  if (!Array.isArray(args.screenshots) || args.screenshots.length === 0) {
29128
30570
  return errorResult(
29129
30571
  "save_project needs a non-empty `screenshots` array (its slot shape + names define the project structure).",
@@ -29142,7 +30584,8 @@ async function handleSaveProject(deps, args) {
29142
30584
  return errorResult(err.message);
29143
30585
  }
29144
30586
  const screenshotNames = resolveScreenshotNames(entries).flat();
29145
- const { devices: outputDevices } = resolveOutputDevices(args.outputs);
30587
+ const listedOutputs = creating && listing2 && !args.panelPresetId && listing2.applied.outputs.length > 0 ? listing2.applied.outputs : void 0;
30588
+ const { devices: outputDevices } = resolveOutputDevices(args.outputs ?? listedOutputs);
29146
30589
  let panelPresetId2 = args.panelPresetId;
29147
30590
  if (outputDevices.length > 0) {
29148
30591
  if (panelPresetId2 && !outputDevices.some((d) => d.panelPresetId === panelPresetId2)) {
@@ -29164,7 +30607,11 @@ async function handleSaveProject(deps, args) {
29164
30607
  screenshotNames,
29165
30608
  // Undefined when the caller passed no `outputs` — the record then migrates its single
29166
30609
  // output from panelPresetId exactly as it did before this field existed.
29167
- outputs: outputDevices.length > 0 ? outputDevices.map((d) => d.id) : void 0
30610
+ outputs: outputDevices.length > 0 ? outputDevices.map((d) => d.id) : void 0,
30611
+ // #1004 — the listing's language set, on a CREATE only. On an update these stay undefined and
30612
+ // `serializeProject` derives the list exactly as it always has, so re-linking an app leaves
30613
+ // `locales`, `baseLocale` and every per-locale caption on the record untouched.
30614
+ ...creating && listing2 ? { locales: listing2.applied.locales, baseLocale: locale2 } : {}
29168
30615
  });
29169
30616
  } catch (err) {
29170
30617
  return errorResult(`could not build the project record: ${err.message}`, "persistence_failed");
@@ -29172,8 +30619,14 @@ async function handleSaveProject(deps, args) {
29172
30619
  if (record8 == null) {
29173
30620
  return errorResult("no project record was produced \u2014 not saved.", "persistence_failed");
29174
30621
  }
29175
- const p = await persistProject(deps, args, record8, "unsigned-new");
29176
- if ("error" in p) return errorResult(p.error);
30622
+ const p = await persistProject(
30623
+ deps,
30624
+ { ...args, projectName: args.projectName?.trim() || listing2?.applied.projectName },
30625
+ record8,
30626
+ "unsigned-new",
30627
+ listing2
30628
+ );
30629
+ if ("error" in p) return errorResult(p.error, p.code, p.details);
29177
30630
  const note = ["screenNote" in p && p.screenNote ? p.screenNote : void 0, baseNote].filter(Boolean).join(" ") || void 0;
29178
30631
  if ("status" in p) {
29179
30632
  return textResult({
@@ -29186,6 +30639,7 @@ async function handleSaveProject(deps, args) {
29186
30639
  panelPresetId: panelPresetId2 ?? "r69",
29187
30640
  locale: locale2,
29188
30641
  ...outputDevices.length > 0 ? { outputs: outputDevices.map((d) => d.id) } : {},
30642
+ ...listing2 ? { linkedApp: appListingView(listing2) } : {},
29189
30643
  ...note ? { note } : {},
29190
30644
  // The one local path that moves bytes off this machine. Stated with the result, from the
29191
30645
  // retention policy's own number, rather than left for the user to discover when the claim
@@ -29209,6 +30663,9 @@ async function handleSaveProject(deps, args) {
29209
30663
  // #40: echoed ONLY when the caller asked for a device set — a legacy save's response body is
29210
30664
  // byte-identical to before.
29211
30665
  ...outputDevices.length > 0 ? { outputs: outputDevices.map((d) => d.id) } : {},
30666
+ // #1004 — the app this save linked, echoed so the caller sees the identity the project now
30667
+ // carries. Absent on every save that named no `appListing`.
30668
+ ...listing2 ? { linkedApp: appListingView(listing2) } : {},
29212
30669
  ...note ? { note } : {},
29213
30670
  // #662 — this is the ONE tool in the contract whose job is a project write, and the envelope
29214
30671
  // says so: `projectMutation: true` here, false on every render. That pair is what makes
@@ -29230,17 +30687,12 @@ function reachFromRecord(record8) {
29230
30687
  function storedScreenshotReport(deps, record8, projectFile) {
29231
30688
  const frames = frameNamesFromProjectFile(projectFile);
29232
30689
  const file = projectFile;
29233
- const baseLocale = typeof file.baseLocale === "string" && file.baseLocale ? file.baseLocale : Array.isArray(file.locales) && typeof file.locales[0] === "string" ? file.locales[0] : "en-US";
29234
- const locales = Array.from(
29235
- /* @__PURE__ */ new Set([
29236
- baseLocale,
29237
- ...Array.isArray(file.locales) ? file.locales.filter((locale2) => typeof locale2 === "string") : []
29238
- ])
29239
- );
30690
+ const { locales, baseLocale } = projectLocaleAxis(projectFile);
29240
30691
  const outputIds = Array.isArray(file.outputs) ? file.outputs.filter((id) => typeof id === "string") : [];
29241
- const presetFamily = typeof file.panelPresetId === "string" ? DEVICES.find((device) => device.panelPresetId === file.panelPresetId)?.screenFamily : void 0;
29242
30692
  const families = Array.from(
29243
- new Set(outputIds.length > 0 ? outputIds.map(screenFamilyForDevice) : [presetFamily ?? BASE_SCREEN_FAMILY])
30693
+ new Set(
30694
+ outputIds.length > 0 ? outputIds.map(screenFamilyForDevice) : [screenFamilyForPreset(projectOutputAxis(projectFile).panelPresetId)]
30695
+ )
29244
30696
  );
29245
30697
  const parsed = parseScreenManifest(record8?.handoffRefs, "lenient", baseLocale);
29246
30698
  const manifest = parsed ? allManifestEntries(parsed) : [];
@@ -29316,6 +30768,7 @@ async function handleReadProject(deps, args) {
29316
30768
  const projectFile = projectFileFromRecord(rec.record);
29317
30769
  const layout = deriveLayoutReport(projectFile);
29318
30770
  const screenshots2 = storedScreenshotReport(deps, rec.record, projectFile);
30771
+ const linkedApp = linkedAppFromRecord(rec.record);
29319
30772
  const historyDecision = await deps.authorizeCapability({
29320
30773
  subject: { kind: "account", userId: deps.userId, mode: "api_token" },
29321
30774
  capability: CAPABILITIES.LOOK_HISTORY_READ,
@@ -29338,9 +30791,13 @@ async function handleReadProject(deps, args) {
29338
30791
  ...historyDenial ? {} : { heldVersion: project2.heldVersion, versions },
29339
30792
  history: historyDenial ?? { available: true, capability: CAPABILITIES.LOOK_HISTORY_READ },
29340
30793
  screenshots: screenshots2,
30794
+ // #1004 — the App Store app this project is linked to, read off the stored envelope. Omitted
30795
+ // entirely for a project that carries none, so a project created without one reads exactly as
30796
+ // it did before.
30797
+ ...linkedApp ? { linkedApp } : {},
29341
30798
  project_file: projectFile,
29342
30799
  ...layout ? { layout } : {},
29343
- message: `Full project for "${project2.name}". project_file is the designer's current strip (frame order in \`panels\`, per-locale words in \`captionText\`, the language list in \`locales\`, the caption base the untranslated locales inherit in \`baseLocale\`, styling). To detect changes, remember updatedAt and compare on your next read_project \u2014 a newer value means the designer edited it. \`layout\` names the composition template the strip is on, derived from its values \u2014 nothing stores it, so it is never stale. ${storedScreenshotSentence(screenshots2, project2.id)}`
30800
+ message: `Full project for "${project2.name}". project_file is the designer's current strip (frame order in \`panels\`, per-locale words in \`captionText\`, the language list in \`locales\`, the caption base the untranslated locales inherit in \`baseLocale\`, styling). To detect changes, remember updatedAt and compare on your next read_project \u2014 a newer value means the designer edited it. \`layout\` names the composition template the strip is on, derived from its values \u2014 nothing stores it, so it is never stale. ` + (linkedApp ? `This project is linked to the App Store app "${linkedApp.name}" by ${linkedApp.seller} (${linkedApp.bundleId}) \u2014 \`linkedApp\` carries its identity, artwork and the languages and devices the listing mapped. Advisory: it says nothing about who owns the app. ` : "") + storedScreenshotSentence(screenshots2, project2.id)
29344
30801
  });
29345
30802
  }
29346
30803
 
@@ -29573,6 +31030,12 @@ var DESTRUCTIVE_WRITE = {
29573
31030
  idempotentHint: false,
29574
31031
  openWorldHint: false
29575
31032
  };
31033
+ var OPEN_WORLD_READ = {
31034
+ readOnlyHint: true,
31035
+ destructiveHint: false,
31036
+ idempotentHint: true,
31037
+ openWorldHint: true
31038
+ };
29576
31039
  var OPEN_WORLD_DESTRUCTIVE_WRITE = {
29577
31040
  readOnlyHint: false,
29578
31041
  destructiveHint: true,
@@ -29607,7 +31070,7 @@ function registerTools(server, deps) {
29607
31070
  "render_strip",
29608
31071
  {
29609
31072
  title: "Render App Store screenshots",
29610
- description: 'Make the store screenshots: raw app screen captures in, finished marketing panels out. Each screenshot is composited into a styled 3D device mockup, laid out as an App Store screenshot strip with the headlines you pass, and returned as per-panel PNGs at the App Store panel size `panelPresetId` names. This is the tool for a strip built from screenshots you have in hand; to reproduce one a designer already saved, use render_project. In ChatGPT, call import_screenshot first and pass the returned refs in the preferred flat form: { "screenshots": [{ "ref": "\u2026" }] } \u2014 each flat entry is one panel, and nested slots only when ONE panel should hold several phones. Art-direct it with `style`/`look` or a project\'s saved look, iterate with a low-res `preview` at a quarter the render cost, and take results `inline` or as `urls` for large payloads. Screenshots can vary per 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. CALL describe_look FIRST when nobody has said how the strip should look \u2014 it returns the five palette presets, the seven layout templates and every styling field with its default, and guessing a colour scheme out of 30 raw fields is how strips end up looking improvised. PICK `style.layout` ONCE, BEFORE ANYTHING ELSE \u2014 off the EDGE the user asked for if they named one, otherwise off the LONGEST HEADLINE in the set: "standard" reserves 2 lines (~36 characters) with the whole device under them, "bleed" reserves 4 (~72) with the device running off the bottom edge, "top-bleed" inverts that. The side bleeds carry the device into the NEIGHBOURING screenshot so the set reads as one swipe \u2014 "lean-right" / "lean-left" put the headline in a narrow column beside it (~26 characters), "swipe" pushes a third of the device across and keeps the full-width headline, "corner-bleed" goes off the bottom and the right at once. A side bleed is for the WHOLE set: apply one to every panel or none, and never mirror it on neighbouring panels \u2014 two devices leaning toward each other overlap. The text region is RESERVED, NOT FITTED \u2014 held at full size whether or not the headline fills it, because a fixed reservation is the only thing that makes every device in a swiped SET land on the same line. Do not close a gap by shrinking vOffset on the short panels, and never assemble a composition out of raw phoneHeight/vOffset numbers: pick a layout, then override one field if you must (the template expands first, your field wins). Past the reservation the caption band grows down INTO the phone \u2014 the failure to avoid. THE RESPONSE ANSWERS BACK: an optional `layout` block names every caption sitting on a device, every caption collision and every device pair overlapping without reading as composed, by frame number and depth. When the repair is deterministic it also carries `layout.correctedLook` \u2014 pass that straight back as `look` to re-render clean. No `layout` key means the composition measured clean. Read it before you show the panels. This tool ONLY renders images: it never creates or updates a project, and the result says so \u2014 `contract.effects.projectMutation` is false on every render. A render with no `project` also carries a `nextStep` with action `offer_save_project`, which states that no project exists and names save_project as the door. That is an OFFER to put to the user, never consent already given: save only after they show they want this work preserved, shared, reused or kept editable, and if you cannot ask them, return the panels with the offer standing. No store credential is involved.',
31073
+ description: 'Make the store screenshots: raw app screen captures in, finished marketing panels out. Each screenshot is composited into a styled 3D device mockup, laid out as an App Store screenshot strip with the headlines you pass, and returned as per-panel PNGs at the App Store panel size `panelPresetId` names. This is the tool for a strip built from screenshots you have in hand; to reproduce one a designer already saved, use render_project. In ChatGPT, call import_screenshot first and pass the returned refs in the preferred flat form: { "screenshots": [{ "ref": "\u2026" }] } \u2014 each flat entry is one panel, and nested slots only when ONE panel should hold several phones. Art-direct it with `style`/`look` or a project\'s saved look, iterate with a low-res `preview` at a quarter the render cost, and take results `inline` or as `urls` for large payloads. Screenshots can vary per 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. CALL describe_look FIRST when nobody has said how the strip should look \u2014 it returns the five palette presets, the seven layout templates and every styling field with its default, and guessing a colour scheme out of 30 raw fields is how strips end up looking improvised. PICK `style.layout` ONCE, BEFORE ANYTHING ELSE \u2014 off the EDGE the user asked for if they named one, otherwise off the LONGEST HEADLINE in the set: "standard" reserves 2 lines (~36 characters) with the whole device under them, "bleed" reserves 4 (~72) with the device running off the bottom edge, "top-bleed" inverts that. The side bleeds carry the device into the NEIGHBOURING screenshot so the set reads as one swipe \u2014 "lean-right" / "lean-left" put the headline in a narrow column beside it (~26 characters), "swipe" pushes a third of the device across and keeps the full-width headline, "corner-bleed" goes off the bottom and the right at once. A side bleed is for the WHOLE set: apply one to every panel or none, and never mirror it on neighbouring panels \u2014 two devices leaning toward each other overlap. The text region is RESERVED, NOT FITTED \u2014 held at full size whether or not the headline fills it, because a fixed reservation is the only thing that makes every device in a swiped SET land on the same line. Do not close a gap by shrinking vOffset on the short panels, and never assemble a composition out of raw deviceSize/vOffset numbers: pick a layout, then override one field if you must (the template expands first, your field wins). Past the reservation the caption band grows down INTO the phone \u2014 the failure to avoid. THE RESPONSE ANSWERS BACK: an optional `layout` block names every caption sitting on a device, every caption collision and every device pair overlapping without reading as composed, by frame number and depth. When the repair is deterministic it also carries `layout.correctedLook` \u2014 pass that straight back as `look` to re-render clean. No `layout` key means the composition measured clean. Read it before you show the panels. This tool ONLY renders images: it never creates or updates a project, and the result says so \u2014 `contract.effects.projectMutation` is false on every render. A render with no `project` also carries a `nextStep` with action `offer_save_project`, which states that no project exists and names save_project as the door. That is an OFFER to put to the user, never consent already given: save only after they show they want this work preserved, shared, reused or kept editable, and if you cannot ask them, return the panels with the offer standing. No store credential is involved.',
29611
31074
  inputSchema: renderStripAdvertisedShape,
29612
31075
  outputSchema: renderStripToolOutputSchema,
29613
31076
  // ADDITIVE, not destructive: this can mint private generated assets but cannot change a
@@ -29681,6 +31144,18 @@ function registerTools(server, deps) {
29681
31144
  },
29682
31145
  seam("read_project", readProjectShape, (args) => handleReadProject(deps, args))
29683
31146
  );
31147
+ server.registerTool(
31148
+ "search_app_listing",
31149
+ {
31150
+ title: "Find an app on the App Store",
31151
+ description: "Find the user\u2019s app on the App Store so the project you save carries its real identity instead of a name you typed: search by app or developer name and every result comes back with the app\u2019s exact name, its seller, its icon, its bundle ID and its numeric app ID, plus the App Store Connect languages and the iPhone device set ShotOps would apply. Let the USER confirm which one is theirs \u2014 the icon and the seller name are what prove it \u2014 then pass that result\u2019s `appId` (or `storeUrl`) as `appListing` on save_project. Pass `country` to search another storefront; an app released in one country is invisible to a search of any other. Free and unmetered: it renders nothing, saves nothing and changes no project. Finding a public listing proves nothing about who owns the app, and no Apple or store credential is involved. Needs an account.",
31152
+ inputSchema: searchAppListingShape,
31153
+ outputSchema: searchAppListingToolOutputSchema,
31154
+ annotations: OPEN_WORLD_READ,
31155
+ _meta: toolMeta(deps, "search_app_listing", "Searching the App Store\u2026", "App Store searched")
31156
+ },
31157
+ seam("search_app_listing", searchAppListingShape, (args) => handleSearchAppListing(deps, args))
31158
+ );
29684
31159
  server.registerTool(
29685
31160
  "render_project",
29686
31161
  {