shotops-mcp 0.9.7 → 0.9.8

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) !== "7345df8a318a3c0989195e1b9c4ce91f2eaf136e27afd8ee123620da60c23410") {
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 = [
@@ -120,7 +156,10 @@ var init_caption = __esm({
120
156
  });
121
157
 
122
158
  // ../mockup-engine/appstore/catalog/defaults.ts
123
- var DEFAULT_LOOK, SHOT_LOOK_DOMAIN, SHOT_LOOK_RANGES, DEFAULT_CAPTION_ANCHOR, DEFAULT_CAPTION_STYLE;
159
+ function deviceSizeFromLegacyPhoneHeight(phoneHeight) {
160
+ return Math.round(phoneHeight / DEVICE_SIZE_BASE_FRACTION * 100) / 100;
161
+ }
162
+ var DEFAULT_LOOK, SHOT_LOOK_DOMAIN, SHOT_LOOK_RANGES, DEVICE_SIZE_BASE_FRACTION, DEFAULT_CAPTION_ANCHOR, DEFAULT_CAPTION_STYLE;
124
163
  var init_defaults = __esm({
125
164
  "../mockup-engine/appstore/catalog/defaults.ts"() {
126
165
  "use strict";
@@ -138,19 +177,24 @@ var init_defaults = __esm({
138
177
  // pushPhonesBelowCaptionBands() solved per-render before e0f587c ("Stop captions moving
139
178
  // devices") deleted it: captions must never move a device, so the clearance has to be baked
140
179
  // into the default instead.
141
- // deviceTop(v) = (1 - phoneHeight)/2 + (v/100)/2 [layoutStrip, +y = down]
180
+ // deviceTop(v) = (1 - deviceHeightFraction(deviceSize))/2 + (v/100)/2 [layoutStrip, +y = down]
142
181
  // 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
182
+ // At deviceSize 100 (82% of panel height) and a 32pt caption, deviceTop = 18.0% and the device sits FLUSH to the bottom
144
183
  // edge (18.0 + 82 = 100.0). A full 2-line headline's ink ends at 13.6%, so the gap is 4.4%;
145
184
  // a 3-line headline still clears by 0.3%. Four lines overlap — that is what Bleed is for, and
146
185
  // 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",
186
+ // catalog/layouts.ts if sizePt or deviceSize changes; its tests are the guard.
187
+ deviceSize: "100",
149
188
  hOffset: "0",
150
189
  vOffset: "18",
151
190
  material: "real",
152
- colorway: "silver",
153
- customColor: "F5F5F5",
191
+ // Deep Blue on the iPhone, Moonstone on the Pixel (which binds no `blue`, so it falls back to its
192
+ // first finish — deviceFinishes.ts). A product call, not a derived one: the blue body is the one
193
+ // that reads as a device against the widest range of strip backgrounds, where Silver disappeared
194
+ // into a light one. `customColor` starts at the same hex so opening the custom well doesn't jump
195
+ // the phone to a different colour before anyone has picked anything.
196
+ colorway: "blue",
197
+ customColor: "32374A",
154
198
  finish: "0",
155
199
  clearcoat: "0",
156
200
  clayTone: "grey",
@@ -164,7 +208,7 @@ var init_defaults = __esm({
164
208
  angle: { valueKind: "angle", required: true, defaultSource: "default-look", defaultValue: DEFAULT_LOOK.angle, persistence: "always" },
165
209
  cameraPos: { valueKind: "camera-position", required: true, defaultSource: "default-look", defaultValue: DEFAULT_LOOK.cameraPos, persistence: "always" },
166
210
  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" },
211
+ deviceSize: { valueKind: "numeric-string", required: true, defaultSource: "default-look", defaultValue: DEFAULT_LOOK.deviceSize, persistence: "always" },
168
212
  hOffset: { valueKind: "numeric-string", required: true, defaultSource: "default-look", defaultValue: DEFAULT_LOOK.hOffset, persistence: "always" },
169
213
  vOffset: { valueKind: "numeric-string", required: true, defaultSource: "default-look", defaultValue: DEFAULT_LOOK.vOffset, persistence: "always" },
170
214
  material: { valueKind: "material", required: true, defaultSource: "default-look", defaultValue: DEFAULT_LOOK.material, persistence: "always" },
@@ -176,21 +220,27 @@ var init_defaults = __esm({
176
220
  clayCustom: { valueKind: "string", required: true, defaultSource: "default-look", defaultValue: DEFAULT_LOOK.clayCustom, persistence: "always" },
177
221
  flatScreen: { valueKind: "boolean", required: true, defaultSource: "default-look", defaultValue: DEFAULT_LOOK.flatScreen, persistence: "always" },
178
222
  clipToFrame: { valueKind: "boolean", required: false, defaultSource: "explicit", defaultValue: false, persistence: "omit-default" },
223
+ deviceFrame: { valueKind: "boolean", required: false, defaultSource: "explicit", defaultValue: true, persistence: "omit-default" },
224
+ screenCornerRadius: { valueKind: "numeric-string", required: false, defaultSource: "explicit", defaultValue: "12", persistence: "omit-default" },
179
225
  glare: { valueKind: "boolean", required: true, defaultSource: "default-look", defaultValue: DEFAULT_LOOK.glare, persistence: "always" },
180
226
  lighting: { valueKind: "boolean", required: true, defaultSource: "default-look", defaultValue: DEFAULT_LOOK.lighting, persistence: "always" },
181
227
  reflections: { valueKind: "boolean", required: true, defaultSource: "default-look", defaultValue: DEFAULT_LOOK.reflections, persistence: "always" }
182
228
  };
183
229
  SHOT_LOOK_RANGES = {
184
230
  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 },
231
+ // Percent of the default device size (see DEVICE_SIZE_BASE_FRACTION): 25–240 is 20–200% of the
232
+ // panel's height. Past ~122 the device is TALLER than the panel and bleeds off an edge — a
233
+ // legitimate, shipped composition, and exactly what the Bleed layout template trades headline
234
+ // room for.
235
+ deviceSize: { min: 25, max: 240 },
189
236
  hOffset: { min: -100, max: 100 },
190
237
  vOffset: { min: -200, max: 200 },
191
238
  finish: { min: 0, max: 1 },
192
- clearcoat: { min: 0, max: 1 }
239
+ clearcoat: { min: 0, max: 1 },
240
+ // Percent of the flat card's WIDTH (#907); 50 turns the short edges into semicircles.
241
+ screenCornerRadius: { min: 0, max: 50 }
193
242
  };
243
+ DEVICE_SIZE_BASE_FRACTION = 0.82;
194
244
  DEFAULT_CAPTION_ANCHOR = { x: 0.5, y: 0.06 };
195
245
  DEFAULT_CAPTION_STYLE = {
196
246
  fontId: "inter",
@@ -362,6 +412,9 @@ function variantKey(variant) {
362
412
  if (variant.locale !== void 0) parts.push(`locale=${variant.locale}`);
363
413
  return parts.join("&");
364
414
  }
415
+ function isBaseVariant(variant) {
416
+ return variantKey(variant) === "";
417
+ }
365
418
  function variantAxes(variant) {
366
419
  const out = [];
367
420
  if (variant.family !== void 0) out.push("family");
@@ -540,9 +593,9 @@ function sha256Hex(text3) {
540
593
  const words = [h0, h1, h2, h3, h4, h5, h6, h7];
541
594
  let out = "";
542
595
  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;
596
+ let hex2 = (words[i] >>> 0).toString(16);
597
+ while (hex2.length < 8) hex2 = `0${hex2}`;
598
+ out += hex2;
546
599
  }
547
600
  return out;
548
601
  }
@@ -1945,7 +1998,7 @@ var init_refineIntent = __esm({
1945
1998
  init_defaults();
1946
1999
  init_fonts();
1947
2000
  MAX_REFINE_PROMPT_LENGTH = 1200;
1948
- AI_COLORWAY_IDS = ["orange", "blue", "silver", "green"];
2001
+ AI_COLORWAY_IDS = ["orange", "blue", "ice", "silver", "red", "black", "green"];
1949
2002
  AI_CLAY_TONES = ["grey", "white", "charcoal"];
1950
2003
  AI_SHOT_RANGES = {
1951
2004
  orbit: { min: -180, max: 180 },
@@ -1982,7 +2035,7 @@ var init_shotLookProjection = __esm({
1982
2035
  valueKind: "camera-position",
1983
2036
  validation: { wireKind: "nullable-camera-position" },
1984
2037
  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)."
2038
+ 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
2039
  },
1987
2040
  roll: {
1988
2041
  exposure: "public",
@@ -1991,12 +2044,12 @@ var init_shotLookProjection = __esm({
1991
2044
  schemaDescription: `${rangeText("roll")}\xB0 clock-hand tilt. Default ${defaultValue("roll")}.`,
1992
2045
  catalogDescription: `${catalogRange("roll")} (degrees, clock-hand tilt; continuous, unlike angle)`
1993
2046
  },
1994
- phoneHeight: {
2047
+ deviceSize: {
1995
2048
  exposure: "public",
1996
2049
  valueKind: "numeric-string",
1997
2050
  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).`
2051
+ 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).`,
2052
+ 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
2053
  },
2001
2054
  hOffset: {
2002
2055
  exposure: "public",
@@ -2022,9 +2075,9 @@ var init_shotLookProjection = __esm({
2022
2075
  colorway: {
2023
2076
  exposure: "public",
2024
2077
  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."
2078
+ validation: { wireKind: "enum", values: ["orange", "blue", "ice", "silver", "red", "black", "green", "custom"] },
2079
+ 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).`,
2080
+ 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
2081
  },
2029
2082
  customColor: {
2030
2083
  exposure: "public",
@@ -2095,6 +2148,20 @@ var init_shotLookProjection = __esm({
2095
2148
  validation: { wireKind: "boolean" },
2096
2149
  schemaDescription: `Clip this complete device composite to its owning frame. Default ${defaultValue("clipToFrame")} (overflow allowed).`,
2097
2150
  catalogDescription: "boolean \u2014 clip this device, its screen, shadow and reflection to its owning frame; default false"
2151
+ },
2152
+ deviceFrame: {
2153
+ exposure: "public",
2154
+ valueKind: "boolean",
2155
+ validation: { wireKind: "boolean" },
2156
+ 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.`,
2157
+ 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.`
2158
+ },
2159
+ screenCornerRadius: {
2160
+ exposure: "public",
2161
+ valueKind: "numeric-string",
2162
+ validation: { wireKind: "bounded-numeric" },
2163
+ 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.`,
2164
+ catalogDescription: `${catalogRange("screenCornerRadius")} (percent of the flat card's width) \u2014 applies only when deviceFrame is false; ignored on a framed device.`
2098
2165
  }
2099
2166
  };
2100
2167
  MCP_SHOT_LOOK_PUBLIC_ENTRIES = Object.entries(MCP_SHOT_LOOK_PROJECTION).filter((entry) => entry[1].exposure === "public");
@@ -2104,24 +2171,12 @@ var init_shotLookProjection = __esm({
2104
2171
  }
2105
2172
  });
2106
2173
 
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"() {
2174
+ // ../mockup-engine/outputDeviceIds.ts
2175
+ var OUTPUT_DEVICE_IDS;
2176
+ var init_outputDeviceIds = __esm({
2177
+ "../mockup-engine/outputDeviceIds.ts"() {
2114
2178
  "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
- ];
2179
+ OUTPUT_DEVICE_IDS = ["iphone-6-9", "iphone-6-5"];
2125
2180
  }
2126
2181
  });
2127
2182
 
@@ -2157,20 +2212,33 @@ function panelLabelFor(presetId, width, height) {
2157
2212
  function panelDimensionsFor(presetId) {
2158
2213
  return PANEL_DIMENSIONS[presetId] ?? PANEL_DIMENSIONS.r69;
2159
2214
  }
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;
2215
+ 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
2216
  var init_panelCatalog = __esm({
2162
2217
  "src/render/panelCatalog.ts"() {
2163
2218
  "use strict";
2164
2219
  init_presets();
2165
- MCP_UNKNOWN_PANEL_PRESET_IDS = ["pixel69"];
2220
+ init_devices();
2221
+ init_outputDeviceIds();
2222
+ init_outputDeviceIds();
2223
+ MCP_UNRENDERED_DEVICE_IDS = ["pixel-10-pro"];
2224
+ MCP_UNKNOWN_PANEL_PRESET_IDS = MCP_UNRENDERED_DEVICE_IDS.map(
2225
+ (id) => deviceById(id)?.panelPresetId ?? id
2226
+ );
2166
2227
  MCP_PANEL_PRESETS = PANEL_PRESETS.filter((preset) => !MCP_UNKNOWN_PANEL_PRESET_IDS.includes(preset.id));
2167
2228
  PANEL_PRESET_IDS = MCP_PANEL_PRESETS.map((preset) => preset.id);
2168
2229
  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
- ];
2230
+ OUTPUT_DEVICES = OUTPUT_DEVICE_IDS.map((id) => {
2231
+ const device = deviceById(id);
2232
+ if (!device || device.comingSoon) {
2233
+ throw new Error(`output device "${id}" is not a selectable device in the engine catalog`);
2234
+ }
2235
+ return {
2236
+ id,
2237
+ label: device.label,
2238
+ panelPresetId: device.panelPresetId,
2239
+ ascDeviceSet: device.ascDeviceSet
2240
+ };
2241
+ });
2174
2242
  NOT_COVERED_BY_THIS_CALL = "not covered by this bundle";
2175
2243
  SIZE_SUFFIX = / · \d+×\d+$/;
2176
2244
  PANEL_LABELS = Object.fromEntries(
@@ -2219,7 +2287,7 @@ var init_layouts = __esm({
2219
2287
  blurb: "Headline on top, whole device below it, nothing cropped. The safe default.",
2220
2288
  character: "safe",
2221
2289
  bleedEdge: "none",
2222
- shotLook: { phoneHeight: "82", hOffset: "0", vOffset: "18", angle: "front", cameraPos: null, roll: "0" },
2290
+ shotLook: { deviceSize: "100", hOffset: "0", vOffset: "18", angle: "front", cameraPos: null, roll: "0" },
2223
2291
  caption: { sizePt: 32, maxWidth: 0.86, reserveLines: 2, align: "center", bandCenter: 0.5 }
2224
2292
  },
2225
2293
  {
@@ -2228,7 +2296,7 @@ var init_layouts = __esm({
2228
2296
  blurb: "Room for a longer headline, device running off the bottom edge. What the market looks like.",
2229
2297
  character: "conventional",
2230
2298
  bleedEdge: "bottom",
2231
- shotLook: { phoneHeight: "82", hOffset: "0", vOffset: "38", angle: "front", cameraPos: null, roll: "0" },
2299
+ shotLook: { deviceSize: "100", hOffset: "0", vOffset: "38", angle: "front", cameraPos: null, roll: "0" },
2232
2300
  caption: { sizePt: 32, maxWidth: 0.86, reserveLines: 4, align: "center", bandCenter: 0.5 }
2233
2301
  },
2234
2302
  {
@@ -2237,7 +2305,7 @@ var init_layouts = __esm({
2237
2305
  blurb: "Device running off the TOP edge, headline underneath it. Inverts the usual reading order.",
2238
2306
  character: "expressive",
2239
2307
  bleedEdge: "top",
2240
- shotLook: { phoneHeight: "82", hOffset: "0", vOffset: "-38", angle: "front", cameraPos: null, roll: "0" },
2308
+ shotLook: { deviceSize: "100", hOffset: "0", vOffset: "-38", angle: "front", cameraPos: null, roll: "0" },
2241
2309
  // The only entry that moves the band. Its caption sits BELOW the device, which the engine
2242
2310
  // could not express at all until #218 gave placeCaptions a bottom-anchored band — before that
2243
2311
  // the only way down was pinning an anchor, which silently costs reflow, stacking and the
@@ -2256,7 +2324,7 @@ var init_layouts = __esm({
2256
2324
  blurb: "Device pushed off the RIGHT edge, headline in a column down the left. Reads as one continuous swipe.",
2257
2325
  character: "expressive",
2258
2326
  bleedEdge: "right",
2259
- shotLook: { phoneHeight: "82", hOffset: "46", vOffset: "18", angle: "front", cameraPos: null, roll: "0" },
2327
+ shotLook: { deviceSize: "100", hOffset: "46", vOffset: "18", angle: "front", cameraPos: null, roll: "0" },
2260
2328
  caption: { sizePt: 32, maxWidth: 0.61, reserveLines: 2, align: "left", bandCenter: 0.375 }
2261
2329
  },
2262
2330
  {
@@ -2265,7 +2333,7 @@ var init_layouts = __esm({
2265
2333
  blurb: "Device pushed off the LEFT edge, headline in a column down the right. The mirror of Lean right.",
2266
2334
  character: "expressive",
2267
2335
  bleedEdge: "left",
2268
- shotLook: { phoneHeight: "82", hOffset: "-46", vOffset: "18", angle: "front", cameraPos: null, roll: "0" },
2336
+ shotLook: { deviceSize: "100", hOffset: "-46", vOffset: "18", angle: "front", cameraPos: null, roll: "0" },
2269
2337
  caption: { sizePt: 32, maxWidth: 0.61, reserveLines: 2, align: "right", bandCenter: 0.625 }
2270
2338
  },
2271
2339
  {
@@ -2279,7 +2347,7 @@ var init_layouts = __esm({
2279
2347
  // too — which makes this template claim `bottom-right` and collide with Corner bleed, the only
2280
2348
  // other entry on that edge. The horizontal push is the whole idea here; the vertical stays out
2281
2349
  // 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" },
2350
+ shotLook: { deviceSize: "100", hOffset: "78", vOffset: "18", angle: "front", cameraPos: null, roll: "0" },
2283
2351
  caption: { sizePt: 32, maxWidth: 0.86, reserveLines: 2, align: "center", bandCenter: 0.5 }
2284
2352
  },
2285
2353
  {
@@ -2288,7 +2356,7 @@ var init_layouts = __esm({
2288
2356
  blurb: "Off the bottom AND the right at once, headline in a column down the left. The most opinionated panel here.",
2289
2357
  character: "expressive",
2290
2358
  bleedEdge: "bottom-right",
2291
- shotLook: { phoneHeight: "82", hOffset: "46", vOffset: "38", angle: "front", cameraPos: null, roll: "0" },
2359
+ shotLook: { deviceSize: "100", hOffset: "46", vOffset: "38", angle: "front", cameraPos: null, roll: "0" },
2292
2360
  caption: { sizePt: 32, maxWidth: 0.61, reserveLines: 4, align: "left", bandCenter: 0.375 }
2293
2361
  }
2294
2362
  ];
@@ -2302,7 +2370,7 @@ var init_layouts = __esm({
2302
2370
  align: "center",
2303
2371
  bandCenter: 0.5
2304
2372
  };
2305
- LAYOUT_POSE_FIELDS = ["phoneHeight", "hOffset", "vOffset"];
2373
+ LAYOUT_POSE_FIELDS = ["deviceSize", "hOffset", "vOffset"];
2306
2374
  LAYOUT_CAMERA_FIELDS = ["angle", "cameraPos", "roll"];
2307
2375
  LAYOUT_CAPTION_FIELDS = Array.from(
2308
2376
  new Set(LAYOUT_TEMPLATE_CATALOG.flatMap((t) => Object.keys(t.caption)))
@@ -2314,7 +2382,8 @@ var init_layouts = __esm({
2314
2382
  function layoutTemplateAgentSummary(template) {
2315
2383
  const reserveLines = template.caption.reserveLines;
2316
2384
  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}].`;
2385
+ 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.";
2386
+ return `${template.blurb}${capacity}${headline} Character: [${template.character}]. Bleeds: [${template.bleedEdge}].`;
2318
2387
  }
2319
2388
  function layoutTemplateById(id) {
2320
2389
  return LAYOUT_TEMPLATES.find((t) => t.id === id);
@@ -2406,12 +2475,20 @@ var init_layoutTemplates = __esm({
2406
2475
  'A request that names a composition outright ("device running off the bottom edge", "headline under the phone") beats the character.',
2407
2476
  "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
2477
  "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."
2478
+ "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.",
2479
+ "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
2480
  ];
2411
2481
  valuesEqual = (a, b) => JSON.stringify(a) === JSON.stringify(b);
2412
2482
  }
2413
2483
  });
2414
2484
 
2485
+ // ../mockup-engine/hexColor.ts
2486
+ var init_hexColor = __esm({
2487
+ "../mockup-engine/hexColor.ts"() {
2488
+ "use strict";
2489
+ }
2490
+ });
2491
+
2415
2492
  // ../mockup-engine/gradient.ts
2416
2493
  function angleFromDirection(dir) {
2417
2494
  return dir === "horizontal" ? HORIZONTAL_ANGLE : VERTICAL_ANGLE;
@@ -2470,6 +2547,7 @@ var HORIZONTAL_ANGLE, VERTICAL_ANGLE, FALLBACK_GRADIENT_FROM, FALLBACK_GRADIENT_
2470
2547
  var init_gradient = __esm({
2471
2548
  "../mockup-engine/gradient.ts"() {
2472
2549
  "use strict";
2550
+ init_hexColor();
2473
2551
  HORIZONTAL_ANGLE = 90;
2474
2552
  VERTICAL_ANGLE = 180;
2475
2553
  FALLBACK_GRADIENT_FROM = "#1b1b2e";
@@ -2766,7 +2844,7 @@ var init_toolContract = __esm({
2766
2844
  "device, if the project targets several), so the Big-jobs timing warning applies unchanged.",
2767
2845
  "",
2768
2846
  "## 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).",
2847
+ "A project targets one or more DEVICES: iphone-6-9 (1320\xD72868) and/or iphone-6-5 (1242\xD72688).",
2770
2848
  "You almost never need to name them. render_project renders EVERY device the project targets \u2014",
2771
2849
  "the top-level panels are the previewed device, and an `outputs` array carries each device's own",
2772
2850
  "panels when there is more than one. emit_bundle does the same for the zip: with a `project` and",
@@ -2815,7 +2893,7 @@ var init_toolContract = __esm({
2815
2893
  "COMPOSE \u2014 every real App Store strip needs BOTH varied devices AND headlines, and that is ONE",
2816
2894
  "call: pass `look` (devices + background) and `style` (style.captions gives the words) together.",
2817
2895
  "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.",
2896
+ "panelPresetId picks the App Store size: r69 (6.9\u2033, default, 1320\xD72868) | r65 | r55 \u2014 iPhone only.",
2819
2897
  "Match it to your source screenshots (1320\xD72868 or 1290\xD72796 \u2192 r69).",
2820
2898
  "Continuous scene: panels have no hard divider, so a phone pushed past its edge with a large",
2821
2899
  "hOffset overflows into the NEIGHBOURING panel and the strip reads as one swipe-through image.",
@@ -2901,6 +2979,21 @@ var init_toolContract = __esm({
2901
2979
  "Skip the names and the record falls back to positional filenames (screen-1.png, screen-2.png\u2026)",
2902
2980
  "the user's folder can't match \u2014 the single worst create-project failure mode. Pass the real filenames.",
2903
2981
  "",
2982
+ "## Link the project to the real app (search_app_listing)",
2983
+ "When the user says which app these screenshots are for, find it instead of typing its name:",
2984
+ 'search_app_listing({ term: "Duolingo" }) returns matching App Store apps with their exact name,',
2985
+ "seller, icon, bundle ID and numeric app ID, plus the App Store Connect languages and iPhone",
2986
+ "devices ShotOps would apply. Show the candidates and let the USER say which one is theirs \u2014 the",
2987
+ "icon and the seller are what prove it \u2014 then pass that result's `appId` (or its `storeUrl`, which",
2988
+ "also carries the storefront) as save_project({ appListing }). The created project then takes the",
2989
+ "app's name, bundle ID, language list and device set; anything you state yourself \u2014 `projectName`,",
2990
+ "`outputs`, `locale` \u2014 wins, and the app is still linked. Passing `appListing` with an existing",
2991
+ "`project` re-links that project and rewrites nothing else. read_project reports the linked app as",
2992
+ "`linkedApp`. Both are free reads over Apple's public listing data: no Apple or store credential is",
2993
+ "involved, and a public listing proves nothing about who owns the app \u2014 the bundle ID stays",
2994
+ "editable. Both need an account; an unsigned local server says so and the save still works without",
2995
+ "the reference.",
2996
+ "",
2904
2997
  '## "Any updates?" \u2014 read back what a designer changed (read_project)',
2905
2998
  "read_look returns only STYLING. To see what a designer edited in the web app \u2014 frame ORDER, the",
2906
2999
  "per-locale caption WORDS, the locale list, structural changes \u2014 call read_project. It returns the",
@@ -3027,7 +3120,7 @@ var init_designIntake = __esm({
3027
3120
  { 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
3121
  // #223 — the bound is read from the engine's range table, not re-typed: this blurb said
3029
3122
  // 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.` }
3123
+ { 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
3124
  ],
3032
3125
  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
3126
  }
@@ -3137,11 +3230,12 @@ var init_lookSchemas = __esm({
3137
3230
  rangeText2 = (field) => `${SHOT_LOOK_RANGES[field].min}\u2013${SHOT_LOOK_RANGES[field].max}`;
3138
3231
  RANGE_UNITS = {
3139
3232
  roll: "degrees of clock-hand tilt",
3140
- phoneHeight: "percent of panel height",
3233
+ deviceSize: "percent of the default device size, 100 = default",
3141
3234
  hOffset: "horizontal position, 0 = centre",
3142
3235
  vOffset: "vertical position, 0 = centre, positive = lower",
3143
3236
  finish: "0 = matte \u2026 1 = glossy",
3144
- clearcoat: "0 = none \u2026 1 = full clear-coat gloss"
3237
+ clearcoat: "0 = none \u2026 1 = full clear-coat gloss",
3238
+ screenCornerRadius: "percent of the flat card width"
3145
3239
  };
3146
3240
  PLACEMENT_FIELDS = LAYOUT_POSE_FIELDS;
3147
3241
  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 +3358,7 @@ var init_lookSchemas = __esm({
3264
3358
  // write door — saveLookForProject in api/_lib/projectLookStore.ts — so this field is purely
3265
3359
  // informational on the wire.
3266
3360
  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."),
3361
+ panelPresetId: z3.string().optional().describe("Canvas size preset, e.g. r69 = 1320\xD72868. Default r69."),
3268
3362
  bgMode: z3.enum(["gradient", "perPanel"]).optional().describe("One gradient across the strip, or a flat colour per panel. Default gradient."),
3269
3363
  // #460 decision 6 — UNLIKE render_strip's `style.background` above, the PERSISTED look keeps
3270
3364
  // both forms: `gradientStops`/`gradientAngle` are canonical (win when present), and
@@ -3543,8 +3637,8 @@ var init_describeLookCatalog = __esm({
3543
3637
  gradientFrom: "#3E3D42",
3544
3638
  gradientTo: "#1E1D22",
3545
3639
  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" } }
3640
+ { panelId: "panel-1", look: { material: "real", colorway: "blue", angle: "left", roll: "-19", deviceSize: "90" } },
3641
+ { panelId: "panel-1", look: { material: "real", colorway: "orange", angle: "right", roll: "8", deviceSize: "105" } }
3548
3642
  ]
3549
3643
  }
3550
3644
  },
@@ -3570,7 +3664,7 @@ var init_describeLookCatalog = __esm({
3570
3664
  "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
3665
  "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
3666
  "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.",
3667
+ "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
3668
  "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
3669
  ]
3576
3670
  };
@@ -3579,7 +3673,7 @@ var init_describeLookCatalog = __esm({
3579
3673
 
3580
3674
  // src/contracts/outputSchemas.ts
3581
3675
  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;
3676
+ 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
3677
  var init_outputSchemas = __esm({
3584
3678
  "src/contracts/outputSchemas.ts"() {
3585
3679
  "use strict";
@@ -3758,6 +3852,20 @@ var init_outputSchemas = __esm({
3758
3852
  zipRef: z4.string().optional(),
3759
3853
  ok: z4.boolean()
3760
3854
  }).passthrough();
3855
+ appListingOutput = z4.object({
3856
+ appId: z4.string().describe("Apple\u2019s numeric app ID, as a string. Pass it as save_project\u2019s `appListing`."),
3857
+ bundleId: z4.string(),
3858
+ name: z4.string(),
3859
+ seller: z4.string(),
3860
+ 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."),
3861
+ iconUrl: z4.string().optional().describe("The app icon on Apple\u2019s CDN. Shown as project chrome; never downloaded and never rendered into a strip."),
3862
+ locales: z4.array(z4.string()).describe("The App Store Connect locales this listing\u2019s languages map to, in catalog order."),
3863
+ baseLocale: z4.string().describe("The caption base the other locales inherit from. Derived \u2014 Apple never says which language is primary."),
3864
+ outputs: z4.array(z4.string()).describe("The ShotOps output devices this listing fills. iPhone only, and EMPTY for an iPad-only app."),
3865
+ supportsIpad: z4.boolean().describe("The listing supports iPad. Recorded, and applied to no output until iPad panels ship."),
3866
+ storefront: z4.string().describe("The two-letter storefront this was read from. It decides `baseLocale`."),
3867
+ droppedLanguageCodes: z4.array(z4.string()).optional().describe("Apple language codes this build maps no App Store Connect locale for. Omitted when nothing was dropped.")
3868
+ }).passthrough();
3761
3869
  saveProjectOutputSchema = z4.object({
3762
3870
  ok: z4.boolean(),
3763
3871
  // Direct authenticated saves return projectId. Unsigned local saves instead return an
@@ -3776,6 +3884,10 @@ var init_outputSchemas = __esm({
3776
3884
  // response stays byte-identical, and a declared-but-absent field never fails the SDK's
3777
3885
  // structuredContent validation (the #196 lesson — an output field is never made required).
3778
3886
  outputs: z4.array(z4.string()).optional(),
3887
+ // #1004 — the App Store app this save linked the project to, echoed so the caller can see the
3888
+ // identity it now carries without a second read. Emitted ONLY when the call passed an
3889
+ // `appListing` reference; a save that did not link one answers exactly as it did before.
3890
+ linkedApp: appListingOutput.optional(),
3779
3891
  // #224 — save_project used to DROP this entirely: it destructured `{ look, style }` out of
3780
3892
  // resolveLook and never forwarded the note, on the one tool where a wrong composition is
3781
3893
  // PERSISTED rather than merely rendered.
@@ -3835,13 +3947,21 @@ var init_outputSchemas = __esm({
3835
3947
  "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
3948
  )
3837
3949
  }).passthrough().optional();
3950
+ searchAppListingOutputSchema = z4.object({
3951
+ ok: z4.boolean(),
3952
+ results: z4.array(appListingOutput),
3953
+ message: z4.string()
3954
+ }).passthrough();
3838
3955
  readProjectOutputSchema = z4.object({
3839
3956
  saved: z4.boolean(),
3840
3957
  project: projectSummary.optional(),
3841
3958
  message: z4.string().optional(),
3842
3959
  layout: layoutReportOutput,
3843
3960
  screenshots: storedScreenshots,
3844
- history: lookHistoryAccess
3961
+ history: lookHistoryAccess,
3962
+ // #1004 — the App Store app this project is linked to. Omitted entirely for a project that
3963
+ // carries none, which is every project created without one.
3964
+ linkedApp: appListingOutput.optional()
3845
3965
  }).passthrough();
3846
3966
  agentOutcomeOutput = z4.object({
3847
3967
  status: z4.enum([
@@ -4063,6 +4183,7 @@ var init_outputSchemas = __esm({
4063
4183
  saveProjectToolOutputSchema = advertisedWithFailure(saveProjectOutputSchema);
4064
4184
  readLookToolOutputSchema = advertisedWithFailure(readLookOutputSchema);
4065
4185
  readProjectToolOutputSchema = advertisedWithFailure(readProjectOutputSchema);
4186
+ searchAppListingToolOutputSchema = advertisedWithFailure(searchAppListingOutputSchema);
4066
4187
  saveLookToolOutputSchema = advertisedWithFailure(saveLookOutputSchema);
4067
4188
  holdLookToolOutputSchema = advertisedWithFailure(holdLookOutputSchema);
4068
4189
  releaseLookToolOutputSchema = advertisedWithFailure(releaseLookOutputSchema);
@@ -4074,7 +4195,7 @@ var init_outputSchemas = __esm({
4074
4195
 
4075
4196
  // src/contracts/schemas.ts
4076
4197
  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;
4198
+ 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
4199
  var init_schemas = __esm({
4079
4200
  "src/contracts/schemas.ts"() {
4080
4201
  "use strict";
@@ -4140,9 +4261,9 @@ var init_schemas = __esm({
4140
4261
  seen.add(id);
4141
4262
  });
4142
4263
  });
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.";
4264
+ 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
4265
  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.";
4266
+ 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
4267
  projectOutputsField = outputDeviceList.optional().describe(projectOutputsDescription);
4147
4268
  clip = z5.enum(["strip", "panel"]).optional().describe(MCP_TOP_LEVEL_CLIP_PRECEDENCE);
4148
4269
  preview = z5.boolean().optional().describe(
@@ -4151,7 +4272,7 @@ var init_schemas = __esm({
4151
4272
  idempotencyKey = z5.string().min(1).max(200).optional().describe(
4152
4273
  "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
4274
  );
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.");
4275
+ 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
4276
  useSavedLook = z5.boolean().optional().describe("If true, style the strip with the project's saved look (ignored when `look`/`style` is given).");
4156
4277
  locale = z5.string().optional().describe(
4157
4278
  '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 +4349,9 @@ var init_schemas = __esm({
4228
4349
  waivers: waiversField,
4229
4350
  idempotencyKey
4230
4351
  };
4352
+ appListingReference = z5.string().trim().min(1).max(500).optional().describe(
4353
+ '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.'
4354
+ );
4231
4355
  saveProjectShape = {
4232
4356
  screenshots: screenshotsForSave,
4233
4357
  panelPresetId,
@@ -4241,8 +4365,17 @@ var init_schemas = __esm({
4241
4365
  version,
4242
4366
  locale,
4243
4367
  projectName,
4368
+ appListing: appListingReference,
4244
4369
  sourceDir
4245
4370
  };
4371
+ searchAppListingShape = {
4372
+ term: z5.string().trim().min(1).max(200).describe(
4373
+ "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`."
4374
+ ),
4375
+ country: z5.string().regex(/^[A-Za-z]{2}$/).optional().describe(
4376
+ `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.`
4377
+ )
4378
+ };
4246
4379
  readLookShape = { project };
4247
4380
  readProjectShape = { project };
4248
4381
  refineProjectFocus = z5.discriminatedUnion("kind", [
@@ -5195,6 +5328,7 @@ var init_toolRegistry = __esm({
5195
5328
  "save_project",
5196
5329
  "read_look",
5197
5330
  "read_project",
5331
+ "search_app_listing",
5198
5332
  "render_project",
5199
5333
  "refine_project",
5200
5334
  "save_look",
@@ -5213,6 +5347,7 @@ var init_toolRegistry = __esm({
5213
5347
  save_project: "mutate",
5214
5348
  read_look: "read",
5215
5349
  read_project: "read",
5350
+ search_app_listing: "read",
5216
5351
  render_project: "render",
5217
5352
  refine_project: "mutate",
5218
5353
  save_look: "mutate",
@@ -5471,6 +5606,11 @@ function operationSupported(deps, tool, account) {
5471
5606
  return deps.deleteAssets ? true : "hosted_connection";
5472
5607
  case "refine_project":
5473
5608
  return deps.refineProject ? true : "account";
5609
+ // #1004 — Apple is reached through the control plane on both doors, and an unsigned caller has
5610
+ // no door there. Derived from the bound dep AND the account, so an anonymous hosted connection
5611
+ // is told 'account' rather than being promised a search its own security scheme refuses.
5612
+ case "search_app_listing":
5613
+ return deps.searchAppListing && account ? true : "account";
5474
5614
  case "save_project":
5475
5615
  return local || account ? true : "account";
5476
5616
  default:
@@ -5528,6 +5668,108 @@ var init_toolsAccount = __esm({
5528
5668
  }
5529
5669
  });
5530
5670
 
5671
+ // ../mockup-engine/appStoreLocales.ts
5672
+ function appStoreLocale(code) {
5673
+ return BY_CODE[code];
5674
+ }
5675
+ var APP_STORE_LOCALE_CATALOG, APP_STORE_LOCALES, BY_CODE;
5676
+ var init_appStoreLocales = __esm({
5677
+ "../mockup-engine/appStoreLocales.ts"() {
5678
+ "use strict";
5679
+ APP_STORE_LOCALE_CATALOG = [
5680
+ { code: "ar-SA", name: "Arabic", script: "rtl", dir: "rtl" },
5681
+ { code: "bn-BD", name: "Bengali", script: "indic", dir: "ltr" },
5682
+ { code: "ca", name: "Catalan", script: "latin", dir: "ltr" },
5683
+ { code: "cs", name: "Czech", script: "latin", dir: "ltr" },
5684
+ { code: "da", name: "Danish", script: "latin", dir: "ltr" },
5685
+ { code: "de-DE", name: "German", script: "latin", dir: "ltr" },
5686
+ { code: "el", name: "Greek", script: "greek", dir: "ltr" },
5687
+ { code: "en-AU", name: "English (Australia)", script: "latin", dir: "ltr" },
5688
+ { code: "en-CA", name: "English (Canada)", script: "latin", dir: "ltr" },
5689
+ { code: "en-GB", name: "English (U.K.)", script: "latin", dir: "ltr" },
5690
+ { code: "en-US", name: "English (U.S.)", script: "latin", dir: "ltr" },
5691
+ { code: "es-ES", name: "Spanish (Spain)", script: "latin", dir: "ltr" },
5692
+ { code: "es-MX", name: "Spanish (Mexico)", script: "latin", dir: "ltr" },
5693
+ { code: "fi", name: "Finnish", script: "latin", dir: "ltr" },
5694
+ { code: "fr-CA", name: "French (Canada)", script: "latin", dir: "ltr" },
5695
+ { code: "fr-FR", name: "French", script: "latin", dir: "ltr" },
5696
+ { code: "gu-IN", name: "Gujarati", script: "indic", dir: "ltr" },
5697
+ { code: "he", name: "Hebrew", script: "rtl", dir: "rtl" },
5698
+ { code: "hi", name: "Hindi", script: "indic", dir: "ltr" },
5699
+ { code: "hr", name: "Croatian", script: "latin", dir: "ltr" },
5700
+ { code: "hu", name: "Hungarian", script: "latin", dir: "ltr" },
5701
+ { code: "id", name: "Indonesian", script: "latin", dir: "ltr" },
5702
+ { code: "it", name: "Italian", script: "latin", dir: "ltr" },
5703
+ { code: "ja", name: "Japanese", script: "cjk", dir: "ltr" },
5704
+ { code: "kn-IN", name: "Kannada", script: "indic", dir: "ltr" },
5705
+ { code: "ko", name: "Korean", script: "cjk", dir: "ltr" },
5706
+ { code: "ml-IN", name: "Malayalam", script: "indic", dir: "ltr" },
5707
+ { code: "mr-IN", name: "Marathi", script: "indic", dir: "ltr" },
5708
+ { code: "ms", name: "Malay", script: "latin", dir: "ltr" },
5709
+ { code: "nl-NL", name: "Dutch", script: "latin", dir: "ltr" },
5710
+ { code: "no", name: "Norwegian", script: "latin", dir: "ltr" },
5711
+ { code: "or-IN", name: "Odia", script: "indic", dir: "ltr" },
5712
+ { code: "pa-IN", name: "Punjabi", script: "indic", dir: "ltr" },
5713
+ { code: "pl", name: "Polish", script: "latin", dir: "ltr" },
5714
+ { code: "pt-BR", name: "Portuguese (Brazil)", script: "latin", dir: "ltr" },
5715
+ { code: "pt-PT", name: "Portuguese (Portugal)", script: "latin", dir: "ltr" },
5716
+ { code: "ro", name: "Romanian", script: "latin", dir: "ltr" },
5717
+ { code: "ru", name: "Russian", script: "cyrillic", dir: "ltr" },
5718
+ { code: "sk", name: "Slovak", script: "latin", dir: "ltr" },
5719
+ { code: "sl-SI", name: "Slovenian", script: "latin", dir: "ltr" },
5720
+ { code: "sv", name: "Swedish", script: "latin", dir: "ltr" },
5721
+ { code: "ta-IN", name: "Tamil", script: "indic", dir: "ltr" },
5722
+ { code: "te-IN", name: "Telugu", script: "indic", dir: "ltr" },
5723
+ { code: "th", name: "Thai", script: "thai", dir: "ltr" },
5724
+ { code: "tr", name: "Turkish", script: "latin", dir: "ltr" },
5725
+ { code: "uk", name: "Ukrainian", script: "cyrillic", dir: "ltr" },
5726
+ { code: "ur-PK", name: "Urdu", script: "rtl", dir: "rtl" },
5727
+ { code: "vi", name: "Vietnamese", script: "latin", dir: "ltr" },
5728
+ { code: "zh-Hans", name: "Chinese (Simplified)", script: "cjk", dir: "ltr" },
5729
+ { code: "zh-Hant", name: "Chinese (Traditional)", script: "cjk", dir: "ltr" }
5730
+ ];
5731
+ APP_STORE_LOCALES = APP_STORE_LOCALE_CATALOG.map((l) => l.code);
5732
+ BY_CODE = Object.fromEntries(
5733
+ APP_STORE_LOCALE_CATALOG.map((l) => [l.code, l])
5734
+ );
5735
+ }
5736
+ });
5737
+
5738
+ // ../mockup-engine/appstore/appListing.ts
5739
+ function readAppListing(envelope) {
5740
+ if (envelope == null || typeof envelope !== "object" || Array.isArray(envelope)) return null;
5741
+ const stored = envelope[APP_LISTING_ENVELOPE_KEY];
5742
+ if (stored == null || typeof stored !== "object" || Array.isArray(stored)) return null;
5743
+ const value = stored;
5744
+ for (const key of ["trackId", "bundleId", "trackName", "sellerName", "storefront", "fetchedAt"]) {
5745
+ if (typeof value[key] !== "string") return null;
5746
+ }
5747
+ if (!Array.isArray(value.languageCodesISO2A) || !Array.isArray(value.supportedDevices)) return null;
5748
+ if (value.applied == null || typeof value.applied !== "object" || Array.isArray(value.applied)) return null;
5749
+ const applied = value.applied;
5750
+ if (typeof applied.projectName !== "string" || typeof applied.bundleId !== "string") return null;
5751
+ if (typeof applied.baseLocale !== "string" || typeof applied.supportsIpad !== "boolean") return null;
5752
+ for (const key of ["locales", "outputs", "droppedLanguageCodes"]) {
5753
+ if (!Array.isArray(applied[key])) return null;
5754
+ }
5755
+ return stored;
5756
+ }
5757
+ function appListingRewrite(listing2) {
5758
+ return { [APP_LISTING_ENVELOPE_KEY]: listing2 ?? void 0 };
5759
+ }
5760
+ var CATALOG_ORDER, APP_LISTING_ENVELOPE_KEY;
5761
+ var init_appListing = __esm({
5762
+ "../mockup-engine/appstore/appListing.ts"() {
5763
+ "use strict";
5764
+ init_appStoreLocales();
5765
+ init_devices();
5766
+ CATALOG_ORDER = Object.fromEntries(
5767
+ APP_STORE_LOCALE_CATALOG.map((locale2, index) => [locale2.code, index])
5768
+ );
5769
+ APP_LISTING_ENVELOPE_KEY = "appListing";
5770
+ }
5771
+ });
5772
+
5531
5773
  // ../mockup-engine/fastlaneBundle.ts
5532
5774
  import { strToU8, zipSync } from "fflate";
5533
5775
  function render(template, vars) {
@@ -5850,8 +6092,17 @@ function canonicalPersistence(look2) {
5850
6092
  }
5851
6093
  return canonical;
5852
6094
  }
6095
+ function upgradeLegacyShotLook(input) {
6096
+ if (!input || typeof input !== "object" || Array.isArray(input) || !("phoneHeight" in input)) return input;
6097
+ const { phoneHeight, ...rest } = input;
6098
+ if (rest.deviceSize === void 0) {
6099
+ const parsed = typeof phoneHeight === "number" ? phoneHeight : Number.parseFloat(String(phoneHeight ?? ""));
6100
+ if (Number.isFinite(parsed)) rest.deviceSize = String(deviceSizeFromLegacyPhoneHeight(parsed));
6101
+ }
6102
+ return rest;
6103
+ }
5853
6104
  function normalizeShotLook(input) {
5854
- return canonicalPersistence({ ...DEFAULT_LOOK, ...input });
6105
+ return canonicalPersistence({ ...DEFAULT_LOOK, ...upgradeLegacyShotLook(input) });
5855
6106
  }
5856
6107
  function serializeShotLook(input) {
5857
6108
  return canonicalPersistence(normalizeShotLook(input));
@@ -5862,7 +6113,7 @@ function comparableShotLook(input) {
5862
6113
  function shotLookNumber(field, raw) {
5863
6114
  const { min, max } = SHOT_LOOK_RANGES[field];
5864
6115
  const parsed = typeof raw === "number" ? raw : Number.parseFloat(raw ?? "");
5865
- const value = Number.isFinite(parsed) ? parsed : Number.parseFloat(DEFAULT_LOOK[field]);
6116
+ const value = Number.isFinite(parsed) ? parsed : Number.parseFloat(String(SHOT_LOOK_DOMAIN[field].defaultValue));
5866
6117
  return Math.max(min, Math.min(max, value));
5867
6118
  }
5868
6119
  var init_shotLook = __esm({
@@ -5916,6 +6167,158 @@ var init_panel = __esm({
5916
6167
  }
5917
6168
  });
5918
6169
 
6170
+ // ../mockup-engine/appstore/fingerprint.ts
6171
+ function isScreenshotFingerprint(value) {
6172
+ return typeof value === "string" && SCREENSHOT_FINGERPRINT.test(value);
6173
+ }
6174
+ async function screenshotFingerprint(bytes) {
6175
+ const subtle = globalThis.crypto?.subtle;
6176
+ if (subtle) return hex(new Uint8Array(await subtle.digest("SHA-256", bytes)));
6177
+ return sha256(bytes);
6178
+ }
6179
+ function sha256(input) {
6180
+ const length = input.length;
6181
+ const padded = new Uint8Array(length + 72 >> 6 << 6);
6182
+ padded.set(input);
6183
+ padded[length] = 128;
6184
+ const bits = length * 8;
6185
+ const view = new DataView(padded.buffer);
6186
+ view.setUint32(padded.length - 8, Math.floor(bits / 4294967296));
6187
+ view.setUint32(padded.length - 4, bits >>> 0);
6188
+ const h = new Uint32Array([
6189
+ 1779033703,
6190
+ 3144134277,
6191
+ 1013904242,
6192
+ 2773480762,
6193
+ 1359893119,
6194
+ 2600822924,
6195
+ 528734635,
6196
+ 1541459225
6197
+ ]);
6198
+ const w = new Uint32Array(64);
6199
+ for (let block = 0; block < padded.length; block += 64) {
6200
+ for (let i = 0; i < 16; i++) w[i] = view.getUint32(block + i * 4);
6201
+ for (let i = 16; i < 64; i++) {
6202
+ const a2 = w[i - 15];
6203
+ const b2 = w[i - 2];
6204
+ const s0 = rotr(a2, 7) ^ rotr(a2, 18) ^ a2 >>> 3;
6205
+ const s1 = rotr(b2, 17) ^ rotr(b2, 19) ^ b2 >>> 10;
6206
+ w[i] = w[i - 16] + s0 + w[i - 7] + s1 >>> 0;
6207
+ }
6208
+ 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]];
6209
+ for (let i = 0; i < 64; i++) {
6210
+ const s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
6211
+ const choose = e & f ^ ~e & g;
6212
+ const t1 = hh + s1 + choose + K[i] + w[i] >>> 0;
6213
+ const s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
6214
+ const majority = a & b ^ a & c ^ b & c;
6215
+ const t2 = s0 + majority >>> 0;
6216
+ hh = g;
6217
+ g = f;
6218
+ f = e;
6219
+ e = d + t1 >>> 0;
6220
+ d = c;
6221
+ c = b;
6222
+ b = a;
6223
+ a = t1 + t2 >>> 0;
6224
+ }
6225
+ h[0] = h[0] + a >>> 0;
6226
+ h[1] = h[1] + b >>> 0;
6227
+ h[2] = h[2] + c >>> 0;
6228
+ h[3] = h[3] + d >>> 0;
6229
+ h[4] = h[4] + e >>> 0;
6230
+ h[5] = h[5] + f >>> 0;
6231
+ h[6] = h[6] + g >>> 0;
6232
+ h[7] = h[7] + hh >>> 0;
6233
+ }
6234
+ return hex(bigEndian(h));
6235
+ }
6236
+ function bigEndian(words) {
6237
+ const out = new Uint8Array(words.length * 4);
6238
+ const view = new DataView(out.buffer);
6239
+ for (let i = 0; i < words.length; i++) view.setUint32(i * 4, words[i]);
6240
+ return out;
6241
+ }
6242
+ var SCREENSHOT_FINGERPRINT, hex, K, rotr;
6243
+ var init_fingerprint = __esm({
6244
+ "../mockup-engine/appstore/fingerprint.ts"() {
6245
+ "use strict";
6246
+ SCREENSHOT_FINGERPRINT = /^[a-f0-9]{64}$/;
6247
+ hex = (bytes) => {
6248
+ let out = "";
6249
+ for (let i = 0; i < bytes.length; i++) out += bytes[i].toString(16).padStart(2, "0");
6250
+ return out;
6251
+ };
6252
+ K = new Uint32Array([
6253
+ 1116352408,
6254
+ 1899447441,
6255
+ 3049323471,
6256
+ 3921009573,
6257
+ 961987163,
6258
+ 1508970993,
6259
+ 2453635748,
6260
+ 2870763221,
6261
+ 3624381080,
6262
+ 310598401,
6263
+ 607225278,
6264
+ 1426881987,
6265
+ 1925078388,
6266
+ 2162078206,
6267
+ 2614888103,
6268
+ 3248222580,
6269
+ 3835390401,
6270
+ 4022224774,
6271
+ 264347078,
6272
+ 604807628,
6273
+ 770255983,
6274
+ 1249150122,
6275
+ 1555081692,
6276
+ 1996064986,
6277
+ 2554220882,
6278
+ 2821834349,
6279
+ 2952996808,
6280
+ 3210313671,
6281
+ 3336571891,
6282
+ 3584528711,
6283
+ 113926993,
6284
+ 338241895,
6285
+ 666307205,
6286
+ 773529912,
6287
+ 1294757372,
6288
+ 1396182291,
6289
+ 1695183700,
6290
+ 1986661051,
6291
+ 2177026350,
6292
+ 2456956037,
6293
+ 2730485921,
6294
+ 2820302411,
6295
+ 3259730800,
6296
+ 3345764771,
6297
+ 3516065817,
6298
+ 3600352804,
6299
+ 4094571909,
6300
+ 275423344,
6301
+ 430227734,
6302
+ 506948616,
6303
+ 659060556,
6304
+ 883997877,
6305
+ 958139571,
6306
+ 1322822218,
6307
+ 1537002063,
6308
+ 1747873779,
6309
+ 1955562222,
6310
+ 2024104815,
6311
+ 2227730452,
6312
+ 2361852424,
6313
+ 2428436474,
6314
+ 2756734187,
6315
+ 3204031479,
6316
+ 3329325298
6317
+ ]);
6318
+ rotr = (value, by) => value >>> by | value << 32 - by;
6319
+ }
6320
+ });
6321
+
5919
6322
  // ../mockup-engine/appstore/screenManifest.ts
5920
6323
  function isShotManifestEntry(entry) {
5921
6324
  return typeof entry.shotId === "string";
@@ -6019,7 +6422,7 @@ function parseList(raw, mode, baseLocale) {
6019
6422
  const shotId = e.shotId === void 0 || e.shotId === null ? void 0 : e.shotId;
6020
6423
  const slot2 = e.slot === void 0 || e.slot === null ? void 0 : e.slot;
6021
6424
  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)) {
6425
+ if (!identified || typeof e.ref !== "string" || e.ref.length === 0 || variant === null || hash !== void 0 && !isScreenshotFingerprint(hash)) {
6023
6426
  if (mode === "strict") return null;
6024
6427
  continue;
6025
6428
  }
@@ -6167,25 +6570,25 @@ function rebindManifestToShots(cells, shots) {
6167
6570
  }
6168
6571
  return { manifest, findings };
6169
6572
  }
6170
- function manifestFromNamedRefs(refs, shots) {
6573
+ function manifestFromNamedRefs(refs, shots, hashes = {}) {
6171
6574
  const idsByFrameName = shotIdsByFrameName(shots);
6172
6575
  const out = [];
6173
6576
  for (const [name, ref] of Object.entries(refs)) {
6174
6577
  const canonical = canonicalizeManifestRef(ref);
6578
+ const hash = hashes[name];
6579
+ const withHash = (entry) => hash === void 0 ? entry : { ...entry, hash };
6175
6580
  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 });
6581
+ if (ids === void 0) out.push(withHash({ slot: name, variant: {}, ref: canonical }));
6582
+ else for (const id of ids) out.push(withHash({ shotId: id, variant: {}, ref: canonical }));
6178
6583
  }
6179
6584
  return out;
6180
6585
  }
6181
- function nonBaseCellEntries(manifest) {
6182
- return manifest.filter((e) => variantKey(e.variant) !== "");
6183
- }
6184
6586
  var UPLOAD_REF_RE, UUID_RE, TRUNCATED_NAMED_UPLOAD_REF, EMPTY;
6185
6587
  var init_screenManifest = __esm({
6186
6588
  "../mockup-engine/appstore/screenManifest.ts"() {
6187
6589
  "use strict";
6188
6590
  init_variant();
6591
+ init_fingerprint();
6189
6592
  UPLOAD_REF_RE = /^uploads\/([^/]+)\/([^/]+(?:\/[^/]+)*)$/;
6190
6593
  UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
6191
6594
  TRUNCATED_NAMED_UPLOAD_REF = /^uploads\/[^/]+\/[^/]+\/\d+__[A-Za-z0-9_-]+$/;
@@ -6193,6 +6596,23 @@ var init_screenManifest = __esm({
6193
6596
  }
6194
6597
  });
6195
6598
 
6599
+ // ../mockup-engine/appstore/screenUploadPlan.ts
6600
+ function planScreenUploads(current, manifest, shots) {
6601
+ const byCell = resolveScreenManifest(manifest, shots).byShotCell;
6602
+ const toUpload = [];
6603
+ for (const c of current) {
6604
+ const stored = byCell[manifestShotCellKey(c.shotId, c.variant)];
6605
+ if (stored === void 0 || stored.hash !== c.hash) toUpload.push(c);
6606
+ }
6607
+ return { toUpload, kept: manifest };
6608
+ }
6609
+ var init_screenUploadPlan = __esm({
6610
+ "../mockup-engine/appstore/screenUploadPlan.ts"() {
6611
+ "use strict";
6612
+ init_screenManifest();
6613
+ }
6614
+ });
6615
+
6196
6616
  // ../mockup-engine/appstore/localeAxis.ts
6197
6617
  function deriveLocales(captionText, explicit, baseLocale = DEFAULT_LOCALE) {
6198
6618
  const dedupe2 = (list) => {
@@ -6252,6 +6672,17 @@ function normalizeLegacyLocales(captionText, locales) {
6252
6672
  }
6253
6673
  return { captionText: nextCaption, locales: locales?.map((c) => LEGACY_LOCALE_ALIASES[c] ?? c) };
6254
6674
  }
6675
+ function projectLocaleAxis(projectFile) {
6676
+ const file = projectFile ?? {};
6677
+ const baseLocaleRaw = normalizeBaseLocale(file.baseLocale);
6678
+ const explicit = Array.isArray(file.locales) ? file.locales.filter((locale2) => typeof locale2 === "string" && locale2.length > 0) : void 0;
6679
+ const { captionText, locales } = normalizeLegacyLocales(
6680
+ normalizeCaptionText(file.captionText, file.captionStyles, baseLocaleRaw),
6681
+ explicit
6682
+ );
6683
+ const baseLocale = normalizeLegacyLocales({ [baseLocaleRaw]: {} }, [baseLocaleRaw]).locales?.[0] ?? baseLocaleRaw;
6684
+ return { captionText, locales: deriveLocales(captionText, locales, baseLocale), baseLocale };
6685
+ }
6255
6686
  var LEGACY_LOCALE_ALIASES;
6256
6687
  var init_localeAxis = __esm({
6257
6688
  "../mockup-engine/appstore/localeAxis.ts"() {
@@ -6552,6 +6983,16 @@ var init_layout = __esm({
6552
6983
  }
6553
6984
  });
6554
6985
 
6986
+ // ../mockup-engine/appstore/framelessCard.ts
6987
+ var FRAMELESS_BLANK_ASPECT;
6988
+ var init_framelessCard = __esm({
6989
+ "../mockup-engine/appstore/framelessCard.ts"() {
6990
+ "use strict";
6991
+ init_shotLook();
6992
+ FRAMELESS_BLANK_ASPECT = 9 / 19.5;
6993
+ }
6994
+ });
6995
+
6555
6996
  // ../mockup-engine/appstore/serialize.ts
6556
6997
  function extractStripStyle(state, captionText, baseLocale = DEFAULT_LOCALE) {
6557
6998
  const gradient = stripGradientOf(state);
@@ -6821,20 +7262,15 @@ function restoreProject(json) {
6821
7262
  const panels = file.panels.map((p) => ({ id: p.id }));
6822
7263
  reseedIdCounters(shots.map((s) => s.id), panels.map((p) => p.id));
6823
7264
  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);
7265
+ const { captionText, locales, baseLocale } = projectLocaleAxis(file);
7266
+ const output2 = projectOutputAxis(file);
6831
7267
  const captionSlotIds2 = normalizeCaptionSlotIds(file.captionSlotIds, captionText, panels.map((panel) => panel.id));
6832
7268
  const canonicalGradient = normalizeGradient(file.gradientStops, file.gradientAngle);
6833
7269
  return {
6834
7270
  shots: shotsInPanelOrder(shots, panels),
6835
7271
  panels,
6836
7272
  selectedShotId,
6837
- panelPresetId: presetById(file.panelPresetId).id,
7273
+ panelPresetId: output2.panelPresetId,
6838
7274
  bgMode: file.bgMode === "perPanel" ? "perPanel" : "gradient",
6839
7275
  // Canonical first, and spread so an old record carries neither key rather than two undefineds.
6840
7276
  // #556 — when it IS there, the mirror is DERIVED from it rather than copied off the record: a
@@ -6868,7 +7304,7 @@ function restoreProject(json) {
6868
7304
  ),
6869
7305
  // Stored device list trusted verbatim (unknown/comingSoon ids dropped); an old record with no
6870
7306
  // `outputs` migrates from its panelPresetId (deriveOutputs) so it loads with a coherent device.
6871
- outputs: deriveOutputs(file.outputs, file.panelPresetId),
7307
+ outputs: output2.outputs,
6872
7308
  // The record carries no per-locale bytes (rule 1) — the studio re-joins them from the byte-cache
6873
7309
  // by frameName after restore (Phase 3). {} = the pre-join shape.
6874
7310
  localeScreens: {},
@@ -6940,7 +7376,6 @@ function serializeProject(state) {
6940
7376
  var init_project = __esm({
6941
7377
  "../mockup-engine/appstore/project.ts"() {
6942
7378
  "use strict";
6943
- init_presets();
6944
7379
  init_devices();
6945
7380
  init_shotLook();
6946
7381
  init_panel();
@@ -7818,9 +8253,19 @@ function applicationOutcome(facts) {
7818
8253
  }
7819
8254
  function headlineForChanges(changed) {
7820
8255
  const first = changed[0] ?? "Updated this project.";
8256
+ const whole = changed.join(" \xB7 ");
8257
+ if (changed.length <= 1 || whole.length <= AGENT_OUTCOME_BOUNDS.headline) {
8258
+ return boundOutcomeText(changed.length > 0 ? whole : first, AGENT_OUTCOME_BOUNDS.headline);
8259
+ }
8260
+ let kept = changed.length - 1;
8261
+ while (kept > 1) {
8262
+ const rest2 = changed.length - kept;
8263
+ const candidate = `${changed.slice(0, kept).join(" \xB7 ")} (+${rest2} more change${rest2 === 1 ? "" : "s"})`;
8264
+ if (candidate.length <= AGENT_OUTCOME_BOUNDS.headline) return candidate;
8265
+ kept -= 1;
8266
+ }
7821
8267
  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);
8268
+ return boundOutcomeText(`${first} (+${rest} more change${rest === 1 ? "" : "s"})`, AGENT_OUTCOME_BOUNDS.headline);
7824
8269
  }
7825
8270
  function failureOutcome(reason, headline) {
7826
8271
  return {
@@ -7970,7 +8415,7 @@ function lookFromAiShotPatch(current, patch, allowHOffset) {
7970
8415
  }
7971
8416
  if (patch.orbit !== void 0) next.cameraPos = posFromAzimuthDeg(normalizedAzimuth(patch.orbit));
7972
8417
  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));
8418
+ if (patch.deviceSize !== void 0) next.deviceSize = String(shotLookNumber("deviceSize", patch.deviceSize));
7974
8419
  if (patch.hOffset !== void 0 && allowHOffset) next.hOffset = String(shotLookNumber("hOffset", patch.hOffset));
7975
8420
  if (patch.vOffset !== void 0) next.vOffset = String(shotLookNumber("vOffset", patch.vOffset));
7976
8421
  if (patch.material !== void 0) next.material = patch.material;
@@ -7992,6 +8437,13 @@ function lookFromAiShotPatch(current, patch, allowHOffset) {
7992
8437
  if (patch.reflections !== void 0) next.reflections = patch.reflections;
7993
8438
  if (patch.clipToFrame === true) next.clipToFrame = true;
7994
8439
  else if (patch.clipToFrame === false) delete next.clipToFrame;
8440
+ if (patch.deviceFrame === false) next.deviceFrame = false;
8441
+ else if (patch.deviceFrame === true) delete next.deviceFrame;
8442
+ if (patch.screenCornerRadius !== void 0) {
8443
+ const radius = String(shotLookNumber("screenCornerRadius", patch.screenCornerRadius));
8444
+ if (radius === SHOT_LOOK_DOMAIN.screenCornerRadius.defaultValue) delete next.screenCornerRadius;
8445
+ else next.screenCornerRadius = radius;
8446
+ }
7995
8447
  return JSON.stringify(next) === JSON.stringify(current) ? current : next;
7996
8448
  }
7997
8449
  function resolveCaptionNumber(input, current, range) {
@@ -8041,6 +8493,7 @@ var init_refinementValues = __esm({
8041
8493
  init_gradient();
8042
8494
  init_panel();
8043
8495
  init_refineIntent();
8496
+ init_defaults();
8044
8497
  init_shotLook();
8045
8498
  AI_SHOT_LOOK_PROJECTION = {
8046
8499
  angle: {
@@ -8060,10 +8513,10 @@ var init_refinementValues = __esm({
8060
8513
  reason: "numeric-string",
8061
8514
  project: (look2) => ({ roll: shotLookNumber("roll", look2.roll) })
8062
8515
  },
8063
- phoneHeight: {
8516
+ deviceSize: {
8064
8517
  disposition: "transformed",
8065
8518
  reason: "numeric-string",
8066
- project: (look2) => ({ phoneHeight: shotLookNumber("phoneHeight", look2.phoneHeight) })
8519
+ project: (look2) => ({ deviceSize: shotLookNumber("deviceSize", look2.deviceSize) })
8067
8520
  },
8068
8521
  hOffset: {
8069
8522
  disposition: "transformed",
@@ -8120,6 +8573,16 @@ var init_refinementValues = __esm({
8120
8573
  reason: "false-is-absence",
8121
8574
  project: (look2) => ({ clipToFrame: look2.clipToFrame === true })
8122
8575
  },
8576
+ deviceFrame: {
8577
+ disposition: "transformed",
8578
+ reason: "true-is-default",
8579
+ project: (look2) => ({ deviceFrame: look2.deviceFrame !== false })
8580
+ },
8581
+ screenCornerRadius: {
8582
+ disposition: "transformed",
8583
+ reason: "numeric-string",
8584
+ project: (look2) => ({ screenCornerRadius: shotLookNumber("screenCornerRadius", look2.screenCornerRadius) })
8585
+ },
8123
8586
  glare: {
8124
8587
  disposition: "direct",
8125
8588
  reason: "same-value",
@@ -8841,7 +9304,7 @@ var TEMPLATE_OWNED_SHOT_FIELDS;
8841
9304
  var init_templatePoseGuard = __esm({
8842
9305
  "../mockup-engine/appstore/templatePoseGuard.ts"() {
8843
9306
  "use strict";
8844
- TEMPLATE_OWNED_SHOT_FIELDS = ["orbit", "roll", "angle", "phoneHeight", "hOffset", "vOffset"];
9307
+ TEMPLATE_OWNED_SHOT_FIELDS = ["orbit", "roll", "angle", "deviceSize", "hOffset", "vOffset"];
8845
9308
  }
8846
9309
  });
8847
9310
 
@@ -9117,6 +9580,33 @@ function applyProjectRefinementWithAssets(state, patch, panelIds, assets = []) {
9117
9580
  ).patch;
9118
9581
  }
9119
9582
  }
9583
+ const layoutFrames = patch.frames?.length === positionalCount ? patch.frames : void 0;
9584
+ if (layoutFrames?.some((frame) => frame?.layout)) {
9585
+ const panelsByTemplate = {};
9586
+ const order = [];
9587
+ requestedPanelIds.forEach((panelId, index) => {
9588
+ const id = layoutFrames[index]?.layout;
9589
+ if (!id || !surviving.has(panelId)) return;
9590
+ if (!panelsByTemplate[id]) {
9591
+ panelsByTemplate[id] = [];
9592
+ order.push(id);
9593
+ }
9594
+ panelsByTemplate[id].push(panelId);
9595
+ });
9596
+ for (const id of order) {
9597
+ const panelIds2 = panelsByTemplate[id];
9598
+ next = applyLayout(next, id, panelIds2);
9599
+ if (layoutTemplateById(id)) {
9600
+ const shots = next.shots;
9601
+ patch = dropTemplatePoses(
9602
+ patch,
9603
+ new Set(panelIds2),
9604
+ requestedPanelIds,
9605
+ (deviceId) => shots.find((shot) => shot.id === deviceId)?.panelId
9606
+ ).patch;
9607
+ }
9608
+ }
9609
+ }
9120
9610
  if (patch.shots?.length === positionalCount) {
9121
9611
  const entries = new Map(requestedPanelIds.map((panelId, index) => [panelId, patch.shots[index]]));
9122
9612
  const shared = new Set(
@@ -9178,11 +9668,16 @@ function applyProjectRefinementWithAssets(state, patch, panelIds, assets = []) {
9178
9668
  ...stripBackground.floorReflection !== void 0 ? { floorReflection: stripBackground.floorReflection } : {}
9179
9669
  };
9180
9670
  }
9181
- if (frameEntries?.some((frame) => frame.background && Object.keys(frame.background).length > 0)) {
9671
+ if (frameEntries?.some((frame) => frame.clearBackground || frame.background && Object.keys(frame.background).length > 0)) {
9182
9672
  const inherited = panelBackgroundFromGradientPatch(stripGradientOf(next));
9183
9673
  const panelBackgrounds = { ...next.panelBackgrounds };
9184
9674
  let changed = false;
9185
9675
  requestedPanelIds.forEach((panelId, index) => {
9676
+ if (frameEntries[index]?.clearBackground && surviving.has(panelId)) {
9677
+ delete panelBackgrounds[panelId];
9678
+ changed = true;
9679
+ return;
9680
+ }
9186
9681
  const entry = frameEntries[index]?.background;
9187
9682
  if (!entry || Object.keys(entry).length === 0 || !surviving.has(panelId)) return;
9188
9683
  const gradient = gradientFromAiBackground(backgroundGradient(panelBackgrounds[panelId] ?? inherited), entry);
@@ -9193,7 +9688,11 @@ function applyProjectRefinementWithAssets(state, patch, panelIds, assets = []) {
9193
9688
  if (changed) next = { ...next, panelBackgrounds };
9194
9689
  }
9195
9690
  if (patch.outputs) {
9196
- const wanted = patch.outputs;
9691
+ const valid = patch.outputs.filter((id, index, all) => {
9692
+ const device = deviceById(id);
9693
+ return device && !device.comingSoon && all.indexOf(id) === index;
9694
+ });
9695
+ const wanted = valid.length > 0 ? valid : next.outputs;
9197
9696
  const outputCaptions = pruneOutputFamilies(next.outputCaptions, wanted);
9198
9697
  const outputScreens = next.outputScreens === void 0 ? void 0 : pruneOutputFamilies(next.outputScreens, wanted);
9199
9698
  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 +9758,6 @@ var init_refinement = __esm({
9259
9758
  }
9260
9759
  });
9261
9760
 
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
9761
  // ../mockup-engine/appstore/agentTurnRepetition.ts
9330
9762
  function appliedOperationOf(operation) {
9331
9763
  if (!NON_REPEATABLE_OPS.has(operation.op)) return null;
@@ -9866,6 +10298,13 @@ var init_agentTurnConformance = __esm({
9866
10298
  summary: "Restyled 1 device",
9867
10299
  broaderChanges: [],
9868
10300
  remainingMismatch: null
10301
+ },
10302
+ receipt: {
10303
+ committed: true,
10304
+ status: "applied",
10305
+ generationIds: ["generation-1", "generation-2"],
10306
+ recordVersion: 8,
10307
+ usage: { inputTokens: 20, cacheReadTokens: 4, imageTokens: null, outputTokens: 10, totalTokens: 30 }
9869
10308
  }
9870
10309
  }
9871
10310
  };
@@ -9968,6 +10407,8 @@ var init_appstore = __esm({
9968
10407
  init_captionModel();
9969
10408
  init_variant();
9970
10409
  init_screenManifest();
10410
+ init_fingerprint();
10411
+ init_screenUploadPlan();
9971
10412
  init_productionInput();
9972
10413
  init_productionReadiness();
9973
10414
  init_localeAxis();
@@ -9977,6 +10418,7 @@ var init_appstore = __esm({
9977
10418
  init_localeVariants();
9978
10419
  init_shot();
9979
10420
  init_layout();
10421
+ init_framelessCard();
9980
10422
  init_serialize();
9981
10423
  init_project();
9982
10424
  init_look();
@@ -10283,6 +10725,70 @@ var init_lookResolution = __esm({
10283
10725
  }
10284
10726
  });
10285
10727
 
10728
+ // ../mockup-engine/appstore/recordEnvelope.ts
10729
+ function isEnvelope(value) {
10730
+ return value != null && typeof value === "object" && !Array.isArray(value);
10731
+ }
10732
+ function isBlankRecord(value) {
10733
+ return isEnvelope(value) && value.record === 0 && value.project === void 0;
10734
+ }
10735
+ function rebuildEnvelope(stored, next) {
10736
+ const out = isEnvelope(stored) ? { ...stored } : {};
10737
+ const { project: project2, manifest, ...explicit } = next;
10738
+ out.record = SAVED_RECORD_VERSION;
10739
+ out.project = project2;
10740
+ if ("manifest" in next) {
10741
+ const folded = manifest ? mergeManifest([], manifest) : [];
10742
+ if (folded.length > 0) out.handoffRefs = folded;
10743
+ else delete out.handoffRefs;
10744
+ }
10745
+ for (const [key, value] of Object.entries(explicit)) {
10746
+ if (value === void 0) delete out[key];
10747
+ else out[key] = value;
10748
+ }
10749
+ return out;
10750
+ }
10751
+ function custodyUnion(...manifests) {
10752
+ const seen = /* @__PURE__ */ new Set();
10753
+ const out = [];
10754
+ for (const manifest of manifests) {
10755
+ for (const entry of manifest) {
10756
+ if (seen.has(entry.ref)) continue;
10757
+ seen.add(entry.ref);
10758
+ out.push(entry);
10759
+ }
10760
+ }
10761
+ return out;
10762
+ }
10763
+ var SAVED_RECORD_VERSION, ENVELOPE_KEYS_A_SAVE_OWNS;
10764
+ var init_recordEnvelope = __esm({
10765
+ "../mockup-engine/appstore/recordEnvelope.ts"() {
10766
+ "use strict";
10767
+ init_screenManifest();
10768
+ SAVED_RECORD_VERSION = 2;
10769
+ ENVELOPE_KEYS_A_SAVE_OWNS = [
10770
+ "record",
10771
+ "project",
10772
+ "fastlaneBundleId",
10773
+ "fastlaneLocale",
10774
+ "lastBundleExportAt",
10775
+ "lastShareAt",
10776
+ "latestVersion",
10777
+ "heldVersion",
10778
+ "source",
10779
+ "baseline",
10780
+ "reach",
10781
+ "handoffRefs",
10782
+ // The App Store listing a project is linked to (#1001). OWNED, because owned is the only kind a
10783
+ // DETACH can delete — an unowned key could never be removed. That does not oblige a save to carry
10784
+ // it: the rule above already says an unnamed key survives. Naming it is the hazard here, so no
10785
+ // ordinary save path may mention `appListing` — only a deliberate attach (a value) or detach
10786
+ // (`undefined` through rebuildEnvelope, `null` through patchRecord).
10787
+ "appListing"
10788
+ ];
10789
+ }
10790
+ });
10791
+
10286
10792
  // ../api/_lib/projectRecord.ts
10287
10793
  function parseScreenPurgeRefs(value) {
10288
10794
  if (!Array.isArray(value)) {
@@ -10299,13 +10805,15 @@ function parseScreenPurgeRefs(value) {
10299
10805
  }
10300
10806
  return entries;
10301
10807
  }
10302
- var PROJECT_RECORD_MAX_BYTES;
10808
+ var PROJECT_RECORD_MAX_BYTES, WRITABLE_ENVELOPE_KEYS;
10303
10809
  var init_projectRecord = __esm({
10304
10810
  "../api/_lib/projectRecord.ts"() {
10305
10811
  "use strict";
10306
10812
  init_projectFileGuard();
10813
+ init_recordEnvelope();
10307
10814
  init_screenManifest();
10308
10815
  PROJECT_RECORD_MAX_BYTES = 2 * 1024 * 1024;
10816
+ WRITABLE_ENVELOPE_KEYS = new Set(ENVELOPE_KEYS_A_SAVE_OWNS);
10309
10817
  }
10310
10818
  });
10311
10819
 
@@ -11241,14 +11749,6 @@ function preservedCells(record8, nextProjectFile) {
11241
11749
  }
11242
11750
  return { manifest: rebound.manifest, findings };
11243
11751
  }
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
11752
  function screenCustodyFromRecord(record8) {
11253
11753
  return parseScreenPurgeRefs(record8?.screenPurgeRefs) ?? [];
11254
11754
  }
@@ -11256,7 +11756,27 @@ function allLiveScreenRefs(record8) {
11256
11756
  const parsed = parseScreenManifest(record8?.handoffRefs);
11257
11757
  return parsed ? allManifestEntries(parsed) : [];
11258
11758
  }
11259
- async function persistProject(deps, args, record8, claimPolicy = "none") {
11759
+ function projectSaveEnvelope(stored, projectFile, manifest, source, listing2) {
11760
+ const custody = custodyUnion(screenCustodyFromRecord(stored), allLiveScreenRefs(stored), manifest);
11761
+ const storedSource = stored != null && typeof stored === "object" && !Array.isArray(stored) ? stored.source : void 0;
11762
+ const storedKind = storedSource != null && typeof storedSource === "object" ? storedSource.kind : void 0;
11763
+ const mcpOrigin = storedKind === "agent" || storedKind === "local-path";
11764
+ const sourceForEnvelope = storedSource != null && !mcpOrigin ? storedSource : source ?? storedSource;
11765
+ return rebuildEnvelope(stored, {
11766
+ project: projectFile,
11767
+ manifest,
11768
+ source: sourceForEnvelope ?? void 0,
11769
+ reach: manifest.length > 0 ? "account" : void 0,
11770
+ screenPurgeRefs: custody.length > 0 ? custody : void 0,
11771
+ // #1004 — the App Store listing, and the ONE shape in which this door may name that key.
11772
+ // SPREAD, never a ternary yielding `undefined`: `rebuildEnvelope` deletes on `undefined`, so
11773
+ // `appListing: listing ?? undefined` would wipe a stored association on every ordinary save —
11774
+ // which is exactly the hazard `recordEnvelope.ts` warns about. A save with no reference names
11775
+ // no key at all, and the stored snapshot survives because nothing touched it.
11776
+ ...listing2 ? { ...appListingRewrite(listing2), fastlaneBundleId: listing2.applied.bundleId } : {}
11777
+ });
11778
+ }
11779
+ async function persistProject(deps, args, record8, claimPolicy = "none", listing2) {
11260
11780
  const names = screenNamesFromRecord(record8);
11261
11781
  const source = names.length === 0 ? void 0 : args.sourceDir ? { kind: "local-path", ref: { dir: args.sourceDir, names } } : { kind: "agent", ref: { names } };
11262
11782
  const introducedRefs = handoffRefsFromScreenshots(args.screenshots, names, deps.userId);
@@ -11270,30 +11790,7 @@ async function persistProject(deps, args, record8, claimPolicy = "none") {
11270
11790
  preserved.manifest,
11271
11791
  manifestFromNamedRefs(handoffRefs, shotRefsFromProjectFile(record8))
11272
11792
  );
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
- };
11793
+ return projectSaveEnvelope(existingRecord, record8, manifest, source, listing2);
11297
11794
  };
11298
11795
  const ctx = {
11299
11796
  actorUserId: deps.userId,
@@ -11359,7 +11856,6 @@ function projectFileFromRecord(record8) {
11359
11856
  const inner = record8?.project;
11360
11857
  return inner != null && typeof inner === "object" ? inner : record8;
11361
11858
  }
11362
- var SAVED_RECORD_VERSION;
11363
11859
  var init_projectPersistence = __esm({
11364
11860
  "src/project/projectPersistence.ts"() {
11365
11861
  "use strict";
@@ -11367,12 +11863,13 @@ var init_projectPersistence = __esm({
11367
11863
  init_projectRecord();
11368
11864
  init_uploadRef();
11369
11865
  init_screenManifest();
11866
+ init_recordEnvelope();
11867
+ init_appListing();
11370
11868
  init_authorization();
11371
11869
  init_projectResolution();
11372
11870
  init_schemas();
11373
11871
  init_screenshotInput();
11374
11872
  init_uploads();
11375
- SAVED_RECORD_VERSION = 2;
11376
11873
  }
11377
11874
  });
11378
11875
 
@@ -11658,7 +12155,6 @@ var init_projectAccessStore = __esm({
11658
12155
  });
11659
12156
 
11660
12157
  // ../api/_lib/projectLookStore.ts
11661
- var FREE_EDITABLE_PROJECT_LIMIT;
11662
12158
  var init_projectLookStore = __esm({
11663
12159
  "../api/_lib/projectLookStore.ts"() {
11664
12160
  "use strict";
@@ -11666,11 +12162,17 @@ var init_projectLookStore = __esm({
11666
12162
  init_look();
11667
12163
  init_lookDivergence();
11668
12164
  init_projectFileGuard();
12165
+ init_shotLook();
11669
12166
  init_supabaseRest();
11670
12167
  init_capabilities();
11671
- init_monetizationPolicy();
11672
12168
  init_projectAccessStore();
11673
- FREE_EDITABLE_PROJECT_LIMIT = MONETIZATION_POLICY.plans.free.ownedProjects;
12169
+ }
12170
+ });
12171
+
12172
+ // ../mockup-engine/access/projectActions.ts
12173
+ var init_projectActions = __esm({
12174
+ "../mockup-engine/access/projectActions.ts"() {
12175
+ "use strict";
11674
12176
  }
11675
12177
  });
11676
12178
 
@@ -11678,6 +12180,8 @@ var init_projectLookStore = __esm({
11678
12180
  var init_policy = __esm({
11679
12181
  "../api/_lib/authorization/policy.ts"() {
11680
12182
  "use strict";
12183
+ init_projectActions();
12184
+ init_monetizationPolicy();
11681
12185
  }
11682
12186
  });
11683
12187
 
@@ -11697,6 +12201,7 @@ var init_production = __esm({
11697
12201
  init_entitlement();
11698
12202
  init_monetizationPolicy();
11699
12203
  init_projectLookStore();
12204
+ init_projectActions();
11700
12205
  init_authorizeCapability();
11701
12206
  init_capabilities();
11702
12207
  init_policy();
@@ -12407,7 +12912,7 @@ var init_package = __esm({
12407
12912
  "package.json"() {
12408
12913
  package_default = {
12409
12914
  name: "shotops-mcp",
12410
- version: "0.9.7",
12915
+ version: "0.9.8",
12411
12916
  private: false,
12412
12917
  type: "module",
12413
12918
  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 +13100,7 @@ function projectStructure(projectFile) {
12595
13100
  }
12596
13101
  function projectCopyLocales(projectFile, locales, family = BASE_SCREEN_FAMILY) {
12597
13102
  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);
13103
+ const { captionText, baseLocale } = projectLocaleAxis(projectFile);
12600
13104
  const outputCaptions = normalizeOutputCaptionText(file?.outputCaptions);
12601
13105
  const { panelIds } = projectStructure(projectFile);
12602
13106
  const out = [];
@@ -12613,8 +13117,7 @@ function projectCopyLocales(projectFile, locales, family = BASE_SCREEN_FAMILY) {
12613
13117
  }
12614
13118
  function projectCopyGaps(projectFile, locales, family = BASE_SCREEN_FAMILY) {
12615
13119
  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);
13120
+ const { captionText, baseLocale } = projectLocaleAxis(projectFile);
12618
13121
  const outputCaptions = normalizeOutputCaptionText(file?.outputCaptions);
12619
13122
  const base = resolveOutputCaptionText(captionText, outputCaptions, family, baseLocale, baseLocale);
12620
13123
  if (!Object.values(base).some((layers) => layers.some((layer) => layer.text.trim().length > 0))) return [];
@@ -13072,8 +13575,7 @@ function onePanelStyle(value, panelIndex2) {
13072
13575
  }
13073
13576
  function projectPanelCaptionLayers(projectFile, panelId, variant) {
13074
13577
  const file = record3(projectFile);
13075
- const baseLocale = projectBaseLocale(projectFile);
13076
- const captionText = normalizeCaptionText(file?.captionText, file?.captionStyles, baseLocale);
13578
+ const { captionText, baseLocale } = projectLocaleAxis(projectFile);
13077
13579
  const outputCaptions = normalizeOutputCaptionText(file?.outputCaptions);
13078
13580
  const resolved = resolveOutputCaptionText(
13079
13581
  captionText,
@@ -13087,6 +13589,7 @@ function projectPanelCaptionLayers(projectFile, panelId, variant) {
13087
13589
  function projectPanelRecipes(projectFile, screens, targets) {
13088
13590
  const structure = projectStructure(projectFile);
13089
13591
  const file = record3(projectFile);
13592
+ const recordPreset = projectOutputAxis(projectFile).panelPresetId;
13090
13593
  const shots = Array.isArray(file?.shots) ? file.shots : [];
13091
13594
  const slotSizes = structure.panelIds.map(
13092
13595
  (panelId) => shots.filter((shot) => record3(shot)?.panelId === panelId).length
@@ -13095,14 +13598,14 @@ function projectPanelRecipes(projectFile, screens, targets) {
13095
13598
  const variant = projectRenderVariant(projectFile, target.locale, target.panelPresetId);
13096
13599
  return [screens.resolve(variant).then((resolved) => {
13097
13600
  const byShot = new Map(resolved.screenManifest.map((entry) => [entry.shotId, entry.bytes]));
13098
- const dimensions = panelDimensionsFor(target.panelPresetId ?? panelPresetFromProjectFile(projectFile) ?? "r69");
13601
+ const dimensions = panelDimensionsFor(target.panelPresetId ?? recordPreset);
13099
13602
  return structure.panelIds.flatMap((panelId, panelIndex2) => {
13100
13603
  const screenshots2 = shots.filter((shot) => record3(shot)?.panelId === panelId).map((shot) => byShot.get(String(record3(shot)?.id))).filter((bytes) => typeof bytes === "string");
13101
13604
  return [{
13102
13605
  key: [target.output, target.locale, `panel-${String(panelIndex2 + 1).padStart(4, "0")}`].filter(Boolean).join("-"),
13103
13606
  panelIndex: panelIndex2,
13104
13607
  screenshots: screenshots2,
13105
- panelPresetId: target.panelPresetId ?? panelPresetFromProjectFile(projectFile),
13608
+ panelPresetId: target.panelPresetId ?? recordPreset,
13106
13609
  look: onePanelLook(projectFile, panelIndex2, slotSizes, panelId),
13107
13610
  captionLayers: projectPanelCaptionLayers(projectFile, panelId, variant),
13108
13611
  backgroundViewport: { panelIndex: panelIndex2, panelCount: structure.panelIds.length },
@@ -13166,10 +13669,6 @@ function frameNamesFromProjectFile(projectFile) {
13166
13669
  if (!Array.isArray(shots)) return [];
13167
13670
  return shots.map((s) => s?.frameName).filter((n) => typeof n === "string" && n.length > 0);
13168
13671
  }
13169
- function panelPresetFromProjectFile(projectFile) {
13170
- const preset = projectFile?.panelPresetId;
13171
- return typeof preset === "string" && preset.length > 0 ? preset : void 0;
13172
- }
13173
13672
  function outputDevicesFromProjectFile(projectFile) {
13174
13673
  const outputs = projectFile?.outputs;
13175
13674
  if (!Array.isArray(outputs)) return { devices: [], dropped: [] };
@@ -13180,7 +13679,7 @@ async function loadProjectRecord(deps, projectId) {
13180
13679
  const project2 = resolvedProject(resolution);
13181
13680
  if (!project2) return { error: describeMiss(resolution, projectId) };
13182
13681
  const rec = await deps.readProjectRecord(project2.id);
13183
- if (!rec) {
13682
+ if (!rec || isBlankRecord(rec.record)) {
13184
13683
  return {
13185
13684
  error: `Project "${project2.name}" has no saved record yet \u2014 open it in ShotOps and save a strip first.`
13186
13685
  };
@@ -13193,27 +13692,22 @@ async function loadProjectRecord(deps, projectId) {
13193
13692
  };
13194
13693
  }
13195
13694
  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]));
13695
+ return projectLocaleAxis(projectFile).baseLocale;
13206
13696
  }
13207
13697
  function projectRenderLocale(projectFile, requested) {
13698
+ const { locales, baseLocale } = projectLocaleAxis(projectFile);
13208
13699
  const candidate = requested?.trim();
13209
- return matchAuthoredLocale(projectLocales(projectFile), candidate) ?? candidate ?? projectBaseLocale(projectFile);
13700
+ return matchAuthoredLocale(locales, candidate) ?? candidate ?? baseLocale;
13701
+ }
13702
+ function activeRenderDevice(projectFile, renderable) {
13703
+ const active = projectOutputAxis(projectFile).activeDeviceId;
13704
+ return renderable.find((device) => device.id === active) ?? renderable[0];
13210
13705
  }
13211
13706
  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;
13707
+ const preset = panelPresetId2 ?? projectOutputAxis(projectFile).panelPresetId;
13215
13708
  const baseLocale = projectBaseLocale(projectFile);
13216
- return canonicalVariant({ family, locale: projectRenderLocale(projectFile, locale2) }, baseLocale);
13709
+ const resolved = projectRenderLocale(projectFile, locale2);
13710
+ return canonicalVariant({ family: screenFamilyForPreset(preset), locale: resolved }, baseLocale);
13217
13711
  }
13218
13712
  async function resolveProjectScreens(deps, loaded, supplied) {
13219
13713
  const baseLocale = projectBaseLocale(loaded.projectFile);
@@ -13262,7 +13756,7 @@ async function resolveProjectScreens(deps, loaded, supplied) {
13262
13756
  "lenient",
13263
13757
  baseLocale
13264
13758
  );
13265
- const storedManifest = parsed ? allManifestEntries(parsed) : [];
13759
+ const storedManifest2 = parsed ? allManifestEntries(parsed) : [];
13266
13760
  const repairFindings = [];
13267
13761
  let canReadStored = Boolean(deps.fetchHandoffBytes || deps.readProjectScreens);
13268
13762
  const storedSources = [];
@@ -13292,7 +13786,7 @@ async function resolveProjectScreens(deps, loaded, supplied) {
13292
13786
  canReadStored = Boolean(deps.fetchHandoffBytes);
13293
13787
  }
13294
13788
  } else if (deps.fetchHandoffBytes) {
13295
- const resolvedManifest = resolveScreenManifest(storedManifest, shots);
13789
+ const resolvedManifest = resolveScreenManifest(storedManifest2, shots);
13296
13790
  repairFindings.push(...resolvedManifest.findings);
13297
13791
  for (const cell of resolvedManifest.cells) {
13298
13792
  let pending;
@@ -13375,7 +13869,7 @@ async function resolveProjectScreens(deps, loaded, supplied) {
13375
13869
  }
13376
13870
  return { screenManifest, missing };
13377
13871
  };
13378
- return { expected, unnamed, storedCount: storedManifest.length, canReadStored, resolve: resolve14, plan, repairFindings };
13872
+ return { expected, unnamed, storedCount: storedManifest2.length, canReadStored, resolve: resolve14, plan, repairFindings };
13379
13873
  }
13380
13874
  function noProjectScreensMessage(screens, suppliedCount, toolName) {
13381
13875
  let lead;
@@ -13393,7 +13887,7 @@ function noProjectScreensMessage(screens, suppliedCount, toolName) {
13393
13887
  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
13888
  }
13395
13889
  function coordinateLabel(variant) {
13396
- return !variant || variantKey(variant) === "" ? "base" : variantKey(variant);
13890
+ return !variant || isBaseVariant(variant) ? "base" : variantKey(variant);
13397
13891
  }
13398
13892
  function projectScreenResolutionNote(shots) {
13399
13893
  const groups = /* @__PURE__ */ new Map();
@@ -13473,8 +13967,8 @@ async function renderProject(deps, args, scope, attempt, signal) {
13473
13967
  const requested = resolveOutputDevices(args.outputs);
13474
13968
  const chosen = requested.devices.length > 0 ? requested : outputDevicesFromProjectFile(projectFile);
13475
13969
  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];
13970
+ const recordPreset = projectOutputAxis(projectFile).panelPresetId;
13971
+ const active = activeRenderDevice(projectFile, chosen.devices);
13478
13972
  const ordered = active ? [active, ...chosen.devices.filter((d) => d.id !== active.id)] : [];
13479
13973
  const mayOverridePreset = requested.devices.length > 0 || chosen.devices.length > 1;
13480
13974
  const requestedLocale = args.locale?.trim() || void 0;
@@ -13498,7 +13992,7 @@ async function renderProject(deps, args, scope, attempt, signal) {
13498
13992
  locales: [snapshotLocale],
13499
13993
  outputs: ordered.map((device) => device.id),
13500
13994
  unsupportedOutputs: chosen.dropped,
13501
- panelPresetId: active?.panelPresetId ?? recordPreset ?? null
13995
+ panelPresetId: active?.panelPresetId ?? recordPreset
13502
13996
  },
13503
13997
  baseLocale: projectBaseLocale(projectFile),
13504
13998
  gaps: repairGaps(screens.repairFindings)
@@ -13519,9 +14013,9 @@ async function renderProject(deps, args, scope, attempt, signal) {
13519
14013
  });
13520
14014
  };
13521
14015
  if (durableHostedProductionAvailable(deps) && args.preview !== true) {
13522
- const activePreset = active && mayOverridePreset ? active.panelPresetId : recordPreset ?? "r69";
14016
+ const activePreset = active && mayOverridePreset ? active.panelPresetId : recordPreset;
13523
14017
  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 } : {} }];
14018
+ const targets = ordered.length > 0 ? ordered.map((device) => ({ locale: snapshotLocale, panelPresetId: device.panelPresetId, output: device.id })) : [{ locale: snapshotLocale, panelPresetId: recordPreset }];
13525
14019
  const recipes = await projectPanelRecipes(projectFile, screens, targets);
13526
14020
  const panelCount = projectStructure(projectFile).panelIds.length || recipes.length;
13527
14021
  const durableResolved = await screens.resolve(activeVariant);
@@ -13768,6 +14262,7 @@ var init_toolsRender = __esm({
13768
14262
  init_productionRenderAccess();
13769
14263
  init_outputMode();
13770
14264
  init_appstore();
14265
+ init_recordEnvelope();
13771
14266
  init_screenshotInput();
13772
14267
  init_uploads();
13773
14268
  init_resultContract();
@@ -13840,10 +14335,87 @@ function publicRefineProjectReceipt(receipt) {
13840
14335
  }
13841
14336
  };
13842
14337
  }
13843
- var init_refineProjectReceipt = __esm({
13844
- "src/project/refineProjectReceipt.ts"() {
14338
+ var init_refineProjectReceipt = __esm({
14339
+ "src/project/refineProjectReceipt.ts"() {
14340
+ "use strict";
14341
+ init_monetizationPolicy();
14342
+ }
14343
+ });
14344
+
14345
+ // ../mockup-engine/recordFromStrip.ts
14346
+ function normalizeSourceName(raw) {
14347
+ const name = raw?.trim();
14348
+ if (!name) return null;
14349
+ return /\.(png|jpe?g|webp)$/i.test(name) ? name : `${name}.png`;
14350
+ }
14351
+ function frameNamesFor(shots, shotNames) {
14352
+ const seen = /* @__PURE__ */ new Map();
14353
+ return shots.map((_, i) => {
14354
+ const real = normalizeSourceName(shotNames?.[i]);
14355
+ if (!real) return `screen-${i + 1}.png`;
14356
+ const count = seen.get(real) ?? 0;
14357
+ seen.set(real, count + 1);
14358
+ if (count === 0) return real;
14359
+ const dot = real.lastIndexOf(".");
14360
+ return `${real.slice(0, dot)}-${count + 1}${real.slice(dot)}`;
14361
+ });
14362
+ }
14363
+ function recordFromStrip(input) {
14364
+ const { look: look2, panelIds, captionText, locale: locale2, shotNames, outputs, locales, baseLocale } = input;
14365
+ const panels = panelIds.map((id) => ({ id }));
14366
+ const frameNames = frameNamesFor(look2.shots, shotNames);
14367
+ const shots = look2.shots.map((s, i) => ({
14368
+ id: `shot-${i + 1}`,
14369
+ panelId: s.panelId,
14370
+ frameNodeId: frameNames[i],
14371
+ frameName: frameNames[i],
14372
+ bytes: null,
14373
+ look: s.look,
14374
+ thumb: null,
14375
+ thumbStale: true
14376
+ }));
14377
+ return serializeProject({
14378
+ shots,
14379
+ panels,
14380
+ selectedShotId: shots[0]?.id ?? null,
14381
+ panelPresetId: look2.panelPresetId,
14382
+ bgMode: look2.bgMode,
14383
+ // #556 — the CANONICAL gradient, forwarded alongside the mirror. `stripFromSlots` genuinely
14384
+ // resolves stops+angle (it runs every styling source through `stripGradientOf`), so omitting
14385
+ // them here was live data loss: a three-stop backdrop at 37° was persisted as its two-stop
14386
+ // mirror on the nearest axis, and the middle stops were gone from the record for good. The
14387
+ // sibling render path (`buildStrip.ts`, #463) always got this right.
14388
+ gradientStops: look2.gradientStops,
14389
+ gradientAngle: look2.gradientAngle,
14390
+ gradientFrom: look2.gradientFrom,
14391
+ gradientTo: look2.gradientTo,
14392
+ gradientDir: look2.gradientDir,
14393
+ panelColors: look2.panelColors,
14394
+ shadow: look2.shadow,
14395
+ // #564 — the same drop, one field away and not a mirror bug at all: `serialize.ts` turns an
14396
+ // absent `floorReflection` into `false`, so a render that showed a floor reflection saved a
14397
+ // project that did not have one (the `studio` design preset is one of the two that turn it on).
14398
+ floorReflection: look2.floorReflection,
14399
+ panelBackgrounds: look2.panelBackgrounds,
14400
+ captionStyles: look2.captionStyles,
14401
+ // #40 — the selected target devices. Passed straight to serializeProject, which runs it
14402
+ // through deriveOutputs: an undefined list migrates to the single device matching
14403
+ // `panelPresetId` (the pre-#40 record, unchanged), a supplied one is trusted verbatim minus
14404
+ // unknown/coming-soon ids.
14405
+ outputs,
14406
+ // #1001 — the selected languages, when the caller knows them. Omitted by every render path, and
14407
+ // `serializeProject` then derives the list exactly as it always has (caption keys ∪ the base).
14408
+ ...locales ? { locales } : {},
14409
+ ...baseLocale ? { baseLocale } : {},
14410
+ // Nest this render's flat caption text under its locale (default en-US) so the stored blob
14411
+ // is locale-correct; serializeProject's normalizeCaptionText passes a nested map through.
14412
+ captionText: captionText && Object.keys(captionText).length > 0 ? { [locale2 || DEFAULT_LOCALE]: captionText } : {}
14413
+ });
14414
+ }
14415
+ var init_recordFromStrip = __esm({
14416
+ "../mockup-engine/recordFromStrip.ts"() {
13845
14417
  "use strict";
13846
- init_monetizationPolicy();
14418
+ init_appstore();
13847
14419
  }
13848
14420
  });
13849
14421
 
@@ -14013,80 +14585,251 @@ var init_stripFromSlots = __esm({
14013
14585
  "use strict";
14014
14586
  init_appstore();
14015
14587
  init_gradient();
14016
- NUMERIC_STRING_FIELDS = ["roll", "phoneHeight", "hOffset", "vOffset", "finish", "clearcoat"];
14588
+ NUMERIC_STRING_FIELDS = ["roll", "deviceSize", "hOffset", "vOffset", "finish", "clearcoat"];
14017
14589
  }
14018
14590
  });
14019
14591
 
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`;
14592
+ // ../mockup-engine/appstore/importScreens.ts
14593
+ var init_importScreens = __esm({
14594
+ "../mockup-engine/appstore/importScreens.ts"() {
14595
+ "use strict";
14596
+ init_defaults();
14597
+ init_recordFromStrip();
14598
+ init_stripFromSlots();
14599
+ init_projectFileGuard();
14600
+ init_recordEnvelope();
14601
+ init_screenManifest();
14602
+ }
14603
+ });
14604
+
14605
+ // ../mockup-engine/appstore/reach.ts
14606
+ function reachUploadsScreens(reach) {
14607
+ return reach === "account";
14025
14608
  }
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)}`;
14036
- });
14609
+ function readReach(value) {
14610
+ if (value === "device" || value === "account") return value;
14611
+ if (value === "shared") return "account";
14612
+ return null;
14037
14613
  }
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 } : {}
14614
+ var init_reach = __esm({
14615
+ "../mockup-engine/appstore/reach.ts"() {
14616
+ "use strict";
14617
+ }
14618
+ });
14619
+
14620
+ // ../mockup-engine/ops/persistenceOps.ts
14621
+ function envelopeOf(record8) {
14622
+ return record8 != null && typeof record8 === "object" && !Array.isArray(record8) ? record8 : {};
14623
+ }
14624
+ function storedManifest(record8) {
14625
+ const parsed = parseScreenManifest(envelopeOf(record8).handoffRefs);
14626
+ return parsed ? allManifestEntries(parsed) : [];
14627
+ }
14628
+ function storedCustody(record8) {
14629
+ const parsed = parseScreenManifest(envelopeOf(record8).screenPurgeRefs);
14630
+ return parsed ? allManifestEntries(parsed) : [];
14631
+ }
14632
+ function writeReach(record8, override) {
14633
+ if (override) return override;
14634
+ return readReach(envelopeOf(record8).reach) ?? "account";
14635
+ }
14636
+ function planScreenSave(args) {
14637
+ const kept = storedManifest(args.record);
14638
+ const reach = writeReach(args.record, args.reach);
14639
+ if (!reachUploadsScreens(reach)) {
14640
+ return { toUpload: [], kept, alreadyDurable: [], nothingToUpload: true };
14641
+ }
14642
+ const { toUpload } = planScreenUploads(args.cells, kept, args.shots);
14643
+ const going = new Set(toUpload.map((cell) => manifestShotCellKey(cell.shotId, cell.variant)));
14644
+ const alreadyDurable = args.cells.filter(
14645
+ (cell) => !going.has(manifestShotCellKey(cell.shotId, cell.variant))
14646
+ );
14647
+ return {
14648
+ toUpload: toUpload.map(({ shotId, variant, hash }) => ({ shotId, variant, hash })),
14649
+ kept,
14650
+ alreadyDurable: alreadyDurable.map(({ shotId, variant, hash }) => ({ shotId, variant, hash })),
14651
+ nothingToUpload: toUpload.length === 0
14652
+ };
14653
+ }
14654
+ function reserveUploads(cells, slots) {
14655
+ const byCell = new Map(cells.map((cell) => [manifestShotCellKey(cell.shotId, cell.variant), cell]));
14656
+ const reserved = [];
14657
+ for (const slot2 of slots) {
14658
+ const cell = byCell.get(manifestShotCellKey(slot2.shotId, slot2.variant));
14659
+ if (!cell) continue;
14660
+ reserved.push({
14661
+ cell,
14662
+ slot: slot2,
14663
+ entry: { shotId: slot2.shotId, variant: slot2.variant, hash: cell.hash, ref: slot2.ref }
14664
+ });
14665
+ }
14666
+ return reserved;
14667
+ }
14668
+ function recordScreenSave(args) {
14669
+ const reach = writeReach(args.record, args.reach);
14670
+ const manifest = reachUploadsScreens(reach) ? mergeManifest(args.kept, args.uploaded) : [];
14671
+ const custody = custodyUnion(storedCustody(args.record), args.heldCustody ?? [], args.kept, args.uploaded);
14672
+ const envelope = envelopeOf(args.record);
14673
+ return {
14674
+ record: rebuildEnvelope(args.record, {
14675
+ project: envelope.project,
14676
+ manifest,
14677
+ reach,
14678
+ ...custody.length > 0 ? { screenPurgeRefs: custody } : {}
14679
+ }),
14680
+ manifest,
14681
+ custody,
14682
+ reach
14683
+ };
14684
+ }
14685
+ async function saveProjectScreens(request, transport) {
14686
+ const reach = writeReach(request.record, request.reach);
14687
+ const plan = planScreenSave({
14688
+ record: request.record,
14689
+ shots: request.shots,
14690
+ cells: request.cells,
14691
+ reach
14692
+ });
14693
+ const build = request.rebuild ?? ((rewrite) => recordScreenSave({
14694
+ record: request.record,
14695
+ kept: rewrite.manifest,
14696
+ uploaded: [],
14697
+ heldCustody: rewrite.custody,
14698
+ reach: rewrite.reach
14699
+ }).record);
14700
+ const overtaken = () => transport.overtaken?.() === true;
14701
+ const held = custodyUnion(storedCustody(request.record), request.heldCustody ?? []);
14702
+ const settled = (uploaded2, custody2, projectId2, stopped) => {
14703
+ const manifest = reachUploadsScreens(reach) ? mergeManifest(plan.kept, uploaded2) : [];
14704
+ return {
14705
+ projectId: projectId2,
14706
+ record: build({ manifest, custody: custody2, reach }),
14707
+ manifest,
14708
+ custody: custody2,
14709
+ uploaded: uploaded2,
14710
+ stopped
14711
+ };
14712
+ };
14713
+ let projectId = request.projectId;
14714
+ if (projectId === null) {
14715
+ if (!transport.insertRow) throw new Error("This project has no row yet and the transport cannot insert one");
14716
+ const custody2 = custodyUnion(held, plan.kept);
14717
+ const created = await transport.insertRow({ record: build({ manifest: plan.kept, custody: custody2, reach }) });
14718
+ projectId = created.projectId;
14719
+ if (overtaken()) return settled([], custody2, projectId, true);
14720
+ }
14721
+ if (plan.nothingToUpload) {
14722
+ return settled([], custodyUnion(held, plan.kept), projectId, false);
14723
+ }
14724
+ const slots = await transport.mintUploadSlots({ projectId, cells: plan.toUpload });
14725
+ if (overtaken()) return settled([], custodyUnion(held, plan.kept), projectId, true);
14726
+ const reserved = reserveUploads(request.cells, slots);
14727
+ const custody = custodyUnion(held, plan.kept, reserved.map(({ entry }) => entry));
14728
+ if (reserved.length === 0) return settled([], custody, projectId, false);
14729
+ await transport.saveRecord({ projectId, record: build({ manifest: plan.kept, custody, reach }) });
14730
+ if (overtaken()) return settled([], custody, projectId, true);
14731
+ const uploaded = [];
14732
+ for (const { entry, slot: slot2, cell } of reserved) {
14733
+ await transport.putBytes({ slot: slot2, bytes: cell.bytes });
14734
+ if (overtaken()) return settled([], custody, projectId, true);
14735
+ uploaded.push(entry);
14736
+ }
14737
+ const done = settled(uploaded, custody, projectId, false);
14738
+ await transport.saveRecord({ projectId, record: done.record });
14739
+ return done;
14740
+ }
14741
+ var init_persistenceOps = __esm({
14742
+ "../mockup-engine/ops/persistenceOps.ts"() {
14743
+ "use strict";
14744
+ init_recordEnvelope();
14745
+ init_importScreens();
14746
+ init_screenManifest();
14747
+ init_screenUploadPlan();
14748
+ init_variant();
14749
+ init_reach();
14750
+ init_reach();
14751
+ }
14752
+ });
14753
+
14754
+ // ../mockup-engine/appstore/blankProject.ts
14755
+ var init_blankProject = __esm({
14756
+ "../mockup-engine/appstore/blankProject.ts"() {
14757
+ "use strict";
14758
+ init_recordFromStrip();
14759
+ init_stripFromSlots();
14760
+ init_recordEnvelope();
14761
+ }
14762
+ });
14763
+
14764
+ // ../mockup-engine/appstore/captionEdit.ts
14765
+ var init_captionEdit = __esm({
14766
+ "../mockup-engine/appstore/captionEdit.ts"() {
14767
+ "use strict";
14768
+ init_defaults();
14769
+ init_captionModel();
14770
+ init_recordTransforms();
14771
+ }
14772
+ });
14773
+
14774
+ // ../mockup-engine/captionTranslationPlan.ts
14775
+ var init_captionTranslationPlan = __esm({
14776
+ "../mockup-engine/captionTranslationPlan.ts"() {
14777
+ "use strict";
14778
+ }
14779
+ });
14780
+
14781
+ // ../mockup-engine/appstore/translationTable.ts
14782
+ var init_translationTable = __esm({
14783
+ "../mockup-engine/appstore/translationTable.ts"() {
14784
+ "use strict";
14785
+ init_appStoreLocales();
14786
+ init_captionEdit();
14787
+ init_captionModel();
14788
+ init_localeAxis();
14789
+ init_captionTranslationPlan();
14790
+ }
14791
+ });
14792
+
14793
+ // ../mockup-engine/ops/editOps.ts
14794
+ function buildRecord(input) {
14795
+ const { look: look2, panelIds, captionText } = stripFromSlots({
14796
+ slotSizes: input.slotSizes,
14797
+ panelPresetId: input.panelPresetId,
14798
+ look: input.look ?? void 0,
14799
+ style: input.style ?? void 0
14800
+ });
14801
+ return recordFromStrip({
14802
+ look: look2,
14803
+ panelIds,
14804
+ captionText,
14805
+ locale: input.locale,
14806
+ shotNames: input.screenshotNames,
14807
+ outputs: input.outputs,
14808
+ ...input.locales ? { locales: input.locales } : {},
14809
+ ...input.baseLocale ? { baseLocale: input.baseLocale } : {}
14084
14810
  });
14085
14811
  }
14086
- var init_recordFromStrip = __esm({
14087
- "../mockup-engine/recordFromStrip.ts"() {
14812
+ var STRIP_PREVIEW;
14813
+ var init_editOps = __esm({
14814
+ "../mockup-engine/ops/editOps.ts"() {
14088
14815
  "use strict";
14816
+ init_gradient();
14817
+ init_variant();
14818
+ init_persistenceOps();
14089
14819
  init_appstore();
14820
+ init_appListing();
14821
+ init_blankProject();
14822
+ init_captionEdit();
14823
+ init_recordEnvelope();
14824
+ init_translationTable();
14825
+ init_recordFromStrip();
14826
+ init_stripFromSlots();
14827
+ STRIP_PREVIEW = {
14828
+ /** The band's aspect, width ÷ height. */
14829
+ bandRatio: 25 / 11,
14830
+ /** How many frames fill the band before it starts scrolling. */
14831
+ framesAcross: 5
14832
+ };
14090
14833
  }
14091
14834
  });
14092
14835
 
@@ -14158,8 +14901,7 @@ var init_renderer = __esm({
14158
14901
  "src/render/renderer.ts"() {
14159
14902
  "use strict";
14160
14903
  init_packagePaths();
14161
- init_stripFromSlots();
14162
- init_recordFromStrip();
14904
+ init_editOps();
14163
14905
  init_log();
14164
14906
  init_staticServer();
14165
14907
  init_browserInstall();
@@ -14245,23 +14987,20 @@ var init_renderer = __esm({
14245
14987
  // buildRecord: the SAME styling seam the render uses (stripFromSlots → recordFromStrip), run
14246
14988
  // in Node with NO browser. Both are pure, browser-free @engine functions and a record carries
14247
14989
  // 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.
14990
+ // — the fix for the Fly proxy timeout on that path. It is the engine's `buildRecord` op, the one
14991
+ // the phone's bridge calls too, so a save_project record, a render({buildRecord:true}) record and
14992
+ // a record built on the phone are the same record rather than three copies kept aligned by hand.
14251
14993
  async buildRecord(input) {
14252
- const { look: look2, panelIds, captionText } = stripFromSlots({
14994
+ return buildRecord({
14253
14995
  slotSizes: input.slotSizes,
14254
14996
  panelPresetId: input.panelPresetId,
14255
14997
  look: input.look ?? void 0,
14256
- style: input.style ?? void 0
14257
- });
14258
- return recordFromStrip({
14259
- look: look2,
14260
- panelIds,
14261
- captionText,
14998
+ style: input.style ?? void 0,
14262
14999
  locale: input.locale,
14263
- shotNames: input.screenshotNames,
14264
- outputs: input.outputs
15000
+ screenshotNames: input.screenshotNames,
15001
+ outputs: input.outputs,
15002
+ locales: input.locales,
15003
+ baseLocale: input.baseLocale
14265
15004
  });
14266
15005
  }
14267
15006
  async render(input) {
@@ -14398,6 +15137,9 @@ function parseControlPlaneResponse(op, value) {
14398
15137
  case "list_projects":
14399
15138
  if (!Array.isArray(body.projects)) throw new Error("control plane response is missing projects");
14400
15139
  if (!object(body.ownedProjects)) throw new Error("control plane response is missing ownedProjects");
15140
+ for (const row2 of body.projects) {
15141
+ if (!object(object(row2)?.actions)) throw new Error("control plane response project row is missing actions");
15142
+ }
14401
15143
  break;
14402
15144
  case "read_look":
14403
15145
  if (!Object.prototype.hasOwnProperty.call(body, "look")) throw new Error("control plane response is missing look");
@@ -14462,6 +15204,16 @@ function parseControlPlaneResponse(op, value) {
14462
15204
  throw new Error("control plane response is missing clear_look result");
14463
15205
  }
14464
15206
  break;
15207
+ case "search_app_listing":
15208
+ if (typeof body.error !== "string" && !Array.isArray(body.results)) {
15209
+ throw new Error("control plane response is missing results");
15210
+ }
15211
+ break;
15212
+ case "resolve_app_listing":
15213
+ if (typeof body.error !== "string" && !Object.prototype.hasOwnProperty.call(body, "listing")) {
15214
+ throw new Error("control plane response is missing listing");
15215
+ }
15216
+ break;
14465
15217
  case "create_project":
14466
15218
  case "duplicate_project":
14467
15219
  requiredString(body.id, "id");
@@ -14698,6 +15450,8 @@ function connectHostedBridge(token2) {
14698
15450
  const res = await callOp(token2, "save_look", { project: projectId, look: look2, ...sourceName ? { sourceName } : {} });
14699
15451
  return { version: res.version };
14700
15452
  },
15453
+ searchAppListing: (term, country) => callOp(token2, "search_app_listing", { term, ...country ? { country } : {} }),
15454
+ resolveAppListing: (reference, country) => callOp(token2, "resolve_app_listing", { reference, ...country ? { country } : {} }),
14701
15455
  async createProject(_userId, name, record8) {
14702
15456
  const res = await callOp(token2, "create_project", { name, record: record8 });
14703
15457
  return { id: res.id, recordVersion: res.recordVersion };
@@ -14751,7 +15505,7 @@ var init_hostedBridge = __esm({
14751
15505
  }
14752
15506
  });
14753
15507
 
14754
- // ../mockup-engine/layoutFindings.ts
15508
+ // ../mockup-engine/layoutFindingSentence.ts
14755
15509
  function describeUnresolvedLayout(findings) {
14756
15510
  if (!findings || findings.length === 0) return null;
14757
15511
  const frames = Array.from(new Set(findings.flatMap((finding) => finding.frames))).sort((left, right) => left - right).map((index) => `Frame ${index + 1}`);
@@ -14762,9 +15516,49 @@ function describeUnresolvedLayout(findings) {
14762
15516
  }
14763
15517
  return `${findings.length} layout overlaps still remain${where}.`;
14764
15518
  }
14765
- var init_layoutFindings = __esm({
14766
- "../mockup-engine/layoutFindings.ts"() {
15519
+ var init_layoutFindingSentence = __esm({
15520
+ "../mockup-engine/layoutFindingSentence.ts"() {
15521
+ "use strict";
15522
+ }
15523
+ });
15524
+
15525
+ // ../mockup-engine/ops/agentTurnOps.ts
15526
+ function createAgentTurnOp(sources) {
15527
+ const assetTable = mintAgentAssets(sources);
15528
+ return {
15529
+ assetTable,
15530
+ run: (input, ports) => runAgentTurn({ ...input, assets: assetTable.descriptors }, {
15531
+ loadProject: ports.loadProject,
15532
+ renderComposed: ports.renderComposed,
15533
+ authorize: ports.authorize,
15534
+ emit: ports.emit,
15535
+ unresolvedLayout: (rendered) => describeUnresolvedLayout(ports.layoutFindings(rendered)),
15536
+ commit: async ({ after, assetBindings, expectedVersion }) => {
15537
+ const current = ports.readCustody();
15538
+ const custody = assetBindings.length > 0 ? bindAgentAssets(
15539
+ assetTable,
15540
+ assetBindings,
15541
+ current.handoffRefs,
15542
+ after.shots.map((shot) => ({ id: shot.id, frameName: shot.frameName }))
15543
+ ) : null;
15544
+ return ports.commitProject({
15545
+ after,
15546
+ expectedVersion,
15547
+ custody: custody && {
15548
+ handoffRefs: custody.handoffRefs,
15549
+ screenPurgeRefs: foldDisplacedRefs(current.screenPurgeRefs, custody.displaced)
15550
+ }
15551
+ });
15552
+ }
15553
+ })
15554
+ };
15555
+ }
15556
+ var init_agentTurnOps = __esm({
15557
+ "../mockup-engine/ops/agentTurnOps.ts"() {
14767
15558
  "use strict";
15559
+ init_layoutFindingSentence();
15560
+ init_agentAssetCustody();
15561
+ init_agentTurnCoordinator();
14768
15562
  }
14769
15563
  });
14770
15564
 
@@ -14825,10 +15619,10 @@ var init_aiShotProjection = __esm({
14825
15619
  schema: z9.enum(["front", "left", "right"]).optional(),
14826
15620
  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
15621
  },
14828
- phoneHeight: {
15622
+ deviceSize: {
14829
15623
  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)`
15624
+ schema: z9.number().min(AI_SHOT_RANGES.deviceSize.min).max(AI_SHOT_RANGES.deviceSize.max).optional(),
15625
+ 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
15626
  },
14833
15627
  hOffset: {
14834
15628
  exposure: "included",
@@ -14899,6 +15693,16 @@ var init_aiShotProjection = __esm({
14899
15693
  exposure: "included",
14900
15694
  schema: z9.boolean().optional(),
14901
15695
  prompt: "flatScreen, glare, lighting, reflections, clipToFrame: true | false."
15696
+ },
15697
+ deviceFrame: {
15698
+ exposure: "included",
15699
+ schema: z9.boolean().optional(),
15700
+ 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.'
15701
+ },
15702
+ screenCornerRadius: {
15703
+ exposure: "included",
15704
+ schema: z9.number().min(AI_SHOT_RANGES.screenCornerRadius.min).max(AI_SHOT_RANGES.screenCornerRadius.max).optional(),
15705
+ 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
15706
  }
14903
15707
  };
14904
15708
  API_AI_SHOT_INCLUDED_ENTRIES = Object.entries(API_AI_SHOT_PROJECTION).filter((entry) => entry[1].exposure === "included");
@@ -14979,7 +15783,11 @@ var init_refineProjectContracts = __esm({
14979
15783
  });
14980
15784
  framePatchSchema = z10.object({
14981
15785
  captionStyle: captionStylePatchSchema.optional(),
14982
- background: backgroundPatchSchema.optional()
15786
+ background: backgroundPatchSchema.optional(),
15787
+ // The per-frame half of the strip-wide `layout`, on the same two-scope footing as `captionStyle`
15788
+ // and `background` above (#266). Without it a composition asked for on SOME frames had to be
15789
+ // spent on ALL of them.
15790
+ layout: z10.enum(START_LAYOUT_IDS).optional()
14983
15791
  });
14984
15792
  currentCaptionStyleSchema = captionStylePatchSchema.extend({
14985
15793
  color: currentColorSchema.optional()
@@ -15418,15 +16226,13 @@ function recordCustody(existing) {
15418
16226
  };
15419
16227
  }
15420
16228
  function recordEnvelope(existing, state, custody) {
15421
- const stored = existing != null && typeof existing === "object" && !Array.isArray(existing) ? { ...existing } : { record: 2 };
15422
- return {
15423
- ...stored,
16229
+ return rebuildEnvelope(existing, {
15424
16230
  project: serializeProject(state),
15425
16231
  ...custody ? {
15426
- handoffRefs: custody.handoffRefs,
16232
+ manifest: custody.handoffRefs,
15427
16233
  ...custody.screenPurgeRefs.length > 0 ? { screenPurgeRefs: custody.screenPurgeRefs } : {}
15428
16234
  } : {}
15429
- };
16235
+ });
15430
16236
  }
15431
16237
  function aiCaptionStyleFromLayer(layer) {
15432
16238
  if (!layer) return {};
@@ -15748,13 +16554,14 @@ async function runMcpProjectAgentTurn(deps, input, runtime) {
15748
16554
  return { ...INVALID_ATTACHMENT };
15749
16555
  }
15750
16556
  }
15751
- const assetTable = mintAgentAssets(attachments.map((attachment) => ({
16557
+ const turnOp = createAgentTurnOp(attachments.map((attachment) => ({
15752
16558
  ref: attachment.ref,
15753
16559
  label: attachment.label,
15754
16560
  ...attachment.width !== void 0 ? { width: attachment.width } : {},
15755
16561
  ...attachment.height !== void 0 ? { height: attachment.height } : {},
15756
16562
  singleUse: false
15757
16563
  })));
16564
+ const assetTable = turnOp.assetTable;
15758
16565
  const turnId = randomUUID3();
15759
16566
  const turnKey = `turn_${turnId}`;
15760
16567
  const startedAt = input.startedAt ?? (/* @__PURE__ */ new Date()).toISOString();
@@ -15783,11 +16590,10 @@ async function runMcpProjectAgentTurn(deps, input, runtime) {
15783
16590
  assets: assetTable.descriptors,
15784
16591
  assetTable
15785
16592
  };
15786
- const result = await runAgentTurn({
16593
+ const result = await turnOp.run({
15787
16594
  turnId,
15788
16595
  instruction: input.instruction,
15789
16596
  focus: input.focus,
15790
- assets: assetTable.descriptors,
15791
16597
  requestedCycle: input.requestedCycle,
15792
16598
  startedAt,
15793
16599
  cancelled: input.cancelled
@@ -15819,7 +16625,7 @@ async function runMcpProjectAgentTurn(deps, input, runtime) {
15819
16625
  const resolved = await withBoundAttachments(stored, state, variant, boundAssets, assetTable, readAttachmentBytes);
15820
16626
  return renderedTurn(deps, state, resolved);
15821
16627
  },
15822
- unresolvedLayout: (rendered) => describeUnresolvedLayout(rendered.layoutFindings),
16628
+ layoutFindings: (rendered) => rendered.layoutFindings,
15823
16629
  authorize: async ({ cycle, state, rendered, previous }) => {
15824
16630
  const request = requestForPass(context, { cycle, state, rendered, previous });
15825
16631
  const authorization = await authorizePass(
@@ -15834,21 +16640,12 @@ async function runMcpProjectAgentTurn(deps, input, runtime) {
15834
16640
  const { patch: _refused, ...rest } = authorization;
15835
16641
  return { ...rest, terminalOutcome: failureOutcome("invalid_plan", handles.failure.message) };
15836
16642
  },
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;
16643
+ readCustody: () => recordCustody(storedRecord),
16644
+ commitProject: async ({ after, expectedVersion, custody }) => {
15845
16645
  const write = await runtime.writeProjectRecord(
15846
16646
  project2.id,
15847
16647
  expectedVersion,
15848
- recordEnvelope(storedRecord, after, bound ? {
15849
- handoffRefs: bound.handoffRefs,
15850
- screenPurgeRefs: foldDisplacedRefs(custody.screenPurgeRefs, bound.displaced)
15851
- } : void 0),
16648
+ recordEnvelope(storedRecord, after, custody ?? void 0),
15852
16649
  { actorUserId: deps.userId }
15853
16650
  );
15854
16651
  if (write.ok) return { ok: true, data: { projectId: project2.id, recordVersion: write.recordVersion } };
@@ -15921,10 +16718,11 @@ var init_agentTurnAdapter = __esm({
15921
16718
  "src/project/agentTurnAdapter.ts"() {
15922
16719
  "use strict";
15923
16720
  init_appstore();
16721
+ init_agentTurnOps();
16722
+ init_recordEnvelope();
15924
16723
  init_agentOutcome();
15925
16724
  init_projectRecord();
15926
16725
  init_gradient();
15927
- init_layoutFindings();
15928
16726
  init_capabilities();
15929
16727
  init_invalidRequest();
15930
16728
  init_refineProjectContracts();
@@ -16263,6 +17061,11 @@ function localToolDeps(token2, options = {}) {
16263
17061
  execute: (_userId, action, operationId) => bridge.productionOperation(action, operationId)
16264
17062
  },
16265
17063
  saveLook: (projectId, look2, sourceName) => bridge.saveLook(projectId, look2, sourceName),
17064
+ // #1004 — the App Store listing doors, through the bridge and nowhere else. This tier runs in
17065
+ // the USER's own process, so a direct Apple client here would be a second copy of the locale
17066
+ // and device tables on their machine; the control plane keeps exactly one.
17067
+ searchAppListing: (_userId, term, country) => bridgedListingCall(() => bridge.searchAppListing(term, country)),
17068
+ resolveAppListing: (_userId, reference, country) => bridgedListingCall(() => bridge.resolveAppListing(reference, country)),
16266
17069
  // `ctx` is NOT forwarded as tool args — it existed so the REMOTE save_project tool could
16267
17070
  // rebuild the record, and the control plane is a dumb writer, so the record built HERE is the
16268
17071
  // one that gets stored. It is still READ, for one thing only: the local file paths whose
@@ -16290,83 +17093,84 @@ function localToolDeps(token2, options = {}) {
16290
17093
  bridged.refineProject = (userId, input) => runLocalRefineTurn({ ...bridged, userId }, bridge, input, options.emit ? { emit: options.emit } : void 0);
16291
17094
  return bridged;
16292
17095
  }
17096
+ async function bridgedListingCall(call) {
17097
+ try {
17098
+ return await call();
17099
+ } catch (err) {
17100
+ const message = err instanceof Error ? err.message : String(err);
17101
+ const throttled = err instanceof ControlPlaneOperationError && err.status === 429 || /refusing new requests/i.test(message);
17102
+ return { error: throttled ? "throttled" : "unavailable", message };
17103
+ }
17104
+ }
16293
17105
  async function pushScreens(bridge, projectId, record8, recordVersion, screenshots2) {
16294
17106
  const local = localScreenPaths(screenshots2);
16295
17107
  if (local.length === 0) return;
16296
17108
  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
17109
  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
17110
  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;
17111
+ const idsByFrameName = shotIdsByFrameName(cloudShots);
17112
+ const cells = [];
17113
+ const unaddressed = [];
17114
+ for (const source of local) {
17115
+ const ids = idsByFrameName[source.name];
17116
+ if (ids === void 0) {
17117
+ unaddressed.push(source.name);
17118
+ continue;
16339
17119
  }
17120
+ const bytes = await readFile4(source.path);
17121
+ const hash = await screenshotFingerprint(bytes);
17122
+ for (const id of ids) cells.push({ shotId: id, variant: {}, hash, bytes: new Uint8Array(bytes) });
16340
17123
  }
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");
17124
+ if (unaddressed.length > 0) {
17125
+ logEvent("warn", "screen_upload_unaddressed", { names: unaddressed.join(", ") });
16344
17126
  }
16345
- const preservedEntries = nonBaseCellEntries(existingLive);
16346
- const preservedKeep = new Set(
16347
- resolveScreenManifest(preservedEntries, cloudShots).cells.map((cell) => cell.entry)
17127
+ if (cells.length === 0) return;
17128
+ let version2 = recordVersion;
17129
+ await saveProjectScreens(
17130
+ {
17131
+ projectId,
17132
+ record: envelope,
17133
+ shots: cloudShots,
17134
+ cells,
17135
+ // #178: refs and `reach` travel together. Studio only fetches bytes from refs when the
17136
+ // record says the screens reach the account, so writing refs without it leaves the screens
17137
+ // unreachable and the "Screens missing" banner firing anyway.
17138
+ reach: "account",
17139
+ rebuild: ({ manifest, custody }) => ({
17140
+ ...envelope,
17141
+ ...manifest.length > 0 ? { handoffRefs: manifest, reach: "account" } : {},
17142
+ screenPurgeRefs: custody
17143
+ })
17144
+ },
17145
+ {
17146
+ async saveRecord({ record: next }) {
17147
+ const written = await bridge.updateProjectRecord(projectId, next, version2);
17148
+ version2 = written?.recordVersion ?? version2 + 1;
17149
+ },
17150
+ async mintUploadSlots({ cells: wanted }) {
17151
+ const nameById = {};
17152
+ for (const shot of cloudShots) nameById[shot.id] = shot.frameName;
17153
+ const names = wanted.map((cell) => nameById[cell.shotId]).filter((name) => !!name);
17154
+ const slots = await bridge.mintScreenUploads(projectId, names);
17155
+ return wanted.flatMap((cell) => {
17156
+ const slot2 = slots.find((minted) => minted.name === nameById[cell.shotId]);
17157
+ return slot2 ? [{ shotId: cell.shotId, variant: cell.variant, ref: slot2.ref, uploadUrl: slot2.uploadUrl }] : [];
17158
+ });
17159
+ },
17160
+ async putBytes({ slot: slot2, bytes }) {
17161
+ const res = await fetch(slot2.uploadUrl, {
17162
+ method: "PUT",
17163
+ headers: { "content-type": "image/png" },
17164
+ body: new Uint8Array(bytes)
17165
+ });
17166
+ if (!res.ok) throw new Error(`upload ${slot2.ref} -> ${res.status}`);
17167
+ }
17168
+ }
16348
17169
  );
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
17170
  } catch (err) {
16359
17171
  logEvent("error", "screen_upload_failed", { message: err.message });
16360
17172
  }
16361
17173
  }
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
17174
  var SIGN_IN, SHARE_LINK_NOT_BRIDGED;
16371
17175
  var init_localDeps = __esm({
16372
17176
  "src/runtime/localDeps.ts"() {
@@ -16379,8 +17183,9 @@ var init_localDeps = __esm({
16379
17183
  init_projectClaimClient();
16380
17184
  init_log();
16381
17185
  init_capabilities();
16382
- init_projectRecord();
16383
17186
  init_screenManifest();
17187
+ init_fingerprint();
17188
+ init_persistenceOps();
16384
17189
  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
17190
  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
17191
  }
@@ -16496,7 +17301,7 @@ function canonicalJson(value) {
16496
17301
  return `${JSON.stringify(stableValue(value), null, 2)}
16497
17302
  `;
16498
17303
  }
16499
- function sha256(value) {
17304
+ function sha2562(value) {
16500
17305
  return createHash5("sha256").update(value).digest("hex");
16501
17306
  }
16502
17307
  async function sha256File(path) {
@@ -17238,7 +18043,7 @@ function desiredReleasePayload(manifest) {
17238
18043
  return payload;
17239
18044
  }
17240
18045
  function computeDesiredId(payload) {
17241
- return sha256(canonicalJson(payload));
18046
+ return sha2562(canonicalJson(payload));
17242
18047
  }
17243
18048
  function parseTextValue(value, label) {
17244
18049
  const item = record4(value, label);
@@ -18519,8 +19324,8 @@ async function assembleDesiredRelease(input) {
18519
19324
  };
18520
19325
  const recordBytes = Buffer.from(canonicalString(project2.record), "utf8");
18521
19326
  const projectBlob = {
18522
- path: seal(sha256(recordBytes), { bytes: recordBytes }),
18523
- sha256: sha256(recordBytes),
19327
+ path: seal(sha2562(recordBytes), { bytes: recordBytes }),
19328
+ sha256: sha2562(recordBytes),
18524
19329
  bytes: recordBytes.byteLength,
18525
19330
  mediaType: "application/json"
18526
19331
  };
@@ -18567,7 +19372,7 @@ async function assembleDesiredRelease(input) {
18567
19372
  throw new Error(`renderer returned a non-PNG panel for ${output2.id}/${locale2} at position ${index + 1}`);
18568
19373
  }
18569
19374
  const position = index + 1;
18570
- const digest4 = sha256(bytes);
19375
+ const digest4 = sha2562(bytes);
18571
19376
  artifacts.push({
18572
19377
  role: "rendered_screenshot",
18573
19378
  custody: "sealed",
@@ -18780,7 +19585,7 @@ function screenshotSetProjection(set) {
18780
19585
  return set ? { id: set.id, deviceSet: set.deviceSet, items: mediaProjection(set.items) } : null;
18781
19586
  }
18782
19587
  function screenshotSetToken(set) {
18783
- return sha256(canonicalJson(screenshotSetProjection(set)));
19588
+ return sha2562(canonicalJson(screenshotSetProjection(set)));
18784
19589
  }
18785
19590
  function previewTypeFor(deviceSet) {
18786
19591
  return deviceSet.replace(/^APP_/, "");
@@ -18836,7 +19641,7 @@ function projectRemoteResource(remote, scope) {
18836
19641
  }
18837
19642
  }
18838
19643
  function resourceToken(remote, scope) {
18839
- return sha256(canonicalJson(projectRemoteResource(remote, scope)));
19644
+ return sha2562(canonicalJson(projectRemoteResource(remote, scope)));
18840
19645
  }
18841
19646
  function scopeKey(scope) {
18842
19647
  return scope.key === null ? scope.resource : `${scope.resource}:${scope.key}`;
@@ -18919,10 +19724,10 @@ var init_releaseDrift = __esm({
18919
19724
 
18920
19725
  // src/release/releaseOperations.ts
18921
19726
  function operationValueHash(value) {
18922
- return sha256(canonicalJson(value));
19727
+ return sha2562(canonicalJson(value));
18923
19728
  }
18924
19729
  function computeOperationId(input) {
18925
- return sha256(canonicalJson({
19730
+ return sha2562(canonicalJson({
18926
19731
  kind: input.kind,
18927
19732
  target: input.target,
18928
19733
  precondition: input.precondition,
@@ -19537,7 +20342,7 @@ function assertSha(value, label) {
19537
20342
  if (typeof value !== "string" || !SHA2562.test(value)) throw new Error(`${label} is not a SHA-256 digest`);
19538
20343
  }
19539
20344
  function valueHash(value) {
19540
- return sha256(canonicalJson(value));
20345
+ return sha2562(canonicalJson(value));
19541
20346
  }
19542
20347
  function assertExactReleasePlan(value) {
19543
20348
  if (!isRecord5(value) || value.kind !== "shotops.release-plan") {
@@ -19636,7 +20441,7 @@ async function readScreenshot(root, reference) {
19636
20441
  if (!bytes.subarray(0, PNG_SIGNATURE3.length).equals(PNG_SIGNATURE3)) {
19637
20442
  throw new Error(`release screenshot is not a PNG: ${reference.source}`);
19638
20443
  }
19639
- if (sha256(bytes) !== reference.sha256) throw new Error(`release screenshot content changed: ${reference.source}`);
20444
+ if (sha2562(bytes) !== reference.sha256) throw new Error(`release screenshot content changed: ${reference.source}`);
19640
20445
  return bytes;
19641
20446
  }
19642
20447
  async function atomicPrivateWrite(path, bytes) {
@@ -19748,7 +20553,7 @@ async function loadArtifact(path, now) {
19748
20553
  throw new Error("release review artifact screenshot manifest does not match the plan");
19749
20554
  }
19750
20555
  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) {
20556
+ 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
20557
  throw new Error("release review artifact screenshot content does not match the plan");
19753
20558
  }
19754
20559
  screenshots2.set(raw.sha256, bytes);
@@ -19854,7 +20659,7 @@ function assertRecord(record8) {
19854
20659
  assertDetail(record8.detail);
19855
20660
  }
19856
20661
  function chain(previous, payload) {
19857
- return sha256(`${previous}\0${canonicalJson(payload)}`);
20662
+ return sha2562(`${previous}\0${canonicalJson(payload)}`);
19858
20663
  }
19859
20664
  function journalFileName(attemptId) {
19860
20665
  return `attempt-${attemptId}.ndjson`;
@@ -20129,7 +20934,7 @@ async function writePartialReceipt(path, receipt) {
20129
20934
  await rename4(temporary, path);
20130
20935
  }
20131
20936
  function receiptDigest(payload) {
20132
- return sha256(canonicalJson(payload));
20937
+ return sha2562(canonicalJson(payload));
20133
20938
  }
20134
20939
  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
20940
  var init_releaseJournal = __esm({
@@ -20983,7 +21788,7 @@ async function inspectLocalEvidence(input) {
20983
21788
  let workspace = null;
20984
21789
  try {
20985
21790
  workspace = await verifyReleaseWorkspace(workspacePath);
20986
- if (workspace.manifest.desiredId !== plan.desired.desiredId || sha256(workspace.text) !== plan.desired.manifestSha256) {
21791
+ if (workspace.manifest.desiredId !== plan.desired.desiredId || sha2562(workspace.text) !== plan.desired.manifestSha256) {
20987
21792
  diagnostics.push("unsupported_evidence");
20988
21793
  }
20989
21794
  } catch {
@@ -21283,7 +22088,7 @@ async function inspectApple(evidence, deps, diagnostics) {
21283
22088
  diagnostics.push("credentials_missing");
21284
22089
  return { editable: false, url: null, context: null };
21285
22090
  }
21286
- if (sha256(setup.issuerId) !== evidence.plan.owner.issuerIdSha256 || sha256(setup.keyId) !== evidence.plan.owner.keyIdSha256) {
22091
+ if (sha2562(setup.issuerId) !== evidence.plan.owner.issuerIdSha256 || sha2562(setup.keyId) !== evidence.plan.owner.keyIdSha256) {
21287
22092
  diagnostics.push("credentials_missing");
21288
22093
  return { editable: false, url: null, context: null };
21289
22094
  }
@@ -22198,7 +23003,7 @@ async function runUpload(request, client, deps, ids, record8) {
22198
23003
  } catch {
22199
23004
  return { state: "not-applied", reason: "validation_failed" };
22200
23005
  }
22201
- if (bytes.byteLength !== request.payload.bytes || sha256(bytes) !== request.payload.sha256) {
23006
+ if (bytes.byteLength !== request.payload.bytes || sha2562(bytes) !== request.payload.sha256) {
22202
23007
  return { state: "not-applied", reason: "validation_failed" };
22203
23008
  }
22204
23009
  let relationshipId;
@@ -23087,7 +23892,7 @@ async function sealBackup(destination, manifest, blobs) {
23087
23892
  await writeFile6(file, bytes, { mode: 384 });
23088
23893
  const written = await stat5(file);
23089
23894
  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");
23895
+ if (await sha256File(file) !== sha2562(bytes)) fail4("a recovery backup image changed content while it was being written");
23091
23896
  await fsyncPath2(file, false);
23092
23897
  }
23093
23898
  const manifestPath = join15(temporary, BACKUP_FILENAME);
@@ -23227,7 +24032,7 @@ async function readSealedImages(workspace, manifest, selected) {
23227
24032
  if (!info?.isFile() || info.isSymbolicLink()) fail4(`the sealed image for ${set.key} is missing or unsafe`);
23228
24033
  if (info.size !== payload.bytes) fail4(`the sealed image for ${set.key} changed size since the plan was approved`);
23229
24034
  const bytes = await readFile12(file);
23230
- if (sha256(bytes) !== payload.sha256) fail4(`the sealed image for ${set.key} changed since the plan was approved`);
24035
+ if (sha2562(bytes) !== payload.sha256) fail4(`the sealed image for ${set.key} changed since the plan was approved`);
23231
24036
  const md5 = md5Hex(bytes);
23232
24037
  if (md5 !== payload.md5) fail4(`the sealed image for ${set.key} does not match the checksum the plan approved`);
23233
24038
  sealed.set(payload.sha256, { bytes, md5 });
@@ -23307,7 +24112,7 @@ async function captureBackup(client, plan, manifest, bound, destination, capture
23307
24112
  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
24113
  }
23309
24114
  const bytes = await client.download(image.asset);
23310
- const digest4 = sha256(bytes);
24115
+ const digest4 = sha2562(bytes);
23311
24116
  const md5 = md5Hex(bytes);
23312
24117
  if (image.sourceFileChecksum && image.sourceFileChecksum !== md5) {
23313
24118
  fail4(`the bytes App Store Connect returned for ${image.fileName} do not match its own checksum; nothing was deleted`);
@@ -23704,7 +24509,7 @@ function assetsFingerprint(assets) {
23704
24509
  });
23705
24510
  }
23706
24511
  function valueHash2(value) {
23707
- return sha256(canonicalJson(value));
24512
+ return sha2562(canonicalJson(value));
23708
24513
  }
23709
24514
  function payloadOf(asset, role) {
23710
24515
  return { role, source: asset.source, sha256: asset.sha256, md5: asset.md5, bytes: asset.bytes };
@@ -24317,7 +25122,7 @@ async function createReleasePlan(input, deps = {}) {
24317
25122
  const approvalExpiresAt = new Date(now.getTime() + APPROVAL_TTL_MS).toISOString();
24318
25123
  const policySha256 = valueHash2(RELEASE_POLICY);
24319
25124
  const remoteSnapshotSha256 = valueHash2(remote);
24320
- const manifestSha256 = sha256(loaded.text);
25125
+ const manifestSha256 = sha2562(loaded.text);
24321
25126
  const commitment = mintCommitmentKey(deps.randomSource);
24322
25127
  const payload = {
24323
25128
  schema: PLAN_SCHEMA2,
@@ -24339,7 +25144,7 @@ async function createReleasePlan(input, deps = {}) {
24339
25144
  buildSha256: loaded.manifest.build?.sha256 ?? null,
24340
25145
  scope: loaded.manifest.scope
24341
25146
  },
24342
- owner: { issuerIdSha256: sha256(setup.issuerId), keyIdSha256: sha256(setup.keyId) },
25147
+ owner: { issuerIdSha256: sha2562(setup.issuerId), keyIdSha256: sha2562(setup.keyId) },
24343
25148
  target: {
24344
25149
  appId: remote.app.id,
24345
25150
  appInfoId: remote.appInfo.id,
@@ -25036,7 +25841,7 @@ async function runPreflight(input, deps, recovery) {
25036
25841
  if (plan.desired.desiredId !== workspace.manifest.desiredId) {
25037
25842
  refuse2("plan_mismatch", "this plan was built from a different desired release than the workspace holds");
25038
25843
  }
25039
- if (plan.desired.manifestSha256 !== sha256(workspace.text)) {
25844
+ if (plan.desired.manifestSha256 !== sha2562(workspace.text)) {
25040
25845
  refuse2("plan_mismatch", "the sealed desired release changed after this plan was built; re-plan before committing");
25041
25846
  }
25042
25847
  if (!EDITABLE_VERSION_STATES.has(plan.target.appStoreState)) {
@@ -25052,7 +25857,7 @@ async function runPreflight(input, deps, recovery) {
25052
25857
  } catch (error) {
25053
25858
  refuse2("setup_unavailable", error.message);
25054
25859
  }
25055
- if (sha256(setup.issuerId) !== plan.owner.issuerIdSha256 || sha256(setup.keyId) !== plan.owner.keyIdSha256) {
25860
+ if (sha2562(setup.issuerId) !== plan.owner.issuerIdSha256 || sha2562(setup.keyId) !== plan.owner.keyIdSha256) {
25056
25861
  refuse2("owner_mismatch", "this machine's App Store Connect setup is not the one this plan was built with");
25057
25862
  }
25058
25863
  let appleToken;
@@ -25255,7 +26060,7 @@ async function runStages(preflight, attemptId, journal, signal, deps, now) {
25255
26060
  function readSealedPayload(workspacePath) {
25256
26061
  return async (reference) => {
25257
26062
  const bytes = await readFile13(join17(workspacePath, ...reference.source.split("/")));
25258
- if (bytes.byteLength !== reference.bytes || sha256(bytes) !== reference.sha256) {
26063
+ if (bytes.byteLength !== reference.bytes || sha2562(bytes) !== reference.sha256) {
25259
26064
  throw new Error("a sealed payload changed since this plan was approved");
25260
26065
  }
25261
26066
  return bytes;
@@ -25861,23 +26666,20 @@ function parsePrepareArgs(argv2) {
25861
26666
  };
25862
26667
  }
25863
26668
  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) {
26669
+ const locales = projectLocaleAxis(projectFile).locales;
26670
+ for (const locale2 of locales) {
25869
26671
  if (!/^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$/.test(locale2)) throw new Error(`project contains an unsafe locale identifier: ${locale2}`);
25870
26672
  }
25871
- return result;
26673
+ return locales;
25872
26674
  }
25873
26675
  function selectedOutputs(projectFile) {
25874
26676
  const resolved = outputDevicesFromProjectFile(projectFile);
25875
26677
  const file = projectFile;
25876
26678
  const declared = Array.isArray(file.outputs) ? file.outputs.filter((id) => typeof id === "string" && id.length > 0) : [];
25877
26679
  if (declared.length > 0) return { outputs: [...resolved.devices], unsupported: resolved.dropped };
25878
- const preset = typeof file.panelPresetId === "string" ? file.panelPresetId : void 0;
26680
+ const preset = projectOutputAxis(projectFile).panelPresetId;
25879
26681
  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"})`);
26682
+ if (!fallback) throw new Error(`project output is unavailable or unsupported (panel preset ${preset})`);
25881
26683
  return { outputs: [fallback], unsupported: [] };
25882
26684
  }
25883
26685
  function localReleaseDoor(token2) {
@@ -26028,6 +26830,7 @@ var RELEASE_PREPARE_USAGE, RELEASE_GC_USAGE, LOCAL_ACTOR;
26028
26830
  var init_releasePrepare = __esm({
26029
26831
  "src/release/releasePrepare.ts"() {
26030
26832
  "use strict";
26833
+ init_appstore();
26031
26834
  init_env();
26032
26835
  init_productionRenderAccess();
26033
26836
  init_projectResolution();
@@ -26278,7 +27081,7 @@ function pageCss() {
26278
27081
  *{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
27082
  .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
27083
  .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)}
27084
+ .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
27085
  .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
27086
  .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
27087
  @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 +27258,7 @@ function serializeReleaseReviewPayload(payload) {
26455
27258
  if (json.length > RELEASE_REVIEW_PAYLOAD_LIMITS.maxPayloadBytes) {
26456
27259
  throw new Error("this plan\u2019s review content is too large to upload for remote review");
26457
27260
  }
26458
- return { bytes: new TextEncoder().encode(json), sha256: sha256(json) };
27261
+ return { bytes: new TextEncoder().encode(json), sha256: sha2562(json) };
26459
27262
  }
26460
27263
  var init_releaseReviewPayload2 = __esm({
26461
27264
  "src/release/releaseReviewPayload.ts"() {
@@ -27423,6 +28226,66 @@ init_toolContract();
27423
28226
  init_toolResults();
27424
28227
  init_toolsAccount();
27425
28228
 
28229
+ // src/tools/toolsAppListing.ts
28230
+ init_toolResults();
28231
+
28232
+ // src/project/appListingLink.ts
28233
+ init_appListing();
28234
+ function appListingView(listing2) {
28235
+ const { applied } = listing2;
28236
+ return {
28237
+ appId: listing2.trackId,
28238
+ bundleId: listing2.bundleId,
28239
+ name: listing2.trackName,
28240
+ seller: listing2.sellerName,
28241
+ ...listing2.trackViewUrl ? { storeUrl: listing2.trackViewUrl } : {},
28242
+ ...applied.iconUrl ? { iconUrl: applied.iconUrl } : {},
28243
+ locales: applied.locales,
28244
+ baseLocale: applied.baseLocale,
28245
+ outputs: applied.outputs,
28246
+ supportsIpad: applied.supportsIpad,
28247
+ storefront: listing2.storefront,
28248
+ ...applied.droppedLanguageCodes.length > 0 ? { droppedLanguageCodes: applied.droppedLanguageCodes } : {}
28249
+ };
28250
+ }
28251
+ function linkedAppFromRecord(record8) {
28252
+ const listing2 = readAppListing(record8);
28253
+ return listing2 ? appListingView(listing2) : null;
28254
+ }
28255
+ function appListingFailureCode(kind) {
28256
+ const codes = {
28257
+ // Both are the caller's to fix with a different reference: one could not be parsed, the other
28258
+ // named no app. Neither is a ShotOps asset, so `asset_not_found` would point at the wrong door.
28259
+ invalid_reference: "invalid_input",
28260
+ not_found: "invalid_input",
28261
+ throttled: "rate_limited",
28262
+ unavailable: "upstream_unavailable"
28263
+ };
28264
+ return codes[kind];
28265
+ }
28266
+
28267
+ // src/tools/toolsAppListing.ts
28268
+ 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.";
28269
+ function failureKind(answer) {
28270
+ const error = answer?.error;
28271
+ return typeof error === "string" ? error : null;
28272
+ }
28273
+ async function handleSearchAppListing(deps, args) {
28274
+ if (!deps.searchAppListing) return errorResult(APP_LISTING_SIGN_IN, "authentication_required");
28275
+ const answer = await deps.searchAppListing(deps.userId, args.term, args.country);
28276
+ const kind = failureKind(answer);
28277
+ if (kind) {
28278
+ return errorResult(answer.message, appListingFailureCode(kind));
28279
+ }
28280
+ const results = answer.results.map(appListingView);
28281
+ const storefront = args.country?.toLowerCase() ?? "us";
28282
+ return textResult({
28283
+ ok: true,
28284
+ results,
28285
+ 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.`
28286
+ });
28287
+ }
28288
+
27426
28289
  // src/tools/toolsBundle.ts
27427
28290
  init_fastlaneBundle();
27428
28291
  init_appstore();
@@ -27456,10 +28319,7 @@ function dedupeLocales(list) {
27456
28319
  return out;
27457
28320
  }
27458
28321
  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;
28322
+ return projectLocaleAxis(projectFile).baseLocale;
27463
28323
  }
27464
28324
  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
28325
  function joinNotes(...parts) {
@@ -28807,7 +29667,7 @@ function readPose(look2) {
28807
29667
  const angle = look2.angle === "left" || look2.angle === "right" || look2.angle === "front" ? look2.angle : DEFAULT_LOOK.angle;
28808
29668
  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
29669
  return {
28810
- phoneHeight: normalizePose(look2.phoneHeight, "phoneHeight"),
29670
+ deviceSize: normalizePose(look2.deviceSize, "deviceSize"),
28811
29671
  hOffset: normalizePose(look2.hOffset, "hOffset"),
28812
29672
  vOffset: normalizePose(look2.vOffset, "vOffset"),
28813
29673
  angle,
@@ -29118,12 +29978,28 @@ init_toolContract();
29118
29978
  init_capabilities();
29119
29979
  init_authorization();
29120
29980
  init_productionInput2();
29981
+ 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
29982
  async function handleSaveProject(deps, args) {
29122
29983
  const looked = await resolveLook(deps, args);
29123
29984
  if (isLookFailure(looked)) return productionInputErrorResult(looked.failure);
29124
29985
  const { look: look2, style: style2, note: resolveNote } = looked;
29125
29986
  const baseNote = [resolveNote, composeConflictNote(look2, style2)].filter(Boolean).join(" ") || void 0;
29126
- const locale2 = args.locale?.trim() || "en-US";
29987
+ const creating = !args.project;
29988
+ let listing2 = null;
29989
+ if (args.appListing) {
29990
+ if (!deps.resolveAppListing) return errorResult(SAVE_APP_LISTING_SIGN_IN, "authentication_required");
29991
+ const answer = await deps.resolveAppListing(deps.userId, args.appListing);
29992
+ const kind = typeof answer.error === "string" ? answer.error : null;
29993
+ if (kind) return errorResult(answer.message, appListingFailureCode(kind));
29994
+ listing2 = answer.listing;
29995
+ if (!listing2) {
29996
+ return errorResult(
29997
+ `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.`,
29998
+ "invalid_input"
29999
+ );
30000
+ }
30001
+ }
30002
+ const locale2 = args.locale?.trim() || (creating && listing2 ? listing2.applied.baseLocale : "en-US");
29127
30003
  if (!Array.isArray(args.screenshots) || args.screenshots.length === 0) {
29128
30004
  return errorResult(
29129
30005
  "save_project needs a non-empty `screenshots` array (its slot shape + names define the project structure).",
@@ -29142,7 +30018,8 @@ async function handleSaveProject(deps, args) {
29142
30018
  return errorResult(err.message);
29143
30019
  }
29144
30020
  const screenshotNames = resolveScreenshotNames(entries).flat();
29145
- const { devices: outputDevices } = resolveOutputDevices(args.outputs);
30021
+ const listedOutputs = creating && listing2 && !args.panelPresetId && listing2.applied.outputs.length > 0 ? listing2.applied.outputs : void 0;
30022
+ const { devices: outputDevices } = resolveOutputDevices(args.outputs ?? listedOutputs);
29146
30023
  let panelPresetId2 = args.panelPresetId;
29147
30024
  if (outputDevices.length > 0) {
29148
30025
  if (panelPresetId2 && !outputDevices.some((d) => d.panelPresetId === panelPresetId2)) {
@@ -29164,7 +30041,11 @@ async function handleSaveProject(deps, args) {
29164
30041
  screenshotNames,
29165
30042
  // Undefined when the caller passed no `outputs` — the record then migrates its single
29166
30043
  // output from panelPresetId exactly as it did before this field existed.
29167
- outputs: outputDevices.length > 0 ? outputDevices.map((d) => d.id) : void 0
30044
+ outputs: outputDevices.length > 0 ? outputDevices.map((d) => d.id) : void 0,
30045
+ // #1004 — the listing's language set, on a CREATE only. On an update these stay undefined and
30046
+ // `serializeProject` derives the list exactly as it always has, so re-linking an app leaves
30047
+ // `locales`, `baseLocale` and every per-locale caption on the record untouched.
30048
+ ...creating && listing2 ? { locales: listing2.applied.locales, baseLocale: locale2 } : {}
29168
30049
  });
29169
30050
  } catch (err) {
29170
30051
  return errorResult(`could not build the project record: ${err.message}`, "persistence_failed");
@@ -29172,7 +30053,13 @@ async function handleSaveProject(deps, args) {
29172
30053
  if (record8 == null) {
29173
30054
  return errorResult("no project record was produced \u2014 not saved.", "persistence_failed");
29174
30055
  }
29175
- const p = await persistProject(deps, args, record8, "unsigned-new");
30056
+ const p = await persistProject(
30057
+ deps,
30058
+ { ...args, projectName: args.projectName?.trim() || listing2?.applied.projectName },
30059
+ record8,
30060
+ "unsigned-new",
30061
+ listing2
30062
+ );
29176
30063
  if ("error" in p) return errorResult(p.error);
29177
30064
  const note = ["screenNote" in p && p.screenNote ? p.screenNote : void 0, baseNote].filter(Boolean).join(" ") || void 0;
29178
30065
  if ("status" in p) {
@@ -29186,6 +30073,7 @@ async function handleSaveProject(deps, args) {
29186
30073
  panelPresetId: panelPresetId2 ?? "r69",
29187
30074
  locale: locale2,
29188
30075
  ...outputDevices.length > 0 ? { outputs: outputDevices.map((d) => d.id) } : {},
30076
+ ...listing2 ? { linkedApp: appListingView(listing2) } : {},
29189
30077
  ...note ? { note } : {},
29190
30078
  // The one local path that moves bytes off this machine. Stated with the result, from the
29191
30079
  // retention policy's own number, rather than left for the user to discover when the claim
@@ -29209,6 +30097,9 @@ async function handleSaveProject(deps, args) {
29209
30097
  // #40: echoed ONLY when the caller asked for a device set — a legacy save's response body is
29210
30098
  // byte-identical to before.
29211
30099
  ...outputDevices.length > 0 ? { outputs: outputDevices.map((d) => d.id) } : {},
30100
+ // #1004 — the app this save linked, echoed so the caller sees the identity the project now
30101
+ // carries. Absent on every save that named no `appListing`.
30102
+ ...listing2 ? { linkedApp: appListingView(listing2) } : {},
29212
30103
  ...note ? { note } : {},
29213
30104
  // #662 — this is the ONE tool in the contract whose job is a project write, and the envelope
29214
30105
  // says so: `projectMutation: true` here, false on every render. That pair is what makes
@@ -29230,17 +30121,12 @@ function reachFromRecord(record8) {
29230
30121
  function storedScreenshotReport(deps, record8, projectFile) {
29231
30122
  const frames = frameNamesFromProjectFile(projectFile);
29232
30123
  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
- );
30124
+ const { locales, baseLocale } = projectLocaleAxis(projectFile);
29240
30125
  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
30126
  const families = Array.from(
29243
- new Set(outputIds.length > 0 ? outputIds.map(screenFamilyForDevice) : [presetFamily ?? BASE_SCREEN_FAMILY])
30127
+ new Set(
30128
+ outputIds.length > 0 ? outputIds.map(screenFamilyForDevice) : [screenFamilyForPreset(projectOutputAxis(projectFile).panelPresetId)]
30129
+ )
29244
30130
  );
29245
30131
  const parsed = parseScreenManifest(record8?.handoffRefs, "lenient", baseLocale);
29246
30132
  const manifest = parsed ? allManifestEntries(parsed) : [];
@@ -29316,6 +30202,7 @@ async function handleReadProject(deps, args) {
29316
30202
  const projectFile = projectFileFromRecord(rec.record);
29317
30203
  const layout = deriveLayoutReport(projectFile);
29318
30204
  const screenshots2 = storedScreenshotReport(deps, rec.record, projectFile);
30205
+ const linkedApp = linkedAppFromRecord(rec.record);
29319
30206
  const historyDecision = await deps.authorizeCapability({
29320
30207
  subject: { kind: "account", userId: deps.userId, mode: "api_token" },
29321
30208
  capability: CAPABILITIES.LOOK_HISTORY_READ,
@@ -29338,9 +30225,13 @@ async function handleReadProject(deps, args) {
29338
30225
  ...historyDenial ? {} : { heldVersion: project2.heldVersion, versions },
29339
30226
  history: historyDenial ?? { available: true, capability: CAPABILITIES.LOOK_HISTORY_READ },
29340
30227
  screenshots: screenshots2,
30228
+ // #1004 — the App Store app this project is linked to, read off the stored envelope. Omitted
30229
+ // entirely for a project that carries none, so a project created without one reads exactly as
30230
+ // it did before.
30231
+ ...linkedApp ? { linkedApp } : {},
29341
30232
  project_file: projectFile,
29342
30233
  ...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)}`
30234
+ 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
30235
  });
29345
30236
  }
29346
30237
 
@@ -29573,6 +30464,12 @@ var DESTRUCTIVE_WRITE = {
29573
30464
  idempotentHint: false,
29574
30465
  openWorldHint: false
29575
30466
  };
30467
+ var OPEN_WORLD_READ = {
30468
+ readOnlyHint: true,
30469
+ destructiveHint: false,
30470
+ idempotentHint: true,
30471
+ openWorldHint: true
30472
+ };
29576
30473
  var OPEN_WORLD_DESTRUCTIVE_WRITE = {
29577
30474
  readOnlyHint: false,
29578
30475
  destructiveHint: true,
@@ -29607,7 +30504,7 @@ function registerTools(server, deps) {
29607
30504
  "render_strip",
29608
30505
  {
29609
30506
  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.',
30507
+ 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
30508
  inputSchema: renderStripAdvertisedShape,
29612
30509
  outputSchema: renderStripToolOutputSchema,
29613
30510
  // ADDITIVE, not destructive: this can mint private generated assets but cannot change a
@@ -29681,6 +30578,18 @@ function registerTools(server, deps) {
29681
30578
  },
29682
30579
  seam("read_project", readProjectShape, (args) => handleReadProject(deps, args))
29683
30580
  );
30581
+ server.registerTool(
30582
+ "search_app_listing",
30583
+ {
30584
+ title: "Find an app on the App Store",
30585
+ 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.",
30586
+ inputSchema: searchAppListingShape,
30587
+ outputSchema: searchAppListingToolOutputSchema,
30588
+ annotations: OPEN_WORLD_READ,
30589
+ _meta: toolMeta(deps, "search_app_listing", "Searching the App Store\u2026", "App Store searched")
30590
+ },
30591
+ seam("search_app_listing", searchAppListingShape, (args) => handleSearchAppListing(deps, args))
30592
+ );
29684
30593
  server.registerTool(
29685
30594
  "render_project",
29686
30595
  {