@notis_ai/cli 0.2.0-beta.178.1 → 0.2.0-beta.179.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11748,6 +11748,78 @@ async function startAppTestServer({ apps, port, harness = {} }) {
11748
11748
  import { spawn as spawn2, spawnSync } from "node:child_process";
11749
11749
  import { mkdirSync as mkdirSync3, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "node:fs";
11750
11750
  import { dirname as dirname5 } from "node:path";
11751
+
11752
+ // src/runtime/screenshot-framing.js
11753
+ function measureScreenshotContent() {
11754
+ const root = document.getElementById("root");
11755
+ if (!root) return null;
11756
+ const viewportWidth = document.documentElement.clientWidth;
11757
+ const visible = (element) => {
11758
+ const rect2 = element.getBoundingClientRect();
11759
+ const style = getComputedStyle(element);
11760
+ return rect2.width > 0 && rect2.height > 0 && style.display !== "none" && style.visibility !== "hidden";
11761
+ };
11762
+ let node = root;
11763
+ let content = null;
11764
+ while (node) {
11765
+ const hasOwnText = Array.from(node.childNodes).some(
11766
+ (child) => child.nodeType === 3 && child.textContent.trim()
11767
+ );
11768
+ const children = Array.from(node.children).filter(visible);
11769
+ if (hasOwnText || children.length !== 1) break;
11770
+ node = children[0];
11771
+ const rect2 = node.getBoundingClientRect();
11772
+ if (["DIV", "MAIN", "SECTION", "ARTICLE", "FORM"].includes(node.tagName) && rect2.width >= 320 && rect2.height >= 160 && rect2.width < viewportWidth * 0.9) {
11773
+ content = node;
11774
+ }
11775
+ }
11776
+ if (!content) return null;
11777
+ content.setAttribute("data-notis-screenshot-content", "");
11778
+ const rect = content.getBoundingClientRect();
11779
+ return {
11780
+ selector: "[data-notis-screenshot-content]",
11781
+ width: rect.width,
11782
+ height: rect.height,
11783
+ viewport_width: viewportWidth
11784
+ };
11785
+ }
11786
+ var MEASURE_SCREENSHOT_CONTENT_SCRIPT = `(${measureScreenshotContent.toString()})()`;
11787
+ function contentCapturePlan(bounds, viewport, outputWidth, outputHeight) {
11788
+ const aspect = outputWidth / outputHeight;
11789
+ if (bounds.width / bounds.height < aspect * 0.75) {
11790
+ const width = Math.max(768, Math.ceil((bounds.width + 32) / 8) * 8);
11791
+ return { focus: false, width, height: Math.round(width / aspect), scale: outputWidth / width };
11792
+ }
11793
+ return {
11794
+ focus: true,
11795
+ width: viewport.width,
11796
+ height: viewport.height,
11797
+ scale: Math.min(4, outputWidth / bounds.width)
11798
+ };
11799
+ }
11800
+ function safeViewportHeight(rectangles, height) {
11801
+ const crossing = rectangles.filter((rect) => rect.top < height && rect.bottom > height && rect.top >= height * 0.85);
11802
+ return crossing.length ? Math.max(Math.ceil(height * 0.85), Math.floor(Math.min(...crossing.map((rect) => rect.top)) - 8)) : height;
11803
+ }
11804
+ var SAFE_VIEWPORT_HEIGHT_SCRIPT = `(() => {
11805
+ const root = document.getElementById('root');
11806
+ if (!root) return innerHeight;
11807
+ const rects = [];
11808
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
11809
+ let node;
11810
+ while ((node = walker.nextNode())) {
11811
+ if (!node.textContent.trim() || getComputedStyle(node.parentElement).visibility !== 'visible') continue;
11812
+ const range = document.createRange(); range.selectNodeContents(node);
11813
+ for (const rect of range.getClientRects()) rects.push({ top: rect.top, bottom: rect.bottom });
11814
+ }
11815
+ for (const control of root.querySelectorAll('button,input,select,textarea')) {
11816
+ const rect = control.getBoundingClientRect();
11817
+ rects.push({ top: rect.top, bottom: rect.bottom });
11818
+ }
11819
+ return (${safeViewportHeight.toString()})(rects, innerHeight);
11820
+ })()`;
11821
+
11822
+ // src/runtime/agent-browser.js
11751
11823
  var PREPARE_CAPTURE_SCRIPT = "(() => { const s = document.getElementById('harness-status'); if (s) s.style.display = 'none'; const r = document.getElementById('root'); if (r) { r.style.paddingTop = '0'; r.style.minHeight = '0'; } document.body.style.minHeight = '0'; document.documentElement.style.minHeight = '0'; return true; })()";
11752
11824
  var FONTS_STATUS_SCRIPT = "(() => (document.fonts ? document.fonts.status : 'loaded'))()";
11753
11825
  var CONTENT_HEIGHT_SCRIPT = "(() => { const r = document.getElementById('root'); if (!r) return Math.ceil(document.documentElement.scrollHeight); return Math.ceil(Math.max(r.scrollHeight, r.getBoundingClientRect().height)); })()";
@@ -12019,6 +12091,7 @@ async function captureHarnessScreenshot({
12019
12091
  sessionName,
12020
12092
  screenshotPath,
12021
12093
  focusSelector = null,
12094
+ frameContent = false,
12022
12095
  width = 2e3,
12023
12096
  height = 1250,
12024
12097
  timeoutMs = 15e3
@@ -12103,12 +12176,49 @@ async function captureHarnessScreenshot({
12103
12176
  }
12104
12177
  }
12105
12178
  await delay(500);
12179
+ let contentBounds = null;
12180
+ if (frameContent && !focusSelector) {
12181
+ const measured = await runAgentBrowser(
12182
+ ["--session", sessionName, "--json", "eval", MEASURE_SCREENSHOT_CONTENT_SCRIPT],
12183
+ { timeoutMs: 5e3 }
12184
+ );
12185
+ if (measured.exitCode === 0) {
12186
+ try {
12187
+ const bounds = parseHarnessFromEval(measured.stdout);
12188
+ if (bounds?.selector === "[data-notis-screenshot-content]" && Number.isFinite(bounds.width) && bounds.width > 0 && Number.isFinite(bounds.height) && bounds.height > 0) {
12189
+ contentBounds = bounds;
12190
+ }
12191
+ } catch {
12192
+ }
12193
+ }
12194
+ }
12195
+ let captureFocus = focusSelector || contentBounds?.selector || null;
12196
+ if (contentBounds && scaledViewport) {
12197
+ const plan = contentCapturePlan(contentBounds, framing || initial, width, height);
12198
+ if (await setViewport(sessionName, plan.width, plan.height, plan.scale)) {
12199
+ await delay(300);
12200
+ if (!plan.focus) {
12201
+ const safeHeight = await evalNumber(sessionName, SAFE_VIEWPORT_HEIGHT_SCRIPT, plan.height);
12202
+ if (safeHeight < plan.height && await setViewport(sessionName, plan.width, safeHeight, plan.scale)) {
12203
+ plan.height = safeHeight;
12204
+ }
12205
+ }
12206
+ captureFocus = plan.focus ? contentBounds.selector : null;
12207
+ framing = {
12208
+ ...framing || {},
12209
+ ...plan,
12210
+ content_bounds: contentBounds,
12211
+ capture_mode: plan.focus ? "content" : "width-fit-viewport"
12212
+ };
12213
+ await delay(300);
12214
+ }
12215
+ }
12106
12216
  const designResult = await evalDesignAssertions(sessionName, { viewport: "capture" });
12107
12217
  const design = Array.isArray(designResult) ? designResult : [];
12108
12218
  mkdirSync3(dirname5(screenshotPath), { recursive: true });
12109
12219
  const screenshotArgs = ["--session", sessionName, "screenshot"];
12110
- if (focusSelector) {
12111
- screenshotArgs.push(focusSelector);
12220
+ if (captureFocus) {
12221
+ screenshotArgs.push(captureFocus);
12112
12222
  }
12113
12223
  screenshotArgs.push(screenshotPath, "--screenshot-format", "png");
12114
12224
  const shot = await runAgentBrowser(screenshotArgs, { timeoutMs: 15e3 });
@@ -12116,7 +12226,9 @@ async function captureHarnessScreenshot({
12116
12226
  return { ok: false, mounted: true, screenshotPath: null, errors: lastErrors, timed_out: false, tool_error: commandError("screenshot", shot) };
12117
12227
  }
12118
12228
  const dimensions = pngDimensions2(screenshotPath);
12119
- if (!focusSelector && framing && (!dimensions || dimensions.width !== width || dimensions.height !== height)) {
12229
+ const expectedDimensions = framing?.capture_mode === "width-fit-viewport" ? { width: Math.round(framing.width * framing.scale), height: Math.round(framing.height * framing.scale) } : { width, height };
12230
+ const tolerance = frameContent ? 1 : 0;
12231
+ if (!captureFocus && framing && (!dimensions || Math.abs(dimensions.width - expectedDimensions.width) > tolerance || Math.abs(dimensions.height - expectedDimensions.height) > tolerance)) {
12120
12232
  await setViewport(sessionName, width, height);
12121
12233
  await delay(300);
12122
12234
  const retry = await runAgentBrowser(
@@ -12128,10 +12240,11 @@ async function captureHarnessScreenshot({
12128
12240
  }
12129
12241
  framing = null;
12130
12242
  }
12131
- if (focusSelector) {
12243
+ if (captureFocus) {
12132
12244
  framing = {
12133
12245
  ...framing || {},
12134
- focus_selector: focusSelector,
12246
+ focus_selector: captureFocus,
12247
+ content_bounds: contentBounds,
12135
12248
  source_dimensions: dimensions
12136
12249
  };
12137
12250
  }
@@ -12322,15 +12435,16 @@ function resolveStoreScreenshotAccent(accent, seed = "notis-app") {
12322
12435
  }
12323
12436
  return ACCENT_NAMES[stableHash(seed) % ACCENT_NAMES.length];
12324
12437
  }
12325
- function storeScreenshotLayout(width = 2e3, height = 1250) {
12438
+ function storeScreenshotLayout(width = 2e3, height = 1250, contentAspect = STORE_SCREENSHOT_ASPECT) {
12439
+ const aspect = Number.isFinite(contentAspect) && contentAspect > 0 ? contentAspect : STORE_SCREENSHOT_ASPECT;
12326
12440
  const horizontalInset = Math.max(48, Math.round(width * 0.06));
12327
12441
  const verticalInset = Math.max(40, Math.round(height * 0.06));
12328
12442
  let cardWidth = width - horizontalInset * 2;
12329
- let cardHeight = Math.round(cardWidth / STORE_SCREENSHOT_ASPECT);
12443
+ let cardHeight = Math.round(cardWidth / aspect);
12330
12444
  const maximumCardHeight = height - verticalInset * 2;
12331
12445
  if (cardHeight > maximumCardHeight) {
12332
12446
  cardHeight = maximumCardHeight;
12333
- cardWidth = Math.round(cardHeight * STORE_SCREENSHOT_ASPECT);
12447
+ cardWidth = Math.round(cardHeight * aspect);
12334
12448
  }
12335
12449
  const cardLeft = Math.round((width - cardWidth) / 2);
12336
12450
  const cardTop = Math.round((height - cardHeight) / 2) - Math.round(height * 8e-3);
@@ -12366,11 +12480,12 @@ async function composeStoreScreenshot({
12366
12480
  }) {
12367
12481
  const sharp = await loadSharp();
12368
12482
  const resolvedTheme = theme === "dark" ? "dark" : "light";
12369
- const layout = storeScreenshotLayout(width, height);
12483
+ const source = await sharp(inputPath).metadata();
12484
+ const layout = storeScreenshotLayout(width, height, source.width / source.height);
12370
12485
  const resolvedAccent = resolveStoreScreenshotAccent(accent, seed);
12371
12486
  const roundedCapture = await sharp(inputPath).resize(layout.cardWidth, layout.cardHeight, {
12372
- fit: "cover",
12373
- position: focused ? "top" : "centre"
12487
+ fit: "contain",
12488
+ background: resolvedTheme === "dark" ? "#09090b" : "#ffffff"
12374
12489
  }).composite([{
12375
12490
  input: roundedRectSvg(layout.cardWidth, layout.cardHeight, layout.radius),
12376
12491
  blend: "dest-in"
@@ -13776,6 +13891,7 @@ ${problems.map((p) => ` - ${p}`).join("\n")}`);
13776
13891
  sessionName: captureSessionName,
13777
13892
  screenshotPath: browserScreenshotPath,
13778
13893
  focusSelector: ctx.options.raw ? null : focus,
13894
+ frameContent: !ctx.options.raw,
13779
13895
  width,
13780
13896
  height,
13781
13897
  timeoutMs: Number.parseInt(ctx.globalOptions.timeoutMs || "", 10) || 15e3
@@ -13790,7 +13906,7 @@ ${problems.map((p) => ` - ${p}`).join("\n")}`);
13790
13906
  height,
13791
13907
  accent: appConfig.accent,
13792
13908
  seed: appConfig.name || manifest.app?.name || appSlug,
13793
- focused: Boolean(focus),
13909
+ focused: Boolean(result.framing?.focus_selector),
13794
13910
  theme: theme || "light"
13795
13911
  });
13796
13912
  } catch (error) {
@@ -46,6 +46,8 @@ For every app UI create/edit task, read both [Design](references/design.md) and
46
46
  no-deploy, and explicit-consent requirements. Store publication is separate.
47
47
  - Preserve the exact app identity, account/team scope, permissions, and user data.
48
48
  Reconcile an uncertain release instead of blindly retrying it.
49
+ - Before Store submission, complete the [publication privacy and portability gate](references/release.md#publication-privacy-and-portability-gate).
50
+ Public examples must be fictional; never publish the owner's workspace data.
49
51
  - Use SDK hooks and declared tools. Let Notis own its sidebar, search, runtime,
50
52
  and rendering boundary; do not query host DOM or recreate that chrome in the app.
51
53
 
@@ -7,8 +7,11 @@ All Notis apps are built using the Notis CLI, either locally in a repo workspace
7
7
  - the portal renders it as a React component inside the portal's React tree
8
8
 
9
9
  Use standard React pages in `app/`, not Next.js or a custom server. The host
10
- chooses a trusted shadow root or an isolated Store frame; do not create your own
11
- iframe, query Portal-owned DOM, or install a window-global runtime. The host owns
10
+ uses one React/Shadow DOM runtime for authored apps, Store installations,
11
+ duplicates and SDK reports. Do not create your own iframe, query Portal-owned
12
+ DOM, or install a window-global runtime. Shadow DOM isolates styles, not
13
+ JavaScript or credentials: loaded bundles share the Portal browser context.
14
+ The host owns
12
15
  theme injection and authentication. App code uses SDK hooks and the final tool
13
16
  names discovered through the CLI, declared in `notis.config.ts` and enforced by
14
17
  the backend. Runtime permissions stay least-authority; releasing an app does not
@@ -65,6 +65,50 @@ not required for an ordinary Workspace update.
65
65
 
66
66
  ## Special cases — read only when relevant
67
67
 
68
+ ### Publication privacy and portability gate
69
+
70
+ Before submitting or updating a Store listing:
71
+
72
+ 1. Inventory the exact public source archive, listing text/media, database schemas,
73
+ starter rows, bundled skills (including scripts/references), and automation
74
+ prompts/configuration. Review their actual contents, not just filenames or
75
+ a passing secret scan. Exclude personal records, transcripts, health/journal
76
+ history, customer details, private repository/account identifiers, credentials,
77
+ local paths, run logs, and private links. Do not merely replace a person's name
78
+ in otherwise real data. Rebuild examples from wholly fictional scenarios.
79
+ 2. Keep live owner databases structure-only (`seedDocuments` absent or false).
80
+ Opting in seeds the database's live rows, including folders: it is not a
81
+ fixture selector. Use fictional screenshot fixtures and an explicit, idempotent
82
+ onboarding demo-data option. If starter rows are needed in the install snapshot,
83
+ publish only from an isolated, verified fictional dataset; never replace or
84
+ delete the owner's real data to prepare a submission. When the user asks for
85
+ examples to come with the app, include them in that verified install snapshot:
86
+ screenshot fixtures or an optional onboarding seed step do not satisfy this.
87
+ 3. Bundle the full dependency closure of every app/automation skill, including
88
+ referenced helpers and resources. Remove private account-specific defaults;
89
+ resolve the installer's databases, connections, repository, timezone and
90
+ delivery choices at runtime. Preserve existing owner's schedules and data.
91
+ 4. Declare a source-owned onboarding skill. It must work through available MCP
92
+ tools or the Notis CLI in any agent harness, without requiring Notis Manager,
93
+ vendor-specific delegation tools, hidden local files, or publisher access.
94
+ Discover tools and inspect schemas before calls; reconcile existing resources
95
+ before creating them. Obtain installer choices before enabling automation or
96
+ external actions. Installing examples must not activate external deliveries.
97
+ 5. Test onboarding as an independent harness-native proof agent using an account
98
+ isolated from the publisher, then rerun to prove no duplicates. Exercise each
99
+ route and its interactions with fictional data, including empty/error states.
100
+ Record exact identities, versions, results and run-created resource cleanup;
101
+ never use owner records as writable test fixtures.
102
+ For bundled starter-data claims, install the actual published listing into an
103
+ empty test account and read back its rows before onboarding or any manual data
104
+ writes. Confirm the installed listing version and remapped relations; do not
105
+ substitute an editable-source deployment for this Store-install test.
106
+ 6. Inspect the final submitted snapshot and media after packaging. Record the
107
+ privacy audit and verification against that exact source version. Any unknown
108
+ provenance, missing dependency, untested onboarding or suspected personal data
109
+ blocks submission until resolved. Never equate submission with review approval
110
+ or Store publication.
111
+
68
112
  ### Unreleased container or stale checkout
69
113
 
70
114
  An unreleased container has no source to pull. Recover its original local source,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@notis_ai/cli",
3
- "version": "0.2.0-beta.178.1",
3
+ "version": "0.2.0-beta.179.1",
4
4
  "description": "Agent-first Notis CLI for apps and generic tool execution",
5
5
  "type": "module",
6
6
  "bin": {
@@ -935,6 +935,7 @@ async function appsScreenshotHandler(ctx) {
935
935
  sessionName: captureSessionName,
936
936
  screenshotPath: browserScreenshotPath,
937
937
  focusSelector: ctx.options.raw ? null : focus,
938
+ frameContent: !ctx.options.raw,
938
939
  width,
939
940
  height,
940
941
  timeoutMs: Number.parseInt(ctx.globalOptions.timeoutMs || '', 10) || 15_000,
@@ -949,7 +950,7 @@ async function appsScreenshotHandler(ctx) {
949
950
  height,
950
951
  accent: appConfig.accent,
951
952
  seed: appConfig.name || manifest.app?.name || appSlug,
952
- focused: Boolean(focus),
953
+ focused: Boolean(result.framing?.focus_selector),
953
954
  theme: theme || 'light',
954
955
  });
955
956
  } catch (error) {
@@ -1,6 +1,7 @@
1
1
  import { spawn, spawnSync } from 'node:child_process';
2
2
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { dirname } from 'node:path';
4
+ import { contentCapturePlan, MEASURE_SCREENSHOT_CONTENT_SCRIPT, SAFE_VIEWPORT_HEIGHT_SCRIPT } from './screenshot-framing.js';
4
5
 
5
6
  export const HIDE_HARNESS_STATUS_SCRIPT =
6
7
  "(() => { const el = document.getElementById('harness-status'); if (el) el.style.opacity = '0'; return true; })()";
@@ -322,18 +323,18 @@ export async function runHarnessRoute({
322
323
 
323
324
  /**
324
325
  * Open a harness route, wait for it to mount, then capture a PNG screenshot of
325
- * the rendered app. Output is always exactly `width` x `height` physical
326
- * pixels (default 2000x1250, the 16:10 the listing validator expects), but the
327
- * CSS viewport is fitted to the rendered content height: short pages render
328
- * larger (no dead whitespace below the app), tall pages get more room before
329
- * cropping. The deviceScaleFactor maps the fitted CSS viewport back onto the
330
- * fixed output frame. Used by `notis apps screenshot`.
326
+ * the rendered app. Raw captures retain exact requested pixel dimensions.
327
+ * Framed captures can select the complete bounded app or a width-fitted first
328
+ * screen; the Store compositor normalizes them to the final listing size.
329
+ * Device scaling keeps text sharp at that size without changing app styles.
330
+ * Used by `notis apps screenshot`.
331
331
  */
332
332
  export async function captureHarnessScreenshot({
333
333
  url,
334
334
  sessionName,
335
335
  screenshotPath,
336
336
  focusSelector = null,
337
+ frameContent = false,
337
338
  width = 2000,
338
339
  height = 1250,
339
340
  timeoutMs = 15_000,
@@ -433,13 +434,50 @@ export async function captureHarnessScreenshot({
433
434
 
434
435
  // Let the route settle (async data, resize transitions) before the capture.
435
436
  await delay(500);
437
+
438
+ let contentBounds = null;
439
+ if (frameContent && !focusSelector) {
440
+ const measured = await runAgentBrowser(
441
+ ['--session', sessionName, '--json', 'eval', MEASURE_SCREENSHOT_CONTENT_SCRIPT],
442
+ { timeoutMs: 5000 },
443
+ );
444
+ if (measured.exitCode === 0) {
445
+ try {
446
+ const bounds = parseHarnessFromEval(measured.stdout);
447
+ if (bounds?.selector === '[data-notis-screenshot-content]'
448
+ && Number.isFinite(bounds.width) && bounds.width > 0
449
+ && Number.isFinite(bounds.height) && bounds.height > 0) {
450
+ contentBounds = bounds;
451
+ }
452
+ } catch { /* Keep the complete viewport if layout measurement is unavailable. */ }
453
+ }
454
+ }
455
+ let captureFocus = focusSelector || contentBounds?.selector || null;
456
+ if (contentBounds && scaledViewport) {
457
+ const plan = contentCapturePlan(contentBounds, framing || initial, width, height);
458
+ if (await setViewport(sessionName, plan.width, plan.height, plan.scale)) {
459
+ await delay(300);
460
+ if (!plan.focus) {
461
+ const safeHeight = await evalNumber(sessionName, SAFE_VIEWPORT_HEIGHT_SCRIPT, plan.height);
462
+ if (safeHeight < plan.height && await setViewport(sessionName, plan.width, safeHeight, plan.scale)) {
463
+ plan.height = safeHeight;
464
+ }
465
+ }
466
+ captureFocus = plan.focus ? contentBounds.selector : null;
467
+ framing = {
468
+ ...(framing || {}), ...plan, content_bounds: contentBounds,
469
+ capture_mode: plan.focus ? 'content' : 'width-fit-viewport',
470
+ };
471
+ await delay(300);
472
+ }
473
+ }
436
474
  const designResult = await evalDesignAssertions(sessionName, { viewport: 'capture' });
437
475
  const design = Array.isArray(designResult) ? designResult : [];
438
476
 
439
477
  mkdirSync(dirname(screenshotPath), { recursive: true });
440
478
  const screenshotArgs = ['--session', sessionName, 'screenshot'];
441
- if (focusSelector) {
442
- screenshotArgs.push(focusSelector);
479
+ if (captureFocus) {
480
+ screenshotArgs.push(captureFocus);
443
481
  }
444
482
  screenshotArgs.push(screenshotPath, '--screenshot-format', 'png');
445
483
  const shot = await runAgentBrowser(screenshotArgs, { timeoutMs: 15_000 });
@@ -451,7 +489,13 @@ export async function captureHarnessScreenshot({
451
489
  // capture came out wrong (e.g. the browser ignored the scale factor),
452
490
  // recapture once at a plain fixed viewport.
453
491
  const dimensions = pngDimensions(screenshotPath);
454
- if (!focusSelector && framing && (!dimensions || dimensions.width !== width || dimensions.height !== height)) {
492
+ const expectedDimensions = framing?.capture_mode === 'width-fit-viewport'
493
+ ? { width: Math.round(framing.width * framing.scale), height: Math.round(framing.height * framing.scale) }
494
+ : { width, height };
495
+ // Fractional browser scaling can round one pixel either way. Framed output
496
+ // is normalized by the compositor; raw captures still require exact pixels.
497
+ const tolerance = frameContent ? 1 : 0;
498
+ if (!captureFocus && framing && (!dimensions || Math.abs(dimensions.width - expectedDimensions.width) > tolerance || Math.abs(dimensions.height - expectedDimensions.height) > tolerance)) {
455
499
  await setViewport(sessionName, width, height);
456
500
  await delay(300);
457
501
  const retry = await runAgentBrowser(
@@ -464,10 +508,11 @@ export async function captureHarnessScreenshot({
464
508
  framing = null;
465
509
  }
466
510
 
467
- if (focusSelector) {
511
+ if (captureFocus) {
468
512
  framing = {
469
513
  ...(framing || {}),
470
- focus_selector: focusSelector,
514
+ focus_selector: captureFocus,
515
+ content_bounds: contentBounds,
471
516
  source_dimensions: dimensions,
472
517
  };
473
518
  }
@@ -0,0 +1,85 @@
1
+ /** Runs only in the capture harness, never in an installed app. */
2
+ export function measureScreenshotContent() {
3
+ const root = document.getElementById('root');
4
+ if (!root) return null;
5
+ const viewportWidth = document.documentElement.clientWidth;
6
+ const visible = (element) => {
7
+ const rect = element.getBoundingClientRect();
8
+ const style = getComputedStyle(element);
9
+ return rect.width > 0 && rect.height > 0
10
+ && style.display !== 'none' && style.visibility !== 'hidden';
11
+ };
12
+
13
+ // Follow layout wrappers only while they contain the entire visible app.
14
+ // Never select one column, card, or control out of a multi-pane layout.
15
+ let node = root;
16
+ let content = null;
17
+ while (node) {
18
+ const hasOwnText = Array.from(node.childNodes).some(
19
+ (child) => child.nodeType === 3 && child.textContent.trim(),
20
+ );
21
+ const children = Array.from(node.children).filter(visible);
22
+ if (hasOwnText || children.length !== 1) break;
23
+ node = children[0];
24
+ const rect = node.getBoundingClientRect();
25
+ if (['DIV', 'MAIN', 'SECTION', 'ARTICLE', 'FORM'].includes(node.tagName)
26
+ && rect.width >= 320 && rect.height >= 160
27
+ && rect.width < viewportWidth * 0.9) {
28
+ content = node;
29
+ }
30
+ }
31
+ if (!content) return null;
32
+ content.setAttribute('data-notis-screenshot-content', '');
33
+ const rect = content.getBoundingClientRect();
34
+ return {
35
+ selector: '[data-notis-screenshot-content]',
36
+ width: rect.width,
37
+ height: rect.height,
38
+ viewport_width: viewportWidth,
39
+ };
40
+ }
41
+
42
+ export const MEASURE_SCREENSHOT_CONTENT_SCRIPT = `(${measureScreenshotContent.toString()})()`;
43
+
44
+ /** Keep long pages readable, and rasterize bounded views at their final width. */
45
+ export function contentCapturePlan(bounds, viewport, outputWidth, outputHeight) {
46
+ const aspect = outputWidth / outputHeight;
47
+ if (bounds.width / bounds.height < aspect * 0.75) {
48
+ const width = Math.max(768, Math.ceil((bounds.width + 32) / 8) * 8);
49
+ return { focus: false, width, height: Math.round(width / aspect), scale: outputWidth / width };
50
+ }
51
+ return {
52
+ focus: true,
53
+ width: viewport.width,
54
+ height: viewport.height,
55
+ scale: Math.min(4, outputWidth / bounds.width),
56
+ };
57
+ }
58
+
59
+ export function safeViewportHeight(rectangles, height) {
60
+ const crossing = rectangles.filter((rect) => rect.top < height && rect.bottom > height
61
+ && rect.top >= height * 0.85);
62
+ return crossing.length
63
+ ? Math.max(Math.ceil(height * 0.85), Math.floor(Math.min(...crossing.map((rect) => rect.top)) - 8))
64
+ : height;
65
+ }
66
+
67
+ // End a long-page viewport in whitespace rather than through a text line or
68
+ // control. Only geometry leaves the browser; no page text is returned.
69
+ export const SAFE_VIEWPORT_HEIGHT_SCRIPT = `(() => {
70
+ const root = document.getElementById('root');
71
+ if (!root) return innerHeight;
72
+ const rects = [];
73
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
74
+ let node;
75
+ while ((node = walker.nextNode())) {
76
+ if (!node.textContent.trim() || getComputedStyle(node.parentElement).visibility !== 'visible') continue;
77
+ const range = document.createRange(); range.selectNodeContents(node);
78
+ for (const rect of range.getClientRects()) rects.push({ top: rect.top, bottom: rect.bottom });
79
+ }
80
+ for (const control of root.querySelectorAll('button,input,select,textarea')) {
81
+ const rect = control.getBoundingClientRect();
82
+ rects.push({ top: rect.top, bottom: rect.bottom });
83
+ }
84
+ return (${safeViewportHeight.toString()})(rects, innerHeight);
85
+ })()`;
@@ -38,15 +38,17 @@ export function resolveStoreScreenshotAccent(accent, seed = 'notis-app') {
38
38
  return ACCENT_NAMES[stableHash(seed) % ACCENT_NAMES.length];
39
39
  }
40
40
 
41
- export function storeScreenshotLayout(width = 2000, height = 1250) {
41
+ export function storeScreenshotLayout(width = 2000, height = 1250, contentAspect = STORE_SCREENSHOT_ASPECT) {
42
+ const aspect = Number.isFinite(contentAspect) && contentAspect > 0
43
+ ? contentAspect : STORE_SCREENSHOT_ASPECT;
42
44
  const horizontalInset = Math.max(48, Math.round(width * 0.06));
43
45
  const verticalInset = Math.max(40, Math.round(height * 0.06));
44
46
  let cardWidth = width - horizontalInset * 2;
45
- let cardHeight = Math.round(cardWidth / STORE_SCREENSHOT_ASPECT);
47
+ let cardHeight = Math.round(cardWidth / aspect);
46
48
  const maximumCardHeight = height - verticalInset * 2;
47
49
  if (cardHeight > maximumCardHeight) {
48
50
  cardHeight = maximumCardHeight;
49
- cardWidth = Math.round(cardHeight * STORE_SCREENSHOT_ASPECT);
51
+ cardWidth = Math.round(cardHeight * aspect);
50
52
  }
51
53
  const cardLeft = Math.round((width - cardWidth) / 2);
52
54
  const cardTop = Math.round((height - cardHeight) / 2) - Math.round(height * 0.008);
@@ -80,7 +82,7 @@ function roundedRectSvg(width, height, radius, { fill = '#ffffff', stroke = null
80
82
 
81
83
  /**
82
84
  * Composite a truthful browser capture into the deterministic Store frame.
83
- * The source pixels are only resized/cropped; the app UI is never redrawn.
85
+ * Fit the complete source pixels; never crop controls or redraw the app UI.
84
86
  */
85
87
  export async function composeStoreScreenshot({
86
88
  inputPath,
@@ -94,13 +96,14 @@ export async function composeStoreScreenshot({
94
96
  }) {
95
97
  const sharp = await loadSharp();
96
98
  const resolvedTheme = theme === 'dark' ? 'dark' : 'light';
97
- const layout = storeScreenshotLayout(width, height);
99
+ const source = await sharp(inputPath).metadata();
100
+ const layout = storeScreenshotLayout(width, height, source.width / source.height);
98
101
  const resolvedAccent = resolveStoreScreenshotAccent(accent, seed);
99
102
 
100
103
  const roundedCapture = await sharp(inputPath)
101
104
  .resize(layout.cardWidth, layout.cardHeight, {
102
- fit: 'cover',
103
- position: focused ? 'top' : 'centre',
105
+ fit: 'contain',
106
+ background: resolvedTheme === 'dark' ? '#09090b' : '#ffffff',
104
107
  })
105
108
  .composite([{
106
109
  input: roundedRectSvg(layout.cardWidth, layout.cardHeight, layout.radius),